detached ba37dbf

feat(neferafk): add idle, lock, output-off and suspend daemon

commit
ba37dbf08a13fa7fa9d2b5db369e9078f5ac36ad
author
brice · · 2026-09-30 19:43 UTC
committer
brice · · 2026-09-30 19:57 UTC
parent
e50679c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
64 files · +11797 -0
A .gitignore +4 -0
@@ -0,0 +1,4 @@
1+.worktrees/
2+bin/
3+*.pprof
4+*.test
A .mockery.yml +45 -0
@@ -0,0 +1,45 @@
1+# Sealed message interfaces are not mockable; channel ports are tested with messages.
2+all: false
3+formatter: goimports
4+force-file-write: true
5+structname: '{{.Mock}}{{.InterfaceName}}'
6+template: testify
7+template-schema: '{{.Template}}.schema.json'
8+require-template-schema-exists: true
9+packages:
10+ github.com/bnema/neferafk/internal/ports:
11+ config:
12+ dir: internal/mocks/ports
13+ pkgname: portsmocks
14+ filename: '{{.InterfaceFile | base | trimSuffix ".go"}}_mock.go'
15+ interfaces:
16+ Visual:
17+ VisualLauncher:
18+ VisualSession:
19+ # Unexported adapter seams: mocks live next to the code, in test files.
20+ github.com/bnema/neferafk/internal/adapters/auth:
21+ config:
22+ dir: internal/adapters/auth
23+ pkgname: auth
24+ filename: pam_mock_test.go
25+ interfaces:
26+ pamBackend:
27+ pamTransaction:
28+ delayClock:
29+ github.com/bnema/neferafk/internal/adapters/logind:
30+ interfaces:
31+ bus:
32+ config:
33+ dir: internal/adapters/logind
34+ pkgname: logind
35+ filename: bus_mock_test.go
36+ github.com/bnema/neferafk/internal/adapters/visual:
37+ config:
38+ dir: internal/adapters/visual
39+ pkgname: visual
40+ filename: visual_mock_test.go
41+ interfaces:
42+ workerLauncher:
43+ workerProcess:
44+ lockRunner:
45+ fadeRunner:
A AGENTS.md +10 -0
@@ -0,0 +1,10 @@
1+# Repository rules
2+
3+- Go 1.27 only. Normal builds/tests use CGO_ENABLED=0; race tests use CGO_ENABLED=1.
4+- Lightweight hexagonal architecture: core imports only stdlib and ports; app wires adapters. Owner goroutines own state, no window-state mutex.
5+- Reuse NeferWL dependency conventions: zerowrap with component on every entry, Mockery v3 testify generated mocks. No handwritten fakes/stubs/spies or swapped function fields.
6+- Cross-layer seams live in internal/ports; generated mocks in internal/mocks/ports. Adapter-internal seams stay unexported with generated same-package test mocks. Add .mockery.yml entries and verify generation.
7+- Signed conventional commits only; no remote/publication/installation or live lock/off/suspend/real credential attempt without authorization.
8+- Credentials travel directly from visual process to the isolated authentication worker, never through policy daemon, render models, logs, argv or debug output.
9+- Pure Go/purego PAM via purego-pam; no C helper or renderer fallback. Standard advertised Wayland protocols and existing NeferGUI Vulkan/DMA-BUF/explicit-sync rendering only.
10+- Flat key = value config with independent disableable lock/fade/output-off/suspend deadlines. Returning activity never unlocks.
A LICENSE +674 -0
@@ -0,0 +1,674 @@
1+ GNU GENERAL PUBLIC LICENSE
2+ Version 3, 29 June 2007
3+
4+ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
5+ Everyone is permitted to copy and distribute verbatim copies
6+ of this license document, but changing it is not allowed.
7+
8+ Preamble
9+
10+ The GNU General Public License is a free, copyleft license for
11+software and other kinds of works.
12+
13+ The licenses for most software and other practical works are designed
14+to take away your freedom to share and change the works. By contrast,
15+the GNU General Public License is intended to guarantee your freedom to
16+share and change all versions of a program--to make sure it remains free
17+software for all its users. We, the Free Software Foundation, use the
18+GNU General Public License for most of our software; it applies also to
19+any other work released this way by its authors. You can apply it to
20+your programs, too.
21+
22+ When we speak of free software, we are referring to freedom, not
23+price. Our General Public Licenses are designed to make sure that you
24+have the freedom to distribute copies of free software (and charge for
25+them if you wish), that you receive source code or can get it if you
26+want it, that you can change the software or use pieces of it in new
27+free programs, and that you know you can do these things.
28+
29+ To protect your rights, we need to prevent others from denying you
30+these rights or asking you to surrender the rights. Therefore, you have
31+certain responsibilities if you distribute copies of the software, or if
32+you modify it: responsibilities to respect the freedom of others.
33+
34+ For example, if you distribute copies of such a program, whether
35+gratis or for a fee, you must pass on to the recipients the same
36+freedoms that you received. You must make sure that they, too, receive
37+or can get the source code. And you must show them these terms so they
38+know their rights.
39+
40+ Developers that use the GNU GPL protect your rights with two steps:
41+(1) assert copyright on the software, and (2) offer you this License
42+giving you legal permission to copy, distribute and/or modify it.
43+
44+ For the developers' and authors' protection, the GPL clearly explains
45+that there is no warranty for this free software. For both users' and
46+authors' sake, the GPL requires that modified versions be marked as
47+changed, so that their problems will not be attributed erroneously to
48+authors of previous versions.
49+
50+ Some devices are designed to deny users access to install or run
51+modified versions of the software inside them, although the manufacturer
52+can do so. This is fundamentally incompatible with the aim of
53+protecting users' freedom to change the software. The systematic
54+pattern of such abuse occurs in the area of products for individuals to
55+use, which is precisely where it is most unacceptable. Therefore, we
56+have designed this version of the GPL to prohibit the practice for those
57+products. If such problems arise substantially in other domains, we
58+stand ready to extend this provision to those domains in future versions
59+of the GPL, as needed to protect the freedom of users.
60+
61+ Finally, every program is threatened constantly by software patents.
62+States should not allow patents to restrict development and use of
63+software on general-purpose computers, but in those that do, we wish to
64+avoid the special danger that patents applied to a free program could
65+make it effectively proprietary. To prevent this, the GPL assures that
66+patents cannot be used to render the program non-free.
67+
68+ The precise terms and conditions for copying, distribution and
69+modification follow.
70+
71+ TERMS AND CONDITIONS
72+
73+ 0. Definitions.
74+
75+ "This License" refers to version 3 of the GNU General Public License.
76+
77+ "Copyright" also means copyright-like laws that apply to other kinds of
78+works, such as semiconductor masks.
79+
80+ "The Program" refers to any copyrightable work licensed under this
81+License. Each licensee is addressed as "you". "Licensees" and
82+"recipients" may be individuals or organizations.
83+
84+ To "modify" a work means to copy from or adapt all or part of the work
85+in a fashion requiring copyright permission, other than the making of an
86+exact copy. The resulting work is called a "modified version" of the
87+earlier work or a work "based on" the earlier work.
88+
89+ A "covered work" means either the unmodified Program or a work based
90+on the Program.
91+
92+ To "propagate" a work means to do anything with it that, without
93+permission, would make you directly or secondarily liable for
94+infringement under applicable copyright law, except executing it on a
95+computer or modifying a private copy. Propagation includes copying,
96+distribution (with or without modification), making available to the
97+public, and in some countries other activities as well.
98+
99+ To "convey" a work means any kind of propagation that enables other
100+parties to make or receive copies. Mere interaction with a user through
101+a computer network, with no transfer of a copy, is not conveying.
102+
103+ An interactive user interface displays "Appropriate Legal Notices"
104+to the extent that it includes a convenient and prominently visible
105+feature that (1) displays an appropriate copyright notice, and (2)
106+tells the user that there is no warranty for the work (except to the
107+extent that warranties are provided), that licensees may convey the
108+work under this License, and how to view a copy of this License. If
109+the interface presents a list of user commands or options, such as a
110+menu, a prominent item in the list meets this criterion.
111+
112+ 1. Source Code.
113+
114+ The "source code" for a work means the preferred form of the work
115+for making modifications to it. "Object code" means any non-source
116+form of a work.
117+
118+ A "Standard Interface" means an interface that either is an official
119+standard defined by a recognized standards body, or, in the case of
120+interfaces specified for a particular programming language, one that
121+is widely used among developers working in that language.
122+
123+ The "System Libraries" of an executable work include anything, other
124+than the work as a whole, that (a) is included in the normal form of
125+packaging a Major Component, but which is not part of that Major
126+Component, and (b) serves only to enable use of the work with that
127+Major Component, or to implement a Standard Interface for which an
128+implementation is available to the public in source code form. A
129+"Major Component", in this context, means a major essential component
130+(kernel, window system, and so on) of the specific operating system
131+(if any) on which the executable work runs, or a compiler used to
132+produce the work, or an object code interpreter used to run it.
133+
134+ The "Corresponding Source" for a work in object code form means all
135+the source code needed to generate, install, and (for an executable
136+work) run the object code and to modify the work, including scripts to
137+control those activities. However, it does not include the work's
138+System Libraries, or general-purpose tools or generally available free
139+programs which are used unmodified in performing those activities but
140+which are not part of the work. For example, Corresponding Source
141+includes interface definition files associated with source files for
142+the work, and the source code for shared libraries and dynamically
143+linked subprograms that the work is specifically designed to require,
144+such as by intimate data communication or control flow between those
145+subprograms and other parts of the work.
146+
147+ The Corresponding Source need not include anything that users
148+can regenerate automatically from other parts of the Corresponding
149+Source.
150+
151+ The Corresponding Source for a work in source code form is that
152+same work.
153+
154+ 2. Basic Permissions.
155+
156+ All rights granted under this License are granted for the term of
157+copyright on the Program, and are irrevocable provided the stated
158+conditions are met. This License explicitly affirms your unlimited
159+permission to run the unmodified Program. The output from running a
160+covered work is covered by this License only if the output, given its
161+content, constitutes a covered work. This License acknowledges your
162+rights of fair use or other equivalent, as provided by copyright law.
163+
164+ You may make, run and propagate covered works that you do not
165+convey, without conditions so long as your license otherwise remains
166+in force. You may convey covered works to others for the sole purpose
167+of having them make modifications exclusively for you, or provide you
168+with facilities for running those works, provided that you comply with
169+the terms of this License in conveying all material for which you do
170+not control copyright. Those thus making or running the covered works
171+for you must do so exclusively on your behalf, under your direction
172+and control, on terms that prohibit them from making any copies of
173+your copyrighted material outside their relationship with you.
174+
175+ Conveying under any other circumstances is permitted solely under
176+the conditions stated below. Sublicensing is not allowed; section 10
177+makes it unnecessary.
178+
179+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
180+
181+ No covered work shall be deemed part of an effective technological
182+measure under any applicable law fulfilling obligations under article
183+11 of the WIPO copyright treaty adopted on 20 December 1996, or
184+similar laws prohibiting or restricting circumvention of such
185+measures.
186+
187+ When you convey a covered work, you waive any legal power to forbid
188+circumvention of technological measures to the extent such circumvention
189+is effected by exercising rights under this License with respect to
190+the covered work, and you disclaim any intention to limit operation or
191+modification of the work as a means of enforcing, against the work's
192+users, your or third parties' legal rights to forbid circumvention of
193+technological measures.
194+
195+ 4. Conveying Verbatim Copies.
196+
197+ You may convey verbatim copies of the Program's source code as you
198+receive it, in any medium, provided that you conspicuously and
199+appropriately publish on each copy an appropriate copyright notice;
200+keep intact all notices stating that this License and any
201+non-permissive terms added in accord with section 7 apply to the code;
202+keep intact all notices of the absence of any warranty; and give all
203+recipients a copy of this License along with the Program.
204+
205+ You may charge any price or no price for each copy that you convey,
206+and you may offer support or warranty protection for a fee.
207+
208+ 5. Conveying Modified Source Versions.
209+
210+ You may convey a work based on the Program, or the modifications to
211+produce it from the Program, in the form of source code under the
212+terms of section 4, provided that you also meet all of these conditions:
213+
214+ a) The work must carry prominent notices stating that you modified
215+ it, and giving a relevant date.
216+
217+ b) The work must carry prominent notices stating that it is
218+ released under this License and any conditions added under section
219+ 7. This requirement modifies the requirement in section 4 to
220+ "keep intact all notices".
221+
222+ c) You must license the entire work, as a whole, under this
223+ License to anyone who comes into possession of a copy. This
224+ License will therefore apply, along with any applicable section 7
225+ additional terms, to the whole of the work, and all its parts,
226+ regardless of how they are packaged. This License gives no
227+ permission to license the work in any other way, but it does not
228+ invalidate such permission if you have separately received it.
229+
230+ d) If the work has interactive user interfaces, each must display
231+ Appropriate Legal Notices; however, if the Program has interactive
232+ interfaces that do not display Appropriate Legal Notices, your
233+ work need not make them do so.
234+
235+ A compilation of a covered work with other separate and independent
236+works, which are not by their nature extensions of the covered work,
237+and which are not combined with it such as to form a larger program,
238+in or on a volume of a storage or distribution medium, is called an
239+"aggregate" if the compilation and its resulting copyright are not
240+used to limit the access or legal rights of the compilation's users
241+beyond what the individual works permit. Inclusion of a covered work
242+in an aggregate does not cause this License to apply to the other
243+parts of the aggregate.
244+
245+ 6. Conveying Non-Source Forms.
246+
247+ You may convey a covered work in object code form under the terms
248+of sections 4 and 5, provided that you also convey the
249+machine-readable Corresponding Source under the terms of this License,
250+in one of these ways:
251+
252+ a) Convey the object code in, or embodied in, a physical product
253+ (including a physical distribution medium), accompanied by the
254+ Corresponding Source fixed on a durable physical medium
255+ customarily used for software interchange.
256+
257+ b) Convey the object code in, or embodied in, a physical product
258+ (including a physical distribution medium), accompanied by a
259+ written offer, valid for at least three years and valid for as
260+ long as you offer spare parts or customer support for that product
261+ model, to give anyone who possesses the object code either (1) a
262+ copy of the Corresponding Source for all the software in the
263+ product that is covered by this License, on a durable physical
264+ medium customarily used for software interchange, for a price no
265+ more than your reasonable cost of physically performing this
266+ conveying of source, or (2) access to copy the
267+ Corresponding Source from a network server at no charge.
268+
269+ c) Convey individual copies of the object code with a copy of the
270+ written offer to provide the Corresponding Source. This
271+ alternative is allowed only occasionally and noncommercially, and
272+ only if you received the object code with such an offer, in accord
273+ with subsection 6b.
274+
275+ d) Convey the object code by offering access from a designated
276+ place (gratis or for a charge), and offer equivalent access to the
277+ Corresponding Source in the same way through the same place at no
278+ further charge. You need not require recipients to copy the
279+ Corresponding Source along with the object code. If the place to
280+ copy the object code is a network server, the Corresponding Source
281+ may be on a different server (operated by you or a third party)
282+ that supports equivalent copying facilities, provided you maintain
283+ clear directions next to the object code saying where to find the
284+ Corresponding Source. Regardless of what server hosts the
285+ Corresponding Source, you remain obligated to ensure that it is
286+ available for as long as needed to satisfy these requirements.
287+
288+ e) Convey the object code using peer-to-peer transmission, provided
289+ you inform other peers where the object code and Corresponding
290+ Source of the work are being offered to the general public at no
291+ charge under subsection 6d.
292+
293+ A separable portion of the object code, whose source code is excluded
294+from the Corresponding Source as a System Library, need not be
295+included in conveying the object code work.
296+
297+ A "User Product" is either (1) a "consumer product", which means any
298+tangible personal property which is normally used for personal, family,
299+or household purposes, or (2) anything designed or sold for incorporation
300+into a dwelling. In determining whether a product is a consumer product,
301+doubtful cases shall be resolved in favor of coverage. For a particular
302+product received by a particular user, "normally used" refers to a
303+typical or common use of that class of product, regardless of the status
304+of the particular user or of the way in which the particular user
305+actually uses, or expects or is expected to use, the product. A product
306+is a consumer product regardless of whether the product has substantial
307+commercial, industrial or non-consumer uses, unless such uses represent
308+the only significant mode of use of the product.
309+
310+ "Installation Information" for a User Product means any methods,
311+procedures, authorization keys, or other information required to install
312+and execute modified versions of a covered work in that User Product from
313+a modified version of its Corresponding Source. The information must
314+suffice to ensure that the continued functioning of the modified object
315+code is in no case prevented or interfered with solely because
316+modification has been made.
317+
318+ If you convey an object code work under this section in, or with, or
319+specifically for use in, a User Product, and the conveying occurs as
320+part of a transaction in which the right of possession and use of the
321+User Product is transferred to the recipient in perpetuity or for a
322+fixed term (regardless of how the transaction is characterized), the
323+Corresponding Source conveyed under this section must be accompanied
324+by the Installation Information. But this requirement does not apply
325+if neither you nor any third party retains the ability to install
326+modified object code on the User Product (for example, the work has
327+been installed in ROM).
328+
329+ The requirement to provide Installation Information does not include a
330+requirement to continue to provide support service, warranty, or updates
331+for a work that has been modified or installed by the recipient, or for
332+the User Product in which it has been modified or installed. Access to a
333+network may be denied when the modification itself materially and
334+adversely affects the operation of the network or violates the rules and
335+protocols for communication across the network.
336+
337+ Corresponding Source conveyed, and Installation Information provided,
338+in accord with this section must be in a format that is publicly
339+documented (and with an implementation available to the public in
340+source code form), and must require no special password or key for
341+unpacking, reading or copying.
342+
343+ 7. Additional Terms.
344+
345+ "Additional permissions" are terms that supplement the terms of this
346+License by making exceptions from one or more of its conditions.
347+Additional permissions that are applicable to the entire Program shall
348+be treated as though they were included in this License, to the extent
349+that they are valid under applicable law. If additional permissions
350+apply only to part of the Program, that part may be used separately
351+under those permissions, but the entire Program remains governed by
352+this License without regard to the additional permissions.
353+
354+ When you convey a copy of a covered work, you may at your option
355+remove any additional permissions from that copy, or from any part of
356+it. (Additional permissions may be written to require their own
357+removal in certain cases when you modify the work.) You may place
358+additional permissions on material, added by you to a covered work,
359+for which you have or can give appropriate copyright permission.
360+
361+ Notwithstanding any other provision of this License, for material you
362+add to a covered work, you may (if authorized by the copyright holders of
363+that material) supplement the terms of this License with terms:
364+
365+ a) Disclaiming warranty or limiting liability differently from the
366+ terms of sections 15 and 16 of this License; or
367+
368+ b) Requiring preservation of specified reasonable legal notices or
369+ author attributions in that material or in the Appropriate Legal
370+ Notices displayed by works containing it; or
371+
372+ c) Prohibiting misrepresentation of the origin of that material, or
373+ requiring that modified versions of such material be marked in
374+ reasonable ways as different from the original version; or
375+
376+ d) Limiting the use for publicity purposes of names of licensors or
377+ authors of the material; or
378+
379+ e) Declining to grant rights under trademark law for use of some
380+ trade names, trademarks, or service marks; or
381+
382+ f) Requiring indemnification of licensors and authors of that
383+ material by anyone who conveys the material (or modified versions of
384+ it) with contractual assumptions of liability to the recipient, for
385+ any liability that these contractual assumptions directly impose on
386+ those licensors and authors.
387+
388+ All other non-permissive additional terms are considered "further
389+restrictions" within the meaning of section 10. If the Program as you
390+received it, or any part of it, contains a notice stating that it is
391+governed by this License along with a term that is a further
392+restriction, you may remove that term. If a license document contains
393+a further restriction but permits relicensing or conveying under this
394+License, you may add to a covered work material governed by the terms
395+of that license document, provided that the further restriction does
396+not survive such relicensing or conveying.
397+
398+ If you add terms to a covered work in accord with this section, you
399+must place, in the relevant source files, a statement of the
400+additional terms that apply to those files, or a notice indicating
401+where to find the applicable terms.
402+
403+ Additional terms, permissive or non-permissive, may be stated in the
404+form of a separately written license, or stated as exceptions;
405+the above requirements apply either way.
406+
407+ 8. Termination.
408+
409+ You may not propagate or modify a covered work except as expressly
410+provided under this License. Any attempt otherwise to propagate or
411+modify it is void, and will automatically terminate your rights under
412+this License (including any patent licenses granted under the third
413+paragraph of section 11).
414+
415+ However, if you cease all violation of this License, then your
416+license from a particular copyright holder is reinstated (a)
417+provisionally, unless and until the copyright holder explicitly and
418+finally terminates your license, and (b) permanently, if the copyright
419+holder fails to notify you of the violation by some reasonable means
420+prior to 60 days after the cessation.
421+
422+ Moreover, your license from a particular copyright holder is
423+reinstated permanently if the copyright holder notifies you of the
424+violation by some reasonable means, this is the first time you have
425+received notice of violation of this License (for any work) from that
426+copyright holder, and you cure the violation prior to 30 days after
427+your receipt of the notice.
428+
429+ Termination of your rights under this section does not terminate the
430+licenses of parties who have received copies or rights from you under
431+this License. If your rights have been terminated and not permanently
432+reinstated, you do not qualify to receive new licenses for the same
433+material under section 10.
434+
435+ 9. Acceptance Not Required for Having Copies.
436+
437+ You are not required to accept this License in order to receive or
438+run a copy of the Program. Ancillary propagation of a covered work
439+occurring solely as a consequence of using peer-to-peer transmission
440+to receive a copy likewise does not require acceptance. However,
441+nothing other than this License grants you permission to propagate or
442+modify any covered work. These actions infringe copyright if you do
443+not accept this License. Therefore, by modifying or propagating a
444+covered work, you indicate your acceptance of this License to do so.
445+
446+ 10. Automatic Licensing of Downstream Recipients.
447+
448+ Each time you convey a covered work, the recipient automatically
449+receives a license from the original licensors, to run, modify and
450+propagate that work, subject to this License. You are not responsible
451+for enforcing compliance by third parties with this License.
452+
453+ An "entity transaction" is a transaction transferring control of an
454+organization, or substantially all assets of one, or subdividing an
455+organization, or merging organizations. If propagation of a covered
456+work results from an entity transaction, each party to that
457+transaction who receives a copy of the work also receives whatever
458+licenses to the work the party's predecessor in interest had or could
459+give under the previous paragraph, plus a right to possession of the
460+Corresponding Source of the work from the predecessor in interest, if
461+the predecessor has it or can get it with reasonable efforts.
462+
463+ You may not impose any further restrictions on the exercise of the
464+rights granted or affirmed under this License. For example, you may
465+not impose a license fee, royalty, or other charge for exercise of
466+rights granted under this License, and you may not initiate litigation
467+(including a cross-claim or counterclaim in a lawsuit) alleging that
468+any patent claim is infringed by making, using, selling, offering for
469+sale, or importing the Program or any portion of it.
470+
471+ 11. Patents.
472+
473+ A "contributor" is a copyright holder who authorizes use under this
474+License of the Program or a work on which the Program is based. The
475+work thus licensed is called the contributor's "contributor version".
476+
477+ A contributor's "essential patent claims" are all patent claims
478+owned or controlled by the contributor, whether already acquired or
479+hereafter acquired, that would be infringed by some manner, permitted
480+by this License, of making, using, or selling its contributor version,
481+but do not include claims that would be infringed only as a
482+consequence of further modification of the contributor version. For
483+purposes of this definition, "control" includes the right to grant
484+patent sublicenses in a manner consistent with the requirements of
485+this License.
486+
487+ Each contributor grants you a non-exclusive, worldwide, royalty-free
488+patent license under the contributor's essential patent claims, to
489+make, use, sell, offer for sale, import and otherwise run, modify and
490+propagate the contents of its contributor version.
491+
492+ In the following three paragraphs, a "patent license" is any express
493+agreement or commitment, however denominated, not to enforce a patent
494+(such as an express permission to practice a patent or covenant not to
495+sue for patent infringement). To "grant" such a patent license to a
496+party means to make such an agreement or commitment not to enforce a
497+patent against the party.
498+
499+ If you convey a covered work, knowingly relying on a patent license,
500+and the Corresponding Source of the work is not available for anyone
501+to copy, free of charge and under the terms of this License, through a
502+publicly available network server or other readily accessible means,
503+then you must either (1) cause the Corresponding Source to be so
504+available, or (2) arrange to deprive yourself of the benefit of the
505+patent license for this particular work, or (3) arrange, in a manner
506+consistent with the requirements of this License, to extend the patent
507+license to downstream recipients. "Knowingly relying" means you have
508+actual knowledge that, but for the patent license, your conveying the
509+covered work in a country, or your recipient's use of the covered work
510+in a country, would infringe one or more identifiable patents in that
511+country that you have reason to believe are valid.
512+
513+ If, pursuant to or in connection with a single transaction or
514+arrangement, you convey, or propagate by procuring conveyance of, a
515+covered work, and grant a patent license to some of the parties
516+receiving the covered work authorizing them to use, propagate, modify
517+or convey a specific copy of the covered work, then the patent license
518+you grant is automatically extended to all recipients of the covered
519+work and works based on it.
520+
521+ A patent license is "discriminatory" if it does not include within
522+the scope of its coverage, prohibits the exercise of, or is
523+conditioned on the non-exercise of one or more of the rights that are
524+specifically granted under this License. You may not convey a covered
525+work if you are a party to an arrangement with a third party that is
526+in the business of distributing software, under which you make payment
527+to the third party based on the extent of your activity of conveying
528+the work, and under which the third party grants, to any of the
529+parties who would receive the covered work from you, a discriminatory
530+patent license (a) in connection with copies of the covered work
531+conveyed by you (or copies made from those copies), or (b) primarily
532+for and in connection with specific products or compilations that
533+contain the covered work, unless you entered into that arrangement,
534+or that patent license was granted, prior to 28 March 2007.
535+
536+ Nothing in this License shall be construed as excluding or limiting
537+any implied license or other defenses to infringement that may
538+otherwise be available to you under applicable patent law.
539+
540+ 12. No Surrender of Others' Freedom.
541+
542+ If conditions are imposed on you (whether by court order, agreement or
543+otherwise) that contradict the conditions of this License, they do not
544+excuse you from the conditions of this License. If you cannot convey a
545+covered work so as to satisfy simultaneously your obligations under this
546+License and any other pertinent obligations, then as a consequence you may
547+not convey it at all. For example, if you agree to terms that obligate you
548+to collect a royalty for further conveying from those to whom you convey
549+the Program, the only way you could satisfy both those terms and this
550+License would be to refrain entirely from conveying the Program.
551+
552+ 13. Use with the GNU Affero General Public License.
553+
554+ Notwithstanding any other provision of this License, you have
555+permission to link or combine any covered work with a work licensed
556+under version 3 of the GNU Affero General Public License into a single
557+combined work, and to convey the resulting work. The terms of this
558+License will continue to apply to the part which is the covered work,
559+but the special requirements of the GNU Affero General Public License,
560+section 13, concerning interaction through a network will apply to the
561+combination as such.
562+
563+ 14. Revised Versions of this License.
564+
565+ The Free Software Foundation may publish revised and/or new versions of
566+the GNU General Public License from time to time. Such new versions will
567+be similar in spirit to the present version, but may differ in detail to
568+address new problems or concerns.
569+
570+ Each version is given a distinguishing version number. If the
571+Program specifies that a certain numbered version of the GNU General
572+Public License "or any later version" applies to it, you have the
573+option of following the terms and conditions either of that numbered
574+version or of any later version published by the Free Software
575+Foundation. If the Program does not specify a version number of the
576+GNU General Public License, you may choose any version ever published
577+by the Free Software Foundation.
578+
579+ If the Program specifies that a proxy can decide which future
580+versions of the GNU General Public License can be used, that proxy's
581+public statement of acceptance of a version permanently authorizes you
582+to choose that version for the Program.
583+
584+ Later license versions may give you additional or different
585+permissions. However, no additional obligations are imposed on any
586+author or copyright holder as a result of your choosing to follow a
587+later version.
588+
589+ 15. Disclaimer of Warranty.
590+
591+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
592+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
593+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
594+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
595+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
596+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
597+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
598+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
599+
600+ 16. Limitation of Liability.
601+
602+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
603+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
604+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
605+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
606+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
607+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
608+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
609+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
610+SUCH DAMAGES.
611+
612+ 17. Interpretation of Sections 15 and 16.
613+
614+ If the disclaimer of warranty and limitation of liability provided
615+above cannot be given local legal effect according to their terms,
616+reviewing courts shall apply local law that most closely approximates
617+an absolute waiver of all civil liability in connection with the
618+Program, unless a warranty or assumption of liability accompanies a
619+copy of the Program in return for a fee.
620+
621+ END OF TERMS AND CONDITIONS
622+
623+ How to Apply These Terms to Your New Programs
624+
625+ If you develop a new program, and you want it to be of the greatest
626+possible use to the public, the best way to achieve this is to make it
627+free software which everyone can redistribute and change under these terms.
628+
629+ To do so, attach the following notices to the program. It is safest
630+to attach them to the start of each source file to most effectively
631+state the exclusion of warranty; and each file should have at least
632+the "copyright" line and a pointer to where the full notice is found.
633+
634+ <one line to give the program's name and a brief idea of what it does.>
635+ Copyright (C) <year> <name of author>
636+
637+ This program is free software: you can redistribute it and/or modify
638+ it under the terms of the GNU General Public License as published by
639+ the Free Software Foundation, either version 3 of the License, or
640+ (at your option) any later version.
641+
642+ This program is distributed in the hope that it will be useful,
643+ but WITHOUT ANY WARRANTY; without even the implied warranty of
644+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
645+ GNU General Public License for more details.
646+
647+ You should have received a copy of the GNU General Public License
648+ along with this program. If not, see <https://www.gnu.org/licenses/>.
649+
650+Also add information on how to contact you by electronic and paper mail.
651+
652+ If the program does terminal interaction, make it output a short
653+notice like this when it starts in an interactive mode:
654+
655+ <program> Copyright (C) <year> <name of author>
656+ This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
657+ This is free software, and you are welcome to redistribute it
658+ under certain conditions; type `show c' for details.
659+
660+The hypothetical commands `show w' and `show c' should show the appropriate
661+parts of the General Public License. Of course, your program's commands
662+might be different; for a GUI interface, you would use an "about box".
663+
664+ You should also get your employer (if you work as a programmer) or school,
665+if any, to sign a "copyright disclaimer" for the program, if necessary.
666+For more information on this, and how to apply and follow the GNU GPL, see
667+<https://www.gnu.org/licenses/>.
668+
669+ The GNU General Public License does not permit incorporating your program
670+into proprietary programs. If your program is a subroutine library, you
671+may consider it more useful to permit linking proprietary applications with
672+the library. If this is what you want to do, use the GNU Lesser General
673+Public License instead of this License. But first, please read
674+<https://www.gnu.org/licenses/why-not-lgpl.html>.
A Makefile +20 -0
@@ -0,0 +1,20 @@
1+.PHONY: build test vet race mocks mocks-check fakes-check check
2+
3+build:
4+ CGO_ENABLED=0 go build ./...
5+test:
6+ CGO_ENABLED=0 go test ./...
7+vet:
8+ CGO_ENABLED=0 go vet ./...
9+race:
10+ CGO_ENABLED=1 go test -race ./...
11+mocks:
12+ mockery
13+mocks-check: mocks
14+ git diff --exit-code -- internal/mocks ':(glob)internal/**/*_mock_test.go'
15+ @test -z "$$(git ls-files --others --exclude-standard -- internal/mocks ':(glob)internal/**/*_mock_test.go')"
16+fakes-check:
17+ @rc=0; grep -rniE '^\s*type\s+\w*(fake|stub|spy|dummy|mock)\w*|^\s*\w*(fake|stub|spy|dummy|mock)\w*(\[[^]]*\])?\s+(struct|interface)\b' --include='*_test.go' --exclude='*_mock_test.go' internal cmd 2>/dev/null || rc=$$?; \
18+ [ $$rc -eq 1 ] || { [ $$rc -eq 0 ] && printf '%s\n' 'handwritten test double: generate with Mockery v3' >&2; exit 1; }
19+check: build vet test fakes-check
20+ CGO_ENABLED=0 staticcheck ./...
A README.md +82 -0
@@ -0,0 +1,82 @@
1+# NeferAFK
2+
3+NeferAFK is an idle daemon for Wayland compositors. It fades the screen, locks the session, turns the outputs off and suspends the machine after configurable idle delays.
4+
5+It uses standard protocols only: `ext-idle-notify-v1`, `ext-session-lock-v1`, `wlr-output-power-management-v1` and systemd-logind. The lock screen renders with [NeferGUI](https://github.com/bnema/nefergui) (Vulkan, DMA-BUF, explicit sync).
6+
7+## Behaviour
8+
9+- Four independent actions: fade, lock, screens off and sleep. Each has its own delay, counted from the last input. Set a delay to `off` to disable the action.
10+- Input undoes the fade and turns the screens back on. It never unlocks: only a successful authentication does.
11+- When the system is about to sleep (lid, menu, `systemctl suspend`), NeferAFK holds the suspend until the lock is confirmed.
12+- If the machine cannot suspend (for example, a sleep inhibitor is active), fade, lock and screens off still run and the sleep action is skipped with a warning.
13+- Typed secrets go from the lock screen straight to an isolated authentication process. They never pass through the daemon.
14+
15+## Requirements
16+
17+- Linux with systemd-logind.
18+- A compositor that advertises `ext_idle_notifier_v1` (v2), `ext_session_lock_manager_v1`, `zwp_linux_dmabuf_v1` (v4), `wp_linux_drm_syncobj_manager_v1`, and `zwlr_output_power_manager_v1` when screens off is enabled.
19+- Password mode needs a PAM service file, `/etc/pam.d/neferafk`, for example:
20+
21+ ```
22+ auth include login
23+ ```
24+
25+## Build
26+
27+```sh
28+CGO_ENABLED=0 go build -o bin/neferafk ./cmd/neferafk
29+```
30+
31+## Usage
32+
33+```
34+neferafk run [--config FILE] run the daemon
35+neferafk lock lock now
36+neferafk status print the daemon state
37+```
38+
39+Start `neferafk run` from your compositor's startup. The daemon writes its log to `$XDG_STATE_HOME/neferafk/logs/daemon.log` (default `~/.local/state/neferafk/logs/daemon.log`).
40+
41+## Configuration
42+
43+The file is `$XDG_CONFIG_HOME/neferafk/config` (default `~/.config/neferafk/config`). It uses flat `key = value` lines, and `#` starts a comment. Changes are applied live. Two cases need a daemon restart: enabling screens off, and enabling lock or sleep when both were off at startup.
44+
45+```
46+fade.after = 6m
47+fade.duration = 2s
48+lock.after = 5m
49+screens.off-after = 7m
50+sleep.after = 20m
51+auth.mode = password
52+```
53+
54+| Key | Values | Default |
55+|---|---|---|
56+| `fade.after` | duration or `off` | `6m` |
57+| `fade.duration` | duration | `2s` |
58+| `lock.after` | duration or `off` | `5m` |
59+| `screens.off-after` | duration or `off` | `7m` |
60+| `sleep.after` | duration or `off` | `20m` |
61+| `auth.mode` | `password` or `pin` | `password` |
62+| `auth.pin-source` | `env` or `pass` | none |
63+| `auth.pin-env` | environment variable name (with `env`) | none |
64+| `auth.pin-entry` | `pass` entry (with `pass`) | none |
65+
66+Durations use Go syntax (`30s`, `5m`, `1h`), up to 24h. The order of the actions comes from their delays: for example, `lock.after = 20s` with `screens.off-after = 50s` turns the screens off 30 seconds after the lock.
67+
68+PIN mode compares a 6 to 32 digit PIN read from the daemon's environment or from `pass show ENTRY`. If the PIN source is unavailable or invalid, the lock asks for the password through PAM.
69+
70+With the defaults, the lock comes first and the fade then darkens the lock screen before the screens go off. Set `fade.after` below `lock.after` to fade before locking.
71+
72+## Development
73+
74+```sh
75+make check # build, vet, tests, handwritten-double check, staticcheck
76+make race # race tests (CGO_ENABLED=1)
77+make mocks-check # Mockery v3 mocks are up to date
78+```
79+
80+## License
81+
82+[GNU GPL v3](LICENSE).
A cmd/neferafk/main.go +199 -0
@@ -0,0 +1,199 @@
1+// Command neferafk is the NeferAFK absence daemon and its private tools.
2+package main
3+
4+import (
5+ "context"
6+ "errors"
7+ "flag"
8+ "fmt"
9+ "io"
10+ "os"
11+ "os/signal"
12+ "syscall"
13+
14+ "github.com/bnema/neferafk/internal/adapters/auth"
15+ "github.com/bnema/neferafk/internal/adapters/control"
16+ "github.com/bnema/neferafk/internal/adapters/visual"
17+ "github.com/bnema/neferafk/internal/app"
18+ "github.com/bnema/zerowrap"
19+)
20+
21+const (
22+ exitOK = 0
23+ exitFailure = 1
24+ exitUsage = 2
25+ exitUnsupported = 3
26+)
27+
28+const usage = `usage: neferafk <command> [flags]
29+
30+commands:
31+ run [--config FILE] run the absence daemon
32+ lock ask the running daemon to lock now
33+ status print the running daemon's state
34+ visual internal: fade/lock process (fd 3 commands in, fd 4 events out)
35+`
36+
37+func main() { os.Exit(execute(os.Args[1:], os.Stdout, os.Stderr)) }
38+
39+func execute(args []string, stdout, stderr io.Writer) int {
40+ if len(args) == 0 {
41+ fmt.Fprint(stderr, usage)
42+ return exitUsage
43+ }
44+ cmd, rest := args[0], args[1:]
45+ switch cmd {
46+ case "run":
47+ return runDaemon(rest, stderr)
48+ case "lock", "status":
49+ return runControl(cmd, rest, stdout, stderr)
50+ case "visual":
51+ return runVisual(rest, os.NewFile(3, "visual-cmd"), os.NewFile(4, "visual-events"), stderr)
52+ case "auth-worker": // hidden: spawned by the visual process only
53+ return runAuthWorker(rest, os.NewFile(3, "auth-in"), os.NewFile(4, "auth-out"), stderr)
54+ case "-h", "--help", "help":
55+ fmt.Fprint(stdout, usage)
56+ return exitOK
57+ }
58+ fmt.Fprintf(stderr, "neferafk: unknown command %q\n%s", cmd, usage)
59+ return exitUsage
60+}
61+
62+func runDaemon(args []string, stderr io.Writer) int {
63+ fs := flag.NewFlagSet("run", flag.ContinueOnError)
64+ fs.SetOutput(stderr)
65+ cfg := fs.String("config", "", "configuration file (default $XDG_CONFIG_HOME/neferafk/config)")
66+ if err := fs.Parse(args); err != nil {
67+ return exitUsage
68+ }
69+ if fs.NArg() != 0 {
70+ fmt.Fprintln(stderr, "neferafk run: unexpected arguments")
71+ return exitUsage
72+ }
73+ exe, err := os.Executable()
74+ if err != nil {
75+ fmt.Fprintf(stderr, "neferafk: %v\n", err)
76+ return exitFailure
77+ }
78+ ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
79+ defer stop()
80+ // Compositors usually start the daemon with stderr discarded, so it also
81+ // logs to $XDG_STATE_HOME/neferafk/logs/daemon.log (never secrets).
82+ log, closeLog, err := zerowrap.NewWithFile(zerowrap.Config{Output: stderr},
83+ zerowrap.FileConfig{Enabled: true, AppName: "neferafk", Name: "daemon", MaxSize: 5, MaxBackups: 1})
84+ if err != nil {
85+ log = zerowrap.New(zerowrap.Config{Output: stderr})
86+ closeLog = func() {}
87+ log.Warn().Str("component", "main").Err(err).Msg("file logging disabled")
88+ }
89+ defer closeLog()
90+ err = app.RunDaemon(ctx, app.DaemonOptions{ConfigPath: *cfg, Executable: exe, Log: log})
91+ var unsup *app.UnsupportedError
92+ switch {
93+ case err == nil:
94+ return exitOK
95+ case errors.As(err, &unsup):
96+ log.Error().Str("component", "main").Strs("missing", unsup.Missing).Msg("unsupported environment")
97+ fmt.Fprintf(stderr, "neferafk: %v\n", unsup)
98+ return exitUnsupported
99+ default:
100+ log.Error().Str("component", "main").Err(err).Msg("daemon failed")
101+ fmt.Fprintf(stderr, "neferafk: %v\n", err)
102+ return exitFailure
103+ }
104+}
105+
106+func runControl(cmd string, args []string, stdout, stderr io.Writer) int {
107+ if len(args) != 0 {
108+ fmt.Fprintf(stderr, "neferafk %s: unexpected arguments\n", cmd)
109+ return exitUsage
110+ }
111+ path, err := control.DefaultPath()
112+ if err != nil {
113+ fmt.Fprintf(stderr, "neferafk: %v\n", err)
114+ return exitFailure
115+ }
116+ reply, err := control.Do(context.Background(), path, cmd)
117+ if err != nil {
118+ fmt.Fprintf(stderr, "neferafk %s: %v\n", cmd, err)
119+ return exitFailure
120+ }
121+ fmt.Fprintln(stdout, reply)
122+ return exitOK
123+}
124+
125+// Descriptors the daemon hands down by exec ExtraFiles: fd 3 in, fd 4 out.
126+// Go clears close-on-exec on them in the child, so anything we spawn later
127+// (`pass`, gpg) would inherit the secret input pipe and the result output pipe
128+// and could read secrets or forge results. Mark them before doing anything.
129+const (
130+ inheritedIn = 3
131+ inheritedOut = 4
132+)
133+
134+func markInheritedCloexec() {
135+ syscall.CloseOnExec(inheritedIn)
136+ syscall.CloseOnExec(inheritedOut)
137+}
138+
139+// runAuthWorker serves one lock's authentication on the private pipes handed
140+// down by the visual process (fd 3: bootstrap + frames in, fd 4: frames out).
141+// Nothing typed ever passes through the policy daemon.
142+func runAuthWorker(args []string, in, out *os.File, stderr io.Writer) int {
143+ if len(args) != 0 || in == nil || out == nil {
144+ fmt.Fprintln(stderr, "neferafk auth-worker: internal command")
145+ return exitUsage
146+ }
147+ markInheritedCloexec() // before anything is read or spawned
148+ if err := auth.HardenProcess(); err != nil {
149+ fmt.Fprintln(stderr, "neferafk auth-worker: hardening failed")
150+ return exitFailure
151+ }
152+ ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
153+ defer stop()
154+ return serveAuth(ctx, in, out, stderr)
155+}
156+
157+// serveAuth reads the bootstrap record and runs the worker loop.
158+func serveAuth(ctx context.Context, in io.Reader, out io.Writer, stderr io.Writer) int {
159+ bootstrap, err := auth.ReadBootstrap(in)
160+ if err != nil {
161+ fmt.Fprintln(stderr, "neferafk auth-worker: invalid bootstrap")
162+ return exitFailure
163+ }
164+ if err := auth.Run(ctx, in, out, bootstrap); err != nil {
165+ fmt.Fprintln(stderr, "neferafk auth-worker: session ended without success")
166+ return exitFailure
167+ }
168+ return exitOK
169+}
170+
171+// runVisual serves the fade/lock process spawned by the daemon: commands on fd
172+// 3, lifecycle events on fd 4. An active lock outlives the daemon and SIGTERM;
173+// it ends only after a successful unlock.
174+func runVisual(args []string, in, out *os.File, stderr io.Writer) int {
175+ if len(args) != 0 || in == nil || out == nil {
176+ fmt.Fprintln(stderr, "neferafk visual: internal command")
177+ return exitUsage
178+ }
179+ markInheritedCloexec() // the auth worker is spawned with its own pipes only
180+ // The visual process holds the typed secret and the PIN bootstrap: no core
181+ // dumps and no ptrace/proc-mem access from same-uid processes.
182+ if err := auth.HardenProcess(); err != nil {
183+ fmt.Fprintln(stderr, "neferafk visual: hardening failed")
184+ return exitFailure
185+ }
186+ exe, err := visual.ExecutablePath()
187+ if err != nil {
188+ fmt.Fprintf(stderr, "neferafk visual: %v\n", err)
189+ return exitFailure
190+ }
191+ ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
192+ defer stop()
193+ log := zerowrap.New(zerowrap.Config{Output: stderr})
194+ if err := visual.Run(ctx, visual.Config{Commands: in, Events: out, Executable: exe, Log: log}); err != nil {
195+ fmt.Fprintf(stderr, "neferafk visual: %v\n", err)
196+ return exitFailure
197+ }
198+ return exitOK
199+}
A cmd/neferafk/main_test.go +160 -0
@@ -0,0 +1,160 @@
1+package main
2+
3+import (
4+ "bytes"
5+ "context"
6+ "golang.org/x/sys/unix"
7+ "os"
8+ "os/exec"
9+ "strings"
10+ "syscall"
11+ "testing"
12+
13+ "github.com/bnema/neferafk/internal/adapters/auth"
14+ "github.com/bnema/neferafk/internal/ports"
15+)
16+
17+func run(t *testing.T, args ...string) (int, string, string) {
18+ t.Helper()
19+ t.Setenv("XDG_STATE_HOME", t.TempDir()) // keep the daemon log out of the user's state
20+ var out, errb bytes.Buffer
21+ code := execute(args, &out, &errb)
22+ return code, out.String(), errb.String()
23+}
24+
25+func TestUsageAndUnknownCommands(t *testing.T) {
26+ if code, _, e := run(t); code != exitUsage || !strings.Contains(e, "usage:") {
27+ t.Fatalf("%d %q", code, e)
28+ }
29+ if code, o, _ := run(t, "help"); code != exitOK || !strings.Contains(o, "lock") {
30+ t.Fatalf("%d %q", code, o)
31+ }
32+ // There is no unlock command, and the hidden worker is not advertised.
33+ if code, o, e := run(t, "unlock"); code != exitUsage || strings.Contains(o+e, "auth-worker") || !strings.Contains(e, "unknown command") {
34+ t.Fatalf("%d %q", code, e)
35+ }
36+ if strings.Contains(usage, "unlock") || strings.Contains(usage, "auth-worker") {
37+ t.Fatal("usage advertises a forbidden or hidden command")
38+ }
39+}
40+
41+func TestVisualIsInternalAndTakesNoArguments(t *testing.T) {
42+ if code, _, e := run(t, "visual", "extra"); code != exitUsage || !strings.Contains(e, "internal command") {
43+ t.Fatalf("%d %q", code, e)
44+ }
45+}
46+
47+func TestControlClientReportsMissingDaemon(t *testing.T) {
48+ t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
49+ for _, c := range []string{"lock", "status"} {
50+ if code, _, e := run(t, c); code != exitFailure || !strings.Contains(e, "daemon not reachable") {
51+ t.Fatalf("%s: %d %q", c, code, e)
52+ }
53+ if code, _, _ := run(t, c, "extra"); code != exitUsage {
54+ t.Fatalf("%s accepted arguments", c)
55+ }
56+ }
57+ t.Setenv("XDG_RUNTIME_DIR", "")
58+ if code, _, e := run(t, "status"); code != exitFailure || !strings.Contains(e, "XDG_RUNTIME_DIR") {
59+ t.Fatalf("%d %q", code, e)
60+ }
61+}
62+
63+func TestRunReportsUnsupportedEnvironmentWithDistinctExit(t *testing.T) {
64+ t.Setenv("WAYLAND_DISPLAY", "")
65+ t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
66+ code, _, e := run(t, "run", "--config", "/nonexistent/neferafk.conf")
67+ if code != exitUnsupported || !strings.Contains(e, "unsupported environment") || !strings.Contains(e, "WAYLAND_DISPLAY") {
68+ t.Fatalf("%d %q", code, e)
69+ }
70+ if code, _, _ := run(t, "run", "extra"); code != exitUsage {
71+ t.Fatal("run accepted positional arguments")
72+ }
73+ if code, _, _ := run(t, "run", "--bogus"); code != exitUsage {
74+ t.Fatal("run accepted an unknown flag")
75+ }
76+}
77+
78+func TestRunRejectsInvalidConfig(t *testing.T) {
79+ t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
80+ path := t.TempDir() + "/config"
81+ if err := writeFile(path, "lock.after = banana\n"); err != nil {
82+ t.Fatal(err)
83+ }
84+ if code, _, e := run(t, "run", "--config", path); code != exitFailure || !strings.Contains(e, "config") {
85+ t.Fatalf("%d %q", code, e)
86+ }
87+}
88+
89+func TestServeAuthRunsWorkerOnPipes(t *testing.T) {
90+ var in, out, errb bytes.Buffer
91+ if err := auth.WriteBootstrap(&in, ports.AuthBootstrap{Generation: 3, PINSource: ports.PINSourceEnv, EnvPIN: []byte("123456")}); err != nil {
92+ t.Fatal(err)
93+ }
94+ if err := auth.WriteFrame(&in, ports.AuthFrame{Kind: ports.AuthAttempt, Generation: 3, Attempt: 1, Payload: []byte("123456")}); err != nil {
95+ t.Fatal(err)
96+ }
97+ if code := serveAuth(context.Background(), &in, &out, &errb); code != exitOK {
98+ t.Fatalf("%d %q", code, errb.String())
99+ }
100+ ready, err := auth.ReadFrame(&out)
101+ if err != nil || ready.Kind != ports.AuthReady || ready.Code != ports.AuthPIN {
102+ t.Fatalf("%+v %v", ready, err)
103+ }
104+ result, err := auth.ReadFrame(&out)
105+ if err != nil || result.Kind != ports.AuthResult || result.Code != ports.AuthSuccess {
106+ t.Fatalf("%+v %v", result, err)
107+ }
108+ // Garbage bootstrap: fail closed, no secret echoed.
109+ errb.Reset()
110+ if code := serveAuth(context.Background(), strings.NewReader("garbage-garbage"), &out, &errb); code != exitFailure || strings.Contains(errb.String(), "garbage") {
111+ t.Fatalf("%d %q", code, errb.String())
112+ }
113+}
114+
115+func writeFile(path, content string) error { return os.WriteFile(path, []byte(content), 0o600) }
116+
117+const cloexecChildEnv = "NEFERAFK_TEST_CLOEXEC_CHILD"
118+
119+// TestInheritedPipesAreCloexec re-executes the test binary with two pipes as
120+// fds 3/4 exactly like the daemon spawns the worker and visual, and checks the
121+// child first sees them inheritable, then close-on-exec after the helper.
122+func TestInheritedPipesAreCloexec(t *testing.T) {
123+ if os.Getenv(cloexecChildEnv) == "1" {
124+ flag := func(fd int) int {
125+ v, err := unix.FcntlInt(uintptr(fd), unix.F_GETFD, 0)
126+ if err != nil {
127+ t.Fatalf("fd %d: %v", fd, err)
128+ }
129+ return v & unix.FD_CLOEXEC
130+ }
131+ for _, fd := range []int{inheritedIn, inheritedOut} {
132+ if flag(fd) != 0 {
133+ t.Fatalf("fd %d already close-on-exec: the test proves nothing", fd)
134+ }
135+ }
136+ markInheritedCloexec()
137+ for _, fd := range []int{inheritedIn, inheritedOut} {
138+ if flag(fd) == 0 {
139+ t.Fatalf("fd %d not close-on-exec after markInheritedCloexec", fd)
140+ }
141+ }
142+ return
143+ }
144+ var files []*os.File
145+ for range 2 {
146+ r, w, err := os.Pipe()
147+ if err != nil {
148+ t.Fatal(err)
149+ }
150+ t.Cleanup(func() { r.Close(); w.Close() })
151+ files = append(files, r)
152+ }
153+ cmd := exec.Command(os.Args[0], "-test.run=^TestInheritedPipesAreCloexec$")
154+ cmd.Env = append(os.Environ(), cloexecChildEnv+"=1")
155+ cmd.ExtraFiles = files
156+ cmd.SysProcAttr = &syscall.SysProcAttr{}
157+ if out, err := cmd.CombinedOutput(); err != nil {
158+ t.Fatalf("child: %v\n%s", err, out)
159+ }
160+}
A go.mod +40 -0
@@ -0,0 +1,40 @@
1+module github.com/bnema/neferafk
2+
3+go 1.27
4+
5+require (
6+ github.com/bnema/nefergui v0.0.0
7+ github.com/bnema/purego-pam v0.0.0
8+ github.com/bnema/wlturbo v0.3.0
9+ github.com/bnema/zerowrap v1.4.1
10+ github.com/godbus/dbus/v5 v5.2.2
11+ github.com/stretchr/testify v1.12.1
12+ golang.org/x/sys v0.48.0
13+)
14+
15+require (
16+ github.com/bnema/purego v0.13.0-bnema.1 // indirect
17+ github.com/bnema/purego-vulkan v0.6.0 // indirect
18+ github.com/bnema/purego-xkbcommon v0.0.0-20260928071113-e79265dfcdc2 // indirect
19+ github.com/go-text/typesetting v0.3.5 // indirect
20+ github.com/mattn/go-colorable v0.1.15 // indirect
21+ github.com/mattn/go-isatty v0.0.22 // indirect
22+ github.com/rs/zerolog v1.35.1 // indirect
23+ github.com/stretchr/objx v0.5.3 // indirect
24+ go.yaml.in/yaml/v3 v3.0.5 // indirect
25+ golang.org/x/image v0.46.0 // indirect
26+ golang.org/x/text v0.42.0 // indirect
27+ gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
28+)
29+
30+replace github.com/bnema/purego-pam => ../purego-pam
31+
32+replace github.com/bnema/nefergui => ../veya/.worktrees/session-lock
33+
34+replace github.com/bnema/wlturbo => ../wlturbo/.worktrees/session-lock
35+
36+replace github.com/bnema/purego => ../purego
37+
38+replace github.com/bnema/purego-vulkan => ../purego-vulkan
39+
40+replace github.com/bnema/purego-xkbcommon => ../purego-xkbcommon/.worktrees/secret-bytes
A go.sum +26 -0 generated file: collapsed
A internal/adapters/auth/bootstrap.go +73 -0
@@ -0,0 +1,73 @@
1+package auth
2+
3+import (
4+ "encoding/binary"
5+ "io"
6+
7+ "github.com/bnema/neferafk/internal/ports"
8+)
9+
10+// The bootstrap record is the first and only non-frame traffic on the worker's
11+// stdin pipe (fd 3), written by the visual process once per lock:
12+//
13+// "NAB1" | u32 length | generation u64 | 3 x (u32 length | bytes)
14+//
15+// where the byte strings are PINSource, PINReference and EnvPIN.
16+const bootstrapMax = 8 + 3*4 + 16 + ports.AuthMaxMetadata + ports.AuthMaxSecret
17+
18+// WriteBootstrap encodes b; it wipes its encoded copy but not b.EnvPIN.
19+func WriteBootstrap(w io.Writer, b ports.AuthBootstrap) error {
20+ if b.Generation == 0 || len(b.PINSource) > 16 || len(b.PINReference) > ports.AuthMaxMetadata || len(b.EnvPIN) > ports.AuthMaxSecret {
21+ return ErrProtocol
22+ }
23+ p := make([]byte, 8, bootstrapMax+8)
24+ binary.BigEndian.PutUint64(p, b.Generation)
25+ for _, f := range [][]byte{[]byte(b.PINSource), []byte(b.PINReference), b.EnvPIN} {
26+ p = binary.BigEndian.AppendUint32(p, uint32(len(f)))
27+ p = append(p, f...)
28+ }
29+ out := append(append(make([]byte, 0, 8+len(p)), "NAB1"...), 0, 0, 0, 0)
30+ binary.BigEndian.PutUint32(out[4:], uint32(len(p)))
31+ out = append(out, p...)
32+ clear(p)
33+ defer clear(out)
34+ return writeAll(w, out)
35+}
36+
37+// ReadBootstrap decodes one record. The caller owns and wipes EnvPIN.
38+func ReadBootstrap(r io.Reader) (ports.AuthBootstrap, error) {
39+ var h [8]byte
40+ if _, err := io.ReadFull(r, h[:]); err != nil {
41+ return ports.AuthBootstrap{}, err
42+ }
43+ n := binary.BigEndian.Uint32(h[4:])
44+ if string(h[:4]) != "NAB1" || n < 8+12 || n > bootstrapMax {
45+ return ports.AuthBootstrap{}, ErrProtocol
46+ }
47+ p := make([]byte, n)
48+ defer clear(p)
49+ if _, err := io.ReadFull(r, p); err != nil {
50+ return ports.AuthBootstrap{}, err
51+ }
52+ b := ports.AuthBootstrap{Generation: binary.BigEndian.Uint64(p)}
53+ rest := p[8:]
54+ var f [3][]byte
55+ for i := range f {
56+ if len(rest) < 4 {
57+ return ports.AuthBootstrap{}, ErrProtocol
58+ }
59+ l := binary.BigEndian.Uint32(rest)
60+ if uint64(l) > uint64(len(rest)-4) {
61+ return ports.AuthBootstrap{}, ErrProtocol
62+ }
63+ f[i], rest = rest[4:4+l], rest[4+l:]
64+ }
65+ if b.Generation == 0 || len(rest) != 0 || len(f[0]) > 16 || len(f[1]) > ports.AuthMaxMetadata || len(f[2]) > ports.AuthMaxSecret {
66+ return ports.AuthBootstrap{}, ErrProtocol
67+ }
68+ b.PINSource, b.PINReference = ports.PINSource(f[0]), string(f[1])
69+ if len(f[2]) > 0 {
70+ b.EnvPIN = append([]byte(nil), f[2]...)
71+ }
72+ return b, nil
73+}
A internal/adapters/auth/bootstrap_test.go +38 -0
@@ -0,0 +1,38 @@
1+package auth
2+
3+import (
4+ "bytes"
5+ "testing"
6+
7+ "github.com/bnema/neferafk/internal/ports"
8+)
9+
10+func TestBootstrapRoundTripAndRejects(t *testing.T) {
11+ in := ports.AuthBootstrap{Generation: 7, PINSource: ports.PINSourceEnv, PINReference: "ref", EnvPIN: []byte("123456")}
12+ var buf bytes.Buffer
13+ if err := WriteBootstrap(&buf, in); err != nil {
14+ t.Fatal(err)
15+ }
16+ valid := bytes.Clone(buf.Bytes())
17+ out, err := ReadBootstrap(&buf)
18+ if err != nil || out.Generation != 7 || out.PINSource != in.PINSource || out.PINReference != "ref" || string(out.EnvPIN) != "123456" {
19+ t.Fatalf("%+v %v", out, err)
20+ }
21+ if err := WriteBootstrap(&buf, ports.AuthBootstrap{}); err == nil {
22+ t.Fatal("zero generation encoded")
23+ }
24+ for _, mutate := range []func([]byte){
25+ func(b []byte) { b[0] = 'X' },
26+ func(b []byte) { b[7]++ },
27+ func(b []byte) { b[8], b[9], b[10], b[11], b[12], b[13], b[14], b[15] = 0, 0, 0, 0, 0, 0, 0, 0 },
28+ } {
29+ bad := bytes.Clone(valid)
30+ mutate(bad)
31+ if _, err := ReadBootstrap(bytes.NewReader(bad)); err == nil {
32+ t.Fatal("corrupt bootstrap accepted")
33+ }
34+ }
35+ if _, err := ReadBootstrap(bytes.NewReader(valid[:len(valid)-1])); err == nil {
36+ t.Fatal("truncated bootstrap accepted")
37+ }
38+}
A internal/adapters/auth/codec.go +125 -0
@@ -0,0 +1,125 @@
1+package auth
2+
3+import (
4+ "encoding/binary"
5+ "errors"
6+ "io"
7+
8+ "github.com/bnema/neferafk/internal/ports"
9+)
10+
11+var ErrProtocol = errors.New("invalid authentication frame")
12+
13+const frameHeader = 22 // magic, kind, generic code, generation, attempt
14+
15+func payloadLimit(kind ports.AuthKind) int {
16+ if kind == ports.AuthAttempt || kind == ports.AuthSecret {
17+ return ports.AuthMaxSecret
18+ }
19+ return ports.AuthMaxMetadata
20+}
21+
22+func validRole(f ports.AuthFrame, n int) bool {
23+ if f.Generation == 0 || n < 0 || n > payloadLimit(f.Kind) {
24+ return false
25+ }
26+ if f.Kind == ports.AuthReady {
27+ if f.Attempt != 0 {
28+ return false
29+ }
30+ switch f.Code {
31+ case ports.AuthPassword, ports.AuthPIN, ports.AuthFallbackPassword:
32+ return n == 0
33+ case ports.AuthUnavailable:
34+ return n == 0 || n == 1
35+ default:
36+ return false
37+ }
38+ }
39+ if f.Attempt == 0 {
40+ return false
41+ }
42+ switch f.Kind {
43+ case ports.AuthAttempt, ports.AuthSecret:
44+ return f.Code == 0
45+ case ports.AuthPrompt, ports.AuthStatus:
46+ return f.Code == ports.AuthPassword && n == 0
47+ case ports.AuthResult:
48+ return n == 0 && (f.Code == ports.AuthSuccess || f.Code == ports.AuthDenied || f.Code == ports.AuthUnavailable || f.Code == ports.AuthInvalid)
49+ case ports.AuthCancel:
50+ return f.Code == ports.AuthInvalid && n == 0
51+ default:
52+ return false
53+ }
54+}
55+
56+func validFrame(f ports.AuthFrame) bool {
57+ if !validRole(f, len(f.Payload)) {
58+ return false
59+ }
60+ return len(f.Payload) != 1 || f.Kind != ports.AuthReady || f.Payload[0] == byte(ports.AuthFallbackToPassword)
61+}
62+
63+// ReadFrame validates the fixed header before allocating any bounded payload.
64+// Partial reads wipe the allocated payload; no error includes input bytes.
65+func ReadFrame(r io.Reader) (ports.AuthFrame, error) {
66+ var prefix [4]byte
67+ if _, err := io.ReadFull(r, prefix[:]); err != nil {
68+ return ports.AuthFrame{}, err
69+ }
70+ n := int(binary.BigEndian.Uint32(prefix[:]))
71+ if n < frameHeader || n > frameHeader+ports.AuthMaxMetadata {
72+ return ports.AuthFrame{}, ErrProtocol
73+ }
74+ var h [frameHeader]byte
75+ if _, err := io.ReadFull(r, h[:]); err != nil {
76+ return ports.AuthFrame{}, err
77+ }
78+ f := ports.AuthFrame{Kind: ports.AuthKind(h[4]), Code: ports.AuthCode(h[5]), Generation: binary.BigEndian.Uint64(h[6:14]), Attempt: binary.BigEndian.Uint64(h[14:22])}
79+ if string(h[:4]) != "NAW1" || !validRole(f, n-frameHeader) {
80+ return ports.AuthFrame{}, ErrProtocol
81+ }
82+ f.Payload = make([]byte, n-frameHeader)
83+ if _, err := io.ReadFull(r, f.Payload); err != nil {
84+ clear(f.Payload)
85+ return ports.AuthFrame{}, err
86+ }
87+ if !validFrame(f) {
88+ clear(f.Payload)
89+ return ports.AuthFrame{}, ErrProtocol
90+ }
91+ return f, nil
92+}
93+
94+func WriteFrame(w io.Writer, f ports.AuthFrame) error {
95+ if !validFrame(f) {
96+ return ErrProtocol
97+ }
98+ var h [4 + frameHeader]byte
99+ binary.BigEndian.PutUint32(h[:4], uint32(frameHeader+len(f.Payload)))
100+ copy(h[4:8], "NAW1")
101+ h[8], h[9] = byte(f.Kind), byte(f.Code)
102+ binary.BigEndian.PutUint64(h[10:18], f.Generation)
103+ binary.BigEndian.PutUint64(h[18:26], f.Attempt)
104+ if err := writeAll(w, h[:]); err != nil {
105+ return err
106+ }
107+ return writeAll(w, f.Payload)
108+}
109+
110+func writeAll(w io.Writer, b []byte) error {
111+ for len(b) != 0 {
112+ n, err := w.Write(b)
113+ if n < 0 || n > len(b) {
114+ return ErrProtocol
115+ }
116+ b = b[n:]
117+ if err != nil {
118+ return err
119+ }
120+ if n == 0 {
121+ return io.ErrShortWrite
122+ }
123+ }
124+ return nil
125+}
A internal/adapters/auth/codec_test.go +52 -0
@@ -0,0 +1,52 @@
1+package auth
2+
3+import (
4+ "bytes"
5+ "encoding/binary"
6+ "errors"
7+ "io"
8+ "testing"
9+
10+ "github.com/bnema/neferafk/internal/ports"
11+)
12+
13+func TestCodecBoundsAndTruncation(t *testing.T) {
14+ f := ports.AuthFrame{Kind: ports.AuthSecret, Generation: 7, Attempt: ^uint64(0), Payload: bytes.Repeat([]byte{'x'}, 512)}
15+ var wire bytes.Buffer
16+ if err := WriteFrame(&wire, f); err != nil {
17+ t.Fatal(err)
18+ }
19+ encoded := bytes.Clone(wire.Bytes())
20+ got, err := ReadFrame(&wire)
21+ if err != nil || got.Generation != 7 || got.Attempt != f.Attempt || !bytes.Equal(got.Payload, f.Payload) {
22+ t.Fatalf("roundtrip %v", err)
23+ }
24+ clear(got.Payload)
25+ for _, n := range []int{1, 4, 10, 26, len(encoded) - 1} {
26+ if _, err := ReadFrame(bytes.NewReader(encoded[:n])); err == nil {
27+ t.Fatalf("truncation %d accepted", n)
28+ }
29+ }
30+ for _, corrupt := range []string{"magic", "kind", "generation", "secret-length", "huge"} {
31+ b := bytes.Clone(encoded)
32+ switch corrupt {
33+ case "magic":
34+ b[4] = 'X'
35+ case "kind":
36+ b[8] = 255
37+ case "generation":
38+ clear(b[10:18])
39+ case "secret-length":
40+ binary.BigEndian.PutUint32(b[:4], frameHeader+513)
41+ case "huge":
42+ binary.BigEndian.PutUint32(b[:4], ^uint32(0))
43+ }
44+ if _, err := ReadFrame(bytes.NewReader(b)); !errors.Is(err, ErrProtocol) {
45+ t.Fatalf("%s error %v", corrupt, err)
46+ }
47+ }
48+ f.Payload = append(f.Payload, 1)
49+ if err := WriteFrame(io.Discard, f); !errors.Is(err, ErrProtocol) {
50+ t.Fatal("oversize secret accepted")
51+ }
52+}
A internal/adapters/auth/pam_mock_test.go +342 -0
@@ -0,0 +1,342 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package auth
6+
7+import (
8+ "time"
9+
10+ "github.com/bnema/purego-pam"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// newMockpamTransaction creates a new instance of mockpamTransaction. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func newMockpamTransaction(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *mockpamTransaction {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &mockpamTransaction{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// mockpamTransaction is an autogenerated mock type for the pamTransaction type
38+type mockpamTransaction struct {
39+ mock.Mock
40+}
41+
42+type mockpamTransaction_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *mockpamTransaction) EXPECT() *mockpamTransaction_Expecter {
47+ return &mockpamTransaction_Expecter{mock: &_m.Mock}
48+}
49+
50+// Authenticate provides a mock function for the type mockpamTransaction
51+func (_mock *mockpamTransaction) Authenticate(flags pam.Flags) error {
52+ ret := _mock.Called(flags)
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for Authenticate")
56+ }
57+
58+ var r0 error
59+ if returnFunc, ok := ret.Get(0).(func(pam.Flags) error); ok {
60+ r0 = returnFunc(flags)
61+ } else {
62+ r0 = ret.Error(0)
63+ }
64+ return r0
65+}
66+
67+// mockpamTransaction_Authenticate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Authenticate'
68+type mockpamTransaction_Authenticate_Call struct {
69+ *mock.Call
70+}
71+
72+// Authenticate is a helper method to define mock.On call
73+// - flags pam.Flags
74+func (_e *mockpamTransaction_Expecter) Authenticate(flags any) *mockpamTransaction_Authenticate_Call {
75+ return &mockpamTransaction_Authenticate_Call{Call: _e.mock.On("Authenticate", flags)}
76+}
77+
78+func (_c *mockpamTransaction_Authenticate_Call) Run(run func(flags pam.Flags)) *mockpamTransaction_Authenticate_Call {
79+ _c.Call.Run(func(args mock.Arguments) {
80+ var arg0 pam.Flags
81+ if args[0] != nil {
82+ arg0 = args[0].(pam.Flags)
83+ }
84+ run(
85+ arg0,
86+ )
87+ })
88+ return _c
89+}
90+
91+func (_c *mockpamTransaction_Authenticate_Call) Return(err error) *mockpamTransaction_Authenticate_Call {
92+ _c.Call.Return(err)
93+ return _c
94+}
95+
96+func (_c *mockpamTransaction_Authenticate_Call) RunAndReturn(run func(flags pam.Flags) error) *mockpamTransaction_Authenticate_Call {
97+ _c.Call.Return(run)
98+ return _c
99+}
100+
101+// End provides a mock function for the type mockpamTransaction
102+func (_mock *mockpamTransaction) End() error {
103+ ret := _mock.Called()
104+
105+ if len(ret) == 0 {
106+ panic("no return value specified for End")
107+ }
108+
109+ var r0 error
110+ if returnFunc, ok := ret.Get(0).(func() error); ok {
111+ r0 = returnFunc()
112+ } else {
113+ r0 = ret.Error(0)
114+ }
115+ return r0
116+}
117+
118+// mockpamTransaction_End_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'End'
119+type mockpamTransaction_End_Call struct {
120+ *mock.Call
121+}
122+
123+// End is a helper method to define mock.On call
124+func (_e *mockpamTransaction_Expecter) End() *mockpamTransaction_End_Call {
125+ return &mockpamTransaction_End_Call{Call: _e.mock.On("End")}
126+}
127+
128+func (_c *mockpamTransaction_End_Call) Run(run func()) *mockpamTransaction_End_Call {
129+ _c.Call.Run(func(args mock.Arguments) {
130+ run()
131+ })
132+ return _c
133+}
134+
135+func (_c *mockpamTransaction_End_Call) Return(err error) *mockpamTransaction_End_Call {
136+ _c.Call.Return(err)
137+ return _c
138+}
139+
140+func (_c *mockpamTransaction_End_Call) RunAndReturn(run func() error) *mockpamTransaction_End_Call {
141+ _c.Call.Return(run)
142+ return _c
143+}
144+
145+// newMockpamBackend creates a new instance of mockpamBackend. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
146+// The first argument is typically a *testing.T value.
147+func newMockpamBackend(t interface {
148+ mock.TestingT
149+ Cleanup(func())
150+}) *mockpamBackend {
151+ if helper, ok := t.(interface{ Helper() }); ok {
152+ helper.Helper()
153+ }
154+
155+ mock := &mockpamBackend{}
156+ mock.Mock.Test(t)
157+
158+ t.Cleanup(func() {
159+ if helper, ok := t.(interface{ Helper() }); ok {
160+ helper.Helper()
161+ }
162+ mock.AssertExpectations(t)
163+ })
164+
165+ return mock
166+}
167+
168+// mockpamBackend is an autogenerated mock type for the pamBackend type
169+type mockpamBackend struct {
170+ mock.Mock
171+}
172+
173+type mockpamBackend_Expecter struct {
174+ mock *mock.Mock
175+}
176+
177+func (_m *mockpamBackend) EXPECT() *mockpamBackend_Expecter {
178+ return &mockpamBackend_Expecter{mock: &_m.Mock}
179+}
180+
181+// start provides a mock function for the type mockpamBackend
182+func (_mock *mockpamBackend) start(service string, user string, conversation pam.Conversation) (pamTransaction, error) {
183+ ret := _mock.Called(service, user, conversation)
184+
185+ if len(ret) == 0 {
186+ panic("no return value specified for start")
187+ }
188+
189+ var r0 pamTransaction
190+ var r1 error
191+ if returnFunc, ok := ret.Get(0).(func(string, string, pam.Conversation) (pamTransaction, error)); ok {
192+ return returnFunc(service, user, conversation)
193+ }
194+ if returnFunc, ok := ret.Get(0).(func(string, string, pam.Conversation) pamTransaction); ok {
195+ r0 = returnFunc(service, user, conversation)
196+ } else {
197+ if ret.Get(0) != nil {
198+ r0 = ret.Get(0).(pamTransaction)
199+ }
200+ }
201+ if returnFunc, ok := ret.Get(1).(func(string, string, pam.Conversation) error); ok {
202+ r1 = returnFunc(service, user, conversation)
203+ } else {
204+ r1 = ret.Error(1)
205+ }
206+ return r0, r1
207+}
208+
209+// mockpamBackend_start_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'start'
210+type mockpamBackend_start_Call struct {
211+ *mock.Call
212+}
213+
214+// start is a helper method to define mock.On call
215+// - service string
216+// - user string
217+// - conversation pam.Conversation
218+func (_e *mockpamBackend_Expecter) start(service any, user any, conversation any) *mockpamBackend_start_Call {
219+ return &mockpamBackend_start_Call{Call: _e.mock.On("start", service, user, conversation)}
220+}
221+
222+func (_c *mockpamBackend_start_Call) Run(run func(service string, user string, conversation pam.Conversation)) *mockpamBackend_start_Call {
223+ _c.Call.Run(func(args mock.Arguments) {
224+ var arg0 string
225+ if args[0] != nil {
226+ arg0 = args[0].(string)
227+ }
228+ var arg1 string
229+ if args[1] != nil {
230+ arg1 = args[1].(string)
231+ }
232+ var arg2 pam.Conversation
233+ if args[2] != nil {
234+ arg2 = args[2].(pam.Conversation)
235+ }
236+ run(
237+ arg0,
238+ arg1,
239+ arg2,
240+ )
241+ })
242+ return _c
243+}
244+
245+func (_c *mockpamBackend_start_Call) Return(pamTransactionMoqParam pamTransaction, err error) *mockpamBackend_start_Call {
246+ _c.Call.Return(pamTransactionMoqParam, err)
247+ return _c
248+}
249+
250+func (_c *mockpamBackend_start_Call) RunAndReturn(run func(service string, user string, conversation pam.Conversation) (pamTransaction, error)) *mockpamBackend_start_Call {
251+ _c.Call.Return(run)
252+ return _c
253+}
254+
255+// newMockdelayClock creates a new instance of mockdelayClock. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
256+// The first argument is typically a *testing.T value.
257+func newMockdelayClock(t interface {
258+ mock.TestingT
259+ Cleanup(func())
260+}) *mockdelayClock {
261+ if helper, ok := t.(interface{ Helper() }); ok {
262+ helper.Helper()
263+ }
264+
265+ mock := &mockdelayClock{}
266+ mock.Mock.Test(t)
267+
268+ t.Cleanup(func() {
269+ if helper, ok := t.(interface{ Helper() }); ok {
270+ helper.Helper()
271+ }
272+ mock.AssertExpectations(t)
273+ })
274+
275+ return mock
276+}
277+
278+// mockdelayClock is an autogenerated mock type for the delayClock type
279+type mockdelayClock struct {
280+ mock.Mock
281+}
282+
283+type mockdelayClock_Expecter struct {
284+ mock *mock.Mock
285+}
286+
287+func (_m *mockdelayClock) EXPECT() *mockdelayClock_Expecter {
288+ return &mockdelayClock_Expecter{mock: &_m.Mock}
289+}
290+
291+// after provides a mock function for the type mockdelayClock
292+func (_mock *mockdelayClock) after(d time.Duration) <-chan time.Time {
293+ ret := _mock.Called(d)
294+
295+ if len(ret) == 0 {
296+ panic("no return value specified for after")
297+ }
298+
299+ var r0 <-chan time.Time
300+ if returnFunc, ok := ret.Get(0).(func(time.Duration) <-chan time.Time); ok {
301+ r0 = returnFunc(d)
302+ } else {
303+ if ret.Get(0) != nil {
304+ r0 = ret.Get(0).(<-chan time.Time)
305+ }
306+ }
307+ return r0
308+}
309+
310+// mockdelayClock_after_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'after'
311+type mockdelayClock_after_Call struct {
312+ *mock.Call
313+}
314+
315+// after is a helper method to define mock.On call
316+// - d time.Duration
317+func (_e *mockdelayClock_Expecter) after(d any) *mockdelayClock_after_Call {
318+ return &mockdelayClock_after_Call{Call: _e.mock.On("after", d)}
319+}
320+
321+func (_c *mockdelayClock_after_Call) Run(run func(d time.Duration)) *mockdelayClock_after_Call {
322+ _c.Call.Run(func(args mock.Arguments) {
323+ var arg0 time.Duration
324+ if args[0] != nil {
325+ arg0 = args[0].(time.Duration)
326+ }
327+ run(
328+ arg0,
329+ )
330+ })
331+ return _c
332+}
333+
334+func (_c *mockdelayClock_after_Call) Return(timeCh <-chan time.Time) *mockdelayClock_after_Call {
335+ _c.Call.Return(timeCh)
336+ return _c
337+}
338+
339+func (_c *mockdelayClock_after_Call) RunAndReturn(run func(d time.Duration) <-chan time.Time) *mockdelayClock_after_Call {
340+ _c.Call.Return(run)
341+ return _c
342+}
A internal/adapters/auth/review_test.go +114 -0
@@ -0,0 +1,114 @@
1+package auth
2+
3+import (
4+ "bytes"
5+ "errors"
6+ "io"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/neferafk/internal/ports"
11+ pam "github.com/bnema/purego-pam"
12+)
13+
14+func TestFallbackReadyIsExplicitEvenWithoutPAM(t *testing.T) {
15+ for _, available := range []bool{false, true} {
16+ var output bytes.Buffer
17+ var backend pamBackend
18+ if available {
19+ backend = newMockpamBackend(t)
20+ }
21+ if err := runScoped(bytes.NewReader(nil), &output, 3, nil, backend, "user", true); !errors.Is(err, io.EOF) {
22+ t.Fatal(err)
23+ }
24+ f, err := ReadFrame(&output)
25+ if err != nil {
26+ t.Fatal(err)
27+ }
28+ if available {
29+ if f.Code != ports.AuthFallbackPassword || len(f.Payload) != 0 {
30+ t.Fatalf("fallback ready %+v", f)
31+ }
32+ } else if f.Code != ports.AuthUnavailable || !bytes.Equal(f.Payload, []byte{byte(ports.AuthFallbackToPassword)}) {
33+ t.Fatalf("fallback unavailable %+v", f)
34+ }
35+ }
36+}
37+
38+func TestEchoOnNeverReceivesPresubmittedCredential(t *testing.T) {
39+ var input, output bytes.Buffer
40+ if err := WriteFrame(&input, ports.AuthFrame{Kind: ports.AuthSecret, Generation: 3, Attempt: 9, Payload: []byte("explicit-response")}); err != nil {
41+ t.Fatal(err)
42+ }
43+ w := &worker{r: &input, w: &output, generation: 3, attempt: 9, deadline: time.Now().Add(time.Second), first: true, secretLen: 8, secret: make([]byte, 512)}
44+ copy(w.secret, "password")
45+ answer := make([]byte, 512)
46+ n, err := w.Respond(pam.Message{Style: pam.PromptEchoOn}, answer)
47+ if err != nil || string(answer[:n]) != "explicit-response" || !w.first {
48+ t.Fatal("echo-on leaked or consumed initial password")
49+ }
50+ clear(answer)
51+ n, err = w.Respond(pam.Message{Style: pam.PromptEchoOff}, answer)
52+ if err != nil || string(answer[:n]) != "password" || w.first || !bytes.Equal(w.secret, make([]byte, 512)) {
53+ t.Fatal("echo-off did not consume and wipe initial credential")
54+ }
55+ f, err := ReadFrame(&output)
56+ if err != nil || f.Kind != ports.AuthPrompt || len(f.Payload) != 0 {
57+ t.Fatal("echo-on did not emit generic prompt")
58+ }
59+}
60+
61+func TestCodecRejectsContradictoryRoles(t *testing.T) {
62+ frames := []ports.AuthFrame{
63+ {Kind: ports.AuthReady, Generation: 1},
64+ {Kind: ports.AuthResult, Generation: 1, Attempt: 1},
65+ {Kind: ports.AuthStatus, Generation: 1, Attempt: 1},
66+ {Kind: ports.AuthResult, Code: ports.AuthPIN, Generation: 1, Attempt: 1},
67+ {Kind: ports.AuthResult, Code: ports.AuthSuccess, Generation: 1, Attempt: 1, Payload: []byte("raw")},
68+ {Kind: ports.AuthReady, Code: ports.AuthUnavailable, Generation: 1, Payload: []byte{99}},
69+ {Kind: ports.AuthReady, Code: ports.AuthPassword, Generation: 1, Attempt: 1},
70+ {Kind: ports.AuthAttempt, Code: ports.AuthSuccess, Generation: 1, Attempt: 1},
71+ {Kind: ports.AuthSecret, Generation: 1},
72+ }
73+ for _, f := range frames {
74+ if err := WriteFrame(io.Discard, f); !errors.Is(err, ErrProtocol) {
75+ t.Fatalf("contradictory role accepted: kind=%d code=%d", f.Kind, f.Code)
76+ }
77+ }
78+}
79+
80+func TestStaleGenerationSecretNeverProducesResult(t *testing.T) {
81+ var input, output bytes.Buffer
82+ f := ports.AuthFrame{Kind: ports.AuthAttempt, Generation: 2, Attempt: 1, Payload: []byte("synthetic")}
83+ if err := WriteFrame(&input, f); err != nil {
84+ t.Fatal(err)
85+ }
86+ if err := run(&input, &output, 3, nil, nil, ""); !errors.Is(err, ErrProtocol) {
87+ t.Fatalf("stale generation accepted: %v", err)
88+ }
89+ ready, err := ReadFrame(&output)
90+ if err != nil || ready.Kind != ports.AuthReady {
91+ t.Fatal("missing readiness")
92+ }
93+ if _, err := ReadFrame(&output); !errors.Is(err, io.EOF) {
94+ t.Fatal("stale secret produced result")
95+ }
96+}
97+
98+func TestDedicatedSecretPageCapacityAndRetirement(t *testing.T) {
99+ for _, size := range []int{32, 512} {
100+ b, err := newSecret(size)
101+ if err != nil {
102+ t.Fatal(err)
103+ }
104+ if len(b.data) != size || cap(b.data) != size || len(b.page) < size {
105+ t.Fatal("secret capacity not bounded")
106+ }
107+ copy(b.data, "synthetic")
108+ b.close()
109+ if b.data != nil || b.page != nil {
110+ t.Fatal("retired native memory still accessible")
111+ }
112+ b.close()
113+ }
114+}
A internal/adapters/auth/secret_linux.go +35 -0
@@ -0,0 +1,35 @@
1+package auth
2+
3+import "golang.org/x/sys/unix"
4+
5+// secretBuffer owns a dedicated anonymous page, not a Go heap page shared with
6+// GC/runtime objects. The logical capacity is fixed (PIN 32, credential 512).
7+// mlock is best effort; no promise covers transient codec/PAM bridge copies.
8+type secretBuffer struct {
9+ page []byte
10+ data []byte
11+ locked bool
12+}
13+
14+func newSecret(size int) (*secretBuffer, error) {
15+ if size < 1 || size > 512 {
16+ return nil, ErrProtocol
17+ }
18+ page, err := unix.Mmap(-1, 0, unix.Getpagesize(), unix.PROT_READ|unix.PROT_WRITE, unix.MAP_PRIVATE|unix.MAP_ANON)
19+ if err != nil {
20+ return nil, errSource
21+ }
22+ return &secretBuffer{page: page, data: page[:size:size], locked: unix.Mlock(page) == nil}, nil
23+}
24+
25+func (b *secretBuffer) close() {
26+ if b == nil || b.page == nil {
27+ return
28+ }
29+ clear(b.page)
30+ if b.locked {
31+ unix.Munlock(b.page)
32+ }
33+ unix.Munmap(b.page)
34+ b.page, b.data = nil, nil
35+}
A internal/adapters/auth/source_linux.go +120 -0
@@ -0,0 +1,120 @@
1+package auth
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "os"
8+ "os/exec"
9+ "strings"
10+ "syscall"
11+ "time"
12+
13+ "github.com/bnema/neferafk/internal/ports"
14+)
15+
16+const sourceLimit = 4096
17+
18+var errSource = errors.New("PIN source unavailable")
19+
20+func validPIN(b []byte) bool {
21+ if len(b) < 6 || len(b) > 32 {
22+ return false
23+ }
24+ for _, v := range b {
25+ if v < '0' || v > '9' {
26+ return false
27+ }
28+ }
29+ return true
30+}
31+
32+func passEntry(entry string) bool {
33+ if entry == "" || len(entry) > ports.AuthMaxMetadata || strings.HasPrefix(entry, "-") || strings.HasPrefix(entry, "/") || strings.ContainsAny(entry, "\\\x00\r\n") {
34+ return false
35+ }
36+ for _, part := range strings.Split(entry, "/") {
37+ if part == "" || part == "." || part == ".." {
38+ return false
39+ }
40+ }
41+ return true
42+}
43+
44+// fixedOutput bounds stdout without retaining raw errors or growing buffers.
45+type fixedOutput struct {
46+ data [sourceLimit]byte
47+ n int
48+}
49+
50+func (b *fixedOutput) Write(p []byte) (int, error) {
51+ if len(p) > len(b.data)-b.n {
52+ return 0, errSource
53+ }
54+ copy(b.data[b.n:], p)
55+ b.n += len(p)
56+ return len(p), nil
57+}
58+
59+func passEnvironment() []string {
60+ var env []string
61+ for _, key := range []string{"HOME", "PATH", "GNUPGHOME", "PASSWORD_STORE_DIR"} {
62+ if v, ok := os.LookupEnv(key); ok {
63+ env = append(env, key+"="+v)
64+ }
65+ }
66+ // Installed pass uses PASSWORD_STORE_GPG_OPTS; prevent TTY/pinentry and
67+ // extension execution. No startup PIN or typed secret enters this env.
68+ return append(env, "PASSWORD_STORE_GPG_OPTS=--batch --no-tty --pinentry-mode error", "PASSWORD_STORE_ENABLE_EXTENSIONS=false", "GPG_TTY=", "LC_ALL=C")
69+}
70+
71+func readPass(ctx context.Context, entry string) ([]byte, error) {
72+ if !passEntry(entry) {
73+ return nil, errSource
74+ }
75+ return commandPIN(ctx, "pass", []string{"show", entry}, passEnvironment())
76+}
77+
78+// commandPIN owns only the bounded source subprocess, never a shell command
79+// assembled from metadata. Tests use a generic child, not a real pass store.
80+func commandPIN(ctx context.Context, executable string, args, env []string) ([]byte, error) {
81+ ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
82+ defer cancel()
83+ cmd := exec.CommandContext(ctx, executable, args...)
84+ cmd.Env = env
85+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
86+ // Kill the complete source group, then Run's Wait reaps the child.
87+ cmd.Cancel = func() error { return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) }
88+ cmd.WaitDelay = 100 * time.Millisecond
89+ var out fixedOutput
90+ cmd.Stdout = &out
91+ defer clear(out.data[:])
92+ if err := cmd.Run(); err != nil {
93+ return nil, errSource
94+ }
95+ line := out.data[:out.n]
96+ if i := bytes.IndexByte(line, '\n'); i >= 0 {
97+ line = line[:i]
98+ }
99+ if !validPIN(line) {
100+ return nil, errSource
101+ }
102+ return bytes.Clone(line), nil
103+}
104+
105+// ResolvePIN is called once per lock, not once per attempt. The caller owns and
106+// must wipe the returned mutable slice. Invalid/unavailable source means PAM.
107+func ResolvePIN(ctx context.Context, bootstrap ports.AuthBootstrap) []byte {
108+ switch bootstrap.PINSource {
109+ case ports.PINSourceEnv:
110+ if validPIN(bootstrap.EnvPIN) {
111+ return bytes.Clone(bootstrap.EnvPIN)
112+ }
113+ case ports.PINSourcePass:
114+ pin, err := readPass(ctx, bootstrap.PINReference)
115+ if err == nil {
116+ return pin
117+ }
118+ }
119+ return nil
120+}
A internal/adapters/auth/source_test.go +64 -0
@@ -0,0 +1,64 @@
1+package auth
2+
3+import (
4+ "bytes"
5+ "context"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/neferafk/internal/ports"
10+)
11+
12+func TestPINSourceValidation(t *testing.T) {
13+ for _, input := range []string{"12345", "123456\n", "12345a", " 123456", "123456 ", "", "123456\r", "123456789012345678901234567890123"} {
14+ if pin := ResolvePIN(context.Background(), ports.AuthBootstrap{PINSource: "env", EnvPIN: []byte(input)}); pin != nil {
15+ t.Fatal("invalid env source accepted")
16+ }
17+ }
18+ in := []byte("123456")
19+ pin := ResolvePIN(context.Background(), ports.AuthBootstrap{PINSource: "env", EnvPIN: in})
20+ if !bytes.Equal(pin, in) {
21+ t.Fatal("valid env source rejected")
22+ }
23+ clear(in)
24+ if string(pin) != "123456" {
25+ t.Fatal("resolved PIN aliases bootstrap")
26+ }
27+ clear(pin)
28+ for _, entry := range []string{"", "-clip", "/absolute", "a/../b", "a//b", "a/./b", "a\\b", "a\x00b"} {
29+ if passEntry(entry) {
30+ t.Fatal("unsafe pass entry accepted")
31+ }
32+ }
33+ if !passEntry("locker/pin") {
34+ t.Fatal("valid pass reference rejected")
35+ }
36+ var out fixedOutput
37+ if _, err := out.Write(make([]byte, sourceLimit)); err != nil {
38+ t.Fatal(err)
39+ }
40+ if _, err := out.Write([]byte{1}); err == nil || out.n != sourceLimit {
41+ t.Fatal("source output cap not enforced")
42+ }
43+ clear(out.data[:])
44+}
45+
46+func TestSourceGenericChildBoundsAndTimeout(t *testing.T) {
47+ pin, err := commandPIN(context.Background(), "/bin/sh", []string{"-c", "printf '123456\\nmetadata\\n'"}, []string{"PATH=/usr/bin:/bin"})
48+ if err != nil || string(pin) != "123456" {
49+ t.Fatalf("first line source: %v", err)
50+ }
51+ clear(pin)
52+ if _, err := commandPIN(context.Background(), "/bin/sh", []string{"-c", "head -c 5000 /dev/zero"}, []string{"PATH=/usr/bin:/bin"}); err == nil {
53+ t.Fatal("oversize source accepted")
54+ }
55+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
56+ defer cancel()
57+ start := time.Now()
58+ if _, err := commandPIN(ctx, "/bin/sh", []string{"-c", "sleep 10 & wait"}, []string{"PATH=/usr/bin:/bin"}); err == nil {
59+ t.Fatal("timed-out source accepted")
60+ }
61+ if time.Since(start) > time.Second {
62+ t.Fatal("source group cancellation did not bound Wait")
63+ }
64+}
A internal/adapters/auth/worker_linux.go +343 -0
@@ -0,0 +1,343 @@
1+package auth
2+
3+import (
4+ "context"
5+ "crypto/subtle"
6+ "io"
7+ "os"
8+ "os/user"
9+ "strconv"
10+ "time"
11+
12+ "github.com/bnema/neferafk/internal/ports"
13+ pam "github.com/bnema/purego-pam"
14+ "golang.org/x/sys/unix"
15+)
16+
17+// These seams cover only managed PAM calls; no native credentials in tests.
18+type pamTransaction interface {
19+ Authenticate(pam.Flags) error
20+ End() error
21+}
22+type pamBackend interface {
23+ start(service, user string, conversation pam.Conversation) (pamTransaction, error)
24+}
25+type managedPAM struct{ library *pam.Library }
26+
27+func (p managedPAM) start(service, user string, conversation pam.Conversation) (pamTransaction, error) {
28+ return p.library.StartConversation(service, user, conversation)
29+}
30+
31+func authenticate(backend pamBackend, name string, conversation pam.Conversation) ports.AuthCode {
32+ t, err := backend.start("neferafk", name, conversation)
33+ if err != nil || t == nil {
34+ return ports.AuthUnavailable
35+ }
36+ result := ports.AuthDenied
37+ // Unlocking re-verifies the logged-in user; like other screen lockers it
38+ // runs only the auth stack. The service file needs no account section.
39+ if t.Authenticate(pam.DisallowNullAuthtok) == nil {
40+ result = ports.AuthSuccess
41+ }
42+ if t.End() != nil {
43+ result = ports.AuthDenied
44+ }
45+ return result
46+}
47+
48+// HardenProcess disables core dumps and ptrace/dump access for the calling
49+// process. It changes process-wide state: call it only at the entrypoint of a
50+// process dedicated to a secret-handling role (the isolated auth worker and the
51+// visual process, which holds the typed secret and PIN bootstrap), never in the
52+// policy daemon. PR_SET_DUMPABLE is reset by execve, and children spawned later
53+// inherit RLIMIT_CORE=0 only. CLI spawning remains external.
54+func HardenProcess() error {
55+ if err := unix.Prctl(unix.PR_SET_DUMPABLE, 0, 0, 0, 0); err != nil {
56+ return errSource
57+ }
58+ if err := unix.Setrlimit(unix.RLIMIT_CORE, &unix.Rlimit{}); err != nil {
59+ return errSource
60+ }
61+ return nil
62+}
63+
64+// MaxDenyDelay caps the pause applied before answering a wrong PIN.
65+const MaxDenyDelay = 30 * time.Second
66+
67+// denyDelay is the pause before answering the nth consecutive wrong PIN
68+// (n >= 1): 1s, 2s, 4s, 8s, then MaxDenyDelay.
69+func denyDelay(failures int) time.Duration {
70+ if failures < 1 {
71+ return 0
72+ }
73+ if failures > 4 {
74+ return MaxDenyDelay
75+ }
76+ return time.Second << (failures - 1)
77+}
78+
79+// delayClock is the time seam for the wrong-PIN delay.
80+type delayClock interface {
81+ // after returns a channel that delivers once d has elapsed.
82+ after(d time.Duration) <-chan time.Time
83+}
84+
85+type realClock struct{}
86+
87+func (realClock) after(d time.Duration) <-chan time.Time { return time.After(d) }
88+
89+// lookahead reads frames, optionally ahead of the main loop while a delay runs,
90+// so a closed input pipe ends the delay at once. Frames read early are kept in
91+// order for the next read.
92+type lookahead struct {
93+ r io.Reader
94+ ch chan frameRead // pending background read
95+ held *frameRead // frame already read during a delay
96+}
97+
98+type frameRead struct {
99+ f ports.AuthFrame
100+ err error
101+}
102+
103+func (l *lookahead) read() (ports.AuthFrame, error) {
104+ switch {
105+ case l.held != nil:
106+ fr := *l.held
107+ l.held = nil
108+ return fr.f, fr.err
109+ case l.ch != nil:
110+ fr := <-l.ch
111+ l.ch = nil
112+ return fr.f, fr.err
113+ }
114+ return ReadFrame(l.r)
115+}
116+
117+// wait returns nil once d elapsed on the clock, or the read error / context
118+// error that interrupted it (EOF: the visual is gone).
119+func (l *lookahead) wait(ctx context.Context, clock delayClock, d time.Duration) error {
120+ if l.held == nil && l.ch == nil {
121+ l.ch = make(chan frameRead, 1)
122+ go func(ch chan<- frameRead) {
123+ f, err := ReadFrame(l.r)
124+ ch <- frameRead{f, err}
125+ }(l.ch)
126+ }
127+ timer := clock.after(d)
128+ ch := l.ch
129+ for {
130+ select {
131+ case <-timer:
132+ return nil
133+ case <-ctx.Done():
134+ if l.held != nil {
135+ clear(l.held.f.Payload) // never leave a read-ahead secret behind
136+ l.held = nil
137+ }
138+ return ctx.Err()
139+ case fr := <-ch:
140+ l.ch, ch = nil, nil
141+ if fr.err != nil {
142+ clear(fr.f.Payload)
143+ return fr.err
144+ }
145+ l.held = &fr
146+ }
147+ }
148+}
149+
150+type worker struct {
151+ r io.Reader
152+ w io.Writer
153+ generation uint64
154+ attempt uint64
155+ deadline time.Time
156+ secret []byte // dedicated secretBuffer page in production
157+ secretLen int
158+ first bool
159+}
160+
161+func (w *worker) frame(kind ports.AuthKind, code ports.AuthCode) error {
162+ return WriteFrame(w.w, ports.AuthFrame{Kind: kind, Code: code, Generation: w.generation, Attempt: w.attempt})
163+}
164+
165+// Respond deliberately ignores PAM text, including usernames/prompts/errors.
166+// All prompts use one generic masked field; multiple prompts are sequential.
167+func (w *worker) Respond(message pam.Message, answer []byte) (int, error) {
168+ if time.Now().After(w.deadline) {
169+ return 0, ErrProtocol
170+ }
171+ switch message.Style {
172+ case pam.TextInfo, pam.ErrorMsg:
173+ return 0, w.frame(ports.AuthStatus, ports.AuthPassword)
174+ case pam.PromptEchoOff, pam.PromptEchoOn:
175+ default:
176+ return 0, ErrProtocol
177+ }
178+ if w.first && message.Style == pam.PromptEchoOff {
179+ if w.secretLen > len(answer) {
180+ return 0, ErrProtocol
181+ }
182+ for _, b := range w.secret[:w.secretLen] {
183+ if b == 0 {
184+ return 0, ErrProtocol
185+ }
186+ }
187+ w.first = false
188+ n := copy(answer, w.secret[:w.secretLen])
189+ clear(w.secret[:])
190+ w.secretLen = 0
191+ return n, nil
192+ }
193+ if err := w.frame(ports.AuthPrompt, ports.AuthPassword); err != nil {
194+ return 0, err
195+ }
196+ f, err := ReadFrame(w.r)
197+ if err != nil {
198+ return 0, err
199+ }
200+ defer clear(f.Payload)
201+ if f.Generation != w.generation || f.Attempt != w.attempt || f.Kind != ports.AuthSecret || len(f.Payload) > len(answer) || time.Now().After(w.deadline) {
202+ return 0, ErrProtocol
203+ }
204+ for _, b := range f.Payload {
205+ if b == 0 {
206+ return 0, ErrProtocol
207+ }
208+ }
209+ return copy(answer, f.Payload), nil
210+}
211+
212+// Run is a synchronous per-lock worker loop. Reader/writer must be private
213+// anonymous CLOEXEC pipes established by the visual process. The visual owner
214+// enforces the 30s watchdog by terminating this process: native PAM calls and
215+// blocked pipe reads cannot be interrupted safely by a Go goroutine.
216+// Bootstrap is consumed once; no PIN re-resolution occurs on wrong attempts.
217+func Run(ctx context.Context, r io.Reader, out io.Writer, bootstrap ports.AuthBootstrap) error {
218+ defer clear(bootstrap.EnvPIN)
219+ if bootstrap.Generation == 0 || len(bootstrap.PINReference) > ports.AuthMaxMetadata || len(bootstrap.EnvPIN) > ports.AuthMaxSecret {
220+ return ErrProtocol
221+ }
222+ pin := ResolvePIN(ctx, bootstrap)
223+ clear(bootstrap.EnvPIN)
224+ defer clear(pin)
225+ var backend pamBackend
226+ var library *pam.Library
227+ name := ""
228+ if pin == nil {
229+ if info, err := os.Stat("/etc/pam.d/neferafk"); err == nil && info.Mode().IsRegular() {
230+ if u, err := user.LookupId(strconv.Itoa(os.Getuid())); err == nil {
231+ name = u.Username
232+ library, _ = pam.Open()
233+ if library != nil {
234+ backend = managedPAM{library}
235+ }
236+ }
237+ }
238+ }
239+ if library != nil {
240+ defer library.Close()
241+ }
242+ return runWith(ctx, realClock{}, r, out, bootstrap.Generation, pin, backend, name, bootstrap.PINSource != "" && pin == nil)
243+}
244+
245+func run(r io.Reader, out io.Writer, generation uint64, pin []byte, backend pamBackend, name string) error {
246+ return runScoped(r, out, generation, pin, backend, name, false)
247+}
248+
249+func runScoped(r io.Reader, out io.Writer, generation uint64, pin []byte, backend pamBackend, name string, fallback bool) error {
250+ return runWith(context.Background(), realClock{}, r, out, generation, pin, backend, name, fallback)
251+}
252+
253+func runWith(ctx context.Context, clock delayClock, r io.Reader, out io.Writer, generation uint64, pin []byte, backend pamBackend, name string, fallback bool) error {
254+ secret, err := newSecret(ports.AuthMaxSecret)
255+ if err != nil {
256+ return err
257+ }
258+ defer secret.close()
259+ w := &worker{r: r, w: out, generation: generation, secret: secret.data}
260+ if pin != nil {
261+ if !validPIN(pin) {
262+ return ErrProtocol
263+ }
264+ stored, err := newSecret(32)
265+ if err != nil {
266+ return err
267+ }
268+ defer stored.close()
269+ copy(stored.data, pin)
270+ n := len(pin)
271+ clear(pin) // transient Go-heap resolution copy, replaced by native page
272+ pin = stored.data[:n]
273+ }
274+ ready := ports.AuthPIN
275+ if pin == nil {
276+ ready = ports.AuthPassword
277+ if fallback {
278+ ready = ports.AuthFallbackPassword
279+ }
280+ if backend == nil {
281+ ready = ports.AuthUnavailable
282+ }
283+ }
284+ readyFrame := ports.AuthFrame{Kind: ports.AuthReady, Code: ready, Generation: generation}
285+ if fallback && backend == nil {
286+ readyFrame.Payload = []byte{byte(ports.AuthFallbackToPassword)}
287+ }
288+ if err := WriteFrame(out, readyFrame); err != nil {
289+ return err
290+ }
291+ in := &lookahead{r: r}
292+ failures := 0 // consecutive wrong PINs; the PAM path has pam_faildelay
293+ for {
294+ f, err := in.read()
295+ if err != nil {
296+ return err
297+ }
298+ if f.Kind != ports.AuthAttempt || f.Generation != generation || f.Attempt == 0 || f.Attempt <= w.attempt {
299+ clear(f.Payload)
300+ return ErrProtocol
301+ }
302+ for _, b := range f.Payload {
303+ if b == 0 {
304+ clear(f.Payload)
305+ return ErrProtocol
306+ }
307+ }
308+ w.attempt, w.deadline, w.first = f.Attempt, time.Now().Add(30*time.Second), true
309+ w.secretLen = copy(w.secret[:], f.Payload)
310+ clear(f.Payload)
311+ result := ports.AuthDenied
312+ if pin != nil {
313+ if subtle.ConstantTimeCompare(w.secret[:w.secretLen], pin) == 1 {
314+ result = ports.AuthSuccess
315+ failures = 0
316+ } else {
317+ failures++
318+ }
319+ } else if backend == nil {
320+ result = ports.AuthUnavailable
321+ } else {
322+ result = authenticate(backend, name, w)
323+ }
324+ if time.Now().After(w.deadline) {
325+ result = ports.AuthDenied
326+ }
327+ clear(w.secret[:])
328+ w.secretLen = 0
329+ if pin != nil && result == ports.AuthDenied {
330+ // Growing pause before every wrong-PIN answer; it ends early on
331+ // cancellation or when the visual closes the pipe.
332+ if err := in.wait(ctx, clock, denyDelay(failures)); err != nil {
333+ return err
334+ }
335+ }
336+ if err := w.frame(ports.AuthResult, result); err != nil {
337+ return err
338+ }
339+ if result == ports.AuthSuccess {
340+ return nil
341+ }
342+ }
343+}
A internal/adapters/auth/worker_linux_test.go +184 -0
@@ -0,0 +1,184 @@
1+//go:build linux
2+
3+package auth
4+
5+import (
6+ "bytes"
7+ "context"
8+ "errors"
9+ "io"
10+ "os"
11+ "os/exec"
12+ "sync"
13+ "testing"
14+ "time"
15+
16+ "github.com/bnema/neferafk/internal/ports"
17+ "golang.org/x/sys/unix"
18+)
19+
20+const hardenChildEnv = "NEFERAFK_TEST_HARDEN_CHILD"
21+
22+// TestHardenProcessDisablesDumpsAndCoreLimit runs in a subprocess because the
23+// helper changes process-wide state.
24+func TestHardenProcessDisablesDumpsAndCoreLimit(t *testing.T) {
25+ if os.Getenv(hardenChildEnv) == "1" {
26+ if dumpable, err := unix.PrctlRetInt(unix.PR_GET_DUMPABLE, 0, 0, 0, 0); err != nil || dumpable != 1 {
27+ t.Fatalf("precondition: dumpable=%d err=%v", dumpable, err)
28+ }
29+ if err := HardenProcess(); err != nil {
30+ t.Fatal(err)
31+ }
32+ if dumpable, err := unix.PrctlRetInt(unix.PR_GET_DUMPABLE, 0, 0, 0, 0); err != nil || dumpable != 0 {
33+ t.Fatalf("dumpable=%d err=%v", dumpable, err)
34+ }
35+ var lim unix.Rlimit
36+ if err := unix.Getrlimit(unix.RLIMIT_CORE, &lim); err != nil || lim.Cur != 0 || lim.Max != 0 {
37+ t.Fatalf("RLIMIT_CORE %+v err=%v", lim, err)
38+ }
39+ return
40+ }
41+ cmd := exec.Command(os.Args[0], "-test.run=^TestHardenProcessDisablesDumpsAndCoreLimit$")
42+ cmd.Env = append(os.Environ(), hardenChildEnv+"=1")
43+ if out, err := cmd.CombinedOutput(); err != nil {
44+ t.Fatalf("child: %v\n%s", err, out)
45+ }
46+}
47+
48+func TestDenyDelaySchedule(t *testing.T) {
49+ want := []time.Duration{0, time.Second, 2 * time.Second, 4 * time.Second, 8 * time.Second, 30 * time.Second, 30 * time.Second, 30 * time.Second}
50+ for n, w := range want {
51+ if got := denyDelay(n); got != w {
52+ t.Fatalf("denyDelay(%d)=%v want %v", n, got, w)
53+ }
54+ }
55+}
56+
57+func readyClock() <-chan time.Time {
58+ c := make(chan time.Time, 1)
59+ c <- time.Time{}
60+ return c
61+}
62+
63+func attemptFrames(t *testing.T, in *bytes.Buffer, pins ...string) {
64+ t.Helper()
65+ for i, p := range pins {
66+ if err := WriteFrame(in, ports.AuthFrame{Kind: ports.AuthAttempt, Generation: 8, Attempt: uint64(i + 1), Payload: []byte(p)}); err != nil {
67+ t.Fatal(err)
68+ }
69+ }
70+}
71+
72+func TestWrongPINDelaysGrowThenCapAndSuccessIsNotDelayed(t *testing.T) {
73+ var in, out bytes.Buffer
74+ attemptFrames(t, &in, "000001", "000002", "000003", "000004", "000005", "000006", "123456")
75+ clock := newMockdelayClock(t)
76+ for _, d := range []time.Duration{1, 2, 4, 8, 30, 30} {
77+ clock.EXPECT().after(d * time.Second).Return(readyClock()).Once()
78+ }
79+ // The right PIN is answered without any delay (no further clock call).
80+ if err := runWith(context.Background(), clock, &in, &out, 8, []byte("123456"), nil, "", false); err != nil {
81+ t.Fatal(err)
82+ }
83+}
84+
85+func TestWrongPINDelayScheduleRestartsForAFreshWorker(t *testing.T) {
86+ // Success ends the worker, so the reset is observable as: the first wrong
87+ // answer of every fresh worker waits 1s again (no shared counter).
88+ for range 2 {
89+ var in, out bytes.Buffer
90+ attemptFrames(t, &in, "000001", "123456")
91+ clock := newMockdelayClock(t)
92+ clock.EXPECT().after(time.Second).Return(readyClock()).Once()
93+ if err := runWith(context.Background(), clock, &in, &out, 8, []byte("123456"), nil, "", false); err != nil {
94+ t.Fatal(err)
95+ }
96+ }
97+}
98+
99+func TestDelayedDenialIsAnsweredOnlyAfterTheClockFires(t *testing.T) {
100+ inR, inW := io.Pipe()
101+ var out lockedBuffer
102+ fire := make(chan time.Time)
103+ clock := newMockdelayClock(t)
104+ clock.EXPECT().after(time.Second).Return((<-chan time.Time)(fire)).Once()
105+ done := make(chan error, 1)
106+ go func() { done <- runWith(context.Background(), clock, inR, &out, 8, []byte("123456"), nil, "", false) }()
107+ if err := WriteFrame(inW, ports.AuthFrame{Kind: ports.AuthAttempt, Generation: 8, Attempt: 1, Payload: []byte("000000")}); err != nil {
108+ t.Fatal(err)
109+ }
110+ time.Sleep(50 * time.Millisecond)
111+ if n := out.frames(); n != 1 { // ready only: no denial yet
112+ t.Fatalf("%d frames before the delay elapsed", n)
113+ }
114+ fire <- time.Time{}
115+ if err := WriteFrame(inW, ports.AuthFrame{Kind: ports.AuthAttempt, Generation: 8, Attempt: 2, Payload: []byte("123456")}); err != nil {
116+ t.Fatal(err)
117+ }
118+ if err := <-done; err != nil {
119+ t.Fatal(err)
120+ }
121+ if n := out.frames(); n != 3 {
122+ t.Fatalf("%d frames", n)
123+ }
124+}
125+
126+func TestDelayEndsOnEOFAndCancellationWithoutAnswer(t *testing.T) {
127+ for _, name := range []string{"eof", "cancel"} {
128+ inR, inW := io.Pipe()
129+ var out lockedBuffer
130+ ctx, cancel := context.WithCancel(context.Background())
131+ clock := newMockdelayClock(t)
132+ clock.EXPECT().after(time.Second).Return((<-chan time.Time)(make(chan time.Time))).Once() // never fires
133+ done := make(chan error, 1)
134+ go func() { done <- runWith(ctx, clock, inR, &out, 8, []byte("123456"), nil, "", false) }()
135+ if err := WriteFrame(inW, ports.AuthFrame{Kind: ports.AuthAttempt, Generation: 8, Attempt: 1, Payload: []byte("000000")}); err != nil {
136+ t.Fatal(err)
137+ }
138+ want := io.EOF
139+ if name == "eof" {
140+ time.Sleep(20 * time.Millisecond)
141+ inW.Close()
142+ } else {
143+ want = context.Canceled
144+ time.Sleep(20 * time.Millisecond)
145+ cancel()
146+ }
147+ select {
148+ case err := <-done:
149+ if !errors.Is(err, want) {
150+ t.Fatalf("%s: %v", name, err)
151+ }
152+ case <-time.After(5 * time.Second):
153+ t.Fatalf("%s: delay did not end", name)
154+ }
155+ if n := out.frames(); n != 1 {
156+ t.Fatalf("%s: answered denial during the delay (%d frames)", name, n)
157+ }
158+ cancel()
159+ }
160+}
161+
162+type lockedBuffer struct {
163+ mu sync.Mutex
164+ b bytes.Buffer
165+}
166+
167+func (l *lockedBuffer) Write(p []byte) (int, error) {
168+ l.mu.Lock()
169+ defer l.mu.Unlock()
170+ return l.b.Write(p)
171+}
172+
173+func (l *lockedBuffer) frames() int {
174+ l.mu.Lock()
175+ cp := bytes.NewBuffer(bytes.Clone(l.b.Bytes()))
176+ l.mu.Unlock()
177+ n := 0
178+ for {
179+ if _, err := ReadFrame(cp); err != nil {
180+ return n
181+ }
182+ n++
183+ }
184+}
A internal/adapters/auth/worker_test.go +133 -0
@@ -0,0 +1,133 @@
1+package auth
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "io"
8+ "testing"
9+ "time"
10+
11+ "github.com/bnema/neferafk/internal/ports"
12+ pam "github.com/bnema/purego-pam"
13+ "github.com/stretchr/testify/mock"
14+)
15+
16+// Unlock runs only the PAM auth stack (like swaylock); account management is
17+// never called, so a service file with only `auth` lines works.
18+func TestPAMRequiresAuthenticationAndEnd(t *testing.T) {
19+ for _, stage := range []string{"success", "start", "authenticate", "end"} {
20+ t.Run(stage, func(t *testing.T) {
21+ b := newMockpamBackend(t)
22+ tx := newMockpamTransaction(t)
23+ boom := errors.New("sensitive native message")
24+ if stage == "start" {
25+ b.EXPECT().start("neferafk", "real-user", mock.Anything).Return(nil, boom).Once()
26+ } else {
27+ b.EXPECT().start("neferafk", "real-user", mock.Anything).Return(tx, nil).Once()
28+ var authErr, endErr error
29+ if stage == "authenticate" {
30+ authErr = boom
31+ }
32+ if stage == "end" {
33+ endErr = boom
34+ }
35+ tx.EXPECT().Authenticate(pam.DisallowNullAuthtok).Return(authErr).Once()
36+ tx.EXPECT().End().Return(endErr).Once()
37+ }
38+ code := authenticate(b, "real-user", &worker{})
39+ if (code == ports.AuthSuccess) != (stage == "success") {
40+ t.Fatalf("stage %s code %d", stage, code)
41+ }
42+ })
43+ }
44+}
45+
46+func TestPINWrongNeverFallsBackAndAttemptsScoped(t *testing.T) {
47+ var input, output bytes.Buffer
48+ for i, secret := range []string{"000000", "123456"} {
49+ if err := WriteFrame(&input, ports.AuthFrame{Kind: ports.AuthAttempt, Generation: 8, Attempt: uint64(i + 1), Payload: []byte(secret)}); err != nil {
50+ t.Fatal(err)
51+ }
52+ }
53+ b := newMockpamBackend(t) // no PAM call allowed for valid resolved PIN
54+ if err := run(&input, &output, 8, []byte("123456"), b, "user"); err != nil {
55+ t.Fatal(err)
56+ }
57+ for i, want := range []ports.AuthCode{ports.AuthPIN, ports.AuthDenied, ports.AuthSuccess} {
58+ f, err := ReadFrame(&output)
59+ if err != nil || f.Code != want || f.Generation != 8 || f.Attempt != uint64(i) || len(f.Payload) != 0 {
60+ t.Fatalf("result %+v err=%v", f, err)
61+ }
62+ }
63+}
64+
65+func TestRunConsumesBootstrapOnceAndWipes(t *testing.T) {
66+ var input, output bytes.Buffer
67+ if err := WriteFrame(&input, ports.AuthFrame{Kind: ports.AuthAttempt, Generation: 8, Attempt: 1, Payload: []byte("123456")}); err != nil {
68+ t.Fatal(err)
69+ }
70+ bootstrap := ports.AuthBootstrap{Generation: 8, PINSource: "env", EnvPIN: []byte("123456")}
71+ if err := Run(context.Background(), &input, &output, bootstrap); err != nil {
72+ t.Fatal(err)
73+ }
74+ if !bytes.Equal(bootstrap.EnvPIN, make([]byte, 6)) {
75+ t.Fatal("bootstrap retained secret")
76+ }
77+ for _, kind := range []ports.AuthKind{ports.AuthReady, ports.AuthResult} {
78+ f, err := ReadFrame(&output)
79+ if err != nil || f.Kind != kind {
80+ t.Fatalf("frame %+v %v", f, err)
81+ }
82+ }
83+}
84+
85+func TestUnavailablePAMAndReplayFailClosed(t *testing.T) {
86+ var input, output bytes.Buffer
87+ for range 2 {
88+ if err := WriteFrame(&input, ports.AuthFrame{Kind: ports.AuthAttempt, Generation: 2, Attempt: 1}); err != nil {
89+ t.Fatal(err)
90+ }
91+ }
92+ if err := run(&input, &output, 2, nil, nil, ""); !errors.Is(err, ErrProtocol) {
93+ t.Fatal(err)
94+ }
95+ for range 2 {
96+ f, err := ReadFrame(&output)
97+ if err != nil || f.Code != ports.AuthUnavailable {
98+ t.Fatalf("result %+v %v", f, err)
99+ }
100+ }
101+ if _, err := ReadFrame(&output); !errors.Is(err, io.EOF) {
102+ t.Fatal("replay produced result")
103+ }
104+}
105+
106+func TestConversationGenericMultiplePromptsAndWipes(t *testing.T) {
107+ var input, output bytes.Buffer
108+ if err := WriteFrame(&input, ports.AuthFrame{Kind: ports.AuthSecret, Generation: 3, Attempt: 9, Payload: []byte("second")}); err != nil {
109+ t.Fatal(err)
110+ }
111+ w := &worker{r: &input, w: &output, generation: 3, attempt: 9, deadline: time.Now().Add(time.Second), first: true, secretLen: 5, secret: make([]byte, 512)}
112+ copy(w.secret[:], "first")
113+ answer := make([]byte, 512)
114+ if n, err := w.Respond(pam.Message{Style: pam.PromptEchoOff, Text: []byte("do not forward")}, answer); err != nil || string(answer[:n]) != "first" {
115+ t.Fatalf("first %d %v", n, err)
116+ }
117+ if !bytes.Equal(w.secret[:], make([]byte, 512)) {
118+ t.Fatal("first secret retained")
119+ }
120+ clear(answer)
121+ if n, err := w.Respond(pam.Message{Style: pam.PromptEchoOn, Text: []byte("raw native prompt")}, answer); err != nil || string(answer[:n]) != "second" {
122+ t.Fatalf("second %d %v", n, err)
123+ }
124+ if _, err := w.Respond(pam.Message{Style: pam.ErrorMsg, Text: []byte("native secret error")}, answer); err != nil {
125+ t.Fatal(err)
126+ }
127+ for _, kind := range []ports.AuthKind{ports.AuthPrompt, ports.AuthStatus} {
128+ f, err := ReadFrame(&output)
129+ if err != nil || f.Kind != kind || len(f.Payload) != 0 || f.Attempt != 9 {
130+ t.Fatalf("generic %+v %v", f, err)
131+ }
132+ }
133+}
A internal/adapters/config/config.go +113 -0
@@ -0,0 +1,113 @@
1+// Package config reads bounded transactional flat key=value configuration.
2+package config
3+
4+import (
5+ "bufio"
6+ "fmt"
7+ "io"
8+ "os"
9+ "path/filepath"
10+ "strings"
11+ "time"
12+ "unicode/utf8"
13+
14+ "github.com/bnema/neferafk/internal/ports"
15+)
16+
17+const MaxBytes = 64 << 10
18+const MaxLine = 4 << 10
19+
20+func DefaultPath() string {
21+ base := os.Getenv("XDG_CONFIG_HOME")
22+ if base == "" {
23+ if home, err := os.UserHomeDir(); err == nil {
24+ base = filepath.Join(home, ".config")
25+ }
26+ }
27+ return filepath.Join(base, "neferafk", "config")
28+}
29+func Load(path string) (ports.Config, error) {
30+ f, err := os.Open(path)
31+ if err != nil {
32+ return ports.Config{}, err
33+ }
34+ defer f.Close()
35+ return Parse(f)
36+}
37+
38+// Parse never returns partial configuration and never resolves PIN references.
39+func Parse(r io.Reader) (ports.Config, error) {
40+ data, err := io.ReadAll(io.LimitReader(r, MaxBytes+1))
41+ if err != nil {
42+ return ports.Config{}, err
43+ }
44+ if !utf8.Valid(data) {
45+ return ports.Config{}, fmt.Errorf("config is not valid UTF-8")
46+ }
47+ if len(data) > MaxBytes {
48+ return ports.Config{}, fmt.Errorf("config exceeds 64KiB")
49+ }
50+ cfg := ports.Defaults()
51+ seen := map[string]bool{}
52+ scanner := bufio.NewScanner(strings.NewReader(string(data)))
53+ scanner.Buffer(make([]byte, MaxLine+1), MaxLine+2)
54+ line := 0
55+ for scanner.Scan() {
56+ line++
57+ text := scanner.Text()
58+ if len(text) > MaxLine {
59+ return ports.Config{}, fmt.Errorf("line %d exceeds 4KiB", line)
60+ }
61+ text = strings.TrimSpace(strings.SplitN(text, "#", 2)[0])
62+ if text == "" {
63+ continue
64+ }
65+ key, value, ok := strings.Cut(text, "=")
66+ key, value = strings.TrimSpace(key), strings.TrimSpace(value)
67+ if !ok || value == "" || seen[key] {
68+ return ports.Config{}, fmt.Errorf("line %d: missing value or duplicate key", line)
69+ }
70+ seen[key] = true
71+ var target *time.Duration
72+ switch key {
73+ case "lock.after":
74+ target = &cfg.LockAfter
75+ case "fade.after":
76+ target = &cfg.FadeAfter
77+ case "fade.duration":
78+ target = &cfg.FadeDuration
79+ case "screens.off-after":
80+ target = &cfg.OffAfter
81+ case "sleep.after":
82+ target = &cfg.SleepAfter
83+ case "auth.mode":
84+ cfg.Auth.Mode = ports.AuthMode(value)
85+ case "auth.pin-source":
86+ cfg.Auth.PINSource = ports.PINSource(value)
87+ case "auth.pin-env":
88+ cfg.Auth.PINEnv = value
89+ case "auth.pin-entry":
90+ cfg.Auth.PINEntry = value
91+ default:
92+ return ports.Config{}, fmt.Errorf("line %d: unknown key", line)
93+ }
94+ if target != nil {
95+ if value == "off" && key != "fade.duration" {
96+ *target = 0
97+ continue
98+ }
99+ d, e := time.ParseDuration(value)
100+ if e != nil || d < 0 || d > ports.MaxDuration || d == 0 && key != "fade.duration" {
101+ return ports.Config{}, fmt.Errorf("line %d: invalid duration", line)
102+ }
103+ *target = d
104+ }
105+ }
106+ if err := scanner.Err(); err != nil {
107+ return ports.Config{}, fmt.Errorf("config line exceeds bound")
108+ }
109+ if err := cfg.Validate(); err != nil {
110+ return ports.Config{}, err
111+ }
112+ return cfg, nil
113+}
A internal/adapters/config/config_test.go +60 -0
@@ -0,0 +1,60 @@
1+package config
2+
3+import (
4+ "github.com/bnema/neferafk/internal/ports"
5+ "strings"
6+ "testing"
7+ "time"
8+)
9+
10+func TestDefaultsAndIndependentOff(t *testing.T) {
11+ c, err := Parse(strings.NewReader("# defaults\n"))
12+ if err != nil || c != ports.Defaults() {
13+ t.Fatal(c, err)
14+ }
15+ c, err = Parse(strings.NewReader("lock.after = off\nfade.after = 1s\nfade.duration = 0s\nscreens.off-after = off\nsleep.after = 24h\nauth.mode = pin\nauth.pin-source = env\nauth.pin-env = NEFERAFK_PIN\n"))
16+ if err != nil || c.LockAfter != 0 || c.FadeAfter != time.Second || c.FadeDuration != 0 || c.SleepAfter != 24*time.Hour || c.Auth.PINEnv != "NEFERAFK_PIN" {
17+ t.Fatal(c, err)
18+ }
19+}
20+func TestRejectTransactionalBoundsUnknownDuplicateAndSecrets(t *testing.T) {
21+ for _, text := range []string{"unknown = x", "lock.after=1s\nlock.after=2s", "lock.after=0s", "lock.after=-1s", "sleep.after=25h", "fade.duration=-1s", "fade.duration=off", "auth.password=false", "auth.pin-reference=x", "auth.mode=unknown", "auth.mode=pin", "auth.pin-source=pass\nauth.pin-entry=-option", "auth.pin-source=pass\nauth.pin-entry=a\x00b", "auth.pin=1234", "auth.pin-source=env\nauth.pin-env=SECRET=123", "auth.pin-source=pass\nauth.pin-entry=../secret", "auth.pin-source=pass\nauth.pin-entry=/secret", strings.Repeat("#", MaxLine+1), strings.Repeat("#\n", MaxBytes)} {
22+ c, err := Parse(strings.NewReader(text))
23+ if err == nil || c != (ports.Config{}) {
24+ t.Fatalf("accepted/partial invalid input length %d", len(text))
25+ }
26+ }
27+}
28+func TestPassReferenceMetadataOnly(t *testing.T) {
29+ c, err := Parse(strings.NewReader("auth.pin-source=pass\nauth.pin-entry=desktop/neferafk\n"))
30+ if err != nil || c.Auth.PINEntry != "desktop/neferafk" {
31+ t.Fatal(c, err)
32+ }
33+}
34+func TestDefaultPath(t *testing.T) {
35+ t.Setenv("XDG_CONFIG_HOME", "/config")
36+ if got := DefaultPath(); got != "/config/neferafk/config" {
37+ t.Fatal(got)
38+ }
39+}
40+
41+func TestUnknownKeyDiagnosticCannotEchoSecret(t *testing.T) {
42+ secret := "accidental-password-should-never-appear"
43+ _, err := Parse(strings.NewReader(secret + " = " + secret))
44+ if err == nil || strings.Contains(err.Error(), secret) {
45+ t.Fatalf("unsafe diagnostic %v", err)
46+ }
47+}
48+func TestRejectInvalidUTF8BeforeParsingAndDirectMetadata(t *testing.T) {
49+ for _, text := range []string{"# \xff", "auth.pin-source=pass\nauth.pin-entry=a\xff"} {
50+ if _, err := Parse(strings.NewReader(text)); err == nil {
51+ t.Fatal("invalid UTF-8 parsed")
52+ }
53+ }
54+ cfg := ports.Defaults()
55+ cfg.Auth.PINSource = ports.PINSourcePass
56+ cfg.Auth.PINEntry = "entry\xff"
57+ if err := cfg.Validate(); err == nil {
58+ t.Fatal("direct invalid UTF-8 metadata admitted")
59+ }
60+}
A internal/adapters/config/watch.go +200 -0
@@ -0,0 +1,200 @@
1+package config
2+
3+import (
4+ "context"
5+ "encoding/binary"
6+ "errors"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+ "time"
11+
12+ "github.com/bnema/neferafk/internal/ports"
13+ "golang.org/x/sys/unix"
14+)
15+
16+func watchedFiles(path string) []string {
17+ files := []string{filepath.Clean(path)}
18+ for len(files) <= 40 {
19+ cur := files[len(files)-1]
20+ target, err := os.Readlink(cur)
21+ if err != nil {
22+ break
23+ }
24+ if !filepath.IsAbs(target) {
25+ target = filepath.Join(filepath.Dir(cur), target)
26+ }
27+ target = filepath.Clean(target)
28+ duplicate := false
29+ for _, p := range files {
30+ if p == target {
31+ duplicate = true
32+ break
33+ }
34+ }
35+ if duplicate {
36+ break
37+ }
38+ files = append(files, target)
39+ }
40+ return files
41+}
42+
43+// watchDirectory finds the nearest existing ancestor. The missing component's
44+// creation wakes inotify; the two-second retry also covers lost/overflow events.
45+func watchDirectory(file string) (directory, name string, missing bool) {
46+ directory, name = filepath.Dir(file), filepath.Base(file)
47+ for {
48+ st, err := os.Stat(directory)
49+ if err == nil && st.IsDir() {
50+ return
51+ }
52+ missing = true
53+ parent := filepath.Dir(directory)
54+ if parent == directory {
55+ return
56+ }
57+ name, directory = filepath.Base(directory), parent
58+ }
59+}
60+
61+// Watch retains last-valid state across missing files/directories and follows
62+// config symlink targets. It always emits the first valid candidate, including
63+// a candidate equal to defaults: this closes an app Load -> Watch startup gap.
64+// Ready closes after initial watch installation, before candidate delivery.
65+// No invalid/deleted candidate emits a defaults reset. Errors never echo file
66+// content or paths, which may themselves contain sensitive source metadata.
67+func Watch(ctx context.Context, path string, out chan<- ports.ConfigChanged, failures chan<- error, ready chan<- struct{}) error {
68+ fd, err := unix.InotifyInit1(unix.IN_CLOEXEC | unix.IN_NONBLOCK)
69+ if err != nil {
70+ return errors.New("initialize config watch")
71+ }
72+ defer unix.Close(fd)
73+ const mask = unix.IN_CLOSE_WRITE | unix.IN_MODIFY | unix.IN_MOVED_TO | unix.IN_CREATE | unix.IN_DELETE | unix.IN_MOVED_FROM | unix.IN_DELETE_SELF | unix.IN_MOVE_SELF
74+ names := map[int32]map[string]bool{}
75+ retry := time.Time{}
76+ rewatch := func() {
77+ // Keep unchanged watches, avoiding IN_IGNORED from our own removals.
78+ want := map[int32]map[string]bool{}
79+ retry = time.Time{}
80+ for _, file := range watchedFiles(path) {
81+ dir, name, missing := watchDirectory(file)
82+ wd, e := unix.InotifyAddWatch(fd, dir, mask)
83+ if e != nil {
84+ retry = time.Now().Add(2 * time.Second)
85+ continue
86+ }
87+ if missing {
88+ retry = time.Now().Add(2 * time.Second)
89+ }
90+ if want[int32(wd)] == nil {
91+ want[int32(wd)] = map[string]bool{}
92+ }
93+ want[int32(wd)][name] = true
94+ }
95+ for wd := range names {
96+ if want[wd] == nil {
97+ _, _ = unix.InotifyRmWatch(fd, uint32(wd))
98+ }
99+ }
100+ names = want
101+ }
102+ rewatch()
103+ if ready != nil {
104+ close(ready)
105+ }
106+ var last ports.Config
107+ haveLast := false
108+ publish := func() bool {
109+ cfg, e := Load(path)
110+ if e != nil {
111+ if !os.IsNotExist(e) && failures != nil {
112+ select {
113+ case failures <- errors.New("config reload rejected"):
114+ default:
115+ }
116+ }
117+ return true
118+ }
119+ if haveLast && cfg == last {
120+ return true
121+ }
122+ select {
123+ case out <- ports.ConfigChanged{Config: cfg}:
124+ last, haveLast = cfg, true
125+ return true
126+ case <-ctx.Done():
127+ return false
128+ }
129+ }
130+ if !publish() {
131+ return nil
132+ }
133+ pending := time.Time{}
134+ buf := make([]byte, 8192)
135+ for ctx.Err() == nil {
136+ now := time.Now()
137+ if !retry.IsZero() && !now.Before(retry) {
138+ rewatch()
139+ pending = now
140+ }
141+ if !pending.IsZero() && !now.Before(pending) {
142+ pending = time.Time{}
143+ rewatch()
144+ if !publish() {
145+ return nil
146+ }
147+ }
148+ timeout := 100 // cancellation bound, no periodic reads while watches are live
149+ if !pending.IsZero() {
150+ timeout = min(timeout, max(1, int(time.Until(pending).Milliseconds())))
151+ }
152+ fds := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}}
153+ _, e := unix.Poll(fds, timeout)
154+ if errors.Is(e, unix.EINTR) {
155+ continue
156+ }
157+ if e != nil {
158+ return errors.New("poll config watch")
159+ }
160+ if fds[0].Revents&unix.POLLIN == 0 {
161+ continue
162+ }
163+ n, e := unix.Read(fd, buf)
164+ if errors.Is(e, unix.EAGAIN) || errors.Is(e, unix.EINTR) {
165+ continue
166+ }
167+ if e != nil {
168+ return errors.New("read config watch")
169+ }
170+ for offset := 0; offset+unix.SizeofInotifyEvent <= n; {
171+ b := buf[offset:]
172+ wd := int32(binary.NativeEndian.Uint32(b))
173+ flags := binary.NativeEndian.Uint32(b[4:])
174+ length := int(binary.NativeEndian.Uint32(b[12:]))
175+ size := unix.SizeofInotifyEvent + length
176+ if size > n-offset {
177+ break
178+ }
179+ if flags&unix.IN_Q_OVERFLOW != 0 {
180+ pending = time.Now().Add(100 * time.Millisecond)
181+ }
182+ if names[wd] != nil {
183+ if flags&(unix.IN_DELETE_SELF|unix.IN_MOVE_SELF|unix.IN_IGNORED) != 0 {
184+ if flags&unix.IN_IGNORED == 0 {
185+ _, _ = unix.InotifyRmWatch(fd, uint32(wd))
186+ }
187+ delete(names, wd)
188+ pending = time.Now().Add(100 * time.Millisecond)
189+ retry = time.Now().Add(2 * time.Second)
190+ }
191+ name := strings.TrimRight(string(b[unix.SizeofInotifyEvent:size]), "\x00")
192+ if names[wd][name] {
193+ pending = time.Now().Add(100 * time.Millisecond)
194+ }
195+ }
196+ offset += size
197+ }
198+ }
199+ return nil
200+}
A internal/adapters/config/watch_test.go +199 -0
@@ -0,0 +1,199 @@
1+package config
2+
3+import (
4+ "context"
5+ "github.com/bnema/neferafk/internal/ports"
6+ "os"
7+ "path/filepath"
8+ "testing"
9+ "time"
10+)
11+
12+func recv[T any](t *testing.T, ch <-chan T) T {
13+ t.Helper()
14+ select {
15+ case v := <-ch:
16+ return v
17+ case <-time.After(2 * time.Second):
18+ t.Fatal("watch timeout")
19+ var z T
20+ return z
21+ }
22+}
23+func save(t *testing.T, path, text string) {
24+ t.Helper()
25+ tmp := path + ".new"
26+ if err := os.WriteFile(tmp, []byte(text), 0600); err != nil {
27+ t.Fatal(err)
28+ }
29+ if err := os.Rename(tmp, path); err != nil {
30+ t.Fatal(err)
31+ }
32+}
33+func TestWatchAtomicInvalidTransactionalAndSymlinkRetarget(t *testing.T) {
34+ dir := t.TempDir()
35+ targetDir := t.TempDir()
36+ path := filepath.Join(dir, "config")
37+ target := filepath.Join(targetDir, "target")
38+ save(t, path, "lock.after=5m\n")
39+ out := make(chan ports.ConfigChanged, 4)
40+ errs := make(chan error, 4)
41+ ready := make(chan struct{})
42+ ctx, cancel := context.WithCancel(context.Background())
43+ done := make(chan error, 1)
44+ go func() { done <- Watch(ctx, path, out, errs, ready) }()
45+ recv(t, ready)
46+ if initial := recv(t, out); initial.Config.LockAfter != 5*time.Minute {
47+ t.Fatal(initial)
48+ }
49+ save(t, path, "lock.after=2m\n")
50+ if got := recv(t, out); got.Config.LockAfter != 2*time.Minute {
51+ t.Fatal(got)
52+ }
53+ save(t, path, "lock.after=-1s\n")
54+ recv(t, errs)
55+ select {
56+ case v := <-out:
57+ t.Fatalf("invalid reload emitted %+v", v)
58+ default:
59+ }
60+ save(t, target, "lock.after=3m\n")
61+ if err := os.Symlink(target, path+".link"); err != nil {
62+ t.Fatal(err)
63+ }
64+ if err := os.Rename(path+".link", path); err != nil {
65+ t.Fatal(err)
66+ }
67+ if got := recv(t, out); got.Config.LockAfter != 3*time.Minute {
68+ t.Fatal(got)
69+ }
70+ save(t, target, "lock.after=4m\n")
71+ if got := recv(t, out); got.Config.LockAfter != 4*time.Minute {
72+ t.Fatal(got)
73+ }
74+ cancel()
75+ if err := recv(t, done); err != nil {
76+ t.Fatal(err)
77+ }
78+}
79+func TestWatchCancellationWhileDeliveryBlocked(t *testing.T) {
80+ path := filepath.Join(t.TempDir(), "config")
81+ save(t, path, "lock.after=5m\n")
82+ out := make(chan ports.ConfigChanged)
83+ ready := make(chan struct{})
84+ ctx, cancel := context.WithCancel(context.Background())
85+ done := make(chan error, 1)
86+ go func() { done <- Watch(ctx, path, out, nil, ready) }()
87+ recv(t, ready)
88+ save(t, path, "lock.after=1m\n")
89+ cancel()
90+ if err := recv(t, done); err != nil {
91+ t.Fatal(err)
92+ }
93+}
94+
95+func TestWatchMissingDirectoryStartupThenRemoveRecreate(t *testing.T) {
96+ root := t.TempDir()
97+ dir := filepath.Join(root, "missing", "nested")
98+ path := filepath.Join(dir, "config")
99+ out := make(chan ports.ConfigChanged, 4)
100+ ready := make(chan struct{})
101+ ctx, cancel := context.WithCancel(context.Background())
102+ defer cancel()
103+ done := make(chan error, 1)
104+ go func() { done <- Watch(ctx, path, out, nil, ready) }()
105+ recv(t, ready)
106+ if err := os.MkdirAll(dir, 0700); err != nil {
107+ t.Fatal(err)
108+ }
109+ save(t, path, "lock.after=1m\n")
110+ if got := recv(t, out); got.Config.LockAfter != time.Minute {
111+ t.Fatal(got)
112+ }
113+ // Rename the entire watched directory out of the path, then recreate it.
114+ if err := os.Rename(dir, dir+"-old"); err != nil {
115+ t.Fatal(err)
116+ }
117+ if err := os.MkdirAll(dir, 0700); err != nil {
118+ t.Fatal(err)
119+ }
120+ save(t, path, "lock.after=2m\n")
121+ if got := recv(t, out); got.Config.LockAfter != 2*time.Minute {
122+ t.Fatal(got)
123+ }
124+ cancel()
125+ if err := recv(t, done); err != nil {
126+ t.Fatal(err)
127+ }
128+}
129+
130+func TestWatchRetargetToUnavailableThenReappearKeepsLastValid(t *testing.T) {
131+ root := t.TempDir()
132+ path := filepath.Join(root, "config")
133+ save(t, path, "lock.after=1m\n")
134+ out := make(chan ports.ConfigChanged, 4)
135+ ready := make(chan struct{})
136+ ctx, cancel := context.WithCancel(context.Background())
137+ defer cancel()
138+ done := make(chan error, 1)
139+ go func() { done <- Watch(ctx, path, out, nil, ready) }()
140+ recv(t, ready)
141+ recv(t, out)
142+ target := filepath.Join(t.TempDir(), "absent", "entry")
143+ if err := os.Symlink(target, path+".link"); err != nil {
144+ t.Fatal(err)
145+ }
146+ if err := os.Rename(path+".link", path); err != nil {
147+ t.Fatal(err)
148+ }
149+ select {
150+ case reset := <-out:
151+ t.Fatalf("missing target reset last valid %+v", reset)
152+ case err := <-done:
153+ t.Fatalf("watch stopped on missing target %v", err)
154+ case <-time.After(200 * time.Millisecond):
155+ }
156+ if err := os.MkdirAll(filepath.Dir(target), 0700); err != nil {
157+ t.Fatal(err)
158+ }
159+ save(t, target, "lock.after=3m\n")
160+ if got := recv(t, out); got.Config.LockAfter != 3*time.Minute {
161+ t.Fatal(got)
162+ }
163+ cancel()
164+ if err := recv(t, done); err != nil {
165+ t.Fatal(err)
166+ }
167+}
168+
169+func TestWatchFirstCandidateClosesLoadStartupGapAndMissingCancellation(t *testing.T) {
170+ path := filepath.Join(t.TempDir(), "config")
171+ save(t, path, "lock.after=1m\n")
172+ old, err := Load(path)
173+ if err != nil {
174+ t.Fatal(err)
175+ }
176+ save(t, path, "lock.after=2m\n") // save after app's Load, before Watch
177+ out := make(chan ports.ConfigChanged, 1)
178+ ready := make(chan struct{})
179+ ctx, cancel := context.WithCancel(context.Background())
180+ done := make(chan error, 1)
181+ go func() { done <- Watch(ctx, path, out, nil, ready) }()
182+ recv(t, ready)
183+ if got := recv(t, out); got.Config == old || got.Config.LockAfter != 2*time.Minute {
184+ t.Fatal(got)
185+ }
186+ cancel()
187+ if err := recv(t, done); err != nil {
188+ t.Fatal(err)
189+ }
190+ ctx, cancel = context.WithCancel(context.Background())
191+ done = make(chan error, 1)
192+ ready = make(chan struct{})
193+ go func() { done <- Watch(ctx, filepath.Join(t.TempDir(), "never", "config"), out, nil, ready) }()
194+ recv(t, ready)
195+ cancel()
196+ if err := recv(t, done); err != nil {
197+ t.Fatal(err)
198+ }
199+}
A internal/adapters/control/control.go +229 -0
@@ -0,0 +1,229 @@
1+// Package control is the tiny private local control socket. Line protocol, one
2+// request per connection:
3+//
4+// -> "lock\n" | "status\n"
5+// <- "ok generation=N acquiring=B protected=B faded=B off=B\n" | "err <reason>\n"
6+//
7+// Only the same UID (SO_PEERCRED) is served, the directory is 0700 and the
8+// socket 0600. There is deliberately no unlock command: unknown words, extra
9+// arguments and oversize lines are rejected.
10+package control
11+
12+import (
13+ "bufio"
14+ "context"
15+ "errors"
16+ "fmt"
17+ "io"
18+ "net"
19+ "os"
20+ "path/filepath"
21+ "strings"
22+ "syscall"
23+ "time"
24+
25+ "github.com/bnema/neferafk/internal/ports"
26+ "github.com/bnema/zerowrap"
27+ "golang.org/x/sys/unix"
28+)
29+
30+const (
31+ maxLine = 16
32+ ioTimeout = 2 * time.Second
33+ replyTimeout = 3 * time.Second
34+ maxConcurrent = 8
35+)
36+
37+// DefaultPath is $XDG_RUNTIME_DIR/neferafk/control.sock.
38+func DefaultPath() (string, error) {
39+ dir := os.Getenv("XDG_RUNTIME_DIR")
40+ if dir == "" || !filepath.IsAbs(dir) {
41+ return "", errors.New("XDG_RUNTIME_DIR is not set to an absolute path")
42+ }
43+ return filepath.Join(dir, "neferafk", "control.sock"), nil
44+}
45+
46+// Server accepts control connections and forwards them to the policy owner.
47+type Server struct {
48+ ln *net.UnixListener
49+ path string
50+ uid uint32
51+ requests chan<- ports.ControlRequest
52+ log zerowrap.Logger
53+}
54+
55+// Listen prepares the private directory (must be owned by this process's UID)
56+// and socket. uid is the only peer UID served; production passes os.Getuid().
57+func Listen(path string, uid uint32, requests chan<- ports.ControlRequest, log zerowrap.Logger) (*Server, error) {
58+ dir := filepath.Dir(path)
59+ if err := os.MkdirAll(dir, 0o700); err != nil {
60+ return nil, err
61+ }
62+ fi, err := os.Lstat(dir)
63+ if err != nil {
64+ return nil, err
65+ }
66+ st, ok := fi.Sys().(*syscall.Stat_t)
67+ if !fi.IsDir() || !ok || st.Uid != uint32(os.Getuid()) {
68+ return nil, fmt.Errorf("control directory %s is not a directory owned by the current user", dir)
69+ }
70+ if fi.Mode().Perm() != 0o700 {
71+ if err := os.Chmod(dir, 0o700); err != nil {
72+ return nil, err
73+ }
74+ }
75+ if fi, err := os.Lstat(path); err == nil {
76+ if fi.Mode()&os.ModeSocket == 0 {
77+ return nil, fmt.Errorf("%s exists and is not a socket", path)
78+ }
79+ if c, err := net.DialTimeout("unix", path, time.Second); err == nil {
80+ c.Close()
81+ return nil, errors.New("another neferafk daemon is already running")
82+ }
83+ if err := os.Remove(path); err != nil {
84+ return nil, err
85+ }
86+ }
87+ ln, err := net.ListenUnix("unix", &net.UnixAddr{Name: path, Net: "unix"})
88+ if err != nil {
89+ return nil, err
90+ }
91+ if err := os.Chmod(path, 0o600); err != nil {
92+ ln.Close()
93+ return nil, err
94+ }
95+ return &Server{ln: ln, path: path, uid: uid, requests: requests, log: log.WithField("component", "control")}, nil
96+}
97+
98+// Close releases the listener and removes the socket without serving (startup
99+// failure path). Serve does this itself on cancellation.
100+func (s *Server) Close() error {
101+ err := s.ln.Close()
102+ if e := os.Remove(s.path); e != nil && !errors.Is(e, os.ErrNotExist) {
103+ err = errors.Join(err, e)
104+ }
105+ return err
106+}
107+
108+// Serve accepts until ctx ends, then removes the socket. It always returns nil
109+// on cancellation.
110+func (s *Server) Serve(ctx context.Context) error {
111+ stop := context.AfterFunc(ctx, func() { s.ln.Close() })
112+ defer stop()
113+ defer os.Remove(s.path)
114+ slots := make(chan struct{}, maxConcurrent)
115+ for {
116+ c, err := s.ln.AcceptUnix()
117+ if err != nil {
118+ if ctx.Err() != nil {
119+ return nil
120+ }
121+ return err
122+ }
123+ select {
124+ case slots <- struct{}{}:
125+ go func() {
126+ defer func() { <-slots }()
127+ s.serve(ctx, c)
128+ }()
129+ default:
130+ c.Close() // shed load instead of queueing
131+ }
132+ }
133+}
134+
135+func peerUID(c *net.UnixConn) (uint32, error) {
136+ raw, err := c.SyscallConn()
137+ if err != nil {
138+ return 0, err
139+ }
140+ var cred *unix.Ucred
141+ var serr error
142+ if err := raw.Control(func(fd uintptr) { cred, serr = unix.GetsockoptUcred(int(fd), unix.SOL_SOCKET, unix.SO_PEERCRED) }); err != nil {
143+ return 0, err
144+ }
145+ if serr != nil {
146+ return 0, serr
147+ }
148+ return cred.Uid, nil
149+}
150+
151+// parse maps a request line to a kind; anything else is rejected.
152+func parse(line string) (ports.ControlKind, bool) {
153+ switch line {
154+ case "lock":
155+ return ports.ControlLock, true
156+ case "status":
157+ return ports.ControlStatus, true
158+ }
159+ return 0, false
160+}
161+
162+func (s *Server) serve(ctx context.Context, c *net.UnixConn) {
163+ defer c.Close()
164+ uid, err := peerUID(c)
165+ if err != nil || uid != s.uid {
166+ s.log.Warn().Msg("control peer rejected")
167+ return // no reply for foreign peers
168+ }
169+ _ = c.SetDeadline(time.Now().Add(ioTimeout))
170+ line, err := bufio.NewReaderSize(io.LimitReader(c, maxLine+1), maxLine+1).ReadString('\n')
171+ line = strings.TrimSuffix(line, "\n")
172+ kind, ok := parse(line)
173+ if err != nil || len(line) > maxLine || !ok {
174+ fmt.Fprint(c, "err unknown command\n")
175+ return
176+ }
177+ reply := make(chan ports.ControlReply, 1)
178+ select {
179+ case s.requests <- ports.ControlRequest{Kind: kind, Reply: reply}:
180+ case <-ctx.Done():
181+ return
182+ case <-time.After(replyTimeout):
183+ fmt.Fprint(c, "err busy\n")
184+ return
185+ }
186+ select {
187+ case r := <-reply:
188+ _ = c.SetWriteDeadline(time.Now().Add(ioTimeout))
189+ fmt.Fprint(c, format(r))
190+ case <-ctx.Done():
191+ case <-time.After(replyTimeout):
192+ fmt.Fprint(c, "err busy\n")
193+ }
194+}
195+
196+func format(r ports.ControlReply) string {
197+ if r.Err != "" {
198+ return "err " + strings.NewReplacer("\n", " ", "\r", " ").Replace(r.Err) + "\n"
199+ }
200+ return fmt.Sprintf("ok generation=%d acquiring=%t protected=%t faded=%t off=%t\n", r.Generation, r.Acquiring, r.Protected, r.Faded, r.Off)
201+}
202+
203+// Do sends one command to the daemon and returns its reply line without the
204+// trailing newline. An "err ..." reply is returned as an error.
205+func Do(ctx context.Context, path, command string) (string, error) {
206+ var d net.Dialer
207+ ctx, cancel := context.WithTimeout(ctx, 2*replyTimeout)
208+ defer cancel()
209+ c, err := d.DialContext(ctx, "unix", path)
210+ if err != nil {
211+ return "", fmt.Errorf("daemon not reachable: %w", err)
212+ }
213+ defer c.Close()
214+ if dl, ok := ctx.Deadline(); ok {
215+ _ = c.SetDeadline(dl)
216+ }
217+ if _, err := fmt.Fprintf(c, "%s\n", command); err != nil {
218+ return "", err
219+ }
220+ line, err := bufio.NewReaderSize(io.LimitReader(c, 256), 256).ReadString('\n')
221+ if err != nil {
222+ return "", fmt.Errorf("no reply from daemon: %w", err)
223+ }
224+ line = strings.TrimSuffix(line, "\n")
225+ if rest, ok := strings.CutPrefix(line, "err "); ok {
226+ return "", errors.New(rest)
227+ }
228+ return line, nil
229+}
A internal/adapters/control/control_test.go +171 -0
@@ -0,0 +1,171 @@
1+package control
2+
3+import (
4+ "context"
5+ "io"
6+ "net"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+ "testing"
11+ "time"
12+
13+ "github.com/bnema/neferafk/internal/ports"
14+ "github.com/bnema/zerowrap"
15+)
16+
17+func socketPath(t *testing.T) string {
18+ t.Helper()
19+ // Unix socket paths are short-limited: keep the temp dir shallow.
20+ dir, err := os.MkdirTemp("", "nc")
21+ if err != nil {
22+ t.Fatal(err)
23+ }
24+ t.Cleanup(func() { os.RemoveAll(dir) })
25+ return filepath.Join(dir, "neferafk", "control.sock")
26+}
27+
28+// start serves with allowed as the only accepted UID and answers requests with
29+// a channel-driven owner stand-in (a goroutine, not a project-interface double).
30+func start(t *testing.T, allowed uint32) (path string, requests <-chan ports.ControlKind) {
31+ t.Helper()
32+ path = socketPath(t)
33+ in := make(chan ports.ControlRequest)
34+ srv, err := Listen(path, allowed, in, zerowrap.New(zerowrap.Config{Output: io.Discard}))
35+ if err != nil {
36+ t.Fatal(err)
37+ }
38+ seen := make(chan ports.ControlKind, 8)
39+ ctx, cancel := context.WithCancel(context.Background())
40+ done := make(chan struct{})
41+ go func() { srv.Serve(ctx); close(done) }()
42+ go func() {
43+ for {
44+ select {
45+ case r := <-in:
46+ seen <- r.Kind
47+ r.Reply <- ports.ControlReply{Generation: 3, Protected: r.Kind == ports.ControlLock}
48+ case <-ctx.Done():
49+ return
50+ }
51+ }
52+ }()
53+ t.Cleanup(func() { cancel(); <-done })
54+ return path, seen
55+}
56+
57+func TestPermissionsAndCommands(t *testing.T) {
58+ path, seen := start(t, uint32(os.Getuid()))
59+ di, _ := os.Stat(filepath.Dir(path))
60+ si, _ := os.Lstat(path)
61+ if di.Mode().Perm() != 0o700 || si.Mode().Perm() != 0o600 || si.Mode()&os.ModeSocket == 0 {
62+ t.Fatalf("dir %v socket %v", di.Mode(), si.Mode())
63+ }
64+ got, err := Do(context.Background(), path, "status")
65+ if err != nil || got != "ok generation=3 acquiring=false protected=false faded=false off=false" {
66+ t.Fatalf("%q %v", got, err)
67+ }
68+ if got, err := Do(context.Background(), path, "lock"); err != nil || !strings.Contains(got, "protected=true") {
69+ t.Fatalf("%q %v", got, err)
70+ }
71+ if k1, k2 := <-seen, <-seen; k1 != ports.ControlStatus || k2 != ports.ControlLock {
72+ t.Fatalf("%v %v", k1, k2)
73+ }
74+}
75+
76+func TestUnknownCommandsRejectedNeverReachOwner(t *testing.T) {
77+ path, seen := start(t, uint32(os.Getuid()))
78+ for _, cmd := range []string{"unlock", "UNLOCK", "lock now", "lock ", " lock", "", "status\nunlock", strings.Repeat("a", 4096)} {
79+ c, err := net.Dial("unix", path)
80+ if err != nil {
81+ t.Fatal(err)
82+ }
83+ c.SetDeadline(time.Now().Add(2 * time.Second))
84+ io.WriteString(c, cmd+"\n")
85+ reply, _ := io.ReadAll(c)
86+ c.Close()
87+ if string(reply) != "err unknown command\n" && !(cmd == "status\nunlock" && strings.HasPrefix(string(reply), "ok ")) {
88+ t.Fatalf("%q -> %q", cmd, reply)
89+ }
90+ }
91+ // "status\nunlock" is a valid status line followed by ignored input.
92+ if k := <-seen; k != ports.ControlStatus {
93+ t.Fatalf("kind %v", k)
94+ }
95+ select {
96+ case k := <-seen:
97+ t.Fatalf("unexpected request %v", k)
98+ case <-time.After(50 * time.Millisecond):
99+ }
100+ if _, err := Do(context.Background(), path, "unlock"); err == nil {
101+ t.Fatal("client got success for unlock")
102+ }
103+}
104+
105+func TestForeignUIDRejectedWithoutReply(t *testing.T) {
106+ // Only uid+1 is allowed, so this process's own credentials are foreign.
107+ path, seen := start(t, uint32(os.Getuid())+1)
108+ if got, err := Do(context.Background(), path, "lock"); err == nil {
109+ t.Fatalf("foreign peer served: %q", got)
110+ }
111+ select {
112+ case k := <-seen:
113+ t.Fatalf("foreign request reached owner: %v", k)
114+ case <-time.After(50 * time.Millisecond):
115+ }
116+}
117+
118+func TestListenRefusesLiveDaemonReplacesStaleSocketAndRejectsNonSocket(t *testing.T) {
119+ path, _ := start(t, uint32(os.Getuid()))
120+ if _, err := Listen(path, uint32(os.Getuid()), make(chan ports.ControlRequest), zerowrap.New(zerowrap.Config{Output: io.Discard})); err == nil {
121+ t.Fatal("second daemon took the live socket")
122+ }
123+ stale := socketPath(t)
124+ os.MkdirAll(filepath.Dir(stale), 0o700)
125+ ln, err := net.Listen("unix", stale)
126+ if err != nil {
127+ t.Fatal(err)
128+ }
129+ ln.(*net.UnixListener).SetUnlinkOnClose(false)
130+ ln.Close()
131+ srv, err := Listen(stale, uint32(os.Getuid()), make(chan ports.ControlRequest), zerowrap.New(zerowrap.Config{Output: io.Discard}))
132+ if err != nil {
133+ t.Fatalf("stale socket not replaced: %v", err)
134+ }
135+ srv.ln.Close()
136+ file := socketPath(t)
137+ os.MkdirAll(filepath.Dir(file), 0o700)
138+ os.WriteFile(file, nil, 0o600)
139+ if _, err := Listen(file, uint32(os.Getuid()), make(chan ports.ControlRequest), zerowrap.New(zerowrap.Config{Output: io.Discard})); err == nil {
140+ t.Fatal("regular file replaced")
141+ }
142+}
143+
144+func TestListenTightensLooseDirectoryAndRejectsForeignDirectory(t *testing.T) {
145+ path := socketPath(t)
146+ os.MkdirAll(filepath.Dir(path), 0o755)
147+ os.Chmod(filepath.Dir(path), 0o755)
148+ srv, err := Listen(path, uint32(os.Getuid()), make(chan ports.ControlRequest), zerowrap.New(zerowrap.Config{Output: io.Discard}))
149+ if err != nil {
150+ t.Fatal(err)
151+ }
152+ srv.ln.Close()
153+ if fi, _ := os.Stat(filepath.Dir(path)); fi.Mode().Perm() != 0o700 {
154+ t.Fatalf("dir mode %v", fi.Mode())
155+ }
156+ // A directory that is not ours (root's /proc) is refused.
157+ if _, err := Listen("/proc/neferafk/control.sock", uint32(os.Getuid()), make(chan ports.ControlRequest), zerowrap.New(zerowrap.Config{Output: io.Discard})); err == nil {
158+ t.Fatal("foreign directory accepted")
159+ }
160+}
161+
162+func TestDefaultPathRequiresAbsoluteRuntimeDir(t *testing.T) {
163+ t.Setenv("XDG_RUNTIME_DIR", "")
164+ if _, err := DefaultPath(); err == nil {
165+ t.Fatal("empty runtime dir accepted")
166+ }
167+ t.Setenv("XDG_RUNTIME_DIR", "/run/user/1000")
168+ if p, err := DefaultPath(); err != nil || p != "/run/user/1000/neferafk/control.sock" {
169+ t.Fatalf("%q %v", p, err)
170+ }
171+}
A internal/adapters/logind/bus.go +34 -0
@@ -0,0 +1,34 @@
1+package logind
2+
3+import (
4+ "context"
5+ "github.com/godbus/dbus/v5"
6+)
7+
8+// bus is an adapter-local godbus seam. newAdapter consumes its connection.
9+type bus interface {
10+ call(context.Context, string, dbus.ObjectPath, string, ...any) ([]any, error)
11+ suspend(context.Context, string) <-chan *dbus.Call
12+ match(context.Context, string) error
13+ signals(chan<- *dbus.Signal)
14+ removeSignals(chan<- *dbus.Signal)
15+ unixFDs() bool
16+ close() error
17+}
18+type connection struct{ conn *dbus.Conn }
19+
20+func (c connection) call(ctx context.Context, destination string, path dbus.ObjectPath, method string, args ...any) ([]any, error) {
21+ call := c.conn.Object(destination, path).CallWithContext(ctx, method, 0, args...)
22+ return call.Body, call.Err
23+}
24+func (c connection) suspend(ctx context.Context, owner string) <-chan *dbus.Call {
25+ call := c.conn.Object(owner, managerPath).GoWithContext(ctx, managerInterface+".Suspend", 0, make(chan *dbus.Call, 1), false)
26+ return call.Done
27+}
28+func (c connection) match(ctx context.Context, rule string) error {
29+ return c.conn.BusObject().CallWithContext(ctx, "org.freedesktop.DBus.AddMatch", 0, rule).Err
30+}
31+func (c connection) signals(ch chan<- *dbus.Signal) { c.conn.Signal(ch) }
32+func (c connection) removeSignals(ch chan<- *dbus.Signal) { c.conn.RemoveSignal(ch) }
33+func (c connection) unixFDs() bool { return c.conn.SupportsUnixFDs() }
34+func (c connection) close() error { return c.conn.Close() }
A internal/adapters/logind/bus_mock_test.go +427 -0
@@ -0,0 +1,427 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package logind
6+
7+import (
8+ "context"
9+
10+ "github.com/godbus/dbus/v5"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// newMockbus creates a new instance of mockbus. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func newMockbus(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *mockbus {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &mockbus{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// mockbus is an autogenerated mock type for the bus type
38+type mockbus struct {
39+ mock.Mock
40+}
41+
42+type mockbus_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *mockbus) EXPECT() *mockbus_Expecter {
47+ return &mockbus_Expecter{mock: &_m.Mock}
48+}
49+
50+// call provides a mock function for the type mockbus
51+func (_mock *mockbus) call(context1 context.Context, s string, objectPath dbus.ObjectPath, s1 string, anyMoqParams ...any) ([]any, error) {
52+ var tmpRet mock.Arguments
53+ if len(anyMoqParams) > 0 {
54+ tmpRet = _mock.Called(context1, s, objectPath, s1, anyMoqParams)
55+ } else {
56+ tmpRet = _mock.Called(context1, s, objectPath, s1)
57+ }
58+ ret := tmpRet
59+
60+ if len(ret) == 0 {
61+ panic("no return value specified for call")
62+ }
63+
64+ var r0 []any
65+ var r1 error
66+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, dbus.ObjectPath, string, ...any) ([]any, error)); ok {
67+ return returnFunc(context1, s, objectPath, s1, anyMoqParams...)
68+ }
69+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, dbus.ObjectPath, string, ...any) []any); ok {
70+ r0 = returnFunc(context1, s, objectPath, s1, anyMoqParams...)
71+ } else {
72+ if ret.Get(0) != nil {
73+ r0 = ret.Get(0).([]any)
74+ }
75+ }
76+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, dbus.ObjectPath, string, ...any) error); ok {
77+ r1 = returnFunc(context1, s, objectPath, s1, anyMoqParams...)
78+ } else {
79+ r1 = ret.Error(1)
80+ }
81+ return r0, r1
82+}
83+
84+// mockbus_call_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'call'
85+type mockbus_call_Call struct {
86+ *mock.Call
87+}
88+
89+// call is a helper method to define mock.On call
90+// - context1 context.Context
91+// - s string
92+// - objectPath dbus.ObjectPath
93+// - s1 string
94+// - anyMoqParams ...any
95+func (_e *mockbus_Expecter) call(context1 any, s any, objectPath any, s1 any, anyMoqParams ...any) *mockbus_call_Call {
96+ return &mockbus_call_Call{Call: _e.mock.On("call",
97+ append([]any{context1, s, objectPath, s1}, anyMoqParams...)...)}
98+}
99+
100+func (_c *mockbus_call_Call) Run(run func(context1 context.Context, s string, objectPath dbus.ObjectPath, s1 string, anyMoqParams ...any)) *mockbus_call_Call {
101+ _c.Call.Run(func(args mock.Arguments) {
102+ var arg0 context.Context
103+ if args[0] != nil {
104+ arg0 = args[0].(context.Context)
105+ }
106+ var arg1 string
107+ if args[1] != nil {
108+ arg1 = args[1].(string)
109+ }
110+ var arg2 dbus.ObjectPath
111+ if args[2] != nil {
112+ arg2 = args[2].(dbus.ObjectPath)
113+ }
114+ var arg3 string
115+ if args[3] != nil {
116+ arg3 = args[3].(string)
117+ }
118+ var arg4 []any
119+ var variadicArgs []any
120+ if len(args) > 4 {
121+ variadicArgs = args[4].([]any)
122+ }
123+ arg4 = variadicArgs
124+ run(
125+ arg0,
126+ arg1,
127+ arg2,
128+ arg3,
129+ arg4...,
130+ )
131+ })
132+ return _c
133+}
134+
135+func (_c *mockbus_call_Call) Return(anyMoqParams1 []any, err error) *mockbus_call_Call {
136+ _c.Call.Return(anyMoqParams1, err)
137+ return _c
138+}
139+
140+func (_c *mockbus_call_Call) RunAndReturn(run func(context1 context.Context, s string, objectPath dbus.ObjectPath, s1 string, anyMoqParams ...any) ([]any, error)) *mockbus_call_Call {
141+ _c.Call.Return(run)
142+ return _c
143+}
144+
145+// close provides a mock function for the type mockbus
146+func (_mock *mockbus) close() error {
147+ ret := _mock.Called()
148+
149+ if len(ret) == 0 {
150+ panic("no return value specified for close")
151+ }
152+
153+ var r0 error
154+ if returnFunc, ok := ret.Get(0).(func() error); ok {
155+ r0 = returnFunc()
156+ } else {
157+ r0 = ret.Error(0)
158+ }
159+ return r0
160+}
161+
162+// mockbus_close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'close'
163+type mockbus_close_Call struct {
164+ *mock.Call
165+}
166+
167+// close is a helper method to define mock.On call
168+func (_e *mockbus_Expecter) close() *mockbus_close_Call {
169+ return &mockbus_close_Call{Call: _e.mock.On("close")}
170+}
171+
172+func (_c *mockbus_close_Call) Run(run func()) *mockbus_close_Call {
173+ _c.Call.Run(func(args mock.Arguments) {
174+ run()
175+ })
176+ return _c
177+}
178+
179+func (_c *mockbus_close_Call) Return(err error) *mockbus_close_Call {
180+ _c.Call.Return(err)
181+ return _c
182+}
183+
184+func (_c *mockbus_close_Call) RunAndReturn(run func() error) *mockbus_close_Call {
185+ _c.Call.Return(run)
186+ return _c
187+}
188+
189+// match provides a mock function for the type mockbus
190+func (_mock *mockbus) match(context1 context.Context, s string) error {
191+ ret := _mock.Called(context1, s)
192+
193+ if len(ret) == 0 {
194+ panic("no return value specified for match")
195+ }
196+
197+ var r0 error
198+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok {
199+ r0 = returnFunc(context1, s)
200+ } else {
201+ r0 = ret.Error(0)
202+ }
203+ return r0
204+}
205+
206+// mockbus_match_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'match'
207+type mockbus_match_Call struct {
208+ *mock.Call
209+}
210+
211+// match is a helper method to define mock.On call
212+// - context1 context.Context
213+// - s string
214+func (_e *mockbus_Expecter) match(context1 any, s any) *mockbus_match_Call {
215+ return &mockbus_match_Call{Call: _e.mock.On("match", context1, s)}
216+}
217+
218+func (_c *mockbus_match_Call) Run(run func(context1 context.Context, s string)) *mockbus_match_Call {
219+ _c.Call.Run(func(args mock.Arguments) {
220+ var arg0 context.Context
221+ if args[0] != nil {
222+ arg0 = args[0].(context.Context)
223+ }
224+ var arg1 string
225+ if args[1] != nil {
226+ arg1 = args[1].(string)
227+ }
228+ run(
229+ arg0,
230+ arg1,
231+ )
232+ })
233+ return _c
234+}
235+
236+func (_c *mockbus_match_Call) Return(err error) *mockbus_match_Call {
237+ _c.Call.Return(err)
238+ return _c
239+}
240+
241+func (_c *mockbus_match_Call) RunAndReturn(run func(context1 context.Context, s string) error) *mockbus_match_Call {
242+ _c.Call.Return(run)
243+ return _c
244+}
245+
246+// removeSignals provides a mock function for the type mockbus
247+func (_mock *mockbus) removeSignals(signalCh chan<- *dbus.Signal) {
248+ _mock.Called(signalCh)
249+ return
250+}
251+
252+// mockbus_removeSignals_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'removeSignals'
253+type mockbus_removeSignals_Call struct {
254+ *mock.Call
255+}
256+
257+// removeSignals is a helper method to define mock.On call
258+// - signalCh chan<- *dbus.Signal
259+func (_e *mockbus_Expecter) removeSignals(signalCh any) *mockbus_removeSignals_Call {
260+ return &mockbus_removeSignals_Call{Call: _e.mock.On("removeSignals", signalCh)}
261+}
262+
263+func (_c *mockbus_removeSignals_Call) Run(run func(signalCh chan<- *dbus.Signal)) *mockbus_removeSignals_Call {
264+ _c.Call.Run(func(args mock.Arguments) {
265+ var arg0 chan<- *dbus.Signal
266+ if args[0] != nil {
267+ arg0 = args[0].(chan<- *dbus.Signal)
268+ }
269+ run(
270+ arg0,
271+ )
272+ })
273+ return _c
274+}
275+
276+func (_c *mockbus_removeSignals_Call) Return() *mockbus_removeSignals_Call {
277+ _c.Call.Return()
278+ return _c
279+}
280+
281+func (_c *mockbus_removeSignals_Call) RunAndReturn(run func(signalCh chan<- *dbus.Signal)) *mockbus_removeSignals_Call {
282+ _c.Run(run)
283+ return _c
284+}
285+
286+// signals provides a mock function for the type mockbus
287+func (_mock *mockbus) signals(signalCh chan<- *dbus.Signal) {
288+ _mock.Called(signalCh)
289+ return
290+}
291+
292+// mockbus_signals_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'signals'
293+type mockbus_signals_Call struct {
294+ *mock.Call
295+}
296+
297+// signals is a helper method to define mock.On call
298+// - signalCh chan<- *dbus.Signal
299+func (_e *mockbus_Expecter) signals(signalCh any) *mockbus_signals_Call {
300+ return &mockbus_signals_Call{Call: _e.mock.On("signals", signalCh)}
301+}
302+
303+func (_c *mockbus_signals_Call) Run(run func(signalCh chan<- *dbus.Signal)) *mockbus_signals_Call {
304+ _c.Call.Run(func(args mock.Arguments) {
305+ var arg0 chan<- *dbus.Signal
306+ if args[0] != nil {
307+ arg0 = args[0].(chan<- *dbus.Signal)
308+ }
309+ run(
310+ arg0,
311+ )
312+ })
313+ return _c
314+}
315+
316+func (_c *mockbus_signals_Call) Return() *mockbus_signals_Call {
317+ _c.Call.Return()
318+ return _c
319+}
320+
321+func (_c *mockbus_signals_Call) RunAndReturn(run func(signalCh chan<- *dbus.Signal)) *mockbus_signals_Call {
322+ _c.Run(run)
323+ return _c
324+}
325+
326+// suspend provides a mock function for the type mockbus
327+func (_mock *mockbus) suspend(context1 context.Context, s string) <-chan *dbus.Call {
328+ ret := _mock.Called(context1, s)
329+
330+ if len(ret) == 0 {
331+ panic("no return value specified for suspend")
332+ }
333+
334+ var r0 <-chan *dbus.Call
335+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) <-chan *dbus.Call); ok {
336+ r0 = returnFunc(context1, s)
337+ } else {
338+ if ret.Get(0) != nil {
339+ r0 = ret.Get(0).(<-chan *dbus.Call)
340+ }
341+ }
342+ return r0
343+}
344+
345+// mockbus_suspend_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'suspend'
346+type mockbus_suspend_Call struct {
347+ *mock.Call
348+}
349+
350+// suspend is a helper method to define mock.On call
351+// - context1 context.Context
352+// - s string
353+func (_e *mockbus_Expecter) suspend(context1 any, s any) *mockbus_suspend_Call {
354+ return &mockbus_suspend_Call{Call: _e.mock.On("suspend", context1, s)}
355+}
356+
357+func (_c *mockbus_suspend_Call) Run(run func(context1 context.Context, s string)) *mockbus_suspend_Call {
358+ _c.Call.Run(func(args mock.Arguments) {
359+ var arg0 context.Context
360+ if args[0] != nil {
361+ arg0 = args[0].(context.Context)
362+ }
363+ var arg1 string
364+ if args[1] != nil {
365+ arg1 = args[1].(string)
366+ }
367+ run(
368+ arg0,
369+ arg1,
370+ )
371+ })
372+ return _c
373+}
374+
375+func (_c *mockbus_suspend_Call) Return(callCh <-chan *dbus.Call) *mockbus_suspend_Call {
376+ _c.Call.Return(callCh)
377+ return _c
378+}
379+
380+func (_c *mockbus_suspend_Call) RunAndReturn(run func(context1 context.Context, s string) <-chan *dbus.Call) *mockbus_suspend_Call {
381+ _c.Call.Return(run)
382+ return _c
383+}
384+
385+// unixFDs provides a mock function for the type mockbus
386+func (_mock *mockbus) unixFDs() bool {
387+ ret := _mock.Called()
388+
389+ if len(ret) == 0 {
390+ panic("no return value specified for unixFDs")
391+ }
392+
393+ var r0 bool
394+ if returnFunc, ok := ret.Get(0).(func() bool); ok {
395+ r0 = returnFunc()
396+ } else {
397+ r0 = ret.Get(0).(bool)
398+ }
399+ return r0
400+}
401+
402+// mockbus_unixFDs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'unixFDs'
403+type mockbus_unixFDs_Call struct {
404+ *mock.Call
405+}
406+
407+// unixFDs is a helper method to define mock.On call
408+func (_e *mockbus_Expecter) unixFDs() *mockbus_unixFDs_Call {
409+ return &mockbus_unixFDs_Call{Call: _e.mock.On("unixFDs")}
410+}
411+
412+func (_c *mockbus_unixFDs_Call) Run(run func()) *mockbus_unixFDs_Call {
413+ _c.Call.Run(func(args mock.Arguments) {
414+ run()
415+ })
416+ return _c
417+}
418+
419+func (_c *mockbus_unixFDs_Call) Return(b bool) *mockbus_unixFDs_Call {
420+ _c.Call.Return(b)
421+ return _c
422+}
423+
424+func (_c *mockbus_unixFDs_Call) RunAndReturn(run func() bool) *mockbus_unixFDs_Call {
425+ _c.Call.Return(run)
426+ return _c
427+}
A internal/adapters/logind/logind.go +382 -0
@@ -0,0 +1,382 @@
1+// Package logind observes the current process's session and owns bounded
2+// logind calls/inhibitor descriptors. It contains no lock or sleep policy.
3+package logind
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "math"
10+ "os"
11+ "strings"
12+ "time"
13+
14+ "github.com/bnema/neferafk/internal/ports"
15+ "github.com/godbus/dbus/v5"
16+ "golang.org/x/sys/unix"
17+)
18+
19+const service = "org.freedesktop.login1"
20+const managerPath dbus.ObjectPath = "/org/freedesktop/login1"
21+const managerInterface = "org.freedesktop.login1.Manager"
22+const sessionInterface = "org.freedesktop.login1.Session"
23+const callTimeout = 3 * time.Second
24+
25+// Adapter is single-owner: Probe, Run and Close must not execute concurrently.
26+// Run owns the connection and closes it and any inhibitor on every exit.
27+type Adapter struct {
28+ bus bus
29+ requirements ports.SystemRequirements
30+ owner string
31+ session dbus.ObjectPath
32+ caps ports.SystemCapabilities
33+ signals chan *dbus.Signal
34+ inhibitor *os.File
35+ cycle uint64
36+ preparing bool
37+ closed bool
38+}
39+
40+// Open connects only when requested. Calling it is unnecessary when all system
41+// features are off. Connection failure is an unsupported capability, not a
42+// reason to force system initialization at daemon startup.
43+func Open(ctx context.Context, requirements ports.SystemRequirements) (*Adapter, error) {
44+ if !requirements.Session && !requirements.Sleep {
45+ return nil, errors.New("logind not requested")
46+ }
47+ conn, err := dbus.ConnectSystemBus(dbus.WithContext(ctx))
48+ if err != nil {
49+ return nil, fmt.Errorf("system bus unavailable: %w", err)
50+ }
51+ a := newAdapter(connection{conn}, requirements)
52+ if err := a.Probe(ctx); err != nil {
53+ a.Close()
54+ return nil, err
55+ }
56+ return a, nil
57+}
58+func newAdapter(b bus, r ports.SystemRequirements) *Adapter {
59+ return &Adapter{bus: b, requirements: r, signals: make(chan *dbus.Signal, 32)}
60+}
61+func (a *Adapter) call(ctx context.Context, path dbus.ObjectPath, method string, args ...any) ([]any, error) {
62+ ctx, cancel := context.WithTimeout(ctx, callTimeout)
63+ defer cancel()
64+ return a.bus.call(ctx, a.owner, path, method, args...)
65+}
66+func (a *Adapter) property(ctx context.Context, path dbus.ObjectPath, iface, name string) (any, error) {
67+ body, err := a.call(ctx, path, "org.freedesktop.DBus.Properties.Get", iface, name)
68+ if err != nil {
69+ return nil, err
70+ }
71+ if len(body) != 1 {
72+ return nil, errors.New("invalid property reply")
73+ }
74+ v, ok := body[0].(dbus.Variant)
75+ if !ok {
76+ return nil, errors.New("invalid property variant")
77+ }
78+ return v.Value(), nil
79+}
80+
81+// Probe is read-only: no Lock/Unlock/Suspend/Inhibit call. It resolves the
82+// unique service owner, PID's session and its real UID; no user override exists.
83+func (a *Adapter) Probe(ctx context.Context) error {
84+ bounded, cancel := context.WithTimeout(ctx, callTimeout)
85+ defer cancel()
86+ body, err := a.bus.call(bounded, "org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus.GetNameOwner", service)
87+ if err != nil {
88+ return fmt.Errorf("logind unavailable: %w", err)
89+ }
90+ if len(body) != 1 {
91+ return errors.New("invalid logind owner reply")
92+ }
93+ owner, ok := body[0].(string)
94+ if !ok || !strings.HasPrefix(owner, ":") {
95+ return errors.New("invalid unique logind owner")
96+ }
97+ a.owner = owner
98+ a.caps = ports.SystemCapabilities{}
99+ if a.requirements.Session {
100+ body, err = a.call(ctx, managerPath, managerInterface+".GetSessionByPID", uint32(os.Getpid()))
101+ if err != nil {
102+ return fmt.Errorf("current process session unavailable: %w", err)
103+ }
104+ if len(body) != 1 {
105+ return errors.New("invalid session reply")
106+ }
107+ path, ok := body[0].(dbus.ObjectPath)
108+ if !ok || !path.IsValid() || !strings.HasPrefix(string(path), "/org/freedesktop/login1/session/") {
109+ return errors.New("invalid session path")
110+ }
111+ value, err := a.property(ctx, path, sessionInterface, "User")
112+ if err != nil {
113+ return err
114+ }
115+ fields, ok := value.([]any)
116+ if !ok || len(fields) != 2 {
117+ return errors.New("invalid session User property")
118+ }
119+ uid, ok := fields[0].(uint32)
120+ if !ok || uid != uint32(os.Getuid()) {
121+ return errors.New("session does not belong to real UID")
122+ }
123+ a.session = path
124+ a.caps.Session = true
125+ }
126+ if a.requirements.Sleep {
127+ body, err = a.call(ctx, managerPath, managerInterface+".CanSuspend")
128+ if err != nil {
129+ return fmt.Errorf("suspend capability unavailable: %w", err)
130+ }
131+ if len(body) != 1 {
132+ return errors.New("invalid CanSuspend reply")
133+ }
134+ auth, ok := body[0].(string)
135+ if !ok {
136+ return errors.New("invalid CanSuspend type")
137+ }
138+ a.caps.SuspendAuthorization = auth
139+ // Suspend(false) cannot request an interactive authorization prompt.
140+ a.caps.Suspend = auth == "yes"
141+ if !a.caps.Suspend {
142+ a.caps.Missing = append(a.caps.Missing, "non-interactive suspend: "+auth)
143+ }
144+ value, err := a.property(ctx, managerPath, managerInterface, "InhibitDelayMaxUSec")
145+ if err != nil {
146+ return err
147+ }
148+ usec, ok := value.(uint64)
149+ if !ok || usec == 0 || usec > uint64(math.MaxInt64/1000) {
150+ return errors.New("invalid inhibitor delay maximum")
151+ }
152+ a.caps.DelayMax = time.Duration(usec) * time.Microsecond
153+ if !a.bus.unixFDs() {
154+ a.caps.Missing = append(a.caps.Missing, "D-Bus Unix FD passing")
155+ a.caps.Suspend = false
156+ }
157+ }
158+ return nil
159+}
160+func (a *Adapter) Capabilities() ports.SystemCapabilities {
161+ c := a.caps
162+ c.Missing = append([]string(nil), c.Missing...)
163+ return c
164+}
165+func (a *Adapter) emit(ctx context.Context, out chan<- ports.SystemEvent, event ports.SystemEvent) error {
166+ select {
167+ case out <- event:
168+ return nil
169+ case <-ctx.Done():
170+ return ctx.Err()
171+ }
172+}
173+
174+// Run starts subscriptions and holds a delay inhibitor only for enabled sleep.
175+// A matching SleepReady releases it. Resume reacquires for the next cycle.
176+// logind's own maximum bounds the delay even if policy never replies.
177+func (a *Adapter) Run(ctx context.Context, commands <-chan ports.SystemCommand, events chan<- ports.SystemEvent) error {
178+ defer a.Close()
179+ if a.owner == "" {
180+ return errors.New("logind must be probed before Run")
181+ }
182+ a.bus.signals(a.signals)
183+ defer a.bus.removeSignals(a.signals)
184+ paths := []struct {
185+ path dbus.ObjectPath
186+ iface string
187+ }{}
188+ if a.requirements.Session {
189+ paths = append(paths, struct {
190+ path dbus.ObjectPath
191+ iface string
192+ }{a.session, sessionInterface})
193+ }
194+ if a.requirements.Sleep {
195+ paths = append(paths, struct {
196+ path dbus.ObjectPath
197+ iface string
198+ }{managerPath, managerInterface})
199+ }
200+ for _, p := range paths {
201+ rule := fmt.Sprintf("type='signal',sender='%s',path='%s',interface='%s'", a.owner, p.path, p.iface)
202+ bounded, cancel := context.WithTimeout(ctx, callTimeout)
203+ err := a.bus.match(bounded, rule)
204+ cancel()
205+ if err != nil {
206+ return err
207+ }
208+ }
209+ if a.requirements.Sleep && a.bus.unixFDs() {
210+ if err := a.inhibit(ctx); err != nil {
211+ a.caps.Missing = append(a.caps.Missing, "sleep delay inhibitor")
212+ a.caps.Suspend = false
213+ }
214+ }
215+ if err := a.emit(ctx, events, ports.SystemCapabilityReport{Capabilities: a.Capabilities()}); err != nil {
216+ return err
217+ }
218+ var suspendReply <-chan *dbus.Call
219+ var suspendCancel context.CancelFunc
220+ defer func() {
221+ if suspendCancel != nil {
222+ suspendCancel()
223+ }
224+ }()
225+ for {
226+ select {
227+ case reply := <-suspendReply:
228+ suspendCancel()
229+ suspendReply, suspendCancel = nil, nil
230+ if reply == nil {
231+ return errors.New("suspend reply channel closed")
232+ }
233+ if reply.Err != nil {
234+ if err := a.emit(ctx, events, ports.SystemFailure{Operation: "suspend", Err: reply.Err}); err != nil {
235+ return err
236+ }
237+ }
238+ case <-ctx.Done():
239+ return ctx.Err()
240+ case sig, ok := <-a.signals:
241+ if !ok {
242+ return errors.New("system bus signal stream closed")
243+ }
244+ event, ok := parseSignal(sig, a.owner, a.session)
245+ if !ok {
246+ continue
247+ }
248+ if prep, ok := event.(ports.SleepPreparation); ok {
249+ if prep.Preparing && !a.preparing {
250+ a.cycle++
251+ }
252+ a.preparing = prep.Preparing
253+ prep.Cycle, prep.MaxDelay, prep.ReceivedAt = a.cycle, a.caps.DelayMax, time.Now()
254+ event = prep
255+ if !prep.Preparing {
256+ a.release()
257+ if err := a.inhibit(ctx); err != nil {
258+ if e := a.emit(ctx, events, ports.SystemFailure{Operation: "inhibit", Err: err}); e != nil {
259+ return e
260+ }
261+ }
262+ }
263+ }
264+ if err := a.emit(ctx, events, event); err != nil {
265+ return err
266+ }
267+ case cmd, ok := <-commands:
268+ if !ok {
269+ return nil
270+ }
271+ switch c := cmd.(type) {
272+ case ports.SystemSleepReady:
273+ if a.preparing && c.Cycle == a.cycle {
274+ a.release()
275+ }
276+ case ports.SystemSuspend:
277+ var err error
278+ if !a.requirements.Sleep || !a.caps.Suspend {
279+ err = errors.New("non-interactive suspend unsupported")
280+ } else if suspendReply != nil {
281+ err = errors.New("suspend request already pending")
282+ } else {
283+ // Never override block inhibitors, including ones owned by
284+ // this UID (which privileged logind policies may otherwise
285+ // permit). Query immediately before the ordinary Suspend call.
286+ blocked, e := a.property(ctx, managerPath, managerInterface, "BlockInhibited")
287+ if e != nil {
288+ err = e
289+ } else if value, ok := blocked.(string); !ok {
290+ err = errors.New("invalid block inhibitor property")
291+ } else {
292+ for _, item := range strings.Split(value, ":") {
293+ if item == "sleep" {
294+ err = errors.New("sleep blocked by inhibitor")
295+ }
296+ }
297+ }
298+ if err != nil {
299+ if e := a.emit(ctx, events, ports.SystemFailure{Operation: "suspend", Err: err}); e != nil {
300+ return e
301+ }
302+ continue
303+ }
304+ bounded, cancel := context.WithTimeout(ctx, callTimeout)
305+ suspendCancel = cancel // released on reply or by the loop's defer
306+ suspendReply = a.bus.suspend(bounded, a.owner)
307+ }
308+ if err != nil {
309+ if e := a.emit(ctx, events, ports.SystemFailure{Operation: "suspend", Err: err}); e != nil {
310+ return e
311+ }
312+ }
313+ }
314+ }
315+ }
316+}
317+
318+func parseSignal(sig *dbus.Signal, owner string, session dbus.ObjectPath) (ports.SystemEvent, bool) {
319+ if sig == nil || sig.Sender != owner {
320+ return nil, false
321+ }
322+ if sig.Path == session && session != "" && len(sig.Body) == 0 {
323+ switch sig.Name {
324+ case sessionInterface + ".Lock":
325+ return ports.SystemLockRequested{}, true
326+ case sessionInterface + ".Unlock":
327+ return ports.SystemUnlockObserved{}, true
328+ }
329+ }
330+ if sig.Path == managerPath && sig.Name == managerInterface+".PrepareForSleep" && len(sig.Body) == 1 {
331+ preparing, ok := sig.Body[0].(bool)
332+ if ok {
333+ return ports.SleepPreparation{Preparing: preparing}, true
334+ }
335+ }
336+ return nil, false
337+}
338+
339+func (a *Adapter) inhibit(ctx context.Context) error {
340+ if !a.requirements.Sleep || a.inhibitor != nil {
341+ return nil
342+ }
343+ body, err := a.call(ctx, managerPath, managerInterface+".Inhibit", "sleep", "neferafk", "session protection", "delay")
344+ if err != nil {
345+ return err
346+ }
347+ if len(body) != 1 {
348+ for _, value := range body {
349+ if fd, ok := value.(dbus.UnixFD); ok && fd >= 0 {
350+ _ = unix.Close(int(fd))
351+ }
352+ }
353+ return errors.New("invalid inhibitor reply")
354+ }
355+ fd, ok := body[0].(dbus.UnixFD)
356+ if !ok || fd < 0 {
357+ return errors.New("invalid inhibitor descriptor")
358+ }
359+ // godbus returns the received descriptor directly, not an os.File and not
360+ // an owned duplicate. Atomically duplicate CLOEXEC then close the received FD.
361+ dup, err := unix.FcntlInt(uintptr(fd), unix.F_DUPFD_CLOEXEC, 0)
362+ _ = unix.Close(int(fd))
363+ if err != nil {
364+ return err
365+ }
366+ a.inhibitor = os.NewFile(uintptr(dup), "logind-sleep-delay")
367+ return nil
368+}
369+func (a *Adapter) release() {
370+ if a.inhibitor != nil {
371+ _ = a.inhibitor.Close()
372+ a.inhibitor = nil
373+ }
374+}
375+func (a *Adapter) Close() error {
376+ if a.closed {
377+ return nil
378+ }
379+ a.closed = true
380+ a.release()
381+ return a.bus.close()
382+}
A internal/adapters/logind/logind_test.go +209 -0
@@ -0,0 +1,209 @@
1+package logind
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "os"
8+ "testing"
9+ "time"
10+
11+ "github.com/bnema/neferafk/internal/ports"
12+ "github.com/godbus/dbus/v5"
13+ "github.com/stretchr/testify/mock"
14+ "golang.org/x/sys/unix"
15+)
16+
17+const testOwner = ":1.42"
18+const testSession dbus.ObjectPath = "/org/freedesktop/login1/session/c1"
19+
20+func expectProbe(t *testing.T, b *mockbus, uid uint32) {
21+ t.Helper()
22+ bounded := mock.MatchedBy(func(ctx context.Context) bool { d, ok := ctx.Deadline(); return ok && time.Until(d) <= callTimeout })
23+ b.EXPECT().call(bounded, "org.freedesktop.DBus", dbus.ObjectPath("/org/freedesktop/DBus"), "org.freedesktop.DBus.GetNameOwner", []any{service}).Return([]any{testOwner}, nil).Once()
24+ b.EXPECT().call(bounded, testOwner, managerPath, managerInterface+".GetSessionByPID", []any{uint32(os.Getpid())}).Return([]any{testSession}, nil).Once()
25+ b.EXPECT().call(bounded, testOwner, testSession, "org.freedesktop.DBus.Properties.Get", []any{sessionInterface, "User"}).Return([]any{dbus.MakeVariant([]any{uid, dbus.ObjectPath("/org/freedesktop/login1/user/_1000")})}, nil).Once()
26+}
27+func TestProbeCurrentPIDRealUIDAndReadOnly(t *testing.T) {
28+ b := newMockbus(t)
29+ expectProbe(t, b, uint32(os.Getuid()))
30+ b.EXPECT().call(mock.Anything, testOwner, managerPath, managerInterface+".CanSuspend").Return([]any{"yes"}, nil).Once()
31+ b.EXPECT().call(mock.Anything, testOwner, managerPath, "org.freedesktop.DBus.Properties.Get", []any{managerInterface, "InhibitDelayMaxUSec"}).Return([]any{dbus.MakeVariant(uint64(5_000_000))}, nil).Once()
32+ b.EXPECT().unixFDs().Return(true).Once()
33+ a := newAdapter(b, ports.SystemRequirements{Session: true, Sleep: true})
34+ if err := a.Probe(context.Background()); err != nil {
35+ t.Fatal(err)
36+ }
37+ if c := a.Capabilities(); !c.Session || !c.Suspend || c.DelayMax != 5*time.Second {
38+ t.Fatalf("capabilities %+v", c)
39+ }
40+ // No mutating call was configured: generated mock rejects Inhibit/Suspend.
41+}
42+func TestProbeRejectsOtherUID(t *testing.T) {
43+ b := newMockbus(t)
44+ expectProbe(t, b, uint32(os.Getuid())+1)
45+ if err := newAdapter(b, ports.SystemRequirements{Session: true}).Probe(context.Background()); err == nil {
46+ t.Fatal("other UID admitted")
47+ }
48+}
49+func TestSignalFilteringAndUnlockNotAuthority(t *testing.T) {
50+ cases := []struct {
51+ sig *dbus.Signal
52+ valid bool
53+ }{
54+ {&dbus.Signal{Sender: testOwner, Path: testSession, Name: sessionInterface + ".Lock"}, true},
55+ {&dbus.Signal{Sender: testOwner, Path: testSession, Name: sessionInterface + ".Unlock"}, true},
56+ {&dbus.Signal{Sender: testOwner, Path: managerPath, Name: managerInterface + ".PrepareForSleep", Body: []any{true}}, true},
57+ {&dbus.Signal{Sender: ":1.99", Path: testSession, Name: sessionInterface + ".Lock"}, false},
58+ {&dbus.Signal{Sender: testOwner, Path: "/org/freedesktop/login1/session/other", Name: sessionInterface + ".Unlock"}, false},
59+ {&dbus.Signal{Sender: testOwner, Path: managerPath, Name: managerInterface + ".PrepareForSleep", Body: []any{"true"}}, false},
60+ {&dbus.Signal{Sender: testOwner, Path: testSession, Name: sessionInterface + ".Lock", Body: []any{true}}, false},
61+ }
62+ for _, tc := range cases {
63+ event, ok := parseSignal(tc.sig, testOwner, testSession)
64+ if ok != tc.valid {
65+ t.Fatalf("signal %+v accepted=%v", tc.sig, ok)
66+ }
67+ if tc.sig.Name == sessionInterface+".Unlock" && ok {
68+ if _, ok := event.(ports.SystemUnlockObserved); !ok {
69+ t.Fatalf("unlock converted to authority %T", event)
70+ }
71+ }
72+ }
73+}
74+func TestInhibitorOwnsCloexecDuplicateAndClosesOnce(t *testing.T) {
75+ b := newMockbus(t)
76+ r, w, err := os.Pipe()
77+ if err != nil {
78+ t.Fatal(err)
79+ }
80+ defer r.Close()
81+ defer w.Close()
82+ received, err := unix.Dup(int(r.Fd()))
83+ if err != nil {
84+ t.Fatal(err)
85+ }
86+ b.EXPECT().call(mock.Anything, testOwner, managerPath, managerInterface+".Inhibit", []any{"sleep", "neferafk", "session protection", "delay"}).Return([]any{dbus.UnixFD(received)}, nil).Once()
87+ b.EXPECT().close().Return(nil).Once()
88+ a := newAdapter(b, ports.SystemRequirements{Sleep: true})
89+ a.owner = testOwner
90+ if err := a.inhibit(context.Background()); err != nil {
91+ t.Fatal(err)
92+ }
93+ if _, err := unix.FcntlInt(uintptr(received), unix.F_GETFD, 0); !errors.Is(err, unix.EBADF) {
94+ t.Fatalf("received FD not closed: %v", err)
95+ }
96+ held := a.inhibitor
97+ flags, err := unix.FcntlInt(held.Fd(), unix.F_GETFD, 0)
98+ if err != nil || flags&unix.FD_CLOEXEC == 0 {
99+ t.Fatal("inhibitor not CLOEXEC")
100+ }
101+ a.release()
102+ a.release()
103+ if _, err := held.Stat(); !errors.Is(err, os.ErrClosed) {
104+ t.Fatal("owned inhibitor still open")
105+ }
106+ if err := a.Close(); err != nil {
107+ t.Fatal(err)
108+ }
109+ if err := a.Close(); err != nil {
110+ t.Fatal(err)
111+ }
112+}
113+func TestRunMatchingSleepCycleAndSuspendFalse(t *testing.T) {
114+ b := newMockbus(t)
115+ a := newAdapter(b, ports.SystemRequirements{Session: true, Sleep: true})
116+ a.owner = testOwner
117+ a.session = testSession
118+ a.caps = ports.SystemCapabilities{Session: true, Suspend: true, DelayMax: 5 * time.Second}
119+ b.EXPECT().signals(mock.Anything).Return().Once()
120+ b.EXPECT().removeSignals(mock.Anything).Return().Once()
121+ b.EXPECT().match(mock.Anything, mock.Anything).Return(nil).Twice()
122+ b.EXPECT().unixFDs().Return(true).Once()
123+ b.EXPECT().close().Return(nil).Once()
124+ r, w, err := os.Pipe()
125+ if err != nil {
126+ t.Fatal(err)
127+ }
128+ defer r.Close()
129+ defer w.Close()
130+ received, err := unix.Dup(int(r.Fd()))
131+ if err != nil {
132+ t.Fatal(err)
133+ }
134+ b.EXPECT().call(mock.Anything, testOwner, managerPath, managerInterface+".Inhibit", []any{"sleep", "neferafk", "session protection", "delay"}).Return([]any{dbus.UnixFD(received)}, nil).Once()
135+ suspended := make(chan struct{})
136+ b.EXPECT().call(mock.Anything, testOwner, managerPath, "org.freedesktop.DBus.Properties.Get", []any{managerInterface, "BlockInhibited"}).Return([]any{dbus.MakeVariant("")}, nil).Once()
137+ b.EXPECT().suspend(mock.Anything, testOwner).RunAndReturn(func(ctx context.Context, _ string) <-chan *dbus.Call {
138+ if _, ok := ctx.Deadline(); !ok {
139+ t.Error("unbounded suspend call")
140+ }
141+ close(suspended)
142+ return make(chan *dbus.Call, 1) // reply held: owner must still process SleepReady
143+ }).Once()
144+ commands := make(chan ports.SystemCommand)
145+ events := make(chan ports.SystemEvent, 8)
146+ done := make(chan error, 1)
147+ go func() { done <- a.Run(context.Background(), commands, events) }()
148+ <-events // capability report synchronizes initial inhibitor ownership
149+ a.signals <- &dbus.Signal{Sender: testOwner, Path: managerPath, Name: managerInterface + ".PrepareForSleep", Body: []any{true}}
150+ prep := (<-events).(ports.SleepPreparation)
151+ if prep.Cycle != 1 || prep.MaxDelay != 5*time.Second || prep.ReceivedAt.IsZero() {
152+ t.Fatalf("sleep prep %+v", prep)
153+ }
154+ commands <- ports.SystemSleepReady{Cycle: 99}
155+ commands <- ports.SystemSuspend{}
156+ <-suspended // ordered owner checkpoint, no live suspend involved
157+ held := a.inhibitor
158+ if held == nil {
159+ t.Fatal("stale cycle released inhibitor")
160+ }
161+ commands <- ports.SystemSleepReady{Cycle: prep.Cycle}
162+ close(commands)
163+ select {
164+ case err := <-done:
165+ if err != nil {
166+ t.Fatal(err)
167+ }
168+ case <-time.After(time.Second):
169+ t.Fatal("Run blocked")
170+ }
171+ if _, err := held.Stat(); !errors.Is(err, os.ErrClosed) {
172+ t.Fatal("matching ready did not close inhibitor")
173+ }
174+}
175+func TestOpenOffDoesNotConnect(t *testing.T) {
176+ if _, err := Open(context.Background(), ports.SystemRequirements{}); err == nil {
177+ t.Fatal("disabled adapter unexpectedly opened")
178+ }
179+}
180+
181+func TestSuspendBlockedAndUnavailableNeverCallsSuspend(t *testing.T) {
182+ for _, blocked := range []bool{true, false} {
183+ t.Run(fmt.Sprint(blocked), func(t *testing.T) {
184+ b := newMockbus(t)
185+ a := newAdapter(b, ports.SystemRequirements{Sleep: true})
186+ a.owner = testOwner
187+ a.caps.Suspend = blocked
188+ b.EXPECT().signals(mock.Anything).Return().Once()
189+ b.EXPECT().removeSignals(mock.Anything).Return().Once()
190+ b.EXPECT().match(mock.Anything, mock.Anything).Return(nil).Once()
191+ b.EXPECT().unixFDs().Return(false).Once()
192+ b.EXPECT().close().Return(nil).Once()
193+ if blocked {
194+ b.EXPECT().call(mock.Anything, testOwner, managerPath, "org.freedesktop.DBus.Properties.Get", []any{managerInterface, "BlockInhibited"}).Return([]any{dbus.MakeVariant("sleep:shutdown")}, nil).Once()
195+ }
196+ commands := make(chan ports.SystemCommand, 1)
197+ events := make(chan ports.SystemEvent, 3)
198+ commands <- ports.SystemSuspend{}
199+ close(commands)
200+ if err := a.Run(context.Background(), commands, events); err != nil {
201+ t.Fatal(err)
202+ }
203+ <-events
204+ if failure, ok := (<-events).(ports.SystemFailure); !ok || failure.Operation != "suspend" {
205+ t.Fatal("missing unsupported/block failure")
206+ }
207+ })
208+ }
209+}
A internal/adapters/visual/auth.go +393 -0
@@ -0,0 +1,393 @@
1+package visual
2+
3+import (
4+ "context"
5+ "io"
6+ "sync/atomic"
7+ "time"
8+
9+ "github.com/bnema/neferafk/internal/adapters/auth"
10+ "github.com/bnema/neferafk/internal/ports"
11+ "github.com/bnema/nefergui"
12+ "github.com/bnema/zerowrap"
13+)
14+
15+// workerTimeout is the watchdog for worker startup and for one attempt. PAM
16+// calls cannot be interrupted from Go, so expiry kills the worker process. It
17+// includes the worker's longest wrong-PIN delay: killing a worker mid-delay
18+// would respawn it with the delay schedule reset.
19+const workerTimeout = 30*time.Second + auth.MaxDenyDelay
20+
21+// workerLauncher starts one isolated `neferafk auth-worker` process.
22+type workerLauncher interface {
23+ launch(ctx context.Context) (workerProcess, error)
24+}
25+
26+// workerProcess is one running worker with its private pipes: stdin carries the
27+// bootstrap record and frames to the worker, stdout carries frames back.
28+type workerProcess interface {
29+ pipes() (stdin io.WriteCloser, stdout io.ReadCloser)
30+ // kill terminates the process (not a foreign call in this process).
31+ kill() error
32+ // wait reaps the process; it blocks until the process is gone.
33+ wait() error
34+}
35+
36+// promptKind is what the lock box asks for. It is metadata, never typed input.
37+type promptKind uint32
38+
39+const (
40+ promptNone promptKind = iota // worker not ready yet
41+ promptPIN // PIN source resolved
42+ promptPassword // plain account password
43+ promptFallback // PIN source unavailable: password
44+ promptUnavailable // no usable authentication
45+ promptMore // PAM asked for another response
46+)
47+
48+type phase uint8
49+
50+const (
51+ phaseNoWorker phase = iota // none alive; the next attempt respawns
52+ phaseStarting // bootstrap sent, waiting for Ready
53+ phaseReady // waiting for a submission
54+ phaseAttempt // attempt sent, waiting for the result
55+ phasePrompt // worker asked for another secret
56+ phaseDone // success; nothing further is accepted
57+)
58+
59+type frameMsg struct {
60+ f ports.AuthFrame
61+ err error
62+}
63+
64+type liveWorker struct {
65+ proc workerProcess
66+ in io.WriteCloser
67+ out io.ReadCloser
68+ frames chan frameMsg
69+ stop chan struct{}
70+}
71+
72+// authController owns the auth state for one lock. Only its run goroutine
73+// touches the fields below "owned"; it never blocks the UI loop, which talks to
74+// it through channels. Typed secrets arrive as private copies on submits and
75+// are wiped as soon as they have been written to the worker pipe.
76+type authController struct {
77+ log zerowrap.Logger
78+ launcher workerLauncher
79+ boot ports.AuthBootstrap // EnvPIN is owned by the caller
80+ timeout time.Duration
81+
82+ submits chan []byte
83+ status chan nefergui.LockStatus
84+ unlock chan struct{}
85+ wake chan struct{}
86+
87+ prompt atomic.Uint32
88+ succeeded atomic.Bool
89+
90+ // owned by run:
91+ w *liveWorker
92+ ph phase
93+ attempt uint64
94+ pending []byte
95+ dog *time.Timer
96+ dogC <-chan time.Time
97+}
98+
99+func newAuthController(log zerowrap.Logger, l workerLauncher, boot ports.AuthBootstrap, timeout time.Duration) *authController {
100+ return &authController{
101+ log: log.WithField("component", "visual-auth"), launcher: l, boot: boot, timeout: timeout,
102+ submits: make(chan []byte, 1), status: make(chan nefergui.LockStatus, 8),
103+ unlock: make(chan struct{}, 1), wake: make(chan struct{}, 1),
104+ }
105+}
106+
107+// submit is called on the UI loop. It copies the secret (the caller wipes its
108+// own) and never blocks; a submission racing a running attempt is dropped.
109+func (c *authController) submit(secret []byte) {
110+ cp := append([]byte(nil), secret...)
111+ select {
112+ case c.submits <- cp:
113+ default:
114+ clear(cp)
115+ }
116+}
117+
118+func (c *authController) setStatus(s nefergui.LockStatus) {
119+ for {
120+ select {
121+ case c.status <- s:
122+ return
123+ default:
124+ select {
125+ case <-c.status:
126+ default:
127+ }
128+ }
129+ }
130+}
131+
132+func (c *authController) setPrompt(p promptKind) {
133+ if c.prompt.Swap(uint32(p)) != uint32(p) {
134+ select {
135+ case c.wake <- struct{}{}:
136+ default:
137+ }
138+ }
139+}
140+
141+func (c *authController) arm() {
142+ if c.dog == nil {
143+ c.dog = time.NewTimer(c.timeout)
144+ } else {
145+ if !c.dog.Stop() {
146+ select {
147+ case <-c.dog.C:
148+ default:
149+ }
150+ }
151+ c.dog.Reset(c.timeout)
152+ }
153+ c.dogC = c.dog.C
154+}
155+
156+func (c *authController) disarm() {
157+ if c.dog != nil && !c.dog.Stop() {
158+ select {
159+ case <-c.dog.C:
160+ default:
161+ }
162+ }
163+ c.dogC = nil
164+}
165+
166+// run serves the lock until ctx ends or authentication succeeded.
167+func (c *authController) run(ctx context.Context) {
168+ defer c.drop()
169+ c.spawn(ctx)
170+ for {
171+ var frames <-chan frameMsg
172+ if c.w != nil {
173+ frames = c.w.frames
174+ }
175+ select {
176+ case <-ctx.Done():
177+ return
178+ case s := <-c.submits:
179+ c.onSubmit(ctx, s)
180+ case m := <-frames:
181+ if c.onFrame(m) {
182+ return
183+ }
184+ case <-c.dogC:
185+ c.log.Warn().Msg("authentication worker timed out; killing it")
186+ c.dogC = nil
187+ c.workerLost(true)
188+ }
189+ }
190+}
191+
192+func (c *authController) drop() {
193+ c.disarm()
194+ c.killWorker()
195+ clear(c.pending)
196+ c.pending = nil
197+ c.drainSubmits()
198+}
199+
200+// drainSubmits wipes any submission left in the queue (the UI may have handed
201+// one over after the last receive). Safe to call again once run has returned.
202+func (c *authController) drainSubmits() {
203+ for {
204+ select {
205+ case s := <-c.submits:
206+ clear(s)
207+ default:
208+ return
209+ }
210+ }
211+}
212+
213+func (c *authController) spawn(ctx context.Context) {
214+ proc, err := c.launcher.launch(ctx)
215+ if err != nil {
216+ c.log.Warn().Err(err).Msg("authentication worker launch failed")
217+ c.ph = phaseNoWorker
218+ return
219+ }
220+ in, out := proc.pipes()
221+ w := &liveWorker{proc: proc, in: in, out: out, frames: make(chan frameMsg), stop: make(chan struct{})}
222+ if err := auth.WriteBootstrap(in, c.boot); err != nil {
223+ c.log.Warn().Msg("authentication worker bootstrap failed")
224+ c.w = w
225+ c.killWorker()
226+ return
227+ }
228+ c.w, c.ph = w, phaseStarting
229+ go readFrames(w)
230+ c.arm()
231+}
232+
233+func readFrames(w *liveWorker) {
234+ for {
235+ f, err := auth.ReadFrame(w.out)
236+ select {
237+ case w.frames <- frameMsg{f: f, err: err}:
238+ case <-w.stop:
239+ clear(f.Payload)
240+ return
241+ }
242+ if err != nil {
243+ return
244+ }
245+ }
246+}
247+
248+// killWorker terminates and reaps the current worker, if any.
249+func (c *authController) killWorker() {
250+ w := c.w
251+ if w == nil {
252+ return
253+ }
254+ c.w = nil
255+ close(w.stop)
256+ _ = w.proc.kill()
257+ _ = w.in.Close()
258+ _ = w.out.Close()
259+ go func() { _ = w.proc.wait() }()
260+}
261+
262+// workerLost handles a dead, timed-out or misbehaving worker: the lock stays,
263+// the next attempt respawns.
264+func (c *authController) workerLost(failed bool) {
265+ inFlight := c.ph == phaseAttempt || c.ph == phasePrompt || c.pending != nil
266+ c.disarm()
267+ c.killWorker()
268+ clear(c.pending)
269+ c.pending = nil
270+ c.ph = phaseNoWorker
271+ if c.prompt.Load() == uint32(promptMore) {
272+ c.setPrompt(promptNone)
273+ }
274+ if failed || inFlight {
275+ c.setStatus(nefergui.LockFailed)
276+ }
277+}
278+
279+func (c *authController) onSubmit(ctx context.Context, s []byte) {
280+ switch c.ph {
281+ case phaseReady:
282+ c.sendAttempt(s)
283+ case phasePrompt:
284+ c.sendSecret(s)
285+ case phaseNoWorker:
286+ clear(c.pending)
287+ c.pending = s
288+ c.setStatus(nefergui.LockBusy)
289+ c.spawn(ctx)
290+ if c.w == nil { // launch failed
291+ clear(c.pending)
292+ c.pending = nil
293+ c.setStatus(nefergui.LockFailed)
294+ }
295+ case phaseStarting: // keep only the latest submission
296+ clear(c.pending)
297+ c.pending = s
298+ default: // attempt running or done: nothing to accept
299+ clear(s)
300+ }
301+}
302+
303+func (c *authController) write(f ports.AuthFrame) bool {
304+ err := auth.WriteFrame(c.w.in, f)
305+ clear(f.Payload)
306+ return err == nil
307+}
308+
309+func (c *authController) sendAttempt(s []byte) {
310+ c.attempt++
311+ c.setStatus(nefergui.LockBusy)
312+ c.ph = phaseAttempt
313+ c.arm()
314+ if !c.write(ports.AuthFrame{Kind: ports.AuthAttempt, Generation: c.boot.Generation, Attempt: c.attempt, Payload: s}) {
315+ c.workerLost(true)
316+ }
317+}
318+
319+func (c *authController) sendSecret(s []byte) {
320+ c.setStatus(nefergui.LockBusy)
321+ c.ph = phaseAttempt
322+ c.arm()
323+ if !c.write(ports.AuthFrame{Kind: ports.AuthSecret, Generation: c.boot.Generation, Attempt: c.attempt, Payload: s}) {
324+ c.workerLost(true)
325+ }
326+}
327+
328+// onFrame applies one worker message; it reports true once authentication
329+// succeeded and the unlock was requested.
330+func (c *authController) onFrame(m frameMsg) bool {
331+ if m.err != nil {
332+ c.log.Warn().Msg("authentication worker ended")
333+ c.workerLost(false)
334+ return false
335+ }
336+ f := m.f
337+ clear(f.Payload)
338+ if f.Generation != c.boot.Generation || (f.Kind != ports.AuthReady && f.Attempt != c.attempt) {
339+ c.log.Warn().Msg("authentication worker sent a frame for another epoch")
340+ c.workerLost(true)
341+ return false
342+ }
343+ switch f.Kind {
344+ case ports.AuthReady:
345+ if c.ph != phaseStarting {
346+ c.workerLost(true)
347+ return false
348+ }
349+ c.disarm()
350+ c.ph = phaseReady
351+ switch f.Code {
352+ case ports.AuthPIN:
353+ c.setPrompt(promptPIN)
354+ case ports.AuthFallbackPassword:
355+ c.setPrompt(promptFallback)
356+ case ports.AuthUnavailable:
357+ c.setPrompt(promptUnavailable)
358+ default:
359+ c.setPrompt(promptPassword)
360+ }
361+ if p := c.pending; p != nil {
362+ c.pending = nil
363+ c.sendAttempt(p)
364+ }
365+ case ports.AuthPrompt:
366+ if c.ph != phaseAttempt {
367+ c.workerLost(true)
368+ return false
369+ }
370+ c.ph = phasePrompt
371+ c.setPrompt(promptMore)
372+ c.setStatus(nefergui.LockIdle)
373+ case ports.AuthStatus:
374+ // PAM text is deliberately never shown.
375+ case ports.AuthResult:
376+ if c.ph != phaseAttempt && c.ph != phasePrompt {
377+ c.workerLost(true)
378+ return false
379+ }
380+ c.disarm()
381+ if f.Code == ports.AuthSuccess {
382+ c.ph = phaseDone
383+ c.succeeded.Store(true)
384+ c.unlock <- struct{}{} // cap 1, sent once
385+ return true
386+ }
387+ c.ph = phaseReady
388+ c.setStatus(nefergui.LockFailed)
389+ default:
390+ c.workerLost(true)
391+ }
392+ return false
393+}
A internal/adapters/visual/auth_test.go +297 -0
@@ -0,0 +1,297 @@
1+package visual
2+
3+import (
4+ "context"
5+ "io"
6+ "sync"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/neferafk/internal/adapters/auth"
11+ "github.com/bnema/neferafk/internal/ports"
12+ "github.com/bnema/nefergui"
13+ "github.com/bnema/zerowrap"
14+ "github.com/stretchr/testify/mock"
15+ "github.com/stretchr/testify/require"
16+)
17+
18+const gen = 7
19+
20+func testLog() zerowrap.Logger { return zerowrap.New(zerowrap.Config{Output: io.Discard}) }
21+
22+// testWorker is the test's end of one worker's private pipes.
23+type testWorker struct {
24+ fromVisual *io.PipeReader // bootstrap and frames the visual wrote
25+ toVisual *io.PipeWriter // frames the worker answers with
26+ killed chan struct{}
27+}
28+
29+// expectWorker queues one launch on l, backed by real in-memory pipes.
30+func expectWorker(t *testing.T, l *mockworkerLauncher) *testWorker {
31+ t.Helper()
32+ inR, inW := io.Pipe()
33+ outR, outW := io.Pipe()
34+ w := &testWorker{fromVisual: inR, toVisual: outW, killed: make(chan struct{})}
35+ proc := newMockworkerProcess(t)
36+ proc.EXPECT().pipes().Return(inW, outR).Maybe()
37+ proc.EXPECT().kill().Run(func() { close(w.killed) }).Return(nil).Maybe()
38+ proc.EXPECT().wait().Return(nil).Maybe()
39+ l.EXPECT().launch(mock.Anything).Return(proc, nil).Once()
40+ t.Cleanup(func() { inR.Close(); outW.Close() })
41+ return w
42+}
43+
44+func (w *testWorker) bootstrap(t *testing.T) ports.AuthBootstrap {
45+ t.Helper()
46+ b, err := auth.ReadBootstrap(w.fromVisual)
47+ require.NoError(t, err)
48+ return b
49+}
50+
51+func (w *testWorker) send(t *testing.T, f ports.AuthFrame) {
52+ t.Helper()
53+ f.Generation = gen
54+ require.NoError(t, auth.WriteFrame(w.toVisual, f))
55+}
56+
57+func (w *testWorker) ready(t *testing.T) {
58+ w.send(t, ports.AuthFrame{Kind: ports.AuthReady, Code: ports.AuthPassword})
59+}
60+
61+func (w *testWorker) result(t *testing.T, attempt uint64, c ports.AuthCode) {
62+ w.send(t, ports.AuthFrame{Kind: ports.AuthResult, Code: c, Attempt: attempt})
63+}
64+
65+func (w *testWorker) attempt(t *testing.T) ports.AuthFrame {
66+ t.Helper()
67+ f, err := auth.ReadFrame(w.fromVisual)
68+ require.NoError(t, err)
69+ return f
70+}
71+
72+func recv[T any](t *testing.T, c <-chan T) T {
73+ t.Helper()
74+ select {
75+ case v := <-c:
76+ return v
77+ case <-time.After(5 * time.Second):
78+ t.Fatal("timed out")
79+ }
80+ panic("unreachable")
81+}
82+
83+func boot() ports.AuthBootstrap {
84+ return ports.AuthBootstrap{Generation: gen, PINSource: "env", EnvPIN: []byte("pin")}
85+}
86+
87+func startCtl(t *testing.T, l workerLauncher, timeout time.Duration) *authController {
88+ c := newAuthController(testLog(), l, boot(), timeout)
89+ ctx, cancel := context.WithCancel(context.Background())
90+ var wg sync.WaitGroup
91+ wg.Add(1)
92+ go func() { defer wg.Done(); c.run(ctx) }()
93+ t.Cleanup(func() { cancel(); wg.Wait() })
94+ return c
95+}
96+
97+func TestSuccessRequestsUnlockOnlyAfterAuthSuccess(t *testing.T) {
98+ l := newMockworkerLauncher(t)
99+ w := expectWorker(t, l)
100+ c := startCtl(t, l, time.Minute)
101+ b := w.bootstrap(t)
102+ require.Equal(t, uint64(gen), b.Generation)
103+ w.ready(t)
104+ c.submit([]byte("hunter2"))
105+ f := w.attempt(t)
106+ require.Equal(t, ports.AuthAttempt, f.Kind)
107+ require.Equal(t, []byte("hunter2"), f.Payload)
108+ select {
109+ case <-c.unlock:
110+ t.Fatal("unlock requested before the result")
111+ case <-time.After(50 * time.Millisecond):
112+ }
113+ w.result(t, f.Attempt, ports.AuthSuccess)
114+ recv(t, c.unlock)
115+ require.True(t, c.succeeded.Load())
116+}
117+
118+func TestDeniedStaysLockedAndAllowsRetry(t *testing.T) {
119+ l := newMockworkerLauncher(t)
120+ w := expectWorker(t, l)
121+ c := startCtl(t, l, time.Minute)
122+ w.bootstrap(t)
123+ w.ready(t)
124+ c.submit([]byte("bad"))
125+ f := w.attempt(t)
126+ w.result(t, f.Attempt, ports.AuthDenied)
127+ for recv(t, c.status) != nefergui.LockFailed {
128+ }
129+ select {
130+ case <-c.unlock:
131+ t.Fatal("denied must not unlock")
132+ case <-time.After(50 * time.Millisecond):
133+ }
134+ c.submit([]byte("good"))
135+ f = w.attempt(t)
136+ require.Equal(t, []byte("good"), f.Payload)
137+ w.result(t, f.Attempt, ports.AuthSuccess)
138+ recv(t, c.unlock)
139+}
140+
141+func TestWorkerCrashStaysLockedAndRespawnsOnNextAttempt(t *testing.T) {
142+ l := newMockworkerLauncher(t)
143+ w1 := expectWorker(t, l)
144+ c := startCtl(t, l, time.Minute)
145+ w1.bootstrap(t)
146+ w1.ready(t)
147+ c.submit([]byte("x"))
148+ w1.attempt(t)
149+ w1.toVisual.Close() // worker died mid-attempt
150+ recv(t, w1.killed)
151+ for recv(t, c.status) != nefergui.LockFailed {
152+ }
153+ require.False(t, c.succeeded.Load())
154+ w2 := expectWorker(t, l)
155+ c.submit([]byte("again"))
156+ w2.bootstrap(t)
157+ w2.ready(t)
158+ f := w2.attempt(t)
159+ require.Equal(t, []byte("again"), f.Payload)
160+ w2.result(t, f.Attempt, ports.AuthSuccess)
161+ recv(t, c.unlock)
162+}
163+
164+func TestWatchdogKillsHungWorkerAndStaysLocked(t *testing.T) {
165+ l := newMockworkerLauncher(t)
166+ w := expectWorker(t, l)
167+ c := startCtl(t, l, 100*time.Millisecond)
168+ w.bootstrap(t)
169+ w.ready(t)
170+ c.submit([]byte("x"))
171+ w.attempt(t) // no result ever comes
172+ recv(t, w.killed)
173+ for recv(t, c.status) != nefergui.LockFailed {
174+ }
175+ select {
176+ case <-c.unlock:
177+ t.Fatal("timeout must not unlock")
178+ default:
179+ }
180+}
181+
182+func TestSubmittedCopiesAreWiped(t *testing.T) {
183+ l := newMockworkerLauncher(t)
184+ w := expectWorker(t, l)
185+ c := newAuthController(testLog(), l, boot(), time.Minute)
186+ ctx := context.Background()
187+ spawned := make(chan struct{})
188+ go func() { c.spawn(ctx); close(spawned) }() // owner role: nothing else runs
189+ w.bootstrap(t)
190+ recv(t, spawned)
191+ t.Cleanup(c.drop)
192+ c.onFrame(frameMsg{f: ports.AuthFrame{Kind: ports.AuthReady, Code: ports.AuthPassword, Generation: gen}})
193+
194+ orig := []byte("hunter2")
195+ c.submit(orig)
196+ cp := <-c.submits
197+ require.NotSame(t, &orig[0], &cp[0], "submit hands the controller a private copy")
198+ got := make(chan []byte, 1)
199+ go func() { got <- w.attempt(t).Payload }()
200+ c.onSubmit(ctx, cp)
201+ require.Equal(t, []byte("hunter2"), recv(t, got))
202+ require.Equal(t, make([]byte, len("hunter2")), cp, "copy wiped once written to the worker pipe")
203+}
204+
205+func TestPromptAndFallbackAreReflected(t *testing.T) {
206+ l := newMockworkerLauncher(t)
207+ w := expectWorker(t, l)
208+ c := startCtl(t, l, time.Minute)
209+ w.bootstrap(t)
210+ w.send(t, ports.AuthFrame{Kind: ports.AuthReady, Code: ports.AuthFallbackPassword})
211+ recv(t, c.wake)
212+ require.Equal(t, promptFallback, promptKind(c.prompt.Load()))
213+ c.submit([]byte("x"))
214+ f := w.attempt(t)
215+ w.send(t, ports.AuthFrame{Kind: ports.AuthPrompt, Code: ports.AuthPassword, Attempt: f.Attempt})
216+ recv(t, c.wake)
217+ require.Equal(t, promptMore, promptKind(c.prompt.Load()))
218+}
219+
220+func TestDropDrainsAndWipesQueuedSubmissions(t *testing.T) {
221+ l := newMockworkerLauncher(t)
222+ c := newAuthController(testLog(), l, boot(), time.Minute)
223+ queued := []byte("hunter2")
224+ c.submits <- queued
225+ c.drop()
226+ require.Equal(t, make([]byte, len("hunter2")), queued, "queued secret wiped")
227+ require.Empty(t, c.submits)
228+
229+ // A submission racing the controller's exit is wiped by a later drain.
230+ late := []byte("late")
231+ c.submits <- late
232+ c.drainSubmits()
233+ require.Equal(t, make([]byte, len("late")), late)
234+}
235+
236+func TestRunExitDrainsQueuedSubmission(t *testing.T) {
237+ l := newMockworkerLauncher(t)
238+ w := expectWorker(t, l)
239+ c := newAuthController(testLog(), l, boot(), time.Minute)
240+ ctx, cancel := context.WithCancel(context.Background())
241+ done := make(chan struct{})
242+ go func() { c.run(ctx); close(done) }()
243+ w.bootstrap(t)
244+ cancel()
245+ recv(t, done)
246+ late := []byte("late-secret")
247+ c.submits <- late // UI handed a secret over after the controller left
248+ c.drainSubmits()
249+ require.Equal(t, make([]byte, len("late-secret")), late)
250+}
251+
252+// A frame for another generation or attempt must never unlock.
253+func TestControllerKillsWorkerOnStaleGenerationOrAttempt(t *testing.T) {
254+ cases := map[string]func(attempt uint64) ports.AuthFrame{
255+ "wrong generation": func(a uint64) ports.AuthFrame {
256+ return ports.AuthFrame{Kind: ports.AuthResult, Code: ports.AuthSuccess, Generation: gen + 1, Attempt: a}
257+ },
258+ "old attempt": func(a uint64) ports.AuthFrame {
259+ return ports.AuthFrame{Kind: ports.AuthResult, Code: ports.AuthSuccess, Generation: gen, Attempt: a - 1}
260+ },
261+ "future attempt": func(a uint64) ports.AuthFrame {
262+ return ports.AuthFrame{Kind: ports.AuthResult, Code: ports.AuthSuccess, Generation: gen, Attempt: a + 1}
263+ },
264+ }
265+ for name, mk := range cases {
266+ t.Run(name, func(t *testing.T) {
267+ l := newMockworkerLauncher(t)
268+ w := expectWorker(t, l)
269+ c := startCtl(t, l, time.Minute)
270+ w.bootstrap(t)
271+ w.ready(t)
272+ c.submit([]byte("first"))
273+ w.result(t, w.attempt(t).Attempt, ports.AuthDenied)
274+ for recv(t, c.status) != nefergui.LockFailed {
275+ }
276+ c.submit([]byte("second")) // attempt 2: attempt 1 is now stale
277+ f := w.attempt(t)
278+ require.EqualValues(t, 2, f.Attempt)
279+ require.NoError(t, auth.WriteFrame(w.toVisual, mk(f.Attempt)))
280+ recv(t, w.killed)
281+ for recv(t, c.status) != nefergui.LockFailed {
282+ }
283+ select {
284+ case <-c.unlock:
285+ t.Fatal("stale frame unlocked")
286+ case <-time.After(50 * time.Millisecond):
287+ }
288+ require.False(t, c.succeeded.Load())
289+ })
290+ }
291+}
292+
293+// The watchdog must outlast the longest wrong-PIN delay, or killing the worker
294+// mid-delay would restart its schedule and defeat the rate limit.
295+func TestWatchdogOutlastsDenyDelay(t *testing.T) {
296+ require.GreaterOrEqual(t, workerTimeout, 30*time.Second+auth.MaxDenyDelay)
297+}
A internal/adapters/visual/fade.go +153 -0
@@ -0,0 +1,153 @@
1+package visual
2+
3+import (
4+ "context"
5+ "fmt"
6+ "sync/atomic"
7+ "time"
8+
9+ "github.com/bnema/nefergui"
10+)
11+
12+// fadeTick is the redraw cadence while the fade animates.
13+const fadeTick = 16 * time.Millisecond
14+
15+// fadeCmd retargets the fade: black fades to opaque, otherwise back to clear.
16+type fadeCmd struct {
17+ black bool
18+ dur time.Duration
19+}
20+
21+// fadeModel is the overlay's animation state. The view (NeferGUI owner loop)
22+// owns the fields below "owned"; other goroutines only use send.
23+type fadeModel struct {
24+ cmds chan fadeCmd
25+ wake chan struct{}
26+ animating atomic.Bool
27+ // finish ends the run once a reveal completed; set by the runner before
28+ // the loop starts.
29+ finish context.CancelFunc
30+
31+ // owned by the view:
32+ started bool
33+ from, to float64
34+ start time.Time
35+ dur time.Duration
36+}
37+
38+func newFadeModel() *fadeModel {
39+ return &fadeModel{cmds: make(chan fadeCmd, 4), wake: make(chan struct{}, 1)}
40+}
41+
42+// send queues a retarget (dropping the oldest when full) and requests a redraw.
43+func (m *fadeModel) send(c fadeCmd) {
44+ for {
45+ select {
46+ case m.cmds <- c:
47+ m.poke()
48+ return
49+ default:
50+ select {
51+ case <-m.cmds:
52+ default:
53+ }
54+ }
55+ }
56+}
57+
58+func (m *fadeModel) poke() {
59+ select {
60+ case m.wake <- struct{}{}:
61+ default:
62+ }
63+}
64+
65+// fadeAlpha is the overlay opacity at now and whether the fade has arrived.
66+func fadeAlpha(from, to float64, start time.Time, dur time.Duration, now time.Time) (float64, bool) {
67+ if dur <= 0 || !now.Before(start.Add(dur)) {
68+ return to, true
69+ }
70+ t := float64(now.Sub(start)) / float64(dur)
71+ return from + (to-from)*t, false
72+}
73+
74+// fadeCSS is the full-screen black layer at opacity a.
75+func fadeCSS(a float64) string {
76+ return fmt.Sprintf("width:100%%;height:100%%;background-color:rgb(0 0 0 / %.1f%%)", a*100)
77+}
78+
79+// fadeView applies queued retargets, then draws the overlay for this instant.
80+func fadeView(f *nefergui.Frame, m *fadeModel) {
81+ now := time.Now()
82+ for {
83+ select {
84+ case c := <-m.cmds:
85+ m.from, _ = fadeAlpha(m.from, m.to, m.start, m.dur, now)
86+ m.to = 0
87+ if c.black {
88+ m.to = 1
89+ }
90+ m.start, m.dur, m.started = now, c.dur, true
91+ continue
92+ default:
93+ }
94+ break
95+ }
96+ a, done := fadeAlpha(m.from, m.to, m.start, m.dur, now)
97+ m.animating.Store(!done)
98+ f.Root(nefergui.Inline(fadeCSS(a)))
99+ if done && m.started && m.to == 0 && m.finish != nil {
100+ m.finish()
101+ }
102+}
103+
104+// fadeRunner runs the overlay until ctx ends or a reveal completes.
105+type fadeRunner interface {
106+ RunFade(ctx context.Context, m *fadeModel) error
107+}
108+
109+// guiFader renders the overlay as a click-through layer-shell surface.
110+type guiFader struct{}
111+
112+func (guiFader) RunFade(ctx context.Context, m *fadeModel) error {
113+ ctx, cancel := context.WithCancel(ctx)
114+ defer cancel()
115+ m.finish = cancel
116+ go func() {
117+ t := time.NewTicker(fadeTick)
118+ defer t.Stop()
119+ for {
120+ select {
121+ case <-ctx.Done():
122+ return
123+ case <-t.C:
124+ if m.animating.Load() {
125+ m.poke()
126+ }
127+ }
128+ }
129+ }()
130+ err := nefergui.Run(ctx, m, fadeView,
131+ nefergui.Title("neferafk-fade"),
132+ nefergui.Size(64, 64),
133+ nefergui.Transparent(),
134+ nefergui.Wake(m.wake),
135+ nefergui.Layer(fadeLayer()),
136+ )
137+ if ctx.Err() != nil && err == context.Canceled {
138+ return nil
139+ }
140+ return err
141+}
142+
143+// fadeLayer: overlay, all anchors, no keyboard, fully click-through.
144+func fadeLayer() nefergui.LayerConfig {
145+ return nefergui.LayerConfig{
146+ Namespace: "neferafk-fade",
147+ Level: nefergui.LayerOverlay,
148+ Anchors: nefergui.AnchorTop | nefergui.AnchorBottom | nefergui.AnchorLeft | nefergui.AnchorRight,
149+ Keyboard: nefergui.KeyboardNone,
150+ ExclusiveZone: -1,
151+ InputRects: []nefergui.Rect{},
152+ }
153+}
A internal/adapters/visual/lock.go +261 -0
@@ -0,0 +1,261 @@
1+package visual
2+
3+import (
4+ "context"
5+ "errors"
6+ "io"
7+ "os"
8+
9+ "github.com/bnema/neferafk/internal/adapters/visualproc"
10+ "github.com/bnema/neferafk/internal/ports"
11+ "github.com/bnema/nefergui"
12+ "github.com/bnema/zerowrap"
13+)
14+
15+var errLockWithoutAuth = errors.New("session lock ended without authentication success")
16+
17+// lockRunner is the session-lock seam over nefergui.RunLock.
18+type lockRunner interface {
19+ RunLock(ctx context.Context, cfg nefergui.LockConfig) error
20+}
21+
22+type guiLocker struct{}
23+
24+func (guiLocker) RunLock(ctx context.Context, cfg nefergui.LockConfig) error {
25+ return nefergui.RunLock(ctx, cfg)
26+}
27+
28+// Config wires the visual process: fd 3 commands in, fd 4 events out.
29+type Config struct {
30+ Commands io.Reader
31+ Events io.Writer
32+ Executable string // neferafk binary that hosts `auth-worker`
33+ Log zerowrap.Logger
34+}
35+
36+// Run serves the daemon until the lock was released, a reveal finished, or the
37+// command pipe closed with nothing held. It returns nil for those exits.
38+func Run(ctx context.Context, cfg Config) error {
39+ s := &session{
40+ log: cfg.Log.WithField("component", "visual"), cmds: cfg.Commands, events: cfg.Events,
41+ locker: guiLocker{}, fader: guiFader{}, launcher: execLauncher{exe: cfg.Executable},
42+ }
43+ return s.run(ctx)
44+}
45+
46+// ExecutablePath is the running binary, for spawning the auth worker.
47+func ExecutablePath() (string, error) { return os.Executable() }
48+
49+type session struct {
50+ log zerowrap.Logger
51+ cmds io.Reader
52+ events io.Writer
53+ locker lockRunner
54+ fader fadeRunner
55+ launcher workerLauncher
56+}
57+
58+type cmdMsg struct {
59+ cmd ports.VisualCommand
60+ err error
61+}
62+
63+type fadeRun struct {
64+ m *fadeModel
65+ cancel context.CancelFunc
66+ done chan error
67+}
68+
69+type lockRun struct {
70+ gen ports.Generation
71+ boot ports.AuthBootstrap
72+ ctl *authController
73+ cancel context.CancelFunc
74+ done chan error // RunLock result
75+ authDone chan struct{} // auth controller stopped and worker killed
76+ locked chan struct{}
77+}
78+
79+func (s *session) readCommands(ctx context.Context, out chan<- cmdMsg) {
80+ for {
81+ c, err := visualproc.ReadCommand(s.cmds)
82+ select {
83+ case out <- cmdMsg{cmd: c, err: err}:
84+ case <-ctx.Done():
85+ wipeCommand(c)
86+ return
87+ }
88+ if err != nil {
89+ return
90+ }
91+ }
92+}
93+
94+func wipeCommand(c ports.VisualCommand) {
95+ if l, ok := c.(ports.VisualLock); ok {
96+ clear(l.Auth.EnvPIN)
97+ }
98+}
99+
100+// run is the owner loop: it alone holds the fade and lock state.
101+func (s *session) run(ctx context.Context) error {
102+ rctx, stop := context.WithCancel(ctx)
103+ defer stop()
104+ cmdCh := make(chan cmdMsg)
105+ go s.readCommands(rctx, cmdCh)
106+ cmdC := (<-chan cmdMsg)(cmdCh)
107+ var fd *fadeRun
108+ var lk *lockRun
109+ stopFade := func() {
110+ if fd != nil {
111+ fd.cancel()
112+ <-fd.done
113+ fd = nil
114+ }
115+ }
116+ defer func() { stopFade() }()
117+ for {
118+ var lockDone <-chan error
119+ var lockedC <-chan struct{}
120+ var fadeDone <-chan error
121+ sig := ctx.Done()
122+ if lk != nil {
123+ // A held lock ignores termination requests: only unlock ends it.
124+ lockDone, lockedC, sig = lk.done, lk.locked, nil
125+ }
126+ if fd != nil {
127+ fadeDone = fd.done
128+ }
129+ select {
130+ case <-sig:
131+ return nil
132+ case m := <-cmdC:
133+ if m.err != nil {
134+ cmdC = nil
135+ s.log.Info().Msg("daemon command pipe closed")
136+ if lk == nil {
137+ return nil // fade-only: nothing left to protect
138+ }
139+ continue // daemon gone: the lock and its auth UI stay up
140+ }
141+ switch c := m.cmd.(type) {
142+ case ports.VisualFade:
143+ switch {
144+ case fd != nil:
145+ if !c.Black || lk == nil {
146+ fd.m.send(fadeCmd{black: c.Black, dur: c.Duration})
147+ }
148+ case lk != nil:
149+ // lock surfaces already cover (or will cover) the screen
150+ case !c.Black:
151+ return nil // nothing to reveal
152+ default:
153+ fd = s.startFade(ctx)
154+ fd.m.send(fadeCmd{black: true, dur: c.Duration})
155+ }
156+ case ports.VisualLock:
157+ if lk != nil {
158+ if lk.gen != c.Generation {
159+ s.log.Warn().Msg("lock for another generation ignored")
160+ }
161+ clear(c.Auth.EnvPIN)
162+ continue
163+ }
164+ lk = s.startLock(ctx, c)
165+ }
166+ case <-lockedC:
167+ lk.locked = nil
168+ if err := visualproc.WriteEvent(s.events, ports.LockConfirmed{Generation: lk.gen}); err != nil {
169+ s.log.Warn().Err(err).Msg("lock confirmation not delivered")
170+ }
171+ stopFade()
172+ case err := <-fadeDone:
173+ fd = nil
174+ if err != nil && !errors.Is(err, context.Canceled) {
175+ s.log.Error().Err(err).Msg("fade failed")
176+ if lk == nil {
177+ return err
178+ }
179+ continue
180+ }
181+ if lk == nil {
182+ return nil // reveal finished
183+ }
184+ case err := <-lockDone:
185+ lk.cancel()
186+ <-lk.authDone
187+ // The UI may have queued a submission after the controller left.
188+ lk.ctl.drainSubmits()
189+ clear(lk.boot.EnvPIN)
190+ gen, succeeded := lk.gen, lk.ctl.succeeded.Load()
191+ lk = nil
192+ if err != nil {
193+ s.log.Error().Err(err).Msg("session lock ended without unlock")
194+ return err
195+ }
196+ if !succeeded {
197+ // RunLock returning nil is not an authorization: only the
198+ // worker's AuthSuccess may release the lock.
199+ s.log.Error().Msg("session lock ended without authentication success")
200+ return errLockWithoutAuth
201+ }
202+ if werr := visualproc.WriteEvent(s.events, ports.LockReleased{Generation: gen}); werr != nil {
203+ s.log.Warn().Err(werr).Msg("lock release not delivered")
204+ }
205+ return nil
206+ }
207+ }
208+}
209+
210+func (s *session) startFade(ctx context.Context) *fadeRun {
211+ fctx, cancel := context.WithCancel(ctx)
212+ f := &fadeRun{m: newFadeModel(), cancel: cancel, done: make(chan error, 1)}
213+ go func() { f.done <- s.fader.RunFade(fctx, f.m) }()
214+ return f
215+}
216+
217+// startLock runs RunLock with the V1 view and a private auth controller. The
218+// lock context is detached from process signals: it ends only with RunLock.
219+func (s *session) startLock(ctx context.Context, c ports.VisualLock) *lockRun {
220+ lctx, cancel := context.WithCancel(context.WithoutCancel(ctx))
221+ ctl := newAuthController(s.log, s.launcher, c.Auth, workerTimeout)
222+ l := &lockRun{
223+ gen: c.Generation, boot: c.Auth, ctl: ctl, cancel: cancel,
224+ done: make(chan error, 1), authDone: make(chan struct{}), locked: make(chan struct{}, 1),
225+ }
226+ go func() { defer close(l.authDone); ctl.run(lctx) }()
227+ go func() {
228+ secret := nefergui.NewSecretBuffer(ports.AuthMaxSecret)
229+ err := s.locker.RunLock(lctx, lockConfig(s.log, ctl, secret, l.locked))
230+ secret.Wipe()
231+ l.done <- err
232+ }()
233+ return l
234+}
235+
236+// lockConfig wires NeferGUI's lock loop to the auth controller. OnSubmit only
237+// copies the secret; the controller wipes the copy after writing it.
238+func lockConfig(log zerowrap.Logger, ctl *authController, secret *nefergui.SecretBuffer, locked chan<- struct{}) nefergui.LockConfig {
239+ log = log.WithField("component", "visual-lock")
240+ return nefergui.LockConfig{
241+ Secret: secret,
242+ View: func(f *nefergui.Frame, st nefergui.LockState) {
243+ lockView(f, st, promptKind(ctl.prompt.Load()))
244+ },
245+ OnLocked: func() {
246+ select {
247+ case locked <- struct{}{}:
248+ default:
249+ }
250+ },
251+ OnOutputError: func(output string, err error) {
252+ // A hotplugged output we could not cover; the compositor keeps it
253+ // black. Only the output name and the error are logged.
254+ log.Warn().Err(err).Str("output", output).Msg("lock surface not created for output")
255+ },
256+ OnSubmit: ctl.submit,
257+ Unlock: ctl.unlock,
258+ Status: ctl.status,
259+ Wake: ctl.wake,
260+ }
261+}
A internal/adapters/visual/session_test.go +222 -0
@@ -0,0 +1,222 @@
1+package visual
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "io"
8+ "testing"
9+ "time"
10+
11+ "github.com/bnema/neferafk/internal/adapters/visualproc"
12+ "github.com/bnema/neferafk/internal/ports"
13+ "github.com/bnema/nefergui"
14+ "github.com/stretchr/testify/mock"
15+ "github.com/stretchr/testify/require"
16+)
17+
18+type harness struct {
19+ cmdW *io.PipeWriter
20+ events *io.PipeReader
21+ done chan error
22+ cfgs chan nefergui.LockConfig
23+ l *mockworkerLauncher
24+ lock *mocklockRunner
25+ fade *mockfadeRunner
26+}
27+
28+// newHarness runs a session over real pipes; RunLock is a mock that publishes
29+// its config and returns nil only once Unlock fires (or ctx ends).
30+func newHarness(t *testing.T) *harness { return newHarnessRun(t, nil) }
31+
32+// newHarnessRun is newHarness with an optional replacement RunLock body.
33+func newHarnessRun(t *testing.T, runLock func(context.Context, nefergui.LockConfig) error) *harness {
34+ t.Helper()
35+ cmdR, cmdW := io.Pipe()
36+ evR, evW := io.Pipe()
37+ h := &harness{cmdW: cmdW, events: evR, done: make(chan error, 1), cfgs: make(chan nefergui.LockConfig, 1),
38+ l: newMockworkerLauncher(t), lock: newMocklockRunner(t), fade: newMockfadeRunner(t)}
39+ if runLock == nil {
40+ runLock = func(ctx context.Context, cfg nefergui.LockConfig) error {
41+ h.cfgs <- cfg
42+ select {
43+ case <-cfg.Unlock:
44+ return nil
45+ case <-ctx.Done():
46+ return ctx.Err()
47+ }
48+ }
49+ }
50+ h.lock.EXPECT().RunLock(mock.Anything, mock.Anything).RunAndReturn(runLock).Maybe()
51+ s := &session{log: testLog(), cmds: cmdR, events: evW, locker: h.lock, fader: h.fade, launcher: h.l}
52+ go func() { h.done <- s.run(context.Background()) }()
53+ t.Cleanup(func() { cmdW.Close(); evR.Close() })
54+ return h
55+}
56+
57+func (h *harness) sendLock(t *testing.T) {
58+ require.NoError(t, visualproc.WriteCommand(h.cmdW, ports.VisualLock{Generation: gen, Auth: boot()}))
59+}
60+
61+func (h *harness) event(t *testing.T) ports.VisualEvent {
62+ t.Helper()
63+ ch := make(chan ports.VisualEvent, 1)
64+ go func() { ev, _ := visualproc.ReadEvent(h.events); ch <- ev }()
65+ return recv(t, ch)
66+}
67+
68+func TestLockReleasedOnlyAfterAuthSuccess(t *testing.T) {
69+ h := newHarness(t)
70+ w := expectWorker(t, h.l)
71+ h.sendLock(t)
72+ cfg := recv(t, h.cfgs)
73+ w.bootstrap(t)
74+ w.ready(t)
75+ cfg.OnLocked()
76+ require.Equal(t, ports.LockConfirmed{Generation: gen}, h.event(t))
77+
78+ cfg.OnSubmit([]byte("bad"))
79+ f := w.attempt(t)
80+ w.result(t, f.Attempt, ports.AuthDenied)
81+ require.Equal(t, nefergui.LockFailed, recvFailed(t, cfg.Status))
82+ select {
83+ case err := <-h.done:
84+ t.Fatalf("session ended after denial: %v", err)
85+ case <-time.After(100 * time.Millisecond):
86+ }
87+
88+ cfg.OnSubmit([]byte("good"))
89+ f = w.attempt(t)
90+ w.result(t, f.Attempt, ports.AuthSuccess)
91+ require.Equal(t, ports.LockReleased{Generation: gen}, h.event(t))
92+ require.NoError(t, recv(t, h.done))
93+ recv(t, w.killed) // worker reaped with the lock
94+}
95+
96+func recvFailed(t *testing.T, c <-chan nefergui.LockStatus) nefergui.LockStatus {
97+ for {
98+ if s := recv(t, c); s == nefergui.LockFailed {
99+ return s
100+ }
101+ }
102+}
103+
104+func TestWorkerCrashKeepsLock(t *testing.T) {
105+ h := newHarness(t)
106+ w := expectWorker(t, h.l)
107+ h.sendLock(t)
108+ cfg := recv(t, h.cfgs)
109+ w.bootstrap(t)
110+ w.ready(t)
111+ cfg.OnLocked()
112+ h.event(t)
113+ cfg.OnSubmit([]byte("x"))
114+ w.attempt(t)
115+ w.toVisual.Close()
116+ recv(t, w.killed)
117+ recvFailed(t, cfg.Status)
118+ select {
119+ case err := <-h.done:
120+ t.Fatalf("worker crash ended the lock: %v", err)
121+ case <-time.After(100 * time.Millisecond):
122+ }
123+}
124+
125+func TestDaemonEOFKeepsLockUntilUnlock(t *testing.T) {
126+ h := newHarness(t)
127+ w := expectWorker(t, h.l)
128+ h.sendLock(t)
129+ cfg := recv(t, h.cfgs)
130+ w.bootstrap(t)
131+ w.ready(t)
132+ cfg.OnLocked()
133+ h.event(t)
134+ h.cmdW.Close() // daemon gone
135+ select {
136+ case err := <-h.done:
137+ t.Fatalf("EOF dropped an active lock: %v", err)
138+ case <-time.After(100 * time.Millisecond):
139+ }
140+ cfg.OnSubmit([]byte("good"))
141+ f := w.attempt(t)
142+ w.result(t, f.Attempt, ports.AuthSuccess)
143+ require.Equal(t, ports.LockReleased{Generation: gen}, h.event(t))
144+ require.NoError(t, recv(t, h.done))
145+}
146+
147+func TestFadeOnlyExitsOnEOF(t *testing.T) {
148+ h := newHarness(t)
149+ started := make(chan *fadeModel, 1)
150+ h.fade.EXPECT().RunFade(mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, m *fadeModel) error {
151+ started <- m
152+ <-ctx.Done()
153+ return nil
154+ }).Once()
155+ require.NoError(t, visualproc.WriteCommand(h.cmdW, ports.VisualFade{Black: true, Duration: time.Second}))
156+ m := recv(t, started)
157+ require.Equal(t, fadeCmd{black: true, dur: time.Second}, recv(t, m.cmds))
158+ h.cmdW.Close()
159+ require.NoError(t, recv(t, h.done))
160+}
161+
162+func TestRevealWithoutFadeExitsWithoutSpawning(t *testing.T) {
163+ h := newHarness(t)
164+ require.NoError(t, visualproc.WriteCommand(h.cmdW, ports.VisualFade{Black: false}))
165+ require.NoError(t, recv(t, h.done))
166+}
167+
168+func TestFinishedRevealExits(t *testing.T) {
169+ h := newHarness(t)
170+ release := make(chan struct{})
171+ h.fade.EXPECT().RunFade(mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, m *fadeModel) error {
172+ <-release
173+ return nil
174+ }).Once()
175+ require.NoError(t, visualproc.WriteCommand(h.cmdW, ports.VisualFade{Black: true}))
176+ close(release)
177+ require.NoError(t, recv(t, h.done))
178+}
179+
180+// noEvent fails if the session emitted anything on the event pipe.
181+func (h *harness) noEvent(t *testing.T) {
182+ t.Helper()
183+ got := make(chan ports.VisualEvent, 1)
184+ go func() {
185+ ev, err := visualproc.ReadEvent(h.events)
186+ if err == nil {
187+ got <- ev
188+ }
189+ }()
190+ select {
191+ case ev := <-got:
192+ t.Fatalf("unexpected event %#v", ev)
193+ case <-time.After(100 * time.Millisecond):
194+ }
195+}
196+
197+func TestRunLockNilWithoutAuthSuccessNeverReleases(t *testing.T) {
198+ h := newHarnessRun(t, func(context.Context, nefergui.LockConfig) error { return nil })
199+ w := expectWorker(t, h.l)
200+ h.sendLock(t)
201+ w.bootstrap(t)
202+ err := recv(t, h.done)
203+ require.ErrorIs(t, err, errLockWithoutAuth)
204+ h.noEvent(t) // no LockReleased
205+}
206+
207+func TestRunLockErrorNeverReleases(t *testing.T) {
208+ for name, runErr := range map[string]error{
209+ "finished": nefergui.ErrLockFinished,
210+ "wrapped": fmt.Errorf("compositor: %w", nefergui.ErrLockFinished),
211+ "other": errors.New("boom"),
212+ } {
213+ t.Run(name, func(t *testing.T) {
214+ h := newHarnessRun(t, func(context.Context, nefergui.LockConfig) error { return runErr })
215+ w := expectWorker(t, h.l)
216+ h.sendLock(t)
217+ w.bootstrap(t)
218+ require.ErrorIs(t, recv(t, h.done), runErr)
219+ h.noEvent(t)
220+ })
221+ }
222+}
A internal/adapters/visual/view.go +61 -0
@@ -0,0 +1,61 @@
1+package visual
2+
3+import (
4+ "github.com/bnema/nefergui"
5+)
6+
7+// V1 lock look: black screen, one centered monospace box with a white border,
8+// masked input (bullets only). No clock, avatar or widgets. The view sees only
9+// the typed-character count, never the secret.
10+const (
11+ cssScreen = "display:flex;flex-direction:column;align-items:center;justify-content:center;" +
12+ "width:100%;height:100%;background-color:#000000;color:#ffffff;font-family:monospace;font-size:18px"
13+ cssBox = "display:flex;flex-direction:column;align-items:center;border:2px solid #ffffff;" +
14+ "padding:24px;min-width:320px;background-color:#000000"
15+ cssLabel = "color:#ffffff"
16+ // The input field: a visible white-underlined line, bullets plus a cursor.
17+ cssMask = "color:#ffffff;min-height:28px;min-width:280px;margin-top:12px;padding:4px 8px;" +
18+ "border-bottom:2px solid #ffffff"
19+ cssHint = "color:#ffffff;margin-top:12px;min-height:22px"
20+)
21+
22+// labelFor is the fixed, non-secret caption for the requested credential.
23+func labelFor(p promptKind) string {
24+ switch p {
25+ case promptPIN:
26+ return "PIN"
27+ case promptFallback:
28+ return "Password (PIN unavailable)"
29+ case promptUnavailable:
30+ return "Authentication unavailable"
31+ case promptMore:
32+ return "Password (again)"
33+ case promptPassword:
34+ return "Password"
35+ }
36+ return "Locked"
37+}
38+
39+// hintFor is the minimal failure/busy line.
40+func hintFor(s nefergui.LockStatus) string {
41+ switch s {
42+ case nefergui.LockBusy:
43+ return "..."
44+ case nefergui.LockFailed:
45+ return "Denied"
46+ }
47+ return " "
48+}
49+
50+// UnlockBox draws the centered box.
51+func UnlockBox(root nefergui.Node, s nefergui.LockState, p promptKind) {
52+ box := root.Column(nefergui.Class("box"), nefergui.Inline(cssBox))
53+ box.Text(labelFor(p), nefergui.Key("label"), nefergui.Inline(cssLabel))
54+ box.Text(s.Bullets()+"_", nefergui.Key("mask"), nefergui.Inline(cssMask))
55+ box.Text(hintFor(s.Status), nefergui.Key("hint"), nefergui.Inline(cssHint))
56+}
57+
58+// lockView builds the whole lock screen.
59+func lockView(f *nefergui.Frame, s nefergui.LockState, p promptKind) {
60+ UnlockBox(f.Root(nefergui.Class("screen"), nefergui.Inline(cssScreen)), s, p)
61+}
A internal/adapters/visual/view_test.go +38 -0
@@ -0,0 +1,38 @@
1+package visual
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/nefergui"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestFadeAlphaInterpolatesAndArrives(t *testing.T) {
12+ t0 := time.Unix(100, 0)
13+ a, done := fadeAlpha(0, 1, t0, 2*time.Second, t0.Add(time.Second))
14+ require.InDelta(t, 0.5, a, 1e-9)
15+ require.False(t, done)
16+ a, done = fadeAlpha(0, 1, t0, 2*time.Second, t0.Add(3*time.Second))
17+ require.Equal(t, 1.0, a)
18+ require.True(t, done)
19+ _, done = fadeAlpha(1, 0, t0, 0, t0)
20+ require.True(t, done)
21+}
22+
23+func TestFadeLayerIsClickThroughOverlay(t *testing.T) {
24+ l := fadeLayer()
25+ require.Equal(t, nefergui.LayerOverlay, l.Level)
26+ require.Equal(t, nefergui.KeyboardNone, l.Keyboard)
27+ require.NotNil(t, l.InputRects)
28+ require.Empty(t, l.InputRects)
29+ all := nefergui.AnchorTop | nefergui.AnchorBottom | nefergui.AnchorLeft | nefergui.AnchorRight
30+ require.Equal(t, all, l.Anchors)
31+}
32+
33+func TestLabelsAndHintsNeverCarryInput(t *testing.T) {
34+ require.Equal(t, "Locked", labelFor(promptNone))
35+ require.Equal(t, "PIN", labelFor(promptPIN))
36+ require.Equal(t, "Denied", hintFor(nefergui.LockFailed))
37+ require.Equal(t, "...", hintFor(nefergui.LockBusy))
38+}
A internal/adapters/visual/visual_mock_test.go +478 -0
@@ -0,0 +1,478 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package visual
6+
7+import (
8+ "context"
9+ "io"
10+
11+ "github.com/bnema/nefergui"
12+ mock "github.com/stretchr/testify/mock"
13+)
14+
15+// newMockworkerLauncher creates a new instance of mockworkerLauncher. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
16+// The first argument is typically a *testing.T value.
17+func newMockworkerLauncher(t interface {
18+ mock.TestingT
19+ Cleanup(func())
20+}) *mockworkerLauncher {
21+ if helper, ok := t.(interface{ Helper() }); ok {
22+ helper.Helper()
23+ }
24+
25+ mock := &mockworkerLauncher{}
26+ mock.Mock.Test(t)
27+
28+ t.Cleanup(func() {
29+ if helper, ok := t.(interface{ Helper() }); ok {
30+ helper.Helper()
31+ }
32+ mock.AssertExpectations(t)
33+ })
34+
35+ return mock
36+}
37+
38+// mockworkerLauncher is an autogenerated mock type for the workerLauncher type
39+type mockworkerLauncher struct {
40+ mock.Mock
41+}
42+
43+type mockworkerLauncher_Expecter struct {
44+ mock *mock.Mock
45+}
46+
47+func (_m *mockworkerLauncher) EXPECT() *mockworkerLauncher_Expecter {
48+ return &mockworkerLauncher_Expecter{mock: &_m.Mock}
49+}
50+
51+// launch provides a mock function for the type mockworkerLauncher
52+func (_mock *mockworkerLauncher) launch(ctx context.Context) (workerProcess, error) {
53+ ret := _mock.Called(ctx)
54+
55+ if len(ret) == 0 {
56+ panic("no return value specified for launch")
57+ }
58+
59+ var r0 workerProcess
60+ var r1 error
61+ if returnFunc, ok := ret.Get(0).(func(context.Context) (workerProcess, error)); ok {
62+ return returnFunc(ctx)
63+ }
64+ if returnFunc, ok := ret.Get(0).(func(context.Context) workerProcess); ok {
65+ r0 = returnFunc(ctx)
66+ } else {
67+ if ret.Get(0) != nil {
68+ r0 = ret.Get(0).(workerProcess)
69+ }
70+ }
71+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
72+ r1 = returnFunc(ctx)
73+ } else {
74+ r1 = ret.Error(1)
75+ }
76+ return r0, r1
77+}
78+
79+// mockworkerLauncher_launch_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'launch'
80+type mockworkerLauncher_launch_Call struct {
81+ *mock.Call
82+}
83+
84+// launch is a helper method to define mock.On call
85+// - ctx context.Context
86+func (_e *mockworkerLauncher_Expecter) launch(ctx any) *mockworkerLauncher_launch_Call {
87+ return &mockworkerLauncher_launch_Call{Call: _e.mock.On("launch", ctx)}
88+}
89+
90+func (_c *mockworkerLauncher_launch_Call) Run(run func(ctx context.Context)) *mockworkerLauncher_launch_Call {
91+ _c.Call.Run(func(args mock.Arguments) {
92+ var arg0 context.Context
93+ if args[0] != nil {
94+ arg0 = args[0].(context.Context)
95+ }
96+ run(
97+ arg0,
98+ )
99+ })
100+ return _c
101+}
102+
103+func (_c *mockworkerLauncher_launch_Call) Return(workerProcessMoqParam workerProcess, err error) *mockworkerLauncher_launch_Call {
104+ _c.Call.Return(workerProcessMoqParam, err)
105+ return _c
106+}
107+
108+func (_c *mockworkerLauncher_launch_Call) RunAndReturn(run func(ctx context.Context) (workerProcess, error)) *mockworkerLauncher_launch_Call {
109+ _c.Call.Return(run)
110+ return _c
111+}
112+
113+// newMockworkerProcess creates a new instance of mockworkerProcess. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
114+// The first argument is typically a *testing.T value.
115+func newMockworkerProcess(t interface {
116+ mock.TestingT
117+ Cleanup(func())
118+}) *mockworkerProcess {
119+ if helper, ok := t.(interface{ Helper() }); ok {
120+ helper.Helper()
121+ }
122+
123+ mock := &mockworkerProcess{}
124+ mock.Mock.Test(t)
125+
126+ t.Cleanup(func() {
127+ if helper, ok := t.(interface{ Helper() }); ok {
128+ helper.Helper()
129+ }
130+ mock.AssertExpectations(t)
131+ })
132+
133+ return mock
134+}
135+
136+// mockworkerProcess is an autogenerated mock type for the workerProcess type
137+type mockworkerProcess struct {
138+ mock.Mock
139+}
140+
141+type mockworkerProcess_Expecter struct {
142+ mock *mock.Mock
143+}
144+
145+func (_m *mockworkerProcess) EXPECT() *mockworkerProcess_Expecter {
146+ return &mockworkerProcess_Expecter{mock: &_m.Mock}
147+}
148+
149+// kill provides a mock function for the type mockworkerProcess
150+func (_mock *mockworkerProcess) kill() error {
151+ ret := _mock.Called()
152+
153+ if len(ret) == 0 {
154+ panic("no return value specified for kill")
155+ }
156+
157+ var r0 error
158+ if returnFunc, ok := ret.Get(0).(func() error); ok {
159+ r0 = returnFunc()
160+ } else {
161+ r0 = ret.Error(0)
162+ }
163+ return r0
164+}
165+
166+// mockworkerProcess_kill_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'kill'
167+type mockworkerProcess_kill_Call struct {
168+ *mock.Call
169+}
170+
171+// kill is a helper method to define mock.On call
172+func (_e *mockworkerProcess_Expecter) kill() *mockworkerProcess_kill_Call {
173+ return &mockworkerProcess_kill_Call{Call: _e.mock.On("kill")}
174+}
175+
176+func (_c *mockworkerProcess_kill_Call) Run(run func()) *mockworkerProcess_kill_Call {
177+ _c.Call.Run(func(args mock.Arguments) {
178+ run()
179+ })
180+ return _c
181+}
182+
183+func (_c *mockworkerProcess_kill_Call) Return(err error) *mockworkerProcess_kill_Call {
184+ _c.Call.Return(err)
185+ return _c
186+}
187+
188+func (_c *mockworkerProcess_kill_Call) RunAndReturn(run func() error) *mockworkerProcess_kill_Call {
189+ _c.Call.Return(run)
190+ return _c
191+}
192+
193+// pipes provides a mock function for the type mockworkerProcess
194+func (_mock *mockworkerProcess) pipes() (io.WriteCloser, io.ReadCloser) {
195+ ret := _mock.Called()
196+
197+ if len(ret) == 0 {
198+ panic("no return value specified for pipes")
199+ }
200+
201+ var r0 io.WriteCloser
202+ var r1 io.ReadCloser
203+ if returnFunc, ok := ret.Get(0).(func() (io.WriteCloser, io.ReadCloser)); ok {
204+ return returnFunc()
205+ }
206+ if returnFunc, ok := ret.Get(0).(func() io.WriteCloser); ok {
207+ r0 = returnFunc()
208+ } else {
209+ if ret.Get(0) != nil {
210+ r0 = ret.Get(0).(io.WriteCloser)
211+ }
212+ }
213+ if returnFunc, ok := ret.Get(1).(func() io.ReadCloser); ok {
214+ r1 = returnFunc()
215+ } else {
216+ if ret.Get(1) != nil {
217+ r1 = ret.Get(1).(io.ReadCloser)
218+ }
219+ }
220+ return r0, r1
221+}
222+
223+// mockworkerProcess_pipes_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'pipes'
224+type mockworkerProcess_pipes_Call struct {
225+ *mock.Call
226+}
227+
228+// pipes is a helper method to define mock.On call
229+func (_e *mockworkerProcess_Expecter) pipes() *mockworkerProcess_pipes_Call {
230+ return &mockworkerProcess_pipes_Call{Call: _e.mock.On("pipes")}
231+}
232+
233+func (_c *mockworkerProcess_pipes_Call) Run(run func()) *mockworkerProcess_pipes_Call {
234+ _c.Call.Run(func(args mock.Arguments) {
235+ run()
236+ })
237+ return _c
238+}
239+
240+func (_c *mockworkerProcess_pipes_Call) Return(stdin io.WriteCloser, stdout io.ReadCloser) *mockworkerProcess_pipes_Call {
241+ _c.Call.Return(stdin, stdout)
242+ return _c
243+}
244+
245+func (_c *mockworkerProcess_pipes_Call) RunAndReturn(run func() (io.WriteCloser, io.ReadCloser)) *mockworkerProcess_pipes_Call {
246+ _c.Call.Return(run)
247+ return _c
248+}
249+
250+// wait provides a mock function for the type mockworkerProcess
251+func (_mock *mockworkerProcess) wait() error {
252+ ret := _mock.Called()
253+
254+ if len(ret) == 0 {
255+ panic("no return value specified for wait")
256+ }
257+
258+ var r0 error
259+ if returnFunc, ok := ret.Get(0).(func() error); ok {
260+ r0 = returnFunc()
261+ } else {
262+ r0 = ret.Error(0)
263+ }
264+ return r0
265+}
266+
267+// mockworkerProcess_wait_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'wait'
268+type mockworkerProcess_wait_Call struct {
269+ *mock.Call
270+}
271+
272+// wait is a helper method to define mock.On call
273+func (_e *mockworkerProcess_Expecter) wait() *mockworkerProcess_wait_Call {
274+ return &mockworkerProcess_wait_Call{Call: _e.mock.On("wait")}
275+}
276+
277+func (_c *mockworkerProcess_wait_Call) Run(run func()) *mockworkerProcess_wait_Call {
278+ _c.Call.Run(func(args mock.Arguments) {
279+ run()
280+ })
281+ return _c
282+}
283+
284+func (_c *mockworkerProcess_wait_Call) Return(err error) *mockworkerProcess_wait_Call {
285+ _c.Call.Return(err)
286+ return _c
287+}
288+
289+func (_c *mockworkerProcess_wait_Call) RunAndReturn(run func() error) *mockworkerProcess_wait_Call {
290+ _c.Call.Return(run)
291+ return _c
292+}
293+
294+// newMockfadeRunner creates a new instance of mockfadeRunner. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
295+// The first argument is typically a *testing.T value.
296+func newMockfadeRunner(t interface {
297+ mock.TestingT
298+ Cleanup(func())
299+}) *mockfadeRunner {
300+ if helper, ok := t.(interface{ Helper() }); ok {
301+ helper.Helper()
302+ }
303+
304+ mock := &mockfadeRunner{}
305+ mock.Mock.Test(t)
306+
307+ t.Cleanup(func() {
308+ if helper, ok := t.(interface{ Helper() }); ok {
309+ helper.Helper()
310+ }
311+ mock.AssertExpectations(t)
312+ })
313+
314+ return mock
315+}
316+
317+// mockfadeRunner is an autogenerated mock type for the fadeRunner type
318+type mockfadeRunner struct {
319+ mock.Mock
320+}
321+
322+type mockfadeRunner_Expecter struct {
323+ mock *mock.Mock
324+}
325+
326+func (_m *mockfadeRunner) EXPECT() *mockfadeRunner_Expecter {
327+ return &mockfadeRunner_Expecter{mock: &_m.Mock}
328+}
329+
330+// RunFade provides a mock function for the type mockfadeRunner
331+func (_mock *mockfadeRunner) RunFade(ctx context.Context, m *fadeModel) error {
332+ ret := _mock.Called(ctx, m)
333+
334+ if len(ret) == 0 {
335+ panic("no return value specified for RunFade")
336+ }
337+
338+ var r0 error
339+ if returnFunc, ok := ret.Get(0).(func(context.Context, *fadeModel) error); ok {
340+ r0 = returnFunc(ctx, m)
341+ } else {
342+ r0 = ret.Error(0)
343+ }
344+ return r0
345+}
346+
347+// mockfadeRunner_RunFade_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RunFade'
348+type mockfadeRunner_RunFade_Call struct {
349+ *mock.Call
350+}
351+
352+// RunFade is a helper method to define mock.On call
353+// - ctx context.Context
354+// - m *fadeModel
355+func (_e *mockfadeRunner_Expecter) RunFade(ctx any, m any) *mockfadeRunner_RunFade_Call {
356+ return &mockfadeRunner_RunFade_Call{Call: _e.mock.On("RunFade", ctx, m)}
357+}
358+
359+func (_c *mockfadeRunner_RunFade_Call) Run(run func(ctx context.Context, m *fadeModel)) *mockfadeRunner_RunFade_Call {
360+ _c.Call.Run(func(args mock.Arguments) {
361+ var arg0 context.Context
362+ if args[0] != nil {
363+ arg0 = args[0].(context.Context)
364+ }
365+ var arg1 *fadeModel
366+ if args[1] != nil {
367+ arg1 = args[1].(*fadeModel)
368+ }
369+ run(
370+ arg0,
371+ arg1,
372+ )
373+ })
374+ return _c
375+}
376+
377+func (_c *mockfadeRunner_RunFade_Call) Return(err error) *mockfadeRunner_RunFade_Call {
378+ _c.Call.Return(err)
379+ return _c
380+}
381+
382+func (_c *mockfadeRunner_RunFade_Call) RunAndReturn(run func(ctx context.Context, m *fadeModel) error) *mockfadeRunner_RunFade_Call {
383+ _c.Call.Return(run)
384+ return _c
385+}
386+
387+// newMocklockRunner creates a new instance of mocklockRunner. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
388+// The first argument is typically a *testing.T value.
389+func newMocklockRunner(t interface {
390+ mock.TestingT
391+ Cleanup(func())
392+}) *mocklockRunner {
393+ if helper, ok := t.(interface{ Helper() }); ok {
394+ helper.Helper()
395+ }
396+
397+ mock := &mocklockRunner{}
398+ mock.Mock.Test(t)
399+
400+ t.Cleanup(func() {
401+ if helper, ok := t.(interface{ Helper() }); ok {
402+ helper.Helper()
403+ }
404+ mock.AssertExpectations(t)
405+ })
406+
407+ return mock
408+}
409+
410+// mocklockRunner is an autogenerated mock type for the lockRunner type
411+type mocklockRunner struct {
412+ mock.Mock
413+}
414+
415+type mocklockRunner_Expecter struct {
416+ mock *mock.Mock
417+}
418+
419+func (_m *mocklockRunner) EXPECT() *mocklockRunner_Expecter {
420+ return &mocklockRunner_Expecter{mock: &_m.Mock}
421+}
422+
423+// RunLock provides a mock function for the type mocklockRunner
424+func (_mock *mocklockRunner) RunLock(ctx context.Context, cfg nefergui.LockConfig) error {
425+ ret := _mock.Called(ctx, cfg)
426+
427+ if len(ret) == 0 {
428+ panic("no return value specified for RunLock")
429+ }
430+
431+ var r0 error
432+ if returnFunc, ok := ret.Get(0).(func(context.Context, nefergui.LockConfig) error); ok {
433+ r0 = returnFunc(ctx, cfg)
434+ } else {
435+ r0 = ret.Error(0)
436+ }
437+ return r0
438+}
439+
440+// mocklockRunner_RunLock_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RunLock'
441+type mocklockRunner_RunLock_Call struct {
442+ *mock.Call
443+}
444+
445+// RunLock is a helper method to define mock.On call
446+// - ctx context.Context
447+// - cfg nefergui.LockConfig
448+func (_e *mocklockRunner_Expecter) RunLock(ctx any, cfg any) *mocklockRunner_RunLock_Call {
449+ return &mocklockRunner_RunLock_Call{Call: _e.mock.On("RunLock", ctx, cfg)}
450+}
451+
452+func (_c *mocklockRunner_RunLock_Call) Run(run func(ctx context.Context, cfg nefergui.LockConfig)) *mocklockRunner_RunLock_Call {
453+ _c.Call.Run(func(args mock.Arguments) {
454+ var arg0 context.Context
455+ if args[0] != nil {
456+ arg0 = args[0].(context.Context)
457+ }
458+ var arg1 nefergui.LockConfig
459+ if args[1] != nil {
460+ arg1 = args[1].(nefergui.LockConfig)
461+ }
462+ run(
463+ arg0,
464+ arg1,
465+ )
466+ })
467+ return _c
468+}
469+
470+func (_c *mocklockRunner_RunLock_Call) Return(err error) *mocklockRunner_RunLock_Call {
471+ _c.Call.Return(err)
472+ return _c
473+}
474+
475+func (_c *mocklockRunner_RunLock_Call) RunAndReturn(run func(ctx context.Context, cfg nefergui.LockConfig) error) *mocklockRunner_RunLock_Call {
476+ _c.Call.Return(run)
477+ return _c
478+}
A internal/adapters/visual/worker.go +71 -0
@@ -0,0 +1,71 @@
1+package visual
2+
3+import (
4+ "context"
5+ "fmt"
6+ "io"
7+ "os"
8+ "os/exec"
9+ "syscall"
10+)
11+
12+// execLauncher spawns `<exe> auth-worker` with private pipes: child fd 3 is the
13+// worker's input, fd 4 its output. No credential ever appears in argv or env.
14+type execLauncher struct{ exe string }
15+
16+type execWorker struct {
17+ cmd *exec.Cmd
18+ in, out *os.File
19+}
20+
21+func (l execLauncher) launch(context.Context) (workerProcess, error) {
22+ inR, inW, err := os.Pipe()
23+ if err != nil {
24+ return nil, fmt.Errorf("create auth worker input pipe: %w", err)
25+ }
26+ outR, outW, err := os.Pipe()
27+ if err != nil {
28+ inR.Close()
29+ inW.Close()
30+ return nil, fmt.Errorf("create auth worker output pipe: %w", err)
31+ }
32+ cmd := exec.Command(l.exe, "auth-worker")
33+ cmd.Stderr = os.Stderr
34+ cmd.Env = workerEnvironment(os.LookupEnv)
35+ cmd.ExtraFiles = []*os.File{inR, outW}
36+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
37+ err = cmd.Start()
38+ inR.Close()
39+ outW.Close()
40+ if err != nil {
41+ inW.Close()
42+ outR.Close()
43+ return nil, fmt.Errorf("start auth worker %s: %w", l.exe, err)
44+ }
45+ return &execWorker{cmd: cmd, in: inW, out: outR}, nil
46+}
47+
48+// workerEnvAllowlist is everything the worker (and the `pass`/gpg it spawns)
49+// may inherit from the visual process; nothing else leaks into it.
50+var workerEnvAllowlist = []string{
51+ "HOME", "PATH", "USER", "LOGNAME", "LANG", "LC_ALL",
52+ "XDG_RUNTIME_DIR", "GNUPGHOME", "PASSWORD_STORE_DIR", "GPG_TTY",
53+}
54+
55+// workerEnvironment builds the worker's environment from the allowlist; only
56+// variables that are present are passed.
57+func workerEnvironment(lookup func(string) (string, bool)) []string {
58+ env := make([]string, 0, len(workerEnvAllowlist))
59+ for _, key := range workerEnvAllowlist {
60+ if v, ok := lookup(key); ok {
61+ env = append(env, key+"="+v)
62+ }
63+ }
64+ return env
65+}
66+
67+func (w *execWorker) pipes() (io.WriteCloser, io.ReadCloser) { return w.in, w.out }
68+func (w *execWorker) kill() error {
69+ return syscall.Kill(-w.cmd.Process.Pid, syscall.SIGKILL)
70+}
71+func (w *execWorker) wait() error { return w.cmd.Wait() }
A internal/adapters/visual/worker_test.go +40 -0
@@ -0,0 +1,40 @@
1+package visual
2+
3+import (
4+ "bytes"
5+ "errors"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/zerowrap"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestWorkerEnvironmentIsAnAllowlistOfPresentVariables(t *testing.T) {
14+ host := map[string]string{
15+ "HOME": "/home/u", "PATH": "/usr/bin", "USER": "u", "GNUPGHOME": "/g", "GPG_TTY": "/dev/pts/1",
16+ "WAYLAND_DISPLAY": "wayland-1", "SSH_AUTH_SOCK": "/s", "NEFERAFK_PIN": "123456", "LC_ALL": "",
17+ }
18+ env := workerEnvironment(func(k string) (string, bool) { v, ok := host[k]; return v, ok })
19+ require.Equal(t, []string{"HOME=/home/u", "PATH=/usr/bin", "USER=u", "LC_ALL=", "GNUPGHOME=/g", "GPG_TTY=/dev/pts/1"}, env)
20+ for _, e := range env {
21+ require.NotContains(t, e, "WAYLAND")
22+ require.NotContains(t, e, "SSH_")
23+ require.NotContains(t, e, "PIN")
24+ }
25+ require.Empty(t, workerEnvironment(func(string) (string, bool) { return "", false }))
26+}
27+
28+func TestLockConfigReportsSkippedOutputWithComponent(t *testing.T) {
29+ var buf bytes.Buffer
30+ log := zerowrap.New(zerowrap.Config{Output: &buf})
31+ ctl := newAuthController(testLog(), nil, boot(), time.Minute)
32+ cfg := lockConfig(log, ctl, nil, make(chan struct{}, 1))
33+ require.NotNil(t, cfg.OnOutputError)
34+ cfg.OnOutputError("HDMI-A-1", errors.New("surface failed"))
35+ out := buf.String()
36+ require.Contains(t, out, "visual-lock")
37+ require.Contains(t, out, "HDMI-A-1")
38+ require.Contains(t, out, "surface failed")
39+ require.Contains(t, out, "WRN")
40+}
A internal/adapters/visualproc/launcher.go +171 -0
@@ -0,0 +1,171 @@
1+package visualproc
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "io"
8+ "os"
9+ "os/exec"
10+ "slices"
11+ "strings"
12+ "sync"
13+ "syscall"
14+ "time"
15+
16+ "github.com/bnema/neferafk/internal/ports"
17+ "github.com/bnema/zerowrap"
18+)
19+
20+const eventBuffer = 8
21+const eofGrace = 2 * time.Second
22+const writeTimeout = 2 * time.Second
23+
24+// Launcher starts visual processes with os/exec. Child fd 3 receives commands
25+// and fd 4 carries events; both are private anonymous pipes (CLOEXEC on the
26+// daemon side). Stdout/stderr are inherited for the journal, stdin is closed.
27+type Launcher struct {
28+ Executable string
29+ Args []string
30+ // Env is the base environment; nil means os.Environ().
31+ Env []string
32+ Log zerowrap.Logger
33+}
34+
35+func (l *Launcher) Launch(ctx context.Context, spawn ports.VisualSpawn) (ports.VisualSession, error) {
36+ cmdR, cmdW, err := os.Pipe()
37+ if err != nil {
38+ return nil, err
39+ }
40+ evR, evW, err := os.Pipe()
41+ if err != nil {
42+ cmdR.Close()
43+ cmdW.Close()
44+ return nil, err
45+ }
46+ env := l.Env
47+ if env == nil {
48+ env = os.Environ()
49+ }
50+ env = slices.DeleteFunc(slices.Clone(env), func(kv string) bool {
51+ name, _, _ := strings.Cut(kv, "=")
52+ return slices.Contains(spawn.OmitEnv, name)
53+ })
54+ cmd := exec.Command(l.Executable, l.Args...)
55+ cmd.Env = env
56+ cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
57+ cmd.ExtraFiles = []*os.File{cmdR, evW}
58+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
59+ err = cmd.Start()
60+ cmdR.Close() // child owns its ends now
61+ evW.Close()
62+ if err != nil {
63+ cmdW.Close()
64+ evR.Close()
65+ return nil, fmt.Errorf("visual launch: %w", err)
66+ }
67+ s := &session{cmd: cmd, w: cmdW, r: evR, events: make(chan ports.VisualEvent, eventBuffer), detached: make(chan struct{}), exited: make(chan struct{}), log: l.Log.WithField("component", "visualproc")}
68+ go s.reap()
69+ go s.pump()
70+ return s, nil
71+}
72+
73+type session struct {
74+ cmd *exec.Cmd
75+ w, r *os.File
76+ events chan ports.VisualEvent
77+ detached chan struct{}
78+ exited chan struct{} // closed by reap once exitErr is set
79+ exitErr error
80+ detach sync.Once
81+ log zerowrap.Logger
82+}
83+
84+// reap is the only caller of cmd.Wait; it outlives Detach so the child never
85+// becomes a zombie of the daemon.
86+func (s *session) reap() {
87+ s.exitErr = s.cmd.Wait()
88+ close(s.exited)
89+}
90+
91+// pump is the only goroutine that reads the event pipe and reaps the child.
92+func (s *session) pump() {
93+ defer close(s.events)
94+ var perr error
95+ for {
96+ ev, err := ReadEvent(s.r)
97+ if err != nil {
98+ perr = err
99+ break
100+ }
101+ select {
102+ case s.events <- ev:
103+ case <-s.detached:
104+ return
105+ }
106+ }
107+ s.r.Close()
108+ var exit error
109+ select {
110+ case <-s.exited:
111+ case <-s.detached:
112+ return
113+ case <-time.After(eofGrace):
114+ // Closed events (or spoke garbage) yet still running: malfunction.
115+ s.killGroup()
116+ select {
117+ case <-s.exited:
118+ case <-s.detached:
119+ return
120+ }
121+ }
122+ exit = s.exitErr
123+ if exit == nil && perr != nil && !errors.Is(perr, io.EOF) {
124+ exit = perr
125+ }
126+ select {
127+ case s.events <- ports.VisualExited{Err: exit}:
128+ case <-s.detached:
129+ }
130+}
131+
132+// Send never blocks the policy owner for long: a child that stops reading its
133+// command pipe is treated as dead by the caller.
134+func (s *session) Send(cmd ports.VisualCommand) error {
135+ _ = s.w.SetWriteDeadline(time.Now().Add(writeTimeout))
136+ return WriteCommand(s.w, cmd)
137+}
138+func (s *session) Events() <-chan ports.VisualEvent { return s.events }
139+
140+// killGroup SIGKILLs the child's process group unless the child was already
141+// reaped: after Wait its pid (and so the group id) may belong to someone else.
142+// The remaining check-to-kill window is bounded by the reaper's Wait return.
143+func (s *session) killGroup() {
144+ select {
145+ case <-s.exited:
146+ return
147+ default:
148+ }
149+ if err := syscall.Kill(-s.cmd.Process.Pid, syscall.SIGKILL); err != nil && !errors.Is(err, syscall.ESRCH) {
150+ s.log.Warn().Err(err).Msg("visual kill failed")
151+ }
152+}
153+
154+// Close kills the process group; the reaper collects it and pump reports
155+// VisualExited. Close is idempotent.
156+func (s *session) Close() error {
157+ s.killGroup()
158+ err := s.w.Close()
159+ if err != nil && !errors.Is(err, os.ErrClosed) {
160+ s.log.Warn().Err(err).Msg("visual command pipe close failed")
161+ return err
162+ }
163+ return nil
164+}
165+
166+// Detach abandons the child; reap collects it whenever it exits.
167+func (s *session) Detach() error {
168+ s.detach.Do(func() { close(s.detached) })
169+ s.r.Close()
170+ return s.w.Close()
171+}
A internal/adapters/visualproc/launcher_test.go +159 -0
@@ -0,0 +1,159 @@
1+package visualproc
2+
3+import (
4+ "context"
5+ "io"
6+ "os"
7+ "os/exec"
8+ "syscall"
9+ "testing"
10+ "time"
11+
12+ "github.com/bnema/neferafk/internal/ports"
13+ "github.com/bnema/zerowrap"
14+)
15+
16+// TestMain doubles as the child process: with NEFERAFK_TEST_CHILD set the test
17+// binary behaves like a tiny visual on fds 3/4 instead of running tests.
18+func TestMain(m *testing.M) {
19+ switch os.Getenv("NEFERAFK_TEST_CHILD") {
20+ case "":
21+ os.Exit(m.Run())
22+ case "echo": // confirm the lock it is asked for, then exit cleanly
23+ if os.Getenv("NEFERAFK_TEST_PIN") != "" {
24+ os.Exit(9) // the omitted variable leaked into the child
25+ }
26+ cmd, err := ReadCommand(os.NewFile(3, "cmd"))
27+ lock, ok := cmd.(ports.VisualLock)
28+ if err != nil || !ok || string(lock.Auth.EnvPIN) != "123456" {
29+ os.Exit(10)
30+ }
31+ if WriteEvent(os.NewFile(4, "ev"), ports.LockConfirmed{Generation: lock.Generation}) != nil {
32+ os.Exit(11)
33+ }
34+ os.Exit(0)
35+ case "crash":
36+ os.Exit(7)
37+ }
38+}
39+
40+func launcher(mode string) *Launcher {
41+ exe, _ := os.Executable()
42+ return &Launcher{Executable: exe, Env: append(os.Environ(), "NEFERAFK_TEST_CHILD="+mode, "NEFERAFK_TEST_PIN=123456"), Log: zerowrap.New(zerowrap.Config{Output: io.Discard})}
43+}
44+
45+func next(t *testing.T, s ports.VisualSession) ports.VisualEvent {
46+ t.Helper()
47+ select {
48+ case ev, ok := <-s.Events():
49+ if !ok {
50+ t.Fatal("events closed")
51+ }
52+ return ev
53+ case <-time.After(5 * time.Second):
54+ t.Fatal("no event")
55+ return nil
56+ }
57+}
58+
59+func TestLauncherPrivatePipesAndEnvOmission(t *testing.T) {
60+ s, err := launcher("echo").Launch(context.Background(), ports.VisualSpawn{OmitEnv: []string{"NEFERAFK_TEST_PIN"}})
61+ if err != nil {
62+ t.Fatal(err)
63+ }
64+ defer s.Close()
65+ if err := s.Send(ports.VisualLock{Generation: 5, Auth: ports.AuthBootstrap{Generation: 5, EnvPIN: []byte("123456")}}); err != nil {
66+ t.Fatal(err)
67+ }
68+ if ev := next(t, s); ev != (ports.LockConfirmed{Generation: 5}) {
69+ t.Fatalf("%#v", ev)
70+ }
71+ ev := next(t, s)
72+ if x, ok := ev.(ports.VisualExited); !ok || x.Err != nil {
73+ t.Fatalf("clean exit reported as %#v", ev)
74+ }
75+ if _, ok := <-s.Events(); ok {
76+ t.Fatal("events not closed after exit")
77+ }
78+}
79+
80+func TestLauncherReportsCrashAsExitNeverAsUnlock(t *testing.T) {
81+ s, err := launcher("crash").Launch(context.Background(), ports.VisualSpawn{})
82+ if err != nil {
83+ t.Fatal(err)
84+ }
85+ defer s.Close()
86+ ev := next(t, s)
87+ if x, ok := ev.(ports.VisualExited); !ok || x.Err == nil {
88+ t.Fatalf("crash reported as %#v", ev)
89+ }
90+}
91+
92+func TestLauncherDetachLeavesChildAndSecondCloseIsSafe(t *testing.T) {
93+ s, err := launcher("echo").Launch(context.Background(), ports.VisualSpawn{OmitEnv: []string{"NEFERAFK_TEST_PIN"}})
94+ if err != nil {
95+ t.Fatal(err)
96+ }
97+ if err := s.Detach(); err != nil {
98+ t.Fatal(err)
99+ }
100+ _ = s.Detach()
101+}
102+
103+func TestLauncherLaunchFailure(t *testing.T) {
104+ if _, err := (&Launcher{Executable: "/nonexistent/neferafk", Log: zerowrap.New(zerowrap.Config{Output: io.Discard})}).Launch(context.Background(), ports.VisualSpawn{}); err == nil {
105+ t.Fatal("missing executable launched")
106+ }
107+}
108+
109+// sleeper starts an unrelated process in its own group standing in for a
110+// reused pid/pgid.
111+func sleeper(t *testing.T) *exec.Cmd {
112+ t.Helper()
113+ c := exec.Command("sleep", "30")
114+ c.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
115+ if err := c.Start(); err != nil {
116+ t.Skipf("sleep unavailable: %v", err)
117+ }
118+ t.Cleanup(func() { _ = c.Process.Kill(); _ = c.Wait() })
119+ return c
120+}
121+
122+func alive(pid int) bool { return syscall.Kill(pid, 0) == nil }
123+
124+func TestCloseAfterReapNeverSignalsTheGroup(t *testing.T) {
125+ victim := sleeper(t)
126+ _, w, err := os.Pipe()
127+ if err != nil {
128+ t.Fatal(err)
129+ }
130+ exited := make(chan struct{})
131+ close(exited) // the visual was already reaped: its pgid may be reused
132+ s := &session{cmd: victim, w: w, exited: exited, log: zerowrap.New(zerowrap.Config{Output: io.Discard})}
133+ if err := s.Close(); err != nil {
134+ t.Fatal(err)
135+ }
136+ if err := s.Close(); err != nil { // idempotent
137+ t.Fatal(err)
138+ }
139+ time.Sleep(50 * time.Millisecond)
140+ if !alive(victim.Process.Pid) {
141+ t.Fatal("Close signalled a process group after the child was reaped")
142+ }
143+}
144+
145+func TestCloseBeforeReapKillsTheGroup(t *testing.T) {
146+ child := sleeper(t)
147+ _, w, _ := os.Pipe()
148+ s := &session{cmd: child, w: w, exited: make(chan struct{}), log: zerowrap.New(zerowrap.Config{Output: io.Discard})}
149+ if err := s.Close(); err != nil {
150+ t.Fatal(err)
151+ }
152+ done := make(chan error, 1)
153+ go func() { done <- child.Wait() }()
154+ select {
155+ case <-done:
156+ case <-time.After(5 * time.Second):
157+ t.Fatal("live child group not killed")
158+ }
159+}
A internal/adapters/visualproc/manager.go +129 -0
@@ -0,0 +1,129 @@
1+package visualproc
2+
3+import (
4+ "context"
5+ "errors"
6+ "sync/atomic"
7+
8+ "github.com/bnema/neferafk/internal/ports"
9+ "github.com/bnema/zerowrap"
10+)
11+
12+type live struct {
13+ session ports.VisualSession
14+ exited atomic.Bool
15+ lockGen atomic.Uint64 // generation the visual was last asked to lock; cleared on release
16+}
17+
18+// Manager implements ports.Visual over a VisualLauncher. Fade/Lock/Close/Detach
19+// are called from the policy owner goroutine only; forwarding goroutines touch
20+// just the atomic exited flag and the event channel. The visual is spawned
21+// lazily and never asked to unlock: there is no such command.
22+type Manager struct {
23+ ctx context.Context
24+ cancel context.CancelFunc
25+ launcher ports.VisualLauncher
26+ out chan<- ports.VisualEvent
27+ log zerowrap.Logger
28+ cur *live
29+}
30+
31+func NewManager(ctx context.Context, launcher ports.VisualLauncher, out chan<- ports.VisualEvent, log zerowrap.Logger) *Manager {
32+ ctx, cancel := context.WithCancel(ctx)
33+ return &Manager{ctx: ctx, cancel: cancel, launcher: launcher, out: out, log: log.WithField("component", "visual")}
34+}
35+
36+// current returns the running session or launches one when spawn is true.
37+func (m *Manager) current(ctx context.Context, spawn bool, sp ports.VisualSpawn) (*live, error) {
38+ if m.cur != nil && m.cur.exited.Load() {
39+ if err := m.cur.session.Close(); err != nil {
40+ m.log.Warn().Err(err).Msg("closing exited visual session")
41+ }
42+ m.cur = nil
43+ }
44+ if m.cur != nil || !spawn {
45+ return m.cur, nil
46+ }
47+ s, err := m.launcher.Launch(ctx, sp)
48+ if err != nil {
49+ return nil, err
50+ }
51+ l := &live{session: s}
52+ m.cur = l
53+ go m.forward(l)
54+ m.log.Info().Msg("visual process spawned")
55+ return l, nil
56+}
57+
58+// forward relays child events. The exited flag is set before the terminal
59+// event is delivered, so a handler reacting to VisualExited already observes it.
60+func (m *Manager) forward(l *live) {
61+ for ev := range l.session.Events() {
62+ switch v := ev.(type) {
63+ case ports.VisualExited:
64+ l.exited.Store(true)
65+ case ports.LockReleased:
66+ l.lockGen.CompareAndSwap(uint64(v.Generation), 0)
67+ }
68+ select {
69+ case m.out <- ev:
70+ case <-m.ctx.Done():
71+ return
72+ }
73+ }
74+ l.exited.Store(true)
75+}
76+
77+// send retries once on a fresh process when the current one died between the
78+// liveness check and the write.
79+func (m *Manager) send(ctx context.Context, spawn bool, sp ports.VisualSpawn, cmd ports.VisualCommand, mark func(*live)) error {
80+ for attempt := 0; attempt < 2; attempt++ {
81+ l, err := m.current(ctx, spawn, sp)
82+ if err != nil || l == nil {
83+ return err
84+ }
85+ if err = l.session.Send(cmd); err == nil {
86+ if mark != nil {
87+ mark(l)
88+ }
89+ return nil
90+ }
91+ l.exited.Store(true)
92+ }
93+ return errors.New("visual unreachable")
94+}
95+
96+func (m *Manager) Fade(ctx context.Context, req ports.VisualFade) error {
97+ // Reveal never spawns a process.
98+ return m.send(ctx, req.Black, req.Spawn, req, nil)
99+}
100+
101+func (m *Manager) Lock(ctx context.Context, req ports.VisualLock) error {
102+ defer clear(req.Auth.EnvPIN)
103+ if m.cur != nil && !m.cur.exited.Load() && m.cur.lockGen.Load() == uint64(req.Generation) {
104+ return nil
105+ }
106+ return m.send(ctx, true, req.Spawn, req, func(l *live) { l.lockGen.Store(uint64(req.Generation)) })
107+}
108+
109+func (m *Manager) Close() error {
110+ defer m.cancel()
111+ if m.cur == nil {
112+ return nil
113+ }
114+ err := m.cur.session.Close()
115+ m.cur = nil
116+ return err
117+}
118+
119+func (m *Manager) Detach() error {
120+ defer m.cancel()
121+ if m.cur == nil {
122+ return nil
123+ }
124+ err := m.cur.session.Detach()
125+ m.cur = nil
126+ return err
127+}
128+
129+var _ ports.Visual = (*Manager)(nil)
A internal/adapters/visualproc/manager_test.go +138 -0
@@ -0,0 +1,138 @@
1+package visualproc
2+
3+import (
4+ "context"
5+ "errors"
6+ "io"
7+ "testing"
8+
9+ portsmocks "github.com/bnema/neferafk/internal/mocks/ports"
10+ "github.com/bnema/neferafk/internal/ports"
11+ "github.com/bnema/zerowrap"
12+ "github.com/stretchr/testify/mock"
13+)
14+
15+type rig struct {
16+ launcher *portsmocks.MockVisualLauncher
17+ out chan ports.VisualEvent
18+ m *Manager
19+}
20+
21+func newRig(t *testing.T) *rig {
22+ r := &rig{launcher: portsmocks.NewMockVisualLauncher(t), out: make(chan ports.VisualEvent, 8)}
23+ r.m = NewManager(context.Background(), r.launcher, r.out, zerowrap.New(zerowrap.Config{Output: io.Discard}))
24+ return r
25+}
26+
27+func newSession(t *testing.T) (*portsmocks.MockVisualSession, chan ports.VisualEvent) {
28+ s := portsmocks.NewMockVisualSession(t)
29+ ev := make(chan ports.VisualEvent, 4)
30+ s.EXPECT().Events().Return((<-chan ports.VisualEvent)(ev)).Maybe()
31+ return s, ev
32+}
33+
34+func TestFadeRevealNeverSpawns(t *testing.T) {
35+ r := newRig(t) // launcher has no expectations
36+ if err := r.m.Fade(context.Background(), ports.VisualFade{}); err != nil {
37+ t.Fatal(err)
38+ }
39+}
40+
41+func TestLazySpawnReuseAndLockIdempotencePerGeneration(t *testing.T) {
42+ r := newRig(t)
43+ s, ev := newSession(t)
44+ spawn := ports.VisualSpawn{OmitEnv: []string{"PIN"}}
45+ r.launcher.EXPECT().Launch(mock.Anything, spawn).Return(s, nil).Once()
46+ s.EXPECT().Send(ports.VisualFade{Black: true, Spawn: spawn}).Return(nil).Once()
47+ lock := ports.VisualLock{Generation: 1, Auth: ports.AuthBootstrap{Generation: 1, EnvPIN: []byte("123456")}, Spawn: spawn}
48+ s.EXPECT().Send(mock.MatchedBy(func(c ports.VisualCommand) bool { l, ok := c.(ports.VisualLock); return ok && l.Generation == 1 })).Return(nil).Once()
49+ s.EXPECT().Send(mock.MatchedBy(func(c ports.VisualCommand) bool { l, ok := c.(ports.VisualLock); return ok && l.Generation == 2 })).Return(nil).Once()
50+ if err := r.m.Fade(context.Background(), ports.VisualFade{Black: true, Spawn: spawn}); err != nil {
51+ t.Fatal(err)
52+ }
53+ pin := lock.Auth.EnvPIN
54+ if err := r.m.Lock(context.Background(), lock); err != nil {
55+ t.Fatal(err)
56+ }
57+ if string(pin) == "123456" {
58+ t.Fatal("manager did not wipe the PIN it owns")
59+ }
60+ if err := r.m.Lock(context.Background(), ports.VisualLock{Generation: 1, Auth: ports.AuthBootstrap{Generation: 1}}); err != nil {
61+ t.Fatal(err) // same generation: no second send (mock enforces)
62+ }
63+ if err := r.m.Lock(context.Background(), ports.VisualLock{Generation: 2, Auth: ports.AuthBootstrap{Generation: 2}}); err != nil {
64+ t.Fatal(err)
65+ }
66+ // Lifecycle events are forwarded in order.
67+ ev <- ports.LockConfirmed{Generation: 2}
68+ if got := <-r.out; got != (ports.LockConfirmed{Generation: 2}) {
69+ t.Fatalf("%#v", got)
70+ }
71+ s.EXPECT().Close().Return(nil).Once()
72+ if err := r.m.Close(); err != nil {
73+ t.Fatal(err)
74+ }
75+}
76+
77+func TestCrashedVisualIsReplacedOnNextLock(t *testing.T) {
78+ r := newRig(t)
79+ s1, ev1 := newSession(t)
80+ s2, _ := newSession(t)
81+ r.launcher.EXPECT().Launch(mock.Anything, mock.Anything).Return(s1, nil).Once()
82+ r.launcher.EXPECT().Launch(mock.Anything, mock.Anything).Return(s2, nil).Once()
83+ s1.EXPECT().Send(mock.Anything).Return(nil).Once()
84+ s1.EXPECT().Close().Return(nil).Once()
85+ s2.EXPECT().Send(mock.Anything).Return(nil).Once()
86+ s2.EXPECT().Detach().Return(nil).Once()
87+ lock := ports.VisualLock{Generation: 1, Auth: ports.AuthBootstrap{Generation: 1}}
88+ if err := r.m.Lock(context.Background(), lock); err != nil {
89+ t.Fatal(err)
90+ }
91+ ev1 <- ports.VisualExited{Err: errors.New("crash")}
92+ if got, ok := (<-r.out).(ports.VisualExited); !ok || got.Err == nil {
93+ t.Fatalf("%#v", got)
94+ }
95+ // Same generation, but the process is gone: a fresh visual re-acquires.
96+ if err := r.m.Lock(context.Background(), lock); err != nil {
97+ t.Fatal(err)
98+ }
99+ if err := r.m.Detach(); err != nil {
100+ t.Fatal(err)
101+ }
102+}
103+
104+func TestWriteFailureRetriesOnFreshProcessThenReports(t *testing.T) {
105+ r := newRig(t)
106+ s1, _ := newSession(t)
107+ s2, _ := newSession(t)
108+ r.launcher.EXPECT().Launch(mock.Anything, mock.Anything).Return(s1, nil).Once()
109+ r.launcher.EXPECT().Launch(mock.Anything, mock.Anything).Return(s2, nil).Once()
110+ s1.EXPECT().Send(mock.Anything).Return(errors.New("broken pipe")).Once()
111+ s1.EXPECT().Close().Return(nil).Once()
112+ s2.EXPECT().Send(mock.Anything).Return(errors.New("broken pipe")).Once()
113+ s2.EXPECT().Close().Return(nil).Once()
114+ err := r.m.Lock(context.Background(), ports.VisualLock{Generation: 1, Auth: ports.AuthBootstrap{Generation: 1}})
115+ if err == nil {
116+ t.Fatal("unreachable visual reported success")
117+ }
118+ r.launcher.EXPECT().Launch(mock.Anything, mock.Anything).Return(nil, errors.New("exec failed")).Once()
119+ if err := r.m.Lock(context.Background(), ports.VisualLock{Generation: 1, Auth: ports.AuthBootstrap{Generation: 1}}); err == nil {
120+ t.Fatal("launch failure swallowed")
121+ }
122+}
123+
124+func TestReleasedLockAllowsRelockOfSameGenerationNumber(t *testing.T) {
125+ r := newRig(t)
126+ s, ev := newSession(t)
127+ r.launcher.EXPECT().Launch(mock.Anything, mock.Anything).Return(s, nil).Once()
128+ s.EXPECT().Send(mock.Anything).Return(nil).Twice()
129+ s.EXPECT().Close().Return(nil).Once()
130+ lock := ports.VisualLock{Generation: 1, Auth: ports.AuthBootstrap{Generation: 1}}
131+ r.m.Lock(context.Background(), lock)
132+ ev <- ports.LockReleased{Generation: 1}
133+ <-r.out
134+ if err := r.m.Lock(context.Background(), lock); err != nil {
135+ t.Fatal(err)
136+ }
137+ r.m.Close()
138+}
A internal/adapters/visualproc/wire.go +168 -0
@@ -0,0 +1,168 @@
1+// Package visualproc launches the separate visual process and speaks the tiny
2+// private daemon<->visual pipe protocol. It never renders and never owns or
3+// releases the session lock; the visual does, and reports the outcome.
4+//
5+// Wire format, both directions (fd 3: daemon->visual, fd 4: visual->daemon):
6+//
7+// "NAV1" | kind u8 | length u32 BE | payload
8+//
9+// Commands: 1 Fade {black u8, duration i64 ns BE}; 2 Lock {generation u64,
10+// then three u32-length-prefixed byte strings: PINSource, PINReference,
11+// EnvPIN}. Events: 3 LockConfirmed {generation u64}; 4 LockReleased
12+// {generation u64}. Process exit is observed by the launcher, not sent.
13+package visualproc
14+
15+import (
16+ "encoding/binary"
17+ "errors"
18+ "io"
19+ "time"
20+
21+ "github.com/bnema/neferafk/internal/ports"
22+)
23+
24+var ErrProtocol = errors.New("visualproc: invalid frame")
25+
26+const (
27+ kindFade byte = iota + 1
28+ kindLock
29+ kindConfirmed
30+ kindReleased
31+ maxPayload = 8 + 3*4 + 2*ports.AuthMaxMetadata + ports.AuthMaxSecret
32+)
33+
34+func writeFrame(w io.Writer, kind byte, payload []byte) error {
35+ buf := make([]byte, 9+len(payload))
36+ defer clear(buf)
37+ copy(buf, "NAV1")
38+ buf[4] = kind
39+ binary.BigEndian.PutUint32(buf[5:], uint32(len(payload)))
40+ copy(buf[9:], payload)
41+ for b := buf; len(b) > 0; {
42+ n, err := w.Write(b)
43+ if err != nil {
44+ return err
45+ }
46+ b = b[n:]
47+ }
48+ return nil
49+}
50+
51+func readFrame(r io.Reader) (byte, []byte, error) {
52+ var h [9]byte
53+ if _, err := io.ReadFull(r, h[:]); err != nil {
54+ return 0, nil, err
55+ }
56+ n := binary.BigEndian.Uint32(h[5:])
57+ if string(h[:4]) != "NAV1" || n > maxPayload {
58+ return 0, nil, ErrProtocol
59+ }
60+ p := make([]byte, n)
61+ if _, err := io.ReadFull(r, p); err != nil {
62+ clear(p)
63+ return 0, nil, err
64+ }
65+ return h[4], p, nil
66+}
67+
68+// WriteCommand encodes a daemon->visual command. The caller wipes any secret
69+// it owns; the encoded copy is wiped here.
70+func WriteCommand(w io.Writer, cmd ports.VisualCommand) error {
71+ switch c := cmd.(type) {
72+ case ports.VisualFade:
73+ p := make([]byte, 9)
74+ if c.Black {
75+ p[0] = 1
76+ }
77+ binary.BigEndian.PutUint64(p[1:], uint64(c.Duration))
78+ return writeFrame(w, kindFade, p)
79+ case ports.VisualLock:
80+ a := c.Auth
81+ fields := [][]byte{[]byte(a.PINSource), []byte(a.PINReference), a.EnvPIN}
82+ if c.Generation == 0 || uint64(c.Generation) != a.Generation || len(fields[1]) > ports.AuthMaxMetadata || len(fields[2]) > ports.AuthMaxSecret || len(fields[0]) > 16 {
83+ return ErrProtocol
84+ }
85+ p := binary.BigEndian.AppendUint64(nil, uint64(c.Generation))
86+ for _, f := range fields {
87+ p = binary.BigEndian.AppendUint32(p, uint32(len(f)))
88+ p = append(p, f...)
89+ }
90+ defer clear(p)
91+ return writeFrame(w, kindLock, p)
92+ }
93+ return ErrProtocol
94+}
95+
96+// ReadCommand decodes a daemon->visual command (used by the visual process).
97+// The returned VisualLock owns EnvPIN and its receiver must wipe it.
98+func ReadCommand(r io.Reader) (ports.VisualCommand, error) {
99+ kind, p, err := readFrame(r)
100+ if err != nil {
101+ return nil, err
102+ }
103+ defer clear(p)
104+ switch kind {
105+ case kindFade:
106+ if len(p) != 9 || p[0] > 1 {
107+ return nil, ErrProtocol
108+ }
109+ d := time.Duration(binary.BigEndian.Uint64(p[1:]))
110+ if d < 0 || d > ports.MaxDuration {
111+ return nil, ErrProtocol
112+ }
113+ return ports.VisualFade{Black: p[0] == 1, Duration: d}, nil
114+ case kindLock:
115+ if len(p) < 8 {
116+ return nil, ErrProtocol
117+ }
118+ gen := binary.BigEndian.Uint64(p)
119+ rest := p[8:]
120+ var f [3][]byte
121+ for i := range f {
122+ if len(rest) < 4 {
123+ return nil, ErrProtocol
124+ }
125+ n := binary.BigEndian.Uint32(rest)
126+ if uint64(n) > uint64(len(rest)-4) {
127+ return nil, ErrProtocol
128+ }
129+ f[i] = rest[4 : 4+n]
130+ rest = rest[4+n:]
131+ }
132+ if gen == 0 || len(rest) != 0 || len(f[0]) > 16 || len(f[1]) > ports.AuthMaxMetadata || len(f[2]) > ports.AuthMaxSecret {
133+ return nil, ErrProtocol
134+ }
135+ return ports.VisualLock{Generation: ports.Generation(gen), Auth: ports.AuthBootstrap{Generation: gen, PINSource: ports.PINSource(f[0]), PINReference: string(f[1]), EnvPIN: append([]byte(nil), f[2]...)}}, nil
136+ }
137+ return nil, ErrProtocol
138+}
139+
140+// WriteEvent encodes a visual->daemon lifecycle event (used by the visual).
141+func WriteEvent(w io.Writer, ev ports.VisualEvent) error {
142+ switch e := ev.(type) {
143+ case ports.LockConfirmed:
144+ return writeFrame(w, kindConfirmed, binary.BigEndian.AppendUint64(nil, uint64(e.Generation)))
145+ case ports.LockReleased:
146+ return writeFrame(w, kindReleased, binary.BigEndian.AppendUint64(nil, uint64(e.Generation)))
147+ }
148+ return ErrProtocol
149+}
150+
151+// ReadEvent decodes a visual->daemon event.
152+func ReadEvent(r io.Reader) (ports.VisualEvent, error) {
153+ kind, p, err := readFrame(r)
154+ if err != nil {
155+ return nil, err
156+ }
157+ if len(p) != 8 || binary.BigEndian.Uint64(p) == 0 {
158+ return nil, ErrProtocol
159+ }
160+ gen := ports.Generation(binary.BigEndian.Uint64(p))
161+ switch kind {
162+ case kindConfirmed:
163+ return ports.LockConfirmed{Generation: gen}, nil
164+ case kindReleased:
165+ return ports.LockReleased{Generation: gen}, nil
166+ }
167+ return nil, ErrProtocol
168+}
A internal/adapters/visualproc/wire_test.go +95 -0
@@ -0,0 +1,95 @@
1+package visualproc
2+
3+import (
4+ "bytes"
5+ "encoding/binary"
6+ "reflect"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/neferafk/internal/ports"
11+)
12+
13+func TestCommandRoundTrip(t *testing.T) {
14+ var b bytes.Buffer
15+ lock := ports.VisualLock{Generation: 4, Auth: ports.AuthBootstrap{Generation: 4, PINSource: ports.PINSourceEnv, PINReference: "r", EnvPIN: []byte("123456")}}
16+ for _, c := range []ports.VisualCommand{ports.VisualFade{Black: true, Duration: 2 * time.Second}, ports.VisualFade{}, lock} {
17+ if err := WriteCommand(&b, c); err != nil {
18+ t.Fatal(err)
19+ }
20+ }
21+ if c, err := ReadCommand(&b); err != nil || !reflect.DeepEqual(c, ports.VisualFade{Black: true, Duration: 2 * time.Second}) {
22+ t.Fatalf("%#v %v", c, err)
23+ }
24+ if c, err := ReadCommand(&b); err != nil || !reflect.DeepEqual(c, ports.VisualFade{}) {
25+ t.Fatalf("%#v %v", c, err)
26+ }
27+ c, err := ReadCommand(&b)
28+ got, ok := c.(ports.VisualLock)
29+ if err != nil || !ok || got.Generation != 4 || got.Auth.Generation != 4 || got.Auth.PINSource != ports.PINSourceEnv || got.Auth.PINReference != "r" || string(got.Auth.EnvPIN) != "123456" {
30+ t.Fatalf("%#v %v", c, err)
31+ }
32+}
33+
34+func TestCommandRejectsMismatchedGenerationAndGarbage(t *testing.T) {
35+ var b bytes.Buffer
36+ if err := WriteCommand(&b, ports.VisualLock{Generation: 1, Auth: ports.AuthBootstrap{Generation: 2}}); err == nil {
37+ t.Fatal("mismatched generations encoded")
38+ }
39+ if err := WriteCommand(&b, ports.VisualLock{}); err == nil {
40+ t.Fatal("zero generation encoded")
41+ }
42+ for _, in := range []string{"", "NAV1", "XXXX\x01\x00\x00\x00\x00", "NAV1\x09\x00\x00\x00\x00", "NAV1\x01\xff\xff\xff\xff", "NAV1\x01\x00\x00\x00\x02ab"} {
43+ if _, err := ReadCommand(bytes.NewReader([]byte(in))); err == nil {
44+ t.Fatalf("%q accepted", in)
45+ }
46+ if _, err := ReadEvent(bytes.NewReader([]byte(in))); err == nil {
47+ t.Fatalf("%q accepted as event", in)
48+ }
49+ }
50+}
51+
52+func TestEventRoundTripAndNoUnlockRequestExists(t *testing.T) {
53+ var b bytes.Buffer
54+ for _, e := range []ports.VisualEvent{ports.LockConfirmed{Generation: 2}, ports.LockReleased{Generation: 2}} {
55+ if err := WriteEvent(&b, e); err != nil {
56+ t.Fatal(err)
57+ }
58+ }
59+ if e, err := ReadEvent(&b); err != nil || e != (ports.LockConfirmed{Generation: 2}) {
60+ t.Fatalf("%#v %v", e, err)
61+ }
62+ if e, err := ReadEvent(&b); err != nil || e != (ports.LockReleased{Generation: 2}) {
63+ t.Fatalf("%#v %v", e, err)
64+ }
65+ if err := WriteEvent(&b, ports.VisualExited{}); err == nil {
66+ t.Fatal("process exit must not be a wire event")
67+ }
68+ // A daemon->visual frame kind is not accepted in the event direction, and
69+ // there is no command kind other than fade and lock.
70+ var c bytes.Buffer
71+ WriteCommand(&c, ports.VisualFade{})
72+ if _, err := ReadEvent(&c); err == nil {
73+ t.Fatal("command parsed as event")
74+ }
75+ if _, err := ReadCommand(bytes.NewReader([]byte("NAV1\x04\x00\x00\x00\x08\x00\x00\x00\x00\x00\x00\x00\x01"))); err == nil {
76+ t.Fatal("event parsed as command")
77+ }
78+}
79+
80+func TestReadCommandBoundsPINSource(t *testing.T) {
81+ // Hand-build a lock frame whose PINSource is 17 bytes: the writer refuses
82+ // it, so craft the payload directly.
83+ p := binary.BigEndian.AppendUint64(nil, 1)
84+ for _, f := range [][]byte{bytes.Repeat([]byte("x"), 17), nil, nil} {
85+ p = binary.BigEndian.AppendUint32(p, uint32(len(f)))
86+ p = append(p, f...)
87+ }
88+ var b bytes.Buffer
89+ if err := writeFrame(&b, kindLock, p); err != nil {
90+ t.Fatal(err)
91+ }
92+ if _, err := ReadCommand(&b); err == nil {
93+ t.Fatal("oversize PINSource accepted")
94+ }
95+}
A internal/adapters/wayland/client.go +589 -0
@@ -0,0 +1,589 @@
1+// Package wayland owns a standard-protocol control connection. It never owns
2+// lock surfaces or grabs input; visual processes own ext-session-lock.
3+package wayland
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "math"
10+ "net"
11+ "sort"
12+ "time"
13+
14+ "github.com/bnema/neferafk/internal/ports"
15+ "github.com/bnema/wlturbo"
16+ "github.com/bnema/wlturbo/protocol/core"
17+ "github.com/bnema/wlturbo/protocol/idlenotify"
18+ "github.com/bnema/wlturbo/protocol/outputpower"
19+ "github.com/bnema/zerowrap"
20+)
21+
22+const queueSize = 64
23+
24+var ErrEventOverflow = errors.New("wayland: event queue overflow")
25+
26+type Options struct {
27+ Requirements ports.WaylandRequirements
28+ Deadlines []time.Duration
29+ Generation uint64
30+}
31+type notice struct {
32+ kind uint8
33+ notification *notification
34+ output *output
35+ name string
36+ mode uint32
37+}
38+type notification struct {
39+ proxy *idlenotify.ExtIdleNotification
40+ token, cycle, generation uint64
41+ seat uint32
42+ after time.Duration
43+ active, wake, idled bool
44+}
45+type seat struct {
46+ proxy *core.Seat
47+ notifications map[time.Duration]*notification
48+ wake *notification
49+}
50+type output struct {
51+ proxy *core.Output
52+ power *outputpower.OutputPower
53+ lifetime uint64
54+ global uint32
55+ name string
56+ off, failed bool
57+ mode uint32
58+ modeKnown bool
59+}
60+type Client struct {
61+ d *wlturbo.Display
62+ options Options
63+ log zerowrap.Logger
64+ notices chan notice
65+ overflow chan struct{}
66+ seats map[uint32]*seat
67+ outputs map[uint32]*output
68+ idle *idlenotify.ExtIdleNotifier
69+ idleGlobal uint32
70+ power *outputpower.OutputPowerManager
71+ powerGlobal uint32
72+ deadlines map[time.Duration]bool
73+ generation, next uint64
74+ dirty bool
75+}
76+
77+// New takes ownership of conn. Run is called exactly once; no other reader or
78+// caller may use the display. Supplying an already connected socket permits
79+// real socketpair tests without a replacement transport.
80+func New(conn net.Conn, options Options, log zerowrap.Logger) (*Client, error) {
81+ deadlines, err := normalize(options.Deadlines)
82+ if err != nil {
83+ _ = conn.Close()
84+ return nil, err
85+ }
86+ d, err := wlturbo.ConnectFromConn(conn)
87+ if err != nil {
88+ _ = conn.Close()
89+ return nil, err
90+ }
91+ c := &Client{d: d, options: options, log: log.WithField("component", "wayland"), notices: make(chan notice, queueSize), overflow: make(chan struct{}, 1), seats: map[uint32]*seat{}, outputs: map[uint32]*output{}, deadlines: deadlines, generation: options.Generation}
92+ d.Registry().AddHandler("*", func(*wlturbo.Registry, uint32, uint32) { c.enqueue(notice{kind: 1}) })
93+ d.Registry().AddGlobalRemoveHandler(c)
94+ return c, nil
95+}
96+func normalize(ds []time.Duration) (map[time.Duration]bool, error) {
97+ out := map[time.Duration]bool{}
98+ for _, d := range ds {
99+ if d == 0 {
100+ continue
101+ }
102+ if d < 0 || d%time.Millisecond != 0 || d/time.Millisecond > math.MaxUint32 {
103+ return nil, fmt.Errorf("wayland: deadline must be positive whole milliseconds within uint32: %s", d)
104+ }
105+ out[d] = true
106+ }
107+ return out, nil
108+}
109+func (c *Client) HandleRegistryGlobalRemove(wlturbo.RegistryGlobalRemoveEvent) {
110+ c.enqueue(notice{kind: 1})
111+}
112+func (c *Client) enqueue(n notice) {
113+ select {
114+ case c.notices <- n:
115+ default:
116+ select {
117+ case c.overflow <- struct{}{}:
118+ default:
119+ }
120+ _ = c.d.Close()
121+ }
122+}
123+
124+// Run uses one dispatch reader; callbacks only enqueue values. All policy,
125+// notification, output and capability state belongs to the Run goroutine.
126+func (c *Client) Run(ctx context.Context, commands <-chan ports.WaylandCommand, events chan<- ports.WaylandEvent) error {
127+ defer c.d.Close()
128+ stop := context.AfterFunc(ctx, func() { _ = c.d.Close() })
129+ defer stop()
130+ if err := c.d.Roundtrip(); err != nil {
131+ if ctx.Err() != nil {
132+ return nil
133+ }
134+ return err
135+ }
136+ // Bootstrap announcements are represented by the registry snapshot.
137+ for len(c.notices) > 0 {
138+ <-c.notices
139+ }
140+ if err := c.reconcile(); err != nil {
141+ return err
142+ }
143+ // Initial seat/output metadata and output-power mode arrive before doctor.
144+ if err := c.d.Roundtrip(); err != nil {
145+ if ctx.Err() != nil {
146+ return nil
147+ }
148+ return err
149+ }
150+ for len(c.notices) > 0 {
151+ if err := c.handle(ctx, events, <-c.notices); err != nil {
152+ return err
153+ }
154+ }
155+ done := make(chan error, 1)
156+ proceed := make(chan struct{})
157+ readerStop := make(chan struct{})
158+ readerExited := make(chan struct{})
159+ go func() {
160+ defer close(readerExited)
161+ for {
162+ err := c.d.Dispatch()
163+ select {
164+ case done <- err:
165+ case <-readerStop:
166+ return
167+ }
168+ if err != nil {
169+ return
170+ }
171+ select {
172+ case <-proceed:
173+ case <-readerStop:
174+ return
175+ }
176+ }
177+ }()
178+ defer func() {
179+ close(readerStop)
180+ _ = c.d.Close()
181+ <-readerExited
182+ }()
183+ // Callbacks only enqueue immutable proxy references; state stays here.
184+ var pendingCommands []ports.WaylandCommand
185+ for {
186+ if c.dirty {
187+ if err := c.emit(ctx, events, ports.WaylandCapabilities{Capabilities: c.capabilities()}); err != nil {
188+ return nil
189+ }
190+ c.dirty = false
191+ }
192+ select {
193+ case <-ctx.Done():
194+ return nil
195+ case <-c.overflow:
196+ return ErrEventOverflow
197+ case err := <-done:
198+ if err != nil {
199+ if ctx.Err() != nil {
200+ return nil
201+ }
202+ select {
203+ case <-c.overflow:
204+ return ErrEventOverflow
205+ default:
206+ }
207+ return err
208+ }
209+ for len(c.notices) > 0 {
210+ if err := c.handle(ctx, events, <-c.notices); err != nil {
211+ return err
212+ }
213+ }
214+ for _, cmd := range pendingCommands {
215+ if err := c.command(cmd); err != nil {
216+ return err
217+ }
218+ }
219+ pendingCommands = nil
220+ select {
221+ case proceed <- struct{}{}:
222+ case <-ctx.Done():
223+ return nil
224+ }
225+ case cmd, ok := <-commands:
226+ if !ok {
227+ commands = nil
228+ continue
229+ }
230+ // Process any completed wire frame before this command. This keeps
231+ // queued old-generation idle and registry removals ahead of reload.
232+ if len(c.notices) > 0 {
233+ if len(pendingCommands) == queueSize {
234+ return ErrEventOverflow
235+ }
236+ pendingCommands = append(pendingCommands, cmd)
237+ } else if err := c.command(cmd); err != nil {
238+ return err
239+ }
240+ }
241+ }
242+}
243+func (c *Client) emit(ctx context.Context, out chan<- ports.WaylandEvent, event ports.WaylandEvent) error {
244+ select {
245+ case out <- event:
246+ return nil
247+ case <-ctx.Done():
248+ return ctx.Err()
249+ }
250+}
251+func (c *Client) handle(ctx context.Context, out chan<- ports.WaylandEvent, n notice) error {
252+ switch n.kind {
253+ case 1:
254+ return c.reconcile()
255+ case 2, 3:
256+ p := n.notification
257+ if !p.active {
258+ return nil
259+ }
260+ if n.kind == 2 {
261+ if p.idled {
262+ return nil
263+ }
264+ p.idled = true
265+ if !p.wake {
266+ return c.emit(ctx, out, ports.WaylandIdle{Generation: p.generation, Proxy: p.token, Cycle: p.cycle, Seat: p.seat, After: p.after})
267+ }
268+ }
269+ if n.kind == 3 {
270+ if !p.idled {
271+ return nil
272+ }
273+ p.idled = false
274+ p.cycle++
275+ if p.wake {
276+ if err := c.setPower(true); err != nil {
277+ return err
278+ }
279+ return c.emit(ctx, out, ports.WaylandActivity{Proxy: p.token, Cycle: p.cycle, Seat: p.seat})
280+ }
281+ }
282+ case 4:
283+ o := n.output
284+ if c.outputs[o.global] != o {
285+ return nil
286+ }
287+ o.name = n.name
288+ c.dirty = true
289+ case 6:
290+ o := n.output
291+ if c.outputs[o.global] != o || o.failed {
292+ return nil
293+ }
294+ o.mode, o.modeKnown = n.mode, true
295+ if n.mode == outputpower.MODE_ON {
296+ o.off = false
297+ }
298+ case 5:
299+ o := n.output
300+ if c.outputs[o.global] != o || o.failed {
301+ return nil
302+ }
303+ c.log.Warn().Uint64("output_lifetime", o.lifetime).Msg("output power control failed")
304+ o.failed = true
305+ o.off = false
306+ if o.power != nil {
307+ if err := o.power.Destroy(); err != nil {
308+ return err
309+ }
310+ o.power = nil
311+ }
312+ c.dirty = true
313+ return c.emit(ctx, out, ports.WaylandPowerFailed{Output: o.lifetime})
314+ }
315+ return nil
316+}
317+func (c *Client) bind(g wlturbo.Global, supported uint32, p wlturbo.Proxy) error {
318+ return c.d.Registry().Bind(g.Name, g.Interface, min(g.Version, supported), p)
319+}
320+func (c *Client) reconcile() error {
321+ globals := c.d.Registry().GetGlobals()
322+ find := func(iface string, minVersion uint32) wlturbo.Global {
323+ var selected wlturbo.Global
324+ for _, g := range globals {
325+ if g.Interface == iface && g.Version >= minVersion && (selected.Name == 0 || g.Name < selected.Name) {
326+ selected = g
327+ }
328+ }
329+ return selected
330+ }
331+ idle := find(idlenotify.ExtIdleNotifierInterface, 2)
332+ wantIdle := c.options.Requirements.Idle || c.options.Requirements.InputWake || len(c.deadlines) > 0
333+ if !wantIdle {
334+ idle = wlturbo.Global{}
335+ }
336+ if c.idleGlobal != idle.Name {
337+ for _, s := range c.seats {
338+ for _, p := range s.notifications {
339+ if err := c.destroyNotification(p); err != nil {
340+ return err
341+ }
342+ }
343+ s.notifications = map[time.Duration]*notification{}
344+ if err := c.destroyNotification(s.wake); err != nil {
345+ return err
346+ }
347+ s.wake = nil
348+ }
349+ if c.idle != nil {
350+ if err := c.idle.Destroy(); err != nil {
351+ return err
352+ }
353+ c.idle = nil
354+ }
355+ c.idleGlobal = idle.Name
356+ if idle.Name != 0 {
357+ c.idle = idlenotify.NewExtIdleNotifier(c.d.Context())
358+ if err := c.bind(idle, 2, c.idle); err != nil {
359+ return err
360+ }
361+ }
362+ }
363+ power := find(outputpower.OutputPowerManagerInterface, 1)
364+ if !c.options.Requirements.OutputPower {
365+ power = wlturbo.Global{}
366+ }
367+ if c.powerGlobal != power.Name {
368+ for _, o := range c.outputs {
369+ if o.power != nil {
370+ if o.off {
371+ if err := o.power.SetMode(outputpower.MODE_ON); err != nil {
372+ return err
373+ }
374+ }
375+ if err := o.power.Destroy(); err != nil {
376+ return err
377+ }
378+ o.power = nil
379+ }
380+ o.off = false
381+ o.failed = false
382+ }
383+ if c.power != nil {
384+ if err := c.power.Destroy(); err != nil {
385+ return err
386+ }
387+ c.power = nil
388+ }
389+ c.powerGlobal = power.Name
390+ if power.Name != 0 {
391+ c.power = outputpower.NewOutputPowerManager(c.d.Context())
392+ if err := c.bind(power, 1, c.power); err != nil {
393+ return err
394+ }
395+ }
396+ }
397+ for name, s := range c.seats {
398+ if g, ok := globals[name]; !ok || g.Interface != core.SeatInterface {
399+ for _, p := range s.notifications {
400+ if err := c.destroyNotification(p); err != nil {
401+ return err
402+ }
403+ }
404+ if err := c.destroyNotification(s.wake); err != nil {
405+ return err
406+ }
407+ if s.proxy.Version() >= 5 {
408+ if err := s.proxy.Release(); err != nil {
409+ return err
410+ }
411+ }
412+ delete(c.seats, name)
413+ }
414+ }
415+ for name, o := range c.outputs {
416+ if g, ok := globals[name]; !ok || g.Interface != core.OutputInterface {
417+ if o.power != nil {
418+ if err := o.power.Destroy(); err != nil {
419+ return err
420+ }
421+ }
422+ if o.proxy.Version() >= 3 {
423+ if err := o.proxy.Release(); err != nil {
424+ return err
425+ }
426+ }
427+ delete(c.outputs, name)
428+ }
429+ }
430+ for name, g := range globals {
431+ if g.Interface == core.SeatInterface && c.seats[name] == nil {
432+ p := core.NewSeat(c.d.Context())
433+ if err := c.bind(g, 5, p); err != nil {
434+ return err
435+ }
436+ c.seats[name] = &seat{proxy: p, notifications: map[time.Duration]*notification{}}
437+ }
438+ if g.Interface == core.OutputInterface && c.outputs[name] == nil {
439+ c.next++
440+ o := &output{global: name, lifetime: c.next, proxy: core.NewOutput(c.d.Context())}
441+ o.proxy.OnName(func(s string) { c.enqueue(notice{kind: 4, output: o, name: s}) })
442+ if err := c.bind(g, 4, o.proxy); err != nil {
443+ return err
444+ }
445+ c.outputs[name] = o
446+ }
447+ }
448+ for name, s := range c.seats {
449+ for d, p := range s.notifications {
450+ if !c.deadlines[d] {
451+ if err := c.destroyNotification(p); err != nil {
452+ return err
453+ }
454+ delete(s.notifications, d)
455+ } else {
456+ p.generation = c.generation
457+ }
458+ }
459+ if c.idle != nil {
460+ for d := range c.deadlines {
461+ if s.notifications[d] == nil {
462+ p, err := c.newNotification(name, s, d, false)
463+ if err != nil {
464+ return err
465+ }
466+ s.notifications[d] = p
467+ }
468+ }
469+ if c.options.Requirements.InputWake && s.wake == nil {
470+ p, err := c.newNotification(name, s, 0, true)
471+ if err != nil {
472+ return err
473+ }
474+ s.wake = p
475+ }
476+ }
477+ }
478+ for _, o := range c.outputs {
479+ if c.power != nil && o.power == nil && !o.failed {
480+ p, err := c.power.GetOutputPower(o.proxy)
481+ if err != nil {
482+ return err
483+ }
484+ o.power = p
485+ p.OnMode(func(mode uint32) { c.enqueue(notice{kind: 6, output: o, mode: mode}) })
486+ p.OnFailed(func() { c.enqueue(notice{kind: 5, output: o}) })
487+ }
488+ }
489+ c.dirty = true
490+ return nil
491+}
492+func (c *Client) newNotification(name uint32, s *seat, d time.Duration, wake bool) (*notification, error) {
493+ var proxy *idlenotify.ExtIdleNotification
494+ var err error
495+ if wake {
496+ proxy, err = c.idle.GetInputIdleNotification(0, s.proxy)
497+ } else {
498+ proxy, err = c.idle.GetIdleNotification(uint32(d/time.Millisecond), s.proxy)
499+ }
500+ if err != nil {
501+ return nil, err
502+ }
503+ c.next++
504+ p := &notification{proxy: proxy, token: c.next, cycle: 1, generation: c.generation, seat: name, after: d, active: true, wake: wake}
505+ proxy.OnIdled(func() { c.enqueue(notice{kind: 2, notification: p}) })
506+ proxy.OnResumed(func() { c.enqueue(notice{kind: 3, notification: p}) })
507+ return p, nil
508+}
509+func (c *Client) destroyNotification(p *notification) error {
510+ if p == nil {
511+ return nil
512+ }
513+ p.active = false
514+ return p.proxy.Destroy()
515+}
516+func (c *Client) setPower(on bool) error {
517+ for _, o := range c.outputs {
518+ if o.power == nil || o.failed {
519+ continue
520+ }
521+ if on && !o.off {
522+ continue
523+ }
524+ if !on && (o.off || o.modeKnown && o.mode == outputpower.MODE_OFF) {
525+ continue // never claim an output somebody else already turned off
526+ }
527+ mode := uint32(outputpower.MODE_OFF)
528+ if on {
529+ mode = outputpower.MODE_ON
530+ }
531+ if err := o.power.SetMode(mode); err != nil {
532+ return err
533+ }
534+ o.off = !on
535+ }
536+ return nil
537+}
538+func (c *Client) capabilities() ports.Capabilities {
539+ result := ports.Capabilities{Globals: map[string]uint32{}}
540+ for _, g := range c.d.Registry().GetGlobals() {
541+ result.Globals[g.Interface] = max(result.Globals[g.Interface], g.Version)
542+ }
543+ for name := range c.seats {
544+ result.Seats = append(result.Seats, name)
545+ }
546+ sort.Slice(result.Seats, func(i, j int) bool { return result.Seats[i] < result.Seats[j] })
547+ for _, o := range c.outputs {
548+ result.Outputs = append(result.Outputs, ports.WaylandOutput{Lifetime: o.lifetime, Global: o.global, Name: o.name, PowerSupported: o.power != nil && !o.failed})
549+ }
550+ sort.Slice(result.Outputs, func(i, j int) bool { return result.Outputs[i].Global < result.Outputs[j].Global })
551+ require := func(enabled bool, name string, version uint32) {
552+ if enabled && result.Globals[name] < version {
553+ result.Missing = append(result.Missing, fmt.Sprintf("%s >= %d", name, version))
554+ }
555+ }
556+ r := c.options.Requirements
557+ require(r.Idle || r.InputWake || len(c.deadlines) > 0, idlenotify.ExtIdleNotifierInterface, 2)
558+ require(r.Idle || r.InputWake || len(c.deadlines) > 0, core.SeatInterface, 1)
559+ require(r.OutputPower, outputpower.OutputPowerManagerInterface, 1)
560+ require(r.OutputPower || r.Visual || r.Lock, core.OutputInterface, 1)
561+ if r.OutputPower {
562+ for _, o := range c.outputs {
563+ if o.failed {
564+ result.Missing = append(result.Missing, fmt.Sprintf("%s output lifetime %d", outputpower.OutputPowerInterface, o.lifetime))
565+ }
566+ }
567+ }
568+ require(r.Lock, "ext_session_lock_manager_v1", 1)
569+ require(r.Visual, "wl_compositor", 1)
570+ require(r.Visual, "zwp_linux_dmabuf_v1", 4)
571+ require(r.Visual, "wp_linux_drm_syncobj_manager_v1", 1)
572+ sort.Strings(result.Missing)
573+ return result
574+}
575+
576+func (c *Client) command(cmd ports.WaylandCommand) error {
577+ switch v := cmd.(type) {
578+ case ports.SetDeadlines:
579+ ds, err := normalize(v.Deadlines)
580+ if err != nil {
581+ return err
582+ }
583+ c.deadlines, c.generation = ds, v.Generation
584+ return c.reconcile()
585+ case ports.OutputPower:
586+ return c.setPower(v.On)
587+ }
588+ return nil
589+}
A internal/adapters/wayland/client_test.go +347 -0
@@ -0,0 +1,347 @@
1+package wayland
2+
3+import (
4+ "context"
5+ "encoding/binary"
6+ "io"
7+ "net"
8+ "os"
9+ "slices"
10+ "testing"
11+ "time"
12+
13+ "github.com/bnema/neferafk/internal/ports"
14+ "github.com/bnema/zerowrap"
15+ "golang.org/x/sys/unix"
16+)
17+
18+func sockets(t *testing.T) (net.Conn, *net.UnixConn) {
19+ t.Helper()
20+ fds, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0)
21+ if err != nil {
22+ t.Fatal(err)
23+ }
24+ files := []*os.File{os.NewFile(uintptr(fds[0]), "client"), os.NewFile(uintptr(fds[1]), "peer")}
25+ a, err := net.FileConn(files[0])
26+ files[0].Close()
27+ if err != nil {
28+ t.Fatal(err)
29+ }
30+ b, err := net.FileConn(files[1])
31+ files[1].Close()
32+ if err != nil {
33+ t.Fatal(err)
34+ }
35+ t.Cleanup(func() { a.Close(); b.Close() })
36+ return a, b.(*net.UnixConn)
37+}
38+func packet(id uint32, op uint16, body []byte) []byte {
39+ b := make([]byte, 8+len(body))
40+ binary.NativeEndian.PutUint32(b, id)
41+ binary.NativeEndian.PutUint32(b[4:], uint32(len(b))<<16|uint32(op))
42+ copy(b[8:], body)
43+ return b
44+}
45+func words(v ...uint32) []byte {
46+ b := make([]byte, 4*len(v))
47+ for i, n := range v {
48+ binary.NativeEndian.PutUint32(b[i*4:], n)
49+ }
50+ return b
51+}
52+func wireString(s string) []byte {
53+ b := make([]byte, 4+(len(s)+4)&^3)
54+ binary.NativeEndian.PutUint32(b, uint32(len(s)+1))
55+ copy(b[4:], s)
56+ return b
57+}
58+func readWire(t *testing.T, p *net.UnixConn) (uint32, uint16, []byte) {
59+ t.Helper()
60+ p.SetReadDeadline(time.Now().Add(2 * time.Second))
61+ h := make([]byte, 8)
62+ if _, err := io.ReadFull(p, h); err != nil {
63+ t.Fatal(err)
64+ }
65+ size := binary.NativeEndian.Uint32(h[4:]) >> 16
66+ b := make([]byte, size-8)
67+ if _, err := io.ReadFull(p, b); err != nil {
68+ t.Fatal(err)
69+ }
70+ return binary.NativeEndian.Uint32(h), uint16(binary.NativeEndian.Uint32(h[4:])), b
71+}
72+func sendWire(t *testing.T, p *net.UnixConn, id uint32, op uint16, b []byte) {
73+ t.Helper()
74+ if _, err := p.Write(packet(id, op, b)); err != nil {
75+ t.Fatal(err)
76+ }
77+}
78+func global(t *testing.T, p *net.UnixConn, registry, name uint32, iface string, v uint32) {
79+ b := append(words(name), wireString(iface)...)
80+ b = append(b, words(v)...)
81+ sendWire(t, p, registry, 0, b)
82+}
83+func synchronize(t *testing.T, p *net.UnixConn, body []byte) {
84+ id := binary.NativeEndian.Uint32(body)
85+ sendWire(t, p, id, 0, words(1))
86+ sendWire(t, p, 1, 1, words(id))
87+}
88+func capability(t *testing.T, out <-chan ports.WaylandEvent) ports.Capabilities {
89+ t.Helper()
90+ for {
91+ select {
92+ case e := <-out:
93+ if c, ok := e.(ports.WaylandCapabilities); ok {
94+ return c.Capabilities
95+ }
96+ case <-time.After(2 * time.Second):
97+ t.Fatal("capability timeout")
98+ }
99+ }
100+}
101+func nextEvent(t *testing.T, out <-chan ports.WaylandEvent) ports.WaylandEvent {
102+ t.Helper()
103+ select {
104+ case e := <-out:
105+ return e
106+ case <-time.After(2 * time.Second):
107+ t.Fatal("event timeout")
108+ return nil
109+ }
110+}
111+
112+func TestDiscoveryIdleReloadPowerAndRemoval(t *testing.T) {
113+ conn, peer := sockets(t)
114+ c, err := New(conn, Options{Requirements: ports.WaylandRequirements{Idle: true, InputWake: true, OutputPower: true, Lock: true, Visual: true}, Generation: 1, Deadlines: []time.Duration{time.Second, time.Second, 2 * time.Second}}, zerowrap.New(zerowrap.Config{Output: io.Discard}))
115+ if err != nil {
116+ t.Fatal(err)
117+ }
118+ _, _, body := readWire(t, peer)
119+ registry := binary.NativeEndian.Uint32(body)
120+ ctx, cancel := context.WithCancel(context.Background())
121+ defer cancel()
122+ commands := make(chan ports.WaylandCommand, 8)
123+ out := make(chan ports.WaylandEvent, 64)
124+ done := make(chan error, 1)
125+ go func() { done <- c.Run(ctx, commands, out) }()
126+ _, _, body = readWire(t, peer)
127+ global(t, peer, registry, 10, "wl_seat", 5)
128+ global(t, peer, registry, 11, "wl_output", 4)
129+ global(t, peer, registry, 12, "ext_idle_notifier_v1", 2)
130+ global(t, peer, registry, 13, "zwlr_output_power_manager_v1", 1)
131+ global(t, peer, registry, 14, "ext_session_lock_manager_v1", 1)
132+ synchronize(t, peer, body)
133+ ids := map[string]uint32{}
134+ notifications := map[uint32]uint32{}
135+ var wake, power uint32
136+ for {
137+ id, op, b := readWire(t, peer)
138+ if id == 1 {
139+ synchronize(t, peer, b)
140+ break
141+ }
142+ if id == registry {
143+ n := int(binary.NativeEndian.Uint32(b[4:]))
144+ iface := string(b[8 : 8+n-1])
145+ ids[iface] = binary.NativeEndian.Uint32(b[len(b)-4:])
146+ if iface == "wl_output" {
147+ sendWire(t, peer, ids[iface], 4, wireString("HEADLESS-1"))
148+ }
149+ continue
150+ }
151+ if id == ids["ext_idle_notifier_v1"] {
152+ child := binary.NativeEndian.Uint32(b)
153+ ms := binary.NativeEndian.Uint32(b[4:])
154+ if op == 2 {
155+ wake = child
156+ } else {
157+ notifications[ms] = child
158+ }
159+ }
160+ if id == ids["zwlr_output_power_manager_v1"] {
161+ power = binary.NativeEndian.Uint32(b)
162+ sendWire(t, peer, power, 0, words(1))
163+ }
164+ }
165+ caps := capability(t, out)
166+ if len(notifications) != 2 || wake == 0 || power == 0 || len(caps.Outputs) != 1 || caps.Outputs[0].Name != "HEADLESS-1" || !caps.Outputs[0].PowerSupported {
167+ t.Fatalf("initial: notifications=%v wake=%d power=%d caps=%+v", notifications, wake, power, caps)
168+ }
169+ if !slices.Contains(caps.Missing, "zwp_linux_dmabuf_v1 >= 4") || slices.Contains(caps.Missing, "ext_session_lock_manager_v1 >= 1") {
170+ t.Fatal(caps.Missing)
171+ }
172+ lifetime := caps.Outputs[0].Lifetime
173+ sendWire(t, peer, notifications[1000], 0, nil)
174+ idle, ok := nextEvent(t, out).(ports.WaylandIdle)
175+ if !ok || idle.Generation != 1 || idle.After != time.Second || idle.Cycle != 1 {
176+ t.Fatal(idle, ok)
177+ }
178+ // Reload retains the one-second proxy and removes only the two-second one.
179+ commands <- ports.SetDeadlines{Generation: 2, Deadlines: []time.Duration{time.Second, 3 * time.Second}}
180+ var added uint32
181+ for i := 0; i < 2; i++ {
182+ id, op, b := readWire(t, peer)
183+ if id == notifications[2000] && op == 0 {
184+ continue
185+ }
186+ if id == ids["ext_idle_notifier_v1"] && op == 1 && binary.NativeEndian.Uint32(b[4:]) == 3000 {
187+ added = binary.NativeEndian.Uint32(b)
188+ continue
189+ }
190+ t.Fatalf("reload request %d/%d %x", id, op, b)
191+ }
192+ if added == 0 {
193+ t.Fatal("new deadline missing")
194+ }
195+ capability(t, out)
196+ // An in-flight event for the deleted notification must never become policy.
197+ sendWire(t, peer, notifications[2000], 0, nil)
198+ sendWire(t, peer, notifications[1000], 1, nil)
199+ sendWire(t, peer, notifications[1000], 0, nil)
200+ idle, ok = nextEvent(t, out).(ports.WaylandIdle)
201+ if !ok || idle.Generation != 2 || idle.Proxy == 0 || idle.Cycle != 2 {
202+ t.Fatal(idle, ok)
203+ }
204+ commands <- ports.OutputPower{On: false}
205+ id, op, b := readWire(t, peer)
206+ if id != power || op != 0 || binary.NativeEndian.Uint32(b) != 0 {
207+ t.Fatalf("off %d/%d %x", id, op, b)
208+ }
209+ sendWire(t, peer, wake, 0, nil)
210+ sendWire(t, peer, wake, 1, nil)
211+ id, op, b = readWire(t, peer)
212+ if id != power || op != 0 || binary.NativeEndian.Uint32(b) != 1 {
213+ t.Fatalf("wake %d/%d %x", id, op, b)
214+ }
215+ if _, ok := nextEvent(t, out).(ports.WaylandActivity); !ok {
216+ t.Fatal("missing activity")
217+ }
218+ // Removing/re-adding the same global number creates a new output lifetime.
219+ sendWire(t, peer, registry, 1, words(11))
220+ for i := 0; i < 2; i++ {
221+ readWire(t, peer)
222+ }
223+ caps = capability(t, out)
224+ if len(caps.Outputs) != 0 {
225+ t.Fatal(caps)
226+ }
227+ global(t, peer, registry, 11, "wl_output", 4)
228+ var newPower uint32
229+ for i := 0; i < 2; i++ {
230+ id, _, b = readWire(t, peer)
231+ if id == registry {
232+ ids["new-output"] = binary.NativeEndian.Uint32(b[len(b)-4:])
233+ } else {
234+ newPower = binary.NativeEndian.Uint32(b)
235+ }
236+ }
237+ caps = capability(t, out)
238+ if len(caps.Outputs) != 1 || caps.Outputs[0].Lifetime == lifetime {
239+ t.Fatal(caps)
240+ }
241+ // A late failed event from the previous output cannot disable its successor.
242+ sendWire(t, peer, power, 1, nil)
243+ sendWire(t, peer, newPower, 1, nil)
244+ id, op, _ = readWire(t, peer)
245+ if id != newPower || op != 1 {
246+ t.Fatal("failed control not destroyed", id, op)
247+ }
248+ if failure, ok := nextEvent(t, out).(ports.WaylandPowerFailed); !ok || failure.Output != caps.Outputs[0].Lifetime {
249+ t.Fatal(failure, ok)
250+ }
251+ caps = capability(t, out)
252+ if caps.Outputs[0].PowerSupported || len(caps.Missing) != 4 {
253+ t.Fatal(caps)
254+ }
255+ cancel()
256+ select {
257+ case err := <-done:
258+ if err != nil {
259+ t.Fatal(err)
260+ }
261+ case <-time.After(2 * time.Second):
262+ t.Fatal("Run failed to join reader")
263+ }
264+}
265+
266+func TestMissingAndDisabledCapabilities(t *testing.T) {
267+ conn, peer := sockets(t)
268+ c, err := New(conn, Options{Requirements: ports.WaylandRequirements{Lock: true, Visual: true}}, zerowrap.New(zerowrap.Config{Output: io.Discard}))
269+ if err != nil {
270+ t.Fatal(err)
271+ }
272+ _, _, b := readWire(t, peer)
273+ registry := binary.NativeEndian.Uint32(b)
274+ ctx, cancel := context.WithCancel(context.Background())
275+ defer cancel()
276+ out := make(chan ports.WaylandEvent, 8)
277+ done := make(chan error, 1)
278+ go func() { done <- c.Run(ctx, nil, out) }()
279+ _, _, b = readWire(t, peer)
280+ global(t, peer, registry, 10, "ext_idle_notifier_v1", 1)
281+ global(t, peer, registry, 11, "zwlr_output_power_manager_v1", 1)
282+ synchronize(t, peer, b)
283+ id, _, b := readWire(t, peer)
284+ if id != 1 {
285+ t.Fatalf("disabled optional global bound: %d", id)
286+ }
287+ synchronize(t, peer, b)
288+ caps := capability(t, out)
289+ if len(caps.Missing) != 5 || slices.Contains(caps.Missing, "ext_idle_notifier_v1 >= 2") {
290+ t.Fatal(caps)
291+ }
292+ cancel()
293+ if err := <-done; err != nil {
294+ t.Fatal(err)
295+ }
296+}
297+
298+func TestDeadlineValidation(t *testing.T) {
299+ for _, d := range []time.Duration{-time.Second, time.Nanosecond, time.Duration(1<<32) * time.Millisecond} {
300+ if _, err := normalize([]time.Duration{d}); err == nil {
301+ t.Fatal(d)
302+ }
303+ }
304+ ds, err := normalize([]time.Duration{0, time.Second, time.Second})
305+ if err != nil || len(ds) != 1 {
306+ t.Fatal(ds, err)
307+ }
308+}
309+
310+func TestCancelBlockedStartup(t *testing.T) {
311+ conn, peer := sockets(t)
312+ c, err := New(conn, Options{}, zerowrap.New(zerowrap.Config{Output: io.Discard}))
313+ if err != nil {
314+ t.Fatal(err)
315+ }
316+ readWire(t, peer)
317+ ctx, cancel := context.WithCancel(context.Background())
318+ done := make(chan error, 1)
319+ go func() { done <- c.Run(ctx, nil, nil) }()
320+ readWire(t, peer) // sync request deliberately unanswered
321+ cancel()
322+ select {
323+ case err := <-done:
324+ if err != nil {
325+ t.Fatal(err)
326+ }
327+ case <-time.After(time.Second):
328+ t.Fatal("cancel did not close socket")
329+ }
330+}
331+
332+func TestBoundedCallbackQueueFailsClosed(t *testing.T) {
333+ conn, peer := sockets(t)
334+ c, err := New(conn, Options{}, zerowrap.New(zerowrap.Config{Output: io.Discard}))
335+ if err != nil {
336+ t.Fatal(err)
337+ }
338+ readWire(t, peer)
339+ for i := 0; i <= queueSize; i++ {
340+ c.enqueue(notice{kind: 1})
341+ }
342+ select {
343+ case <-c.overflow:
344+ default:
345+ t.Fatal("overflow not reported")
346+ }
347+}
A internal/app/daemon.go +166 -0
@@ -0,0 +1,166 @@
1+package app
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "net"
8+ "os"
9+ "path/filepath"
10+ "sync"
11+
12+ "github.com/bnema/neferafk/internal/adapters/config"
13+ "github.com/bnema/neferafk/internal/adapters/control"
14+ "github.com/bnema/neferafk/internal/adapters/logind"
15+ "github.com/bnema/neferafk/internal/adapters/visualproc"
16+ "github.com/bnema/neferafk/internal/adapters/wayland"
17+ "github.com/bnema/neferafk/internal/ports"
18+ "github.com/bnema/zerowrap"
19+)
20+
21+// DaemonOptions configures RunDaemon.
22+type DaemonOptions struct {
23+ ConfigPath string // empty: config.DefaultPath()
24+ // Executable is re-executed as `<Executable> visual` for the visual process.
25+ Executable string
26+ Log zerowrap.Logger
27+}
28+
29+// RunDaemon wires the real adapters around one Owner. It returns nil on a
30+// requested shutdown, *UnsupportedError for missing capabilities, or the first
31+// fatal adapter error. Nothing in the daemon initialises Vulkan or fonts.
32+func RunDaemon(ctx context.Context, o DaemonOptions) (result error) {
33+ log := o.Log.WithField("component", "daemon")
34+ path := o.ConfigPath
35+ if path == "" {
36+ path = config.DefaultPath()
37+ }
38+ cfg, err := config.Load(path)
39+ switch {
40+ case errors.Is(err, os.ErrNotExist):
41+ cfg = ports.Defaults()
42+ case err != nil:
43+ return fmt.Errorf("config: %w", err)
44+ }
45+ ctx, cancel := context.WithCancel(ctx)
46+ defer cancel()
47+
48+ wlReq, sysReq := Requirements(cfg)
49+ conn, err := dialWayland()
50+ if err != nil {
51+ return &UnsupportedError{Missing: []string{"wayland: " + err.Error()}}
52+ }
53+ // Until the adapters own their resources (started), every early return
54+ // releases the compositor connection and logind, joining Close errors.
55+ started := false
56+ var sys *logind.Adapter
57+ defer func() {
58+ if started {
59+ return
60+ }
61+ if e := conn.Close(); e != nil && !errors.Is(e, net.ErrClosed) {
62+ result = errors.Join(result, fmt.Errorf("close wayland connection: %w", e))
63+ }
64+ if sys != nil {
65+ if e := sys.Close(); e != nil {
66+ result = errors.Join(result, fmt.Errorf("close logind: %w", e))
67+ }
68+ }
69+ }()
70+ // Initial deadlines come from the owner's first ArmIdle (SetDeadlines).
71+ wl, err := wayland.New(conn, wayland.Options{Requirements: wlReq, Generation: 1}, o.Log)
72+ if err != nil {
73+ return fmt.Errorf("wayland: %w", err)
74+ }
75+ sys, err = logind.Open(ctx, sysReq)
76+ if err != nil {
77+ sys = nil
78+ return &UnsupportedError{Missing: []string{"logind: " + err.Error()}}
79+ }
80+
81+ uid := uint32(os.Getuid())
82+ sockPath, err := control.DefaultPath()
83+ if err != nil {
84+ return err
85+ }
86+ controlCh := make(chan ports.ControlRequest, 8)
87+ srv, err := control.Listen(sockPath, uid, controlCh, o.Log)
88+ if err != nil {
89+ return fmt.Errorf("control socket: %w", err)
90+ }
91+
92+ cfgCh := make(chan ports.ConfigChanged, 4)
93+ failCh := make(chan error, 4)
94+ wlEvents := make(chan ports.WaylandEvent, 64)
95+ wlCmds := make(chan ports.WaylandCommand, 16)
96+ sysEvents := make(chan ports.SystemEvent, 16)
97+ sysCmds := make(chan ports.SystemCommand, 16)
98+ visEvents := make(chan ports.VisualEvent, 16)
99+
100+ launcher := &visualproc.Launcher{Executable: o.Executable, Args: []string{"visual"}, Log: o.Log}
101+ visual := visualproc.NewManager(ctx, launcher, visEvents, o.Log)
102+ owner, err := NewOwner(Options{Config: cfg, Visual: visual, Log: o.Log, Gate: true, ExpectSystem: true},
103+ Inputs{Config: cfgCh, Failures: failCh, Wayland: wlEvents, System: sysEvents, Visual: visEvents, Control: controlCh},
104+ Outputs{Wayland: wlCmds, System: sysCmds})
105+ if err != nil {
106+ return errors.Join(err, srv.Close())
107+ }
108+ started = true
109+
110+ var wg sync.WaitGroup
111+ var mu sync.Mutex
112+ var first error
113+ fail := func(name string, err error) {
114+ if err == nil || ctx.Err() != nil && errors.Is(err, context.Canceled) {
115+ return
116+ }
117+ mu.Lock()
118+ if first == nil {
119+ first = fmt.Errorf("%s: %w", name, err)
120+ }
121+ mu.Unlock()
122+ cancel()
123+ }
124+ spawn := func(name string, fn func() error) {
125+ wg.Add(1)
126+ go func() { defer wg.Done(); fail(name, fn()) }()
127+ }
128+ spawn("config watch", func() error { return config.Watch(ctx, path, cfgCh, failCh, nil) })
129+ spawn("wayland", func() error { return wl.Run(ctx, wlCmds, wlEvents) })
130+ spawn("logind", func() error { return sys.Run(ctx, sysCmds, sysEvents) })
131+ spawn("control", func() error { return srv.Serve(ctx) })
132+
133+ ownerErr := owner.Run(ctx)
134+ cancel()
135+ wg.Wait()
136+ log.Info().Msg("daemon stopped")
137+ var unsup *UnsupportedError
138+ if errors.As(ownerErr, &unsup) {
139+ return ownerErr
140+ }
141+ mu.Lock()
142+ defer mu.Unlock()
143+ if ownerErr != nil {
144+ return ownerErr
145+ }
146+ return first
147+}
148+
149+func dialWayland() (net.Conn, error) {
150+ name := os.Getenv("WAYLAND_DISPLAY")
151+ if name == "" {
152+ return nil, errors.New("WAYLAND_DISPLAY is not set")
153+ }
154+ if !filepath.IsAbs(name) {
155+ dir := os.Getenv("XDG_RUNTIME_DIR")
156+ if dir == "" {
157+ return nil, errors.New("XDG_RUNTIME_DIR is not set")
158+ }
159+ name = filepath.Join(dir, name)
160+ }
161+ c, err := net.Dial("unix", name)
162+ if err != nil {
163+ return nil, fmt.Errorf("connect compositor: %w", err)
164+ }
165+ return c, nil
166+}
A internal/app/owner.go +548 -0
@@ -0,0 +1,548 @@
1+// Package app wires adapters to the pure policy. The Owner goroutine is the
2+// only place policy state is read or written; adapters talk to it through
3+// channels and the ports.Visual port. Nothing here can unlock a session:
4+// unlock exists only as a visual-reported LockReleased lifecycle result.
5+package app
6+
7+import (
8+ "context"
9+ "errors"
10+ "fmt"
11+ "os"
12+ "slices"
13+ "time"
14+
15+ "github.com/bnema/neferafk/internal/core"
16+ "github.com/bnema/neferafk/internal/ports"
17+ "github.com/bnema/zerowrap"
18+)
19+
20+const maxQueue = 256
21+
22+// Inputs are the owner's event channels. Any may be nil (feature absent).
23+type Inputs struct {
24+ Config <-chan ports.ConfigChanged
25+ Failures <-chan error
26+ Wayland <-chan ports.WaylandEvent
27+ System <-chan ports.SystemEvent
28+ Visual <-chan ports.VisualEvent
29+ Control <-chan ports.ControlRequest
30+}
31+
32+// Outputs are adapter command channels. The owner never blocks on them: sends
33+// go through bounded per-channel queues so an adapter that is itself blocked
34+// emitting an event cannot deadlock with the owner.
35+type Outputs struct {
36+ Wayland chan<- ports.WaylandCommand
37+ System chan<- ports.SystemCommand
38+}
39+
40+type Options struct {
41+ Config ports.Config
42+ Visual ports.Visual
43+ // LookupEnv reads the daemon's own startup environment for the PIN env
44+ // source (the accepted, documented residual custody). Defaults to os.LookupEnv.
45+ LookupEnv func(string) (string, bool)
46+ Log zerowrap.Logger
47+ // RetryMin/RetryMax bound the lock (re)spawn backoff; defaults 250ms/5s.
48+ RetryMin, RetryMax time.Duration
49+ // Gate, when set, makes Run fail with *UnsupportedError unless the latest
50+ // Wayland report and the first System report (if ExpectSystem) are
51+ // complete within GateTimeout (default 10s). Missing System support fails
52+ // at once; missing Wayland globals may still be advertised late.
53+ Gate bool
54+ ExpectSystem bool
55+ GateTimeout time.Duration
56+}
57+
58+type Owner struct {
59+ policy *core.Policy
60+ cfg ports.Config
61+ startWL ports.WaylandRequirements
62+ startSY ports.SystemRequirements
63+ startAuth ports.AuthConfig
64+ opt Options
65+ in Inputs
66+ out Outputs
67+ log zerowrap.Logger
68+ initial []ports.PolicyEffect
69+
70+ wq []ports.WaylandCommand
71+ sq []ports.SystemCommand
72+ regs []ports.IdleNotification
73+ regGen map[uint64]ports.Generation
74+ regOrigin map[uint64]time.Time // when policy first registered each ID
75+ idle map[uint32]map[time.Duration]bool // adapter proxies currently idle, per seat and duration
76+ seats map[uint32]bool // seats from the latest capability report
77+ seatsKnown bool
78+ fired map[uint64]bool // registrations already delivered to policy
79+ deferred *time.Timer // fires registrations whose origin+After lies ahead
80+ lockGen ports.Generation
81+ delay time.Duration
82+ retry *time.Timer
83+ sleeping bool
84+ sleepCycle uint64
85+ wlCaps *ports.Capabilities
86+ sysCaps *ports.SystemCapabilities
87+ gated bool
88+}
89+
90+func NewOwner(o Options, in Inputs, out Outputs) (*Owner, error) {
91+ if o.Visual == nil {
92+ return nil, errors.New("app: visual port required")
93+ }
94+ if o.LookupEnv == nil {
95+ o.LookupEnv = os.LookupEnv
96+ }
97+ if o.RetryMin <= 0 {
98+ o.RetryMin = 250 * time.Millisecond
99+ }
100+ if o.RetryMax < o.RetryMin {
101+ o.RetryMax = max(5*time.Second, o.RetryMin)
102+ }
103+ if o.GateTimeout <= 0 {
104+ o.GateTimeout = 10 * time.Second
105+ }
106+ p, initial, err := core.New(o.Config, time.Now())
107+ if err != nil {
108+ return nil, err
109+ }
110+ w, s := Requirements(o.Config)
111+ ow := &Owner{policy: p, cfg: o.Config, startAuth: o.Config.Auth, startWL: w, startSY: s, opt: o, in: in, out: out, log: o.Log.WithField("component", "app"), initial: initial, regGen: map[uint64]ports.Generation{}, regOrigin: map[uint64]time.Time{}, idle: map[uint32]map[time.Duration]bool{}, seats: map[uint32]bool{}, fired: map[uint64]bool{}, delay: o.RetryMin}
112+ ow.retry = time.NewTimer(time.Hour)
113+ ow.retry.Stop()
114+ ow.deferred = time.NewTimer(time.Hour)
115+ ow.deferred.Stop()
116+ ow.gated = !o.Gate
117+ return ow, nil
118+}
119+
120+// Run is the policy owner goroutine. It returns nil on ctx cancellation, an
121+// *UnsupportedError when the startup gate fails, or a fatal channel error.
122+func (o *Owner) Run(ctx context.Context) error {
123+ defer o.retry.Stop()
124+ defer o.deferred.Stop()
125+ defer o.shutdown()
126+ for _, e := range o.initial {
127+ if err := o.apply(ctx, e); err != nil {
128+ return err
129+ }
130+ }
131+ o.initial = nil
132+ var gate <-chan time.Time
133+ var gateTimer *time.Timer
134+ if o.opt.Gate && !o.gated {
135+ gateTimer = time.NewTimer(o.opt.GateTimeout)
136+ defer gateTimer.Stop()
137+ gate = gateTimer.C
138+ }
139+ for {
140+ var wc chan<- ports.WaylandCommand
141+ var wv ports.WaylandCommand
142+ if len(o.wq) > 0 {
143+ wc, wv = o.out.Wayland, o.wq[0]
144+ }
145+ var sc chan<- ports.SystemCommand
146+ var sv ports.SystemCommand
147+ if len(o.sq) > 0 {
148+ sc, sv = o.out.System, o.sq[0]
149+ }
150+ var err error
151+ select {
152+ case <-ctx.Done():
153+ return nil
154+ case <-gate:
155+ if o.wlCaps != nil && (o.sysCaps != nil || !o.opt.ExpectSystem) {
156+ return &UnsupportedError{Missing: unsupported(o.wlCaps, o.sysCaps)}
157+ }
158+ return &UnsupportedError{Missing: []string{"capability reports not received before timeout"}}
159+ case wc <- wv:
160+ o.wq = o.wq[1:]
161+ case sc <- sv:
162+ o.sq = o.sq[1:]
163+ case ev, ok := <-o.in.Config:
164+ if !ok {
165+ return errors.New("config channel closed")
166+ }
167+ err = o.reload(ctx, ev.Config)
168+ case e, ok := <-o.in.Failures:
169+ if !ok {
170+ o.in.Failures = nil
171+ continue
172+ }
173+ o.log.Warn().Err(e).Msg("config change rejected; keeping last valid configuration")
174+ case ev, ok := <-o.in.Wayland:
175+ if !ok {
176+ return errors.New("wayland channel closed")
177+ }
178+ err = o.wayland(ctx, ev)
179+ case ev, ok := <-o.in.System:
180+ if !ok {
181+ return errors.New("system channel closed")
182+ }
183+ err = o.system(ctx, ev)
184+ case ev, ok := <-o.in.Visual:
185+ if !ok {
186+ return errors.New("visual channel closed")
187+ }
188+ err = o.visual(ctx, ev)
189+ case req, ok := <-o.in.Control:
190+ if !ok {
191+ o.in.Control = nil
192+ continue
193+ }
194+ err = o.control(ctx, req)
195+ case <-o.retry.C:
196+ err = o.startLock(ctx)
197+ case <-o.deferred.C:
198+ err = o.fireDue(ctx)
199+ }
200+ if err != nil {
201+ return err
202+ }
203+ if e := o.checkGate(); e != nil {
204+ return e
205+ }
206+ if o.gated && gateTimer != nil {
207+ gateTimer.Stop() // gate passed: the timeout must never fire afterwards
208+ gateTimer, gate = nil, nil
209+ }
210+ if len(o.wq) > maxQueue || len(o.sq) > maxQueue {
211+ return errors.New("adapter command queue overflow")
212+ }
213+ }
214+}
215+
216+func (o *Owner) checkGate() error {
217+ if o.gated || o.wlCaps == nil || o.opt.ExpectSystem && o.sysCaps == nil {
218+ return nil
219+ }
220+ if m := unsupported(o.wlCaps, o.sysCaps); len(m) > 0 {
221+ if len(o.wlCaps.Missing) > 0 && len(unsupported(nil, o.sysCaps)) == 0 {
222+ return nil // wait for a later Wayland report or the gate timeout
223+ }
224+ o.gated = true
225+ return &UnsupportedError{Missing: m}
226+ }
227+ o.gated = true
228+ if w := suspendWarning(o.cfg, o.sysCaps); w != "" {
229+ o.log.Warn().Msg(w)
230+ }
231+ return nil
232+}
233+
234+// shutdown runs on every Run exit. A held (or being acquired) lock is never
235+// torn down: the visual is detached and keeps its lock and auth UI.
236+func (o *Owner) shutdown() {
237+ st := o.policy.State()
238+ var err error
239+ if st.Acquiring || st.Protected {
240+ // Never tear down a lock we hold: leave the visual (and its auth UI) up.
241+ err = o.opt.Visual.Detach()
242+ } else {
243+ err = o.opt.Visual.Close()
244+ }
245+ if err != nil {
246+ o.log.Warn().Err(err).Msg("visual shutdown")
247+ }
248+}
249+
250+// handle feeds the policy and applies the resulting effects in order.
251+func (o *Owner) handle(ctx context.Context, ev ports.PolicyEvent) error {
252+ effects, err := o.policy.Handle(time.Now(), ev)
253+ if err != nil {
254+ o.log.Warn().Err(err).Msgf("policy rejected %T", ev)
255+ return nil
256+ }
257+ for _, e := range effects {
258+ if err := o.apply(ctx, e); err != nil {
259+ return err
260+ }
261+ }
262+ return nil
263+}
264+
265+func (o *Owner) apply(ctx context.Context, effect ports.PolicyEffect) error {
266+ switch e := effect.(type) {
267+ case ports.ArmIdle:
268+ regs := map[uint64]ports.Generation{}
269+ origins := map[uint64]time.Time{}
270+ var ds []time.Duration
271+ now := time.Now()
272+ for _, n := range e.Notifications {
273+ g, ok := o.regGen[n.ID]
274+ if !ok {
275+ g = e.Generation // retained registrations keep their admitting generation
276+ }
277+ regs[n.ID] = g
278+ origins[n.ID] = now
279+ if t, ok := o.regOrigin[n.ID]; ok {
280+ origins[n.ID] = t
281+ }
282+ if !slices.Contains(ds, n.After) {
283+ ds = append(ds, n.After)
284+ }
285+ }
286+ fired := map[uint64]bool{}
287+ for id := range regs {
288+ fired[id] = o.fired[id]
289+ }
290+ o.regGen, o.regOrigin, o.regs, o.fired = regs, origins, e.Notifications, fired
291+ for _, m := range o.idle {
292+ for d := range m {
293+ if !slices.Contains(ds, d) {
294+ delete(m, d) // adapter destroys that proxy; a re-add starts fresh
295+ }
296+ }
297+ }
298+ o.armDeferred()
299+ // Always sent, even when deadlines are unchanged: the adapter stamps
300+ // idle events with the newest generation it was told about, and it
301+ // retains proxies (no rebase) when durations are unchanged.
302+ o.wq = append(o.wq, ports.SetDeadlines{Generation: uint64(e.Generation), Deadlines: ds})
303+ case ports.AcquireLock:
304+ o.lockGen = e.Generation
305+ o.delay = o.opt.RetryMin
306+ return o.startLock(ctx)
307+ case ports.SetFade:
308+ if err := o.opt.Visual.Fade(ctx, ports.VisualFade{Black: e.Black, Duration: e.Duration, Spawn: o.spawn()}); err != nil {
309+ o.log.Warn().Err(err).Bool("black", e.Black).Msg("fade request failed")
310+ }
311+ case ports.SetOutputPower:
312+ o.wq = append(o.wq, ports.OutputPower(e))
313+ case ports.Suspend:
314+ o.sq = append(o.sq, ports.SystemSuspend{})
315+ case ports.SleepReady:
316+ if o.sleeping {
317+ o.sq = append(o.sq, ports.SystemSleepReady{Cycle: o.sleepCycle})
318+ }
319+ }
320+ return nil
321+}
322+
323+// spawn omits both the current and the startup-captured pin-env names: the
324+// startup value is what the daemon holds, whatever a reload later selects.
325+func (o *Owner) spawn() ports.VisualSpawn {
326+ var names []string
327+ for _, a := range []ports.AuthConfig{o.cfg.Auth, o.startAuth} {
328+ if a.PINEnv != "" && !slices.Contains(names, a.PINEnv) {
329+ names = append(names, a.PINEnv)
330+ }
331+ }
332+ return ports.VisualSpawn{OmitEnv: names}
333+}
334+
335+func (o *Owner) bootstrap() ports.AuthBootstrap {
336+ a := o.cfg.Auth
337+ b := ports.AuthBootstrap{Generation: uint64(o.lockGen), PINSource: a.PINSource}
338+ switch a.PINSource {
339+ case ports.PINSourceEnv:
340+ if v, ok := o.opt.LookupEnv(a.PINEnv); ok {
341+ b.EnvPIN = []byte(v)
342+ }
343+ case ports.PINSourcePass:
344+ b.PINReference = a.PINEntry
345+ }
346+ return b
347+}
348+
349+func (o *Owner) lockWanted() bool {
350+ st := o.policy.State()
351+ return st.Acquiring || st.Protected
352+}
353+
354+// startLock asks the visual to hold ext-session-lock for the lock generation,
355+// (re)spawning it as needed. Failures back off and retry; the compositor keeps
356+// any acquired lock in the meantime.
357+func (o *Owner) startLock(ctx context.Context) error {
358+ if !o.lockWanted() || o.lockGen == 0 {
359+ return nil
360+ }
361+ err := o.opt.Visual.Lock(ctx, ports.VisualLock{Generation: o.lockGen, Auth: o.bootstrap(), Spawn: o.spawn()})
362+ if err != nil {
363+ o.log.Warn().Err(err).Uint64("generation", uint64(o.lockGen)).Dur("retry_in", o.delay).Msg("lock visual unavailable")
364+ o.armRetry()
365+ }
366+ return nil
367+}
368+
369+func (o *Owner) armRetry() {
370+ o.retry.Reset(o.delay)
371+ o.delay = min(o.delay*2, o.opt.RetryMax)
372+}
373+
374+func (o *Owner) reload(ctx context.Context, cfg ports.Config) error {
375+ if err := cfg.Validate(); err != nil {
376+ o.log.Warn().Err(err).Msg("config reload rejected")
377+ return nil
378+ }
379+ w, s := Requirements(cfg)
380+ if w.OutputPower && !o.startWL.OutputPower || s.Sleep && !o.startSY.Sleep {
381+ o.log.Warn().Msg("reload enables output-off or sleep support that was not initialised at startup; restart the daemon")
382+ }
383+ if o.gated && o.cfg.SleepAfter == 0 {
384+ if w := suspendWarning(cfg, o.sysCaps); w != "" {
385+ o.log.Warn().Msg(w)
386+ }
387+ }
388+ o.cfg = cfg
389+ return o.handle(ctx, ports.Reload{Config: cfg})
390+}
391+
392+func (o *Owner) wayland(ctx context.Context, ev ports.WaylandEvent) error {
393+ switch v := ev.(type) {
394+ case ports.WaylandIdle:
395+ // The adapter keeps one proxy per distinct duration, measured from the
396+ // last input, while policy registrations (one per origin) are relative
397+ // to their own registration time. A registration is delivered once its
398+ // duration is idle and its origin+After has passed; later ones wait on
399+ // the deferred timer (this is what keeps an equal-valued reload from
400+ // rebasing early). Policy drops anything stale.
401+ if o.idle[v.Seat] == nil {
402+ o.idle[v.Seat] = map[time.Duration]bool{}
403+ }
404+ o.idle[v.Seat][v.After] = true
405+ return o.fireDue(ctx)
406+ case ports.WaylandActivity:
407+ // Undoes fade and wakes outputs; the policy has no unlock path.
408+ // Only the active seat stops being idle; other seats keep their state.
409+ delete(o.idle, v.Seat)
410+ clear(o.fired)
411+ return o.handle(ctx, ports.Activity{})
412+ case ports.WaylandCapabilities:
413+ c := v.Capabilities
414+ if o.gated && o.wlCaps != nil && len(c.Missing) > 0 {
415+ o.log.Warn().Strs("missing", c.Missing).Msg("wayland capabilities degraded")
416+ }
417+ if !o.gated || o.wlCaps == nil {
418+ // Until the gate passes the latest report counts: a compositor may
419+ // advertise outputs after the daemon connected.
420+ o.wlCaps = &c
421+ }
422+ o.seats, o.seatsKnown = map[uint32]bool{}, true
423+ for _, id := range c.Seats {
424+ o.seats[id] = true
425+ }
426+ for id := range o.idle {
427+ if !o.seats[id] {
428+ delete(o.idle, id) // removed seat no longer counts
429+ }
430+ }
431+ return o.fireDue(ctx)
432+ case ports.WaylandPowerFailed:
433+ o.log.Warn().Uint64("output", v.Output).Msg("output power control failed")
434+ }
435+ return nil
436+}
437+
438+// allIdle reports whether every known seat has duration d idle. Known seats are
439+// the latest capability report's; nothing fires before the first report.
440+func (o *Owner) allIdle(d time.Duration) bool {
441+ if !o.seatsKnown || len(o.seats) == 0 {
442+ return false
443+ }
444+ for id := range o.seats {
445+ if !o.idle[id][d] {
446+ return false
447+ }
448+ }
449+ return true
450+}
451+
452+// fireDue delivers every registration whose duration is idle on all known
453+// seats and whose origin+After has passed, and re-arms the deferred timer.
454+func (o *Owner) fireDue(ctx context.Context) error {
455+ now := time.Now()
456+ for _, n := range slices.Clone(o.regs) {
457+ if !o.allIdle(n.After) || o.fired[n.ID] || o.regOrigin[n.ID].Add(n.After).After(now) {
458+ continue
459+ }
460+ o.fired[n.ID] = true
461+ if err := o.handle(ctx, ports.IdleDue{Generation: o.regGen[n.ID], Notification: n.ID, After: n.After}); err != nil {
462+ return err
463+ }
464+ }
465+ o.armDeferred()
466+ return nil
467+}
468+
469+func (o *Owner) armDeferred() {
470+ o.deferred.Stop()
471+ var next time.Time
472+ for _, n := range o.regs {
473+ if !o.allIdle(n.After) || o.fired[n.ID] {
474+ continue
475+ }
476+ if t := o.regOrigin[n.ID].Add(n.After); next.IsZero() || t.Before(next) {
477+ next = t
478+ }
479+ }
480+ if !next.IsZero() {
481+ o.deferred.Reset(max(time.Until(next), time.Millisecond))
482+ }
483+}
484+
485+func (o *Owner) system(ctx context.Context, ev ports.SystemEvent) error {
486+ switch v := ev.(type) {
487+ case ports.SystemCapabilityReport:
488+ c := v.Capabilities
489+ if o.sysCaps == nil {
490+ o.sysCaps = &c
491+ }
492+ case ports.SystemLockRequested:
493+ return o.handle(ctx, ports.LockRequested{})
494+ case ports.SystemUnlockObserved:
495+ // An observation, never authority: only the visual releases the lock.
496+ o.log.Info().Msg("logind unlock observed; ignored")
497+ case ports.SleepPreparation:
498+ o.sleeping, o.sleepCycle = v.Preparing, v.Cycle
499+ if v.Preparing {
500+ return o.handle(ctx, ports.BeforeSleep{})
501+ }
502+ case ports.SystemFailure:
503+ o.log.Warn().Err(v.Err).Str("operation", v.Operation).Msg("system operation failed")
504+ }
505+ return nil
506+}
507+
508+func (o *Owner) visual(ctx context.Context, ev ports.VisualEvent) error {
509+ switch v := ev.(type) {
510+ case ports.LockConfirmed:
511+ o.delay = o.opt.RetryMin
512+ return o.handle(ctx, v)
513+ case ports.LockReleased:
514+ if err := o.handle(ctx, v); err != nil {
515+ return err
516+ }
517+ if !o.lockWanted() {
518+ o.lockGen = 0
519+ o.retry.Stop()
520+ }
521+ case ports.VisualExited:
522+ if o.lockWanted() {
523+ // A dead lock visual leaves the session locked; bring it back.
524+ o.log.Warn().Err(v.Err).Uint64("generation", uint64(o.lockGen)).Msg("lock visual exited; respawning")
525+ o.armRetry()
526+ } else {
527+ o.log.Info().Err(v.Err).Msg("visual exited")
528+ }
529+ }
530+ return nil
531+}
532+
533+func (o *Owner) control(ctx context.Context, req ports.ControlRequest) error {
534+ if req.Reply == nil {
535+ return nil // nobody to answer, and nothing to apply
536+ }
537+ if req.Kind == ports.ControlLock {
538+ if err := o.handle(ctx, ports.LockRequested{}); err != nil {
539+ return err
540+ }
541+ } else if req.Kind != ports.ControlStatus {
542+ req.Reply <- ports.ControlReply{Err: fmt.Sprintf("unknown request %d", req.Kind)}
543+ return nil
544+ }
545+ st := o.policy.State()
546+ req.Reply <- ports.ControlReply{Generation: st.Generation, Acquiring: st.Acquiring, Protected: st.Protected, Faded: st.Faded, Off: st.Off}
547+ return nil
548+}
A internal/app/owner_test.go +728 -0
@@ -0,0 +1,728 @@
1+package app
2+
3+import (
4+ "context"
5+ "errors"
6+ "io"
7+ "strings"
8+ "sync"
9+ "testing"
10+ "time"
11+
12+ portsmocks "github.com/bnema/neferafk/internal/mocks/ports"
13+ "github.com/bnema/neferafk/internal/ports"
14+ "github.com/bnema/zerowrap"
15+ "github.com/stretchr/testify/mock"
16+)
17+
18+const wait = 2 * time.Second
19+
20+type harness struct {
21+ t *testing.T
22+ visual *portsmocks.MockVisual
23+ cfgCh chan ports.ConfigChanged
24+ fail chan error
25+ wlEv chan ports.WaylandEvent
26+ sysEv chan ports.SystemEvent
27+ visEv chan ports.VisualEvent
28+ ctl chan ports.ControlRequest
29+ wlCmd chan ports.WaylandCommand
30+ sysCmd chan ports.SystemCommand
31+ done chan error
32+ cancel context.CancelFunc
33+ owner *Owner
34+}
35+
36+// newHarness starts an owner. Like the real Wayland adapter, non-gate harnesses
37+// report one seat (0) before any idle event.
38+func newHarness(t *testing.T, cfg ports.Config, tweak func(*Options)) *harness {
39+ t.Helper()
40+ h, gate := newBareHarness(t, cfg, tweak)
41+ if !gate {
42+ h.send(ports.WaylandCapabilities{Capabilities: ports.Capabilities{Seats: []uint32{0}}})
43+ }
44+ return h
45+}
46+
47+// newBareHarness starts an owner without any capability report.
48+func newBareHarness(t *testing.T, cfg ports.Config, tweak func(*Options)) (*harness, bool) {
49+ t.Helper()
50+ h := &harness{t: t, visual: portsmocks.NewMockVisual(t),
51+ cfgCh: make(chan ports.ConfigChanged), fail: make(chan error, 4), wlEv: make(chan ports.WaylandEvent), sysEv: make(chan ports.SystemEvent),
52+ visEv: make(chan ports.VisualEvent), ctl: make(chan ports.ControlRequest), wlCmd: make(chan ports.WaylandCommand, 32), sysCmd: make(chan ports.SystemCommand, 32), done: make(chan error, 1)}
53+ opt := Options{Config: cfg, Visual: h.visual, Log: zerowrap.New(zerowrap.Config{Output: io.Discard}), RetryMin: 5 * time.Millisecond, RetryMax: 20 * time.Millisecond}
54+ if tweak != nil {
55+ tweak(&opt)
56+ }
57+ o, err := NewOwner(opt, Inputs{Config: h.cfgCh, Failures: h.fail, Wayland: h.wlEv, System: h.sysEv, Visual: h.visEv, Control: h.ctl}, Outputs{Wayland: h.wlCmd, System: h.sysCmd})
58+ if err != nil {
59+ t.Fatal(err)
60+ }
61+ h.owner = o
62+ ctx, cancel := context.WithCancel(context.Background())
63+ h.cancel = cancel
64+ go func() { h.done <- o.Run(ctx) }()
65+ t.Cleanup(func() { h.stop() })
66+ return h, opt.Gate
67+}
68+
69+// stop ends the owner (once) and returns its error.
70+func (h *harness) stop() error {
71+ h.cancel()
72+ select {
73+ case err := <-h.done:
74+ h.done <- err
75+ return err
76+ case <-time.After(wait):
77+ h.t.Fatal("owner did not stop")
78+ return nil
79+ }
80+}
81+
82+// result waits for the owner to exit on its own (without cancelling it).
83+func (h *harness) result() error {
84+ select {
85+ case err := <-h.done:
86+ h.done <- err
87+ return err
88+ case <-time.After(wait):
89+ h.t.Fatal("owner did not exit")
90+ return nil
91+ }
92+}
93+
94+func (h *harness) deadlines() ports.SetDeadlines {
95+ h.t.Helper()
96+ for {
97+ select {
98+ case c := <-h.wlCmd:
99+ if d, ok := c.(ports.SetDeadlines); ok {
100+ return d
101+ }
102+ case <-time.After(wait):
103+ h.t.Fatal("no SetDeadlines")
104+ }
105+ }
106+}
107+func (h *harness) wlCommand() ports.WaylandCommand {
108+ h.t.Helper()
109+ select {
110+ case c := <-h.wlCmd:
111+ return c
112+ case <-time.After(wait):
113+ h.t.Fatal("no wayland command")
114+ return nil
115+ }
116+}
117+func (h *harness) noWlCommand() {
118+ h.t.Helper()
119+ select {
120+ case c := <-h.wlCmd:
121+ h.t.Fatalf("unexpected wayland command %#v", c)
122+ case <-time.After(60 * time.Millisecond):
123+ }
124+}
125+func (h *harness) sysCommand() ports.SystemCommand {
126+ h.t.Helper()
127+ select {
128+ case c := <-h.sysCmd:
129+ return c
130+ case <-time.After(wait):
131+ h.t.Fatal("no system command")
132+ return nil
133+ }
134+}
135+func (h *harness) noSysCommand() {
136+ h.t.Helper()
137+ select {
138+ case c := <-h.sysCmd:
139+ h.t.Fatalf("unexpected system command %#v", c)
140+ case <-time.After(60 * time.Millisecond):
141+ }
142+}
143+func (h *harness) send(ev ports.WaylandEvent) {
144+ h.t.Helper()
145+ select {
146+ case h.wlEv <- ev:
147+ case <-time.After(wait):
148+ h.t.Fatal("owner stuck")
149+ }
150+}
151+func (h *harness) status() ports.ControlReply {
152+ h.t.Helper()
153+ reply := make(chan ports.ControlReply, 1)
154+ select {
155+ case h.ctl <- ports.ControlRequest{Kind: ports.ControlStatus, Reply: reply}:
156+ case <-time.After(wait):
157+ h.t.Fatal("owner stuck")
158+ }
159+ return <-reply
160+}
161+
162+func cfgOf(lock, fade, off, sleep time.Duration) ports.Config {
163+ c := ports.Defaults()
164+ c.LockAfter, c.FadeAfter, c.OffAfter, c.SleepAfter = lock, fade, off, sleep
165+ c.FadeDuration = time.Second
166+ return c
167+}
168+
169+const step = 30 * time.Millisecond
170+
171+// lockCapture makes Visual.Lock record its request.
172+func (h *harness) lockCapture() <-chan ports.VisualLock {
173+ locks := make(chan ports.VisualLock, 8)
174+ h.visual.EXPECT().Lock(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, r ports.VisualLock) error {
175+ locks <- r
176+ return nil
177+ })
178+ return locks
179+}
180+func lockOf(t *testing.T, ch <-chan ports.VisualLock) ports.VisualLock {
181+ t.Helper()
182+ select {
183+ case l := <-ch:
184+ return l
185+ case <-time.After(wait):
186+ t.Fatal("no lock request")
187+ return ports.VisualLock{}
188+ }
189+}
190+
191+func TestIdleActionsAreIndependentAndDisabledOnesNeverFire(t *testing.T) {
192+ // Only fade and output-off are enabled; lock and sleep are "off".
193+ h := newHarness(t, cfgOf(0, step, 2*step, 0), nil)
194+ fades := make(chan ports.VisualFade, 4)
195+ h.visual.EXPECT().Fade(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, f ports.VisualFade) error { fades <- f; return nil })
196+ h.visual.EXPECT().Close().Return(nil).Once()
197+ d := h.deadlines()
198+ if len(d.Deadlines) != 2 || d.Deadlines[0] != step || d.Deadlines[1] != 2*step {
199+ t.Fatalf("deadlines %v", d.Deadlines)
200+ }
201+ h.send(ports.WaylandIdle{After: step})
202+ if f := <-fades; !f.Black || f.Duration != time.Second {
203+ t.Fatalf("fade %+v", f)
204+ }
205+ h.send(ports.WaylandIdle{After: 2 * step})
206+ if c := h.wlCommand(); c != (ports.OutputPower{On: false}) {
207+ t.Fatalf("command %#v", c)
208+ }
209+ h.noSysCommand() // sleep is off: nothing suspends
210+ if st := h.status(); st.Acquiring || st.Protected {
211+ t.Fatalf("locked without lock.after: %+v", st)
212+ }
213+}
214+
215+func TestActivityUndoesFadeWakesOutputsButNeverUnlocks(t *testing.T) {
216+ h := newHarness(t, cfgOf(step, 2*step, 3*step, 0), nil)
217+ locks := h.lockCapture()
218+ fades := make(chan ports.VisualFade, 4)
219+ h.visual.EXPECT().Fade(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, f ports.VisualFade) error { fades <- f; return nil })
220+ h.visual.EXPECT().Detach().Return(nil).Once() // shutdown with a held lock detaches, never closes
221+ d := h.deadlines()
222+ for _, after := range d.Deadlines {
223+ h.send(ports.WaylandIdle{After: after})
224+ }
225+ l := lockOf(t, locks)
226+ if l.Generation != 1 || l.Auth.Generation != 1 {
227+ t.Fatalf("lock %+v", l)
228+ }
229+ h.visEv <- ports.LockConfirmed{Generation: l.Generation}
230+ if f := <-fades; !f.Black {
231+ t.Fatal("fade not started")
232+ }
233+ if c := h.wlCommand(); c != (ports.OutputPower{On: false}) {
234+ t.Fatalf("command %#v", c)
235+ }
236+ h.send(ports.WaylandActivity{})
237+ if f := <-fades; f.Black {
238+ t.Fatal("fade not reversed")
239+ }
240+ if c := h.wlCommand(); c != (ports.OutputPower{On: true}) {
241+ t.Fatalf("command %#v", c)
242+ }
243+ st := h.status()
244+ if !st.Protected || st.Faded || st.Off {
245+ t.Fatalf("status %+v", st)
246+ }
247+ // Repeated activity, logind Unlock, and a reload: the lock stays held.
248+ h.send(ports.WaylandActivity{})
249+ h.sysEv <- ports.SystemUnlockObserved{}
250+ h.cfgCh <- ports.ConfigChanged{Config: cfgOf(step, 0, 0, 0)}
251+ if st := h.status(); !st.Protected {
252+ t.Fatalf("lock released: %+v", st)
253+ }
254+ h.stop()
255+}
256+
257+func TestLockIsNeverForcedOrReordered(t *testing.T) {
258+ // Fade only: no lock is acquired (mock has no Lock expectation).
259+ h := newHarness(t, cfgOf(0, step, 0, 0), nil)
260+ h.visual.EXPECT().Fade(mock.Anything, mock.Anything).Return(nil)
261+ h.visual.EXPECT().Close().Return(nil).Once()
262+ d := h.deadlines()
263+ h.send(ports.WaylandIdle{After: d.Deadlines[0]})
264+ h.status()
265+ // Off and sleep share the lock's deadline: they wait for confirmation.
266+ h2 := newHarness(t, cfgOf(step, 0, step, step), nil)
267+ locks := h2.lockCapture()
268+ h2.visual.EXPECT().Detach().Return(nil).Once()
269+ d2 := h2.deadlines()
270+ if len(d2.Deadlines) != 1 {
271+ t.Fatalf("deadlines %v", d2.Deadlines)
272+ }
273+ h2.send(ports.WaylandIdle{After: step})
274+ l := lockOf(t, locks)
275+ h2.noWlCommand()
276+ h2.noSysCommand()
277+ h2.visEv <- ports.LockConfirmed{Generation: l.Generation}
278+ if c := h2.wlCommand(); c != (ports.OutputPower{On: false}) {
279+ t.Fatalf("command %#v", c)
280+ }
281+ if c := h2.sysCommand(); c != (ports.SystemSuspend{}) {
282+ t.Fatalf("command %#v", c)
283+ }
284+}
285+
286+func TestReloadRearmsChangedDeadlinesAndRejectedConfigKeepsState(t *testing.T) {
287+ h := newHarness(t, cfgOf(0, step, 0, 0), nil)
288+ h.visual.EXPECT().Close().Return(nil).Once()
289+ if d := h.deadlines(); len(d.Deadlines) != 1 || d.Deadlines[0] != step {
290+ t.Fatalf("%v", d.Deadlines)
291+ }
292+ h.cfgCh <- ports.ConfigChanged{Config: cfgOf(0, 3*step, 0, 0)}
293+ d := h.deadlines()
294+ if d.Generation != 2 || len(d.Deadlines) != 1 || d.Deadlines[0] != 3*step {
295+ t.Fatalf("reload %+v", d)
296+ }
297+ // Identical config: no new arming. Invalid config: rejected, state kept.
298+ h.cfgCh <- ports.ConfigChanged{Config: cfgOf(0, 3*step, 0, 0)}
299+ bad := cfgOf(0, 3*step, 0, 0)
300+ bad.LockAfter = -1
301+ h.cfgCh <- ports.ConfigChanged{Config: bad}
302+ h.fail <- errors.New("config reload rejected")
303+ h.status()
304+ h.noWlCommand()
305+ // Disabling everything clears the deadlines.
306+ h.cfgCh <- ports.ConfigChanged{Config: cfgOf(0, 0, 0, 0)}
307+ if d := h.deadlines(); len(d.Deadlines) != 0 {
308+ t.Fatalf("still armed: %v", d.Deadlines)
309+ }
310+}
311+
312+func TestEqualDurationReloadDoesNotFireEarly(t *testing.T) {
313+ // Lock (enabled) and fade share an idle duration only after the reload;
314+ // the fade registration is new and must wait its own full duration even
315+ // though the adapter's proxy for that duration is already idle.
316+ after := 120 * time.Millisecond
317+ h := newHarness(t, cfgOf(after, 0, 0, 0), nil)
318+ locks := h.lockCapture()
319+ fades := make(chan ports.VisualFade, 4)
320+ h.visual.EXPECT().Fade(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, f ports.VisualFade) error { fades <- f; return nil })
321+ h.visual.EXPECT().Detach().Return(nil).Once()
322+ h.deadlines()
323+ time.Sleep(after / 2)
324+ h.cfgCh <- ports.ConfigChanged{Config: cfgOf(after, after, 0, 0)}
325+ h.deadlines()
326+ start := time.Now()
327+ h.send(ports.WaylandIdle{After: after}) // proxy idles at the original origin
328+ lockOf(t, locks)
329+ select {
330+ case <-fades:
331+ if time.Since(start) < after/2-10*time.Millisecond {
332+ t.Fatal("fade fired before its own deadline")
333+ }
334+ case <-time.After(wait):
335+ t.Fatal("fade never fired")
336+ }
337+}
338+
339+func TestSleepReadyOnlyAfterLockConfirmed(t *testing.T) {
340+ h := newHarness(t, cfgOf(time.Hour, 0, 0, 0), nil)
341+ locks := h.lockCapture()
342+ h.visual.EXPECT().Detach().Return(nil).Once()
343+ h.deadlines()
344+ h.sysEv <- ports.SleepPreparation{Preparing: true, Cycle: 7}
345+ l := lockOf(t, locks) // acquiring the lock is what BeforeSleep asks for
346+ h.noSysCommand() // ...and the inhibitor is held until it is confirmed
347+ h.visEv <- ports.LockConfirmed{Generation: l.Generation}
348+ if c := h.sysCommand(); c != (ports.SystemSleepReady{Cycle: 7}) {
349+ t.Fatalf("command %#v", c)
350+ }
351+ // Already protected: the next cycle is released immediately.
352+ h.sysEv <- ports.SleepPreparation{Preparing: false, Cycle: 7}
353+ h.sysEv <- ports.SleepPreparation{Preparing: true, Cycle: 8}
354+ if c := h.sysCommand(); c != (ports.SystemSleepReady{Cycle: 8}) {
355+ t.Fatalf("command %#v", c)
356+ }
357+ h.noSysCommand()
358+}
359+
360+func TestSleepWithLockDisabledIsReleasedWithoutLocking(t *testing.T) {
361+ h := newHarness(t, cfgOf(0, 0, 0, time.Hour), nil) // no Lock expectation
362+ h.visual.EXPECT().Close().Return(nil).Once()
363+ h.deadlines()
364+ h.sysEv <- ports.SleepPreparation{Preparing: true, Cycle: 1}
365+ if c := h.sysCommand(); c != (ports.SystemSleepReady{Cycle: 1}) {
366+ t.Fatalf("command %#v", c)
367+ }
368+}
369+
370+func TestLogindLockRequestAcquiresAndControlLockIsIdempotent(t *testing.T) {
371+ h := newHarness(t, cfgOf(0, 0, 0, 0), nil)
372+ locks := h.lockCapture()
373+ h.visual.EXPECT().Detach().Return(nil).Once()
374+ h.deadlines()
375+ h.sysEv <- ports.SystemLockRequested{}
376+ lockOf(t, locks)
377+ reply := make(chan ports.ControlReply, 1)
378+ h.ctl <- ports.ControlRequest{Kind: ports.ControlLock, Reply: reply}
379+ if r := <-reply; !r.Acquiring || r.Err != "" {
380+ t.Fatalf("reply %+v", r)
381+ }
382+ select {
383+ case <-locks:
384+ t.Fatal("second acquisition")
385+ case <-time.After(60 * time.Millisecond):
386+ }
387+}
388+
389+func TestControlUnknownKindRejected(t *testing.T) {
390+ h := newHarness(t, cfgOf(0, 0, 0, 0), nil)
391+ h.visual.EXPECT().Close().Return(nil).Once()
392+ reply := make(chan ports.ControlReply, 1)
393+ h.ctl <- ports.ControlRequest{Kind: ports.ControlKind(99), Reply: reply}
394+ if r := <-reply; r.Err == "" {
395+ t.Fatal("unknown request accepted")
396+ }
397+}
398+
399+func TestVisualCrashKeepsLockAndRespawnsWithBackoff(t *testing.T) {
400+ h := newHarness(t, cfgOf(0, 0, 0, 0), nil)
401+ locks := make(chan ports.VisualLock, 8)
402+ calls := 0
403+ h.visual.EXPECT().Lock(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, r ports.VisualLock) error {
404+ calls++
405+ locks <- r
406+ if calls == 2 {
407+ return errors.New("spawn failed") // first respawn attempt fails, retried
408+ }
409+ return nil
410+ })
411+ h.visual.EXPECT().Detach().Return(nil).Once()
412+ h.deadlines()
413+ h.sysEv <- ports.SystemLockRequested{}
414+ l := lockOf(t, locks)
415+ h.visEv <- ports.LockConfirmed{Generation: l.Generation}
416+ h.visEv <- ports.VisualExited{Err: errors.New("crash")}
417+ for i := 0; i < 2; i++ {
418+ if again := lockOf(t, locks); again.Generation != l.Generation {
419+ t.Fatalf("respawn generation %d", again.Generation)
420+ }
421+ }
422+ if st := h.status(); !st.Protected {
423+ t.Fatalf("crash released the lock: %+v", st)
424+ }
425+}
426+
427+func TestVisualExitAfterReleaseDoesNotRespawn(t *testing.T) {
428+ h := newHarness(t, cfgOf(0, 0, 0, 0), nil)
429+ locks := h.lockCapture()
430+ h.visual.EXPECT().Close().Return(nil).Once()
431+ h.deadlines()
432+ h.sysEv <- ports.SystemLockRequested{}
433+ l := lockOf(t, locks)
434+ h.visEv <- ports.LockConfirmed{Generation: l.Generation}
435+ h.visEv <- ports.LockReleased{Generation: l.Generation}
436+ h.visEv <- ports.VisualExited{}
437+ if st := h.status(); st.Protected || st.Acquiring {
438+ t.Fatalf("status %+v", st)
439+ }
440+ select {
441+ case <-locks:
442+ t.Fatal("respawned after successful unlock")
443+ case <-time.After(60 * time.Millisecond):
444+ }
445+}
446+
447+func TestLockBootstrapCarriesMetadataAndEnvPINStaysOutOfChildEnvironment(t *testing.T) {
448+ cfg := cfgOf(0, 0, 0, 0)
449+ cfg.Auth = ports.AuthConfig{Mode: ports.AuthModePIN, PINSource: ports.PINSourceEnv, PINEnv: "NEFER_PIN"}
450+ h := newHarness(t, cfg, func(o *Options) {
451+ o.LookupEnv = func(k string) (string, bool) { return "123456", k == "NEFER_PIN" }
452+ })
453+ locks := h.lockCapture()
454+ h.visual.EXPECT().Detach().Return(nil).Once()
455+ h.deadlines()
456+ h.sysEv <- ports.SystemLockRequested{}
457+ l := lockOf(t, locks)
458+ if l.Auth.PINSource != ports.PINSourceEnv || string(l.Auth.EnvPIN) != "123456" || len(l.Spawn.OmitEnv) != 1 || l.Spawn.OmitEnv[0] != "NEFER_PIN" {
459+ t.Fatalf("lock %+v", l)
460+ }
461+ // Pass source: only the entry name travels, never a value.
462+ cfg.Auth = ports.AuthConfig{Mode: ports.AuthModePIN, PINSource: ports.PINSourcePass, PINEntry: "afk/pin"}
463+ h.cfgCh <- ports.ConfigChanged{Config: cfg}
464+}
465+
466+func TestStartupGate(t *testing.T) {
467+ ok := ports.Capabilities{}
468+ for _, tc := range []struct {
469+ name string
470+ wl ports.Capabilities
471+ sys ports.SystemCapabilities
472+ wantErr bool
473+ }{
474+ {"supported", ok, ports.SystemCapabilities{Session: true, Suspend: true}, false},
475+ {"wayland missing", ports.Capabilities{Missing: []string{"ext_idle_notifier_v1 >= 2"}}, ports.SystemCapabilities{Session: true, Suspend: true}, true},
476+ {"no session", ok, ports.SystemCapabilities{}, true},
477+ {"suspend unavailable but sleep off", ok, ports.SystemCapabilities{Session: true, Missing: []string{"non-interactive suspend: no"}}, false},
478+ } {
479+ t.Run(tc.name, func(t *testing.T) {
480+ h := newHarness(t, cfgOf(0, 0, 0, 0), func(o *Options) { o.Gate, o.ExpectSystem, o.GateTimeout = true, true, 100*time.Millisecond })
481+ h.visual.EXPECT().Close().Return(nil).Maybe()
482+ h.send(ports.WaylandCapabilities{Capabilities: tc.wl})
483+ h.sysEv <- ports.SystemCapabilityReport{Capabilities: tc.sys}
484+ if tc.wantErr {
485+ var u *UnsupportedError
486+ if err := h.result(); !errors.As(err, &u) || len(u.Missing) == 0 {
487+ t.Fatalf("err %v", err)
488+ }
489+ return
490+ }
491+ h.status()
492+ })
493+ }
494+}
495+
496+func TestStartupGateWaitsForLateWaylandGlobals(t *testing.T) {
497+ h := newHarness(t, cfgOf(0, 0, 0, 0), func(o *Options) {
498+ o.Gate, o.ExpectSystem, o.GateTimeout = true, true, 80*time.Millisecond
499+ })
500+ h.visual.EXPECT().Close().Return(nil).Maybe()
501+ h.send(ports.WaylandCapabilities{Capabilities: ports.Capabilities{Missing: []string{"wl_output >= 1"}}})
502+ h.sysEv <- ports.SystemCapabilityReport{Capabilities: ports.SystemCapabilities{Session: true, Suspend: true}}
503+ h.send(ports.WaylandCapabilities{})
504+ time.Sleep(3 * 80 * time.Millisecond)
505+ select {
506+ case err := <-h.done:
507+ t.Fatalf("owner exited although the late report was complete: %v", err)
508+ default:
509+ }
510+ h.status()
511+}
512+
513+func TestStartupGateFailsWhenWaylandStaysIncomplete(t *testing.T) {
514+ h := newHarness(t, cfgOf(0, 0, 0, 0), func(o *Options) {
515+ o.Gate, o.ExpectSystem, o.GateTimeout = true, true, 50*time.Millisecond
516+ })
517+ h.visual.EXPECT().Close().Return(nil).Maybe()
518+ h.send(ports.WaylandCapabilities{Capabilities: ports.Capabilities{Missing: []string{"wl_output >= 1"}}})
519+ h.sysEv <- ports.SystemCapabilityReport{Capabilities: ports.SystemCapabilities{Session: true, Suspend: true}}
520+ var u *UnsupportedError
521+ if err := h.result(); !errors.As(err, &u) || len(u.Missing) != 1 || u.Missing[0] != "wayland: wl_output >= 1" {
522+ t.Fatalf("err %v", err)
523+ }
524+}
525+
526+func TestStartupGateFailsAtOnceWhenSystemMissing(t *testing.T) {
527+ h := newHarness(t, cfgOf(0, 0, 0, 0), func(o *Options) { o.Gate, o.ExpectSystem, o.GateTimeout = true, true, time.Hour })
528+ h.visual.EXPECT().Close().Return(nil).Maybe()
529+ h.send(ports.WaylandCapabilities{Capabilities: ports.Capabilities{Missing: []string{"wl_output >= 1"}}})
530+ h.sysEv <- ports.SystemCapabilityReport{Capabilities: ports.SystemCapabilities{}}
531+ var u *UnsupportedError
532+ if err := h.result(); !errors.As(err, &u) || len(u.Missing) != 2 {
533+ t.Fatalf("err %v", err)
534+ }
535+}
536+
537+// lockedBuffer is a real io.Writer shared by the owner and the test.
538+type lockedBuffer struct {
539+ mu sync.Mutex
540+ b strings.Builder
541+}
542+
543+func (l *lockedBuffer) Write(p []byte) (int, error) {
544+ l.mu.Lock()
545+ defer l.mu.Unlock()
546+ return l.b.Write(p)
547+}
548+func (l *lockedBuffer) String() string {
549+ l.mu.Lock()
550+ defer l.mu.Unlock()
551+ return l.b.String()
552+}
553+
554+func TestStartupGateSuspendUnavailableDoesNotBlockOtherActions(t *testing.T) {
555+ logs := &lockedBuffer{}
556+ h := newHarness(t, cfgOf(0, 0, 0, time.Hour), func(o *Options) {
557+ o.Gate, o.ExpectSystem = true, true
558+ o.Log = zerowrap.New(zerowrap.Config{Output: logs})
559+ })
560+ h.visual.EXPECT().Close().Return(nil).Maybe()
561+ h.send(ports.WaylandCapabilities{})
562+ h.sysEv <- ports.SystemCapabilityReport{Capabilities: ports.SystemCapabilities{Session: true, SuspendAuthorization: "inhibited", Missing: []string{"non-interactive suspend: inhibited"}}}
563+ h.status() // still running: only the sleep action is degraded
564+ if !strings.Contains(logs.String(), "suspend unavailable (inhibited)") {
565+ t.Fatalf("missing suspend warning in %q", logs.String())
566+ }
567+}
568+
569+func TestStartupGateSleepInhibitorStillRequired(t *testing.T) {
570+ h := newHarness(t, cfgOf(0, 0, 0, time.Hour), func(o *Options) { o.Gate, o.ExpectSystem = true, true })
571+ h.visual.EXPECT().Close().Return(nil).Maybe()
572+ h.send(ports.WaylandCapabilities{})
573+ h.sysEv <- ports.SystemCapabilityReport{Capabilities: ports.SystemCapabilities{Session: true, Missing: []string{"sleep delay inhibitor"}}}
574+ var u *UnsupportedError
575+ if err := h.result(); !errors.As(err, &u) {
576+ t.Fatalf("err %v", err)
577+ }
578+}
579+
580+func TestNewOwnerValidation(t *testing.T) {
581+ if _, err := NewOwner(Options{Config: ports.Defaults()}, Inputs{}, Outputs{}); err == nil {
582+ t.Fatal("nil visual accepted")
583+ }
584+ bad := ports.Defaults()
585+ bad.LockAfter = -1
586+ if _, err := NewOwner(Options{Config: bad, Visual: portsmocks.NewMockVisual(t)}, Inputs{}, Outputs{}); err == nil {
587+ t.Fatal("invalid config accepted")
588+ }
589+}
590+
591+func TestGatePassedSurvivesPastGateTimeout(t *testing.T) {
592+ h := newHarness(t, cfgOf(0, 0, 0, 0), func(o *Options) {
593+ o.Gate, o.ExpectSystem, o.GateTimeout = true, true, 80*time.Millisecond
594+ })
595+ h.visual.EXPECT().Close().Return(nil).Maybe()
596+ h.send(ports.WaylandCapabilities{})
597+ h.sysEv <- ports.SystemCapabilityReport{Capabilities: ports.SystemCapabilities{Session: true, Suspend: true}}
598+ time.Sleep(3 * 80 * time.Millisecond)
599+ select {
600+ case err := <-h.done:
601+ t.Fatalf("owner exited after the gate passed: %v", err)
602+ default:
603+ }
604+ h.status() // still serving
605+}
606+
607+func TestGateTimeoutStillFailsWithoutReports(t *testing.T) {
608+ h := newHarness(t, cfgOf(0, 0, 0, 0), func(o *Options) { o.Gate, o.GateTimeout = true, 50*time.Millisecond })
609+ h.visual.EXPECT().Close().Return(nil).Maybe()
610+ var u *UnsupportedError
611+ if err := h.result(); !errors.As(err, &u) {
612+ t.Fatalf("err %v", err)
613+ }
614+}
615+
616+func TestDeadlineNeedsEverySeatIdleAndActivityClearsOnlyItsSeat(t *testing.T) {
617+ h := newHarness(t, cfgOf(0, step, 0, 0), nil)
618+ fades := make(chan ports.VisualFade, 4)
619+ h.visual.EXPECT().Fade(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, f ports.VisualFade) error { fades <- f; return nil })
620+ h.visual.EXPECT().Close().Return(nil).Once()
621+ h.deadlines()
622+ h.send(ports.WaylandCapabilities{Capabilities: ports.Capabilities{Seats: []uint32{1, 2}}})
623+ h.send(ports.WaylandIdle{Seat: 1, After: step})
624+ select {
625+ case <-fades:
626+ t.Fatal("fade fired with one of two seats idle")
627+ case <-time.After(3 * step):
628+ }
629+ h.send(ports.WaylandIdle{Seat: 2, After: step})
630+ if f := <-fades; !f.Black {
631+ t.Fatal("fade not started once all seats idle")
632+ }
633+ // Seat 2 becomes active: undo, and only seat 2's idle state is cleared.
634+ h.send(ports.WaylandActivity{Seat: 2})
635+ if f := <-fades; f.Black {
636+ t.Fatal("fade not reversed")
637+ }
638+ // Seat 1 stayed idle (its own state was not cleared), so seat 2 idling
639+ // again completes "all seats idle" and the re-armed deadline fires anew...
640+ h.send(ports.WaylandIdle{Seat: 2, After: step})
641+ if f := <-fades; !f.Black {
642+ t.Fatal("re-armed fade did not fire once seat 2 idled again")
643+ }
644+ // ...but activity on seat 1 clears seat 1 only: with seat 1 busy and seat 2
645+ // idle, nothing may fire.
646+ h.send(ports.WaylandActivity{Seat: 1})
647+ if f := <-fades; f.Black {
648+ t.Fatal("fade not reversed")
649+ }
650+ select {
651+ case f := <-fades:
652+ t.Fatalf("fired with seat 1 busy: %+v", f)
653+ case <-time.After(3 * step):
654+ }
655+}
656+
657+func TestNothingFiresBeforeFirstSeatReport(t *testing.T) {
658+ h, _ := newBareHarness(t, cfgOf(0, step, 0, 0), nil)
659+ fades := make(chan ports.VisualFade, 4)
660+ h.visual.EXPECT().Fade(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, f ports.VisualFade) error { fades <- f; return nil })
661+ h.visual.EXPECT().Close().Return(nil).Once()
662+ h.deadlines()
663+ h.send(ports.WaylandIdle{Seat: 1, After: step})
664+ select {
665+ case f := <-fades:
666+ t.Fatalf("fired before any seat report: %+v", f)
667+ case <-time.After(3 * step):
668+ }
669+ h.send(ports.WaylandCapabilities{Capabilities: ports.Capabilities{Seats: []uint32{1}}})
670+ if f := <-fades; !f.Black {
671+ t.Fatal("deadline did not fire once the idle seat was reported")
672+ }
673+}
674+
675+func TestRemovedSeatNoLongerBlocksDeadline(t *testing.T) {
676+ h := newHarness(t, cfgOf(0, step, 0, 0), nil)
677+ fades := make(chan ports.VisualFade, 4)
678+ h.visual.EXPECT().Fade(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, f ports.VisualFade) error { fades <- f; return nil })
679+ h.visual.EXPECT().Close().Return(nil).Once()
680+ h.deadlines()
681+ h.send(ports.WaylandCapabilities{Capabilities: ports.Capabilities{Seats: []uint32{1, 2}}})
682+ h.send(ports.WaylandIdle{Seat: 1, After: step})
683+ h.send(ports.WaylandCapabilities{Capabilities: ports.Capabilities{Seats: []uint32{1}}})
684+ if f := <-fades; !f.Black {
685+ t.Fatal("removing the busy seat did not release the deadline")
686+ }
687+}
688+
689+func TestClosedOptionalChannelsAndNilReplyAreTolerated(t *testing.T) {
690+ h := newHarness(t, cfgOf(0, 0, 0, 0), nil)
691+ h.visual.EXPECT().Close().Return(nil).Once()
692+ close(h.fail)
693+ close(h.ctl)
694+ time.Sleep(30 * time.Millisecond)
695+ select {
696+ case err := <-h.done:
697+ t.Fatalf("owner died on closed optional channel: %v", err)
698+ default:
699+ }
700+ // A request with no Reply channel must not panic or apply anything.
701+ h2 := newHarness(t, cfgOf(0, 0, 0, 0), nil)
702+ h2.visual.EXPECT().Close().Return(nil).Once()
703+ h2.ctl <- ports.ControlRequest{Kind: ports.ControlLock}
704+ if st := h2.status(); st.Acquiring || st.Protected {
705+ t.Fatalf("nil-reply lock request applied: %+v", st)
706+ }
707+}
708+
709+func TestSpawnOmitsStartupAndCurrentPINEnvNames(t *testing.T) {
710+ cfg := cfgOf(0, 0, 0, 0)
711+ cfg.Auth = ports.AuthConfig{Mode: ports.AuthModePIN, PINSource: ports.PINSourceEnv, PINEnv: "STARTUP_PIN"}
712+ h := newHarness(t, cfg, nil)
713+ locks := h.lockCapture()
714+ h.visual.EXPECT().Detach().Return(nil).Once()
715+ h.deadlines()
716+ next := cfg
717+ next.Auth = ports.AuthConfig{Mode: ports.AuthModePIN, PINSource: ports.PINSourceEnv, PINEnv: "OTHER_PIN"}
718+ h.cfgCh <- ports.ConfigChanged{Config: next}
719+ h.sysEv <- ports.SystemLockRequested{}
720+ l := lockOf(t, locks)
721+ if len(l.Spawn.OmitEnv) != 2 || l.Spawn.OmitEnv[0] != "OTHER_PIN" || l.Spawn.OmitEnv[1] != "STARTUP_PIN" {
722+ t.Fatalf("omit %v", l.Spawn.OmitEnv)
723+ }
724+ // Pass source after reload: the startup env name is still kept out.
725+ next.Auth = ports.AuthConfig{Mode: ports.AuthModePIN, PINSource: ports.PINSourcePass, PINEntry: "afk/pin"}
726+ h.cfgCh <- ports.ConfigChanged{Config: next}
727+ h.status()
728+}
A internal/app/requirements.go +61 -0
@@ -0,0 +1,61 @@
1+package app
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "github.com/bnema/neferafk/internal/ports"
8+)
9+
10+// Requirements derives what the daemon needs from its startup config. Idle
11+// notification/input-wake and lock/visual protocols are always required: a
12+// daemon that cannot observe activity or lock is unsupported, and manual lock
13+// (logind Lock, control socket) needs the lock protocols even with lock.after
14+// off. Output power and sleep support are required only when enabled; enabling
15+// them later by live reload needs a restart (a warning is logged).
16+func Requirements(cfg ports.Config) (ports.WaylandRequirements, ports.SystemRequirements) {
17+ return ports.WaylandRequirements{Idle: true, InputWake: true, OutputPower: cfg.OffAfter > 0, Lock: true, Visual: true},
18+ ports.SystemRequirements{Session: true, Sleep: cfg.SleepAfter > 0 || cfg.LockAfter > 0}
19+}
20+
21+// UnsupportedError lists every missing capability, one per line.
22+type UnsupportedError struct{ Missing []string }
23+
24+func (e *UnsupportedError) Error() string {
25+ return "unsupported environment:\n - " + strings.Join(e.Missing, "\n - ")
26+}
27+
28+const suspendMissing = "non-interactive suspend"
29+
30+// unsupported merges adapter reports. Missing non-interactive suspend never
31+// blocks startup: fade, lock and output-off still run and only the sleep
32+// action is skipped (suspendWarning). The sleep delay inhibitor stays required
33+// because before-sleep locking depends on it.
34+func unsupported(wl *ports.Capabilities, sys *ports.SystemCapabilities) []string {
35+ var missing []string
36+ if wl != nil {
37+ for _, m := range wl.Missing {
38+ missing = append(missing, "wayland: "+m)
39+ }
40+ }
41+ if sys != nil {
42+ if !sys.Session {
43+ missing = append(missing, "logind: current session")
44+ }
45+ for _, m := range sys.Missing {
46+ if strings.HasPrefix(m, suspendMissing) {
47+ continue // degrades only the sleep action, see suspendWarning
48+ }
49+ missing = append(missing, "logind: "+m)
50+ }
51+ }
52+ return missing
53+}
54+
55+// suspendWarning reports why an enabled sleep action cannot suspend, or "".
56+func suspendWarning(cfg ports.Config, sys *ports.SystemCapabilities) string {
57+ if cfg.SleepAfter == 0 || sys == nil || sys.Suspend {
58+ return ""
59+ }
60+ return fmt.Sprintf("suspend unavailable (%s); sleep.after is skipped", sys.SuspendAuthorization)
61+}
A internal/core/policy.go +223 -0
@@ -0,0 +1,223 @@
1+// Package core owns pure absence policy. Call Handle on one owner goroutine;
2+// adapters perform returned effects. Times are explicit, not inferred idle age.
3+package core
4+
5+import (
6+ "fmt"
7+ "slices"
8+ "time"
9+
10+ "github.com/bnema/neferafk/internal/ports"
11+)
12+
13+type deadline struct {
14+ after time.Duration
15+ notification uint64
16+ done, due bool
17+}
18+type registration struct {
19+ id uint64
20+ generation ports.Generation
21+ after time.Duration
22+ actions []ports.Action
23+}
24+type Policy struct {
25+ cfg ports.Config
26+ generation ports.Generation
27+ nextNotification uint64
28+ deadlines [4]deadline
29+ registrations []registration
30+ acquiring, protected bool
31+ lockGeneration ports.Generation
32+ faded, off, beforeSleep bool
33+}
34+type State struct {
35+ Generation ports.Generation
36+ Acquiring, Protected, Faded, Off bool
37+}
38+
39+func (p *Policy) State() State { return State{p.generation, p.acquiring, p.protected, p.faded, p.off} }
40+func afters(c ports.Config) [4]time.Duration {
41+ return [4]time.Duration{c.LockAfter, c.FadeAfter, c.OffAfter, c.SleepAfter}
42+}
43+
44+func New(cfg ports.Config, now time.Time) (*Policy, []ports.PolicyEffect, error) {
45+ if err := cfg.Validate(); err != nil {
46+ return nil, nil, err
47+ }
48+ p := &Policy{cfg: cfg, generation: 1}
49+ for a, d := range afters(cfg) {
50+ p.deadlines[a] = deadline{after: d}
51+ }
52+ return p, p.arm(now), nil
53+}
54+
55+func (p *Policy) arm(_ time.Time) []ports.PolicyEffect {
56+ old := p.registrations
57+ p.registrations = nil
58+ for a := range p.deadlines {
59+ d := &p.deadlines[a]
60+ if d.after == 0 || d.done || d.due {
61+ continue
62+ }
63+ i := slices.IndexFunc(p.registrations, func(r registration) bool {
64+ return d.notification != 0 && r.id == d.notification || d.notification == 0 && r.generation == p.generation && r.after == d.after
65+ })
66+ if i < 0 {
67+ r := registration{after: d.after, generation: p.generation}
68+ if d.notification != 0 {
69+ j := slices.IndexFunc(old, func(r registration) bool { return r.id == d.notification })
70+ if j >= 0 {
71+ r = old[j]
72+ r.actions = nil
73+ }
74+ }
75+ if r.id == 0 {
76+ p.nextNotification++
77+ r.id = p.nextNotification
78+ }
79+ r.actions = []ports.Action{ports.Action(a)}
80+ p.registrations = append(p.registrations, r)
81+ d.notification = r.id
82+ } else {
83+ p.registrations[i].actions = append(p.registrations[i].actions, ports.Action(a))
84+ d.notification = p.registrations[i].id
85+ }
86+ }
87+ slices.SortFunc(p.registrations, func(a, b registration) int {
88+ if a.after < b.after {
89+ return -1
90+ }
91+ if a.after > b.after {
92+ return 1
93+ }
94+ return 0
95+ })
96+ n := make([]ports.IdleNotification, 0, len(p.registrations))
97+ for _, r := range p.registrations {
98+ n = append(n, ports.IdleNotification{ID: r.id, After: r.after})
99+ }
100+ return []ports.PolicyEffect{ports.ArmIdle{Generation: p.generation, Notifications: n}}
101+}
102+
103+func (p *Policy) acquire(out *[]ports.PolicyEffect) {
104+ if p.acquiring || p.protected {
105+ return
106+ }
107+ p.acquiring = true
108+ p.lockGeneration = p.generation
109+ *out = append(*out, ports.AcquireLock{Generation: p.lockGeneration})
110+}
111+
112+func (p *Policy) dispatch() []ports.PolicyEffect {
113+ var out []ports.PolicyEffect
114+ // Due flags for one notification are established before dispatch; lock is
115+ // first only for simultaneous due actions, never because enabled/later.
116+ for a := ports.Lock; a <= ports.Sleep; a++ {
117+ d := &p.deadlines[a]
118+ if !d.due || d.done || d.after == 0 {
119+ continue
120+ }
121+ if (a == ports.OutputOff || a == ports.Sleep) && p.acquiring {
122+ continue
123+ }
124+ d.done = true
125+ switch a {
126+ case ports.Lock:
127+ p.acquire(&out)
128+ case ports.Fade:
129+ p.faded = true
130+ out = append(out, ports.SetFade{Black: true, Duration: p.cfg.FadeDuration})
131+ case ports.OutputOff:
132+ p.off = true
133+ out = append(out, ports.SetOutputPower{On: false})
134+ case ports.Sleep:
135+ out = append(out, ports.Suspend{})
136+ }
137+ }
138+ return out
139+}
140+
141+func (p *Policy) Handle(now time.Time, event ports.PolicyEvent) ([]ports.PolicyEffect, error) {
142+ switch v := event.(type) {
143+ case ports.IdleDue:
144+ i := slices.IndexFunc(p.registrations, func(r registration) bool {
145+ return r.generation == v.Generation && (v.Notification != 0 && r.id == v.Notification || v.Notification == 0 && r.after == v.After)
146+ })
147+ if i < 0 {
148+ return nil, nil
149+ }
150+ for _, a := range p.registrations[i].actions {
151+ p.deadlines[a].due = true
152+ }
153+ return p.dispatch(), nil
154+ case ports.Activity:
155+ p.generation++
156+ var out []ports.PolicyEffect
157+ if p.faded {
158+ p.faded = false
159+ out = append(out, ports.SetFade{Black: false, Duration: p.cfg.FadeDuration})
160+ }
161+ if p.off {
162+ p.off = false
163+ out = append(out, ports.SetOutputPower{On: true})
164+ }
165+ for a, d := range afters(p.cfg) {
166+ p.deadlines[a] = deadline{after: d}
167+ }
168+ return append(out, p.arm(now)...), nil
169+ case ports.Reload:
170+ if err := v.Config.Validate(); err != nil {
171+ return nil, err
172+ }
173+ if v.Config == p.cfg {
174+ return nil, nil
175+ }
176+ for a, d := range afters(v.Config) {
177+ old := &p.deadlines[a]
178+ if old.after != d {
179+ // Enablement changes even for completed actions; completion is
180+ // pinned for this cycle, but old due/proxy identity is retired.
181+ *old = deadline{after: d, done: old.done}
182+ }
183+ }
184+ p.cfg = v.Config
185+ p.generation++
186+ return p.arm(now), nil
187+ case ports.LockConfirmed:
188+ if !p.acquiring || v.Generation != p.lockGeneration {
189+ return nil, nil
190+ }
191+ p.acquiring = false
192+ p.protected = true
193+ out := p.dispatch()
194+ if p.beforeSleep {
195+ p.beforeSleep = false
196+ out = append(out, ports.SleepReady{})
197+ }
198+ return out, nil
199+ case ports.LockReleased:
200+ if !p.protected || v.Generation != p.lockGeneration {
201+ return nil, nil
202+ }
203+ p.protected = false // only authoritative successful visual-owner release
204+ return nil, nil
205+ case ports.LockRequested:
206+ var out []ports.PolicyEffect
207+ p.acquire(&out)
208+ return out, nil
209+ case ports.BeforeSleep:
210+ if p.protected {
211+ return []ports.PolicyEffect{ports.SleepReady{}}, nil
212+ }
213+ if p.cfg.LockAfter == 0 && !p.acquiring {
214+ return []ports.PolicyEffect{ports.SleepReady{}}, nil
215+ }
216+ p.beforeSleep = true
217+ var out []ports.PolicyEffect
218+ p.acquire(&out)
219+ return out, nil
220+ default:
221+ return nil, fmt.Errorf("unknown policy event %T", event)
222+ }
223+}
A internal/core/policy_test.go +302 -0
@@ -0,0 +1,302 @@
1+package core
2+
3+import (
4+ "fmt"
5+ "github.com/bnema/neferafk/internal/ports"
6+ "slices"
7+ "testing"
8+ "time"
9+)
10+
11+func handle(t *testing.T, p *Policy, now time.Time, e ports.PolicyEvent) []ports.PolicyEffect {
12+ t.Helper()
13+ out, err := p.Handle(now, e)
14+ if err != nil {
15+ t.Fatal(err)
16+ }
17+ return out
18+}
19+func actions(effects []ports.PolicyEffect) []ports.Action {
20+ var a []ports.Action
21+ for _, e := range effects {
22+ switch e.(type) {
23+ case ports.AcquireLock:
24+ a = append(a, ports.Lock)
25+ case ports.SetFade:
26+ a = append(a, ports.Fade)
27+ case ports.SetOutputPower:
28+ a = append(a, ports.OutputOff)
29+ case ports.Suspend:
30+ a = append(a, ports.Sleep)
31+ }
32+ }
33+ return a
34+}
35+func config(ds [4]time.Duration) ports.Config {
36+ c := ports.Defaults()
37+ c.LockAfter, c.FadeAfter, c.OffAfter, c.SleepAfter = ds[0], ds[1], ds[2], ds[3]
38+ return c
39+}
40+
41+func TestAllSixteenSubsetsTies(t *testing.T) {
42+ for bits := 0; bits < 16; bits++ {
43+ t.Run(fmt.Sprint(bits), func(t *testing.T) {
44+ var ds [4]time.Duration
45+ var want []ports.Action
46+ for i := range 4 {
47+ if bits&(1<<i) != 0 {
48+ ds[i] = time.Second
49+ want = append(want, ports.Action(i))
50+ }
51+ }
52+ now := time.Unix(100, 0)
53+ p, armed, err := New(config(ds), now)
54+ if err != nil {
55+ t.Fatal(err)
56+ }
57+ arm := armed[0].(ports.ArmIdle)
58+ if len(arm.Notifications) != min(bits, 1) {
59+ t.Fatal(arm)
60+ }
61+ got := handle(t, p, now.Add(time.Second), ports.IdleDue{Generation: arm.Generation, After: time.Second})
62+ if bits&1 != 0 {
63+ got = append(got, handle(t, p, now.Add(time.Second), ports.LockConfirmed{Generation: arm.Generation})...)
64+ }
65+ if !slices.Equal(actions(got), want) {
66+ t.Fatalf("got %v want %v", actions(got), want)
67+ }
68+ })
69+ }
70+}
71+
72+func TestAllTwentyFourDeadlineOrders(t *testing.T) {
73+ var visit func([]int, []int)
74+ visit = func(prefix, remaining []int) {
75+ if len(remaining) > 0 {
76+ for i, v := range remaining {
77+ visit(append(slices.Clone(prefix), v), append(slices.Clone(remaining[:i]), remaining[i+1:]...))
78+ }
79+ return
80+ }
81+ t.Run(fmt.Sprint(prefix), func(t *testing.T) {
82+ var ds [4]time.Duration
83+ for rank, a := range prefix {
84+ ds[a] = time.Duration(rank+1) * time.Second
85+ }
86+ now := time.Unix(100, 0)
87+ p, _, _ := New(config(ds), now)
88+ var got []ports.Action
89+ for rank := range 4 {
90+ d := time.Duration(rank+1) * time.Second
91+ out := handle(t, p, now.Add(d), ports.IdleDue{Generation: 1, After: d})
92+ got = append(got, actions(out)...)
93+ if p.acquiring {
94+ got = append(got, actions(handle(t, p, now.Add(d), ports.LockConfirmed{Generation: p.lockGeneration}))...)
95+ }
96+ }
97+ var want []ports.Action
98+ for _, a := range prefix {
99+ want = append(want, ports.Action(a))
100+ }
101+ if !slices.Equal(got, want) {
102+ t.Fatalf("got %v want %v", got, want)
103+ }
104+ })
105+ }
106+ visit(nil, []int{0, 1, 2, 3})
107+}
108+
109+func TestAcquiringHoldsOnlyDueOffSleepActivityNeverUnlocks(t *testing.T) {
110+ now := time.Unix(100, 0)
111+ p, _, _ := New(config([4]time.Duration{time.Second, 2 * time.Second, 3 * time.Second, 4 * time.Second}), now)
112+ handle(t, p, now, ports.IdleDue{Generation: 1, After: time.Second})
113+ if len(handle(t, p, now, ports.IdleDue{Generation: 1, After: 3 * time.Second})) != 0 {
114+ t.Fatal("off before lock confirmation")
115+ }
116+ if got := actions(handle(t, p, now, ports.IdleDue{Generation: 1, After: 2 * time.Second})); !slices.Equal(got, []ports.Action{ports.Fade}) {
117+ t.Fatal(got)
118+ }
119+ handle(t, p, now, ports.IdleDue{Generation: 1, After: 4 * time.Second})
120+ if got := actions(handle(t, p, now, ports.LockConfirmed{Generation: 1})); !slices.Equal(got, []ports.Action{ports.OutputOff, ports.Sleep}) {
121+ t.Fatal(got)
122+ }
123+ out := handle(t, p, now, ports.Activity{})
124+ if !p.protected || p.off || p.faded {
125+ t.Fatal(p.State())
126+ }
127+ if len(out) != 3 {
128+ t.Fatal(out)
129+ }
130+ if len(handle(t, p, now, ports.IdleDue{Generation: 1, After: time.Second})) != 0 {
131+ t.Fatal("old proxy event ran")
132+ }
133+ handle(t, p, now, ports.LockReleased{Generation: 99})
134+ if !p.protected {
135+ t.Fatal("stale unlock")
136+ }
137+ handle(t, p, now, ports.LockReleased{Generation: 1})
138+ if p.protected {
139+ t.Fatal("valid visual release ignored")
140+ }
141+}
142+
143+func TestReloadRebasesOnlyChangedPendingAndKeepsProtection(t *testing.T) {
144+ now := time.Unix(100, 0)
145+ cfg := ports.Defaults()
146+ p, _, _ := New(cfg, now)
147+ next := cfg
148+ next.FadeAfter = 8 * time.Minute
149+ out := handle(t, p, now.Add(time.Minute), ports.Reload{Config: next})
150+ arm := out[0].(ports.ArmIdle)
151+ want := []ports.IdleNotification{{ID: 1, After: 5 * time.Minute}, {ID: 3, After: 7 * time.Minute}, {ID: 5, After: 8 * time.Minute}, {ID: 4, After: 20 * time.Minute}}
152+ if !slices.Equal(arm.Notifications, want) {
153+ t.Fatalf("arm %+v", arm)
154+ }
155+ handle(t, p, now, ports.IdleDue{Generation: 1, Notification: 1, After: 5 * time.Minute})
156+ next.LockAfter = 0
157+ handle(t, p, now, ports.Reload{Config: next})
158+ if !p.acquiring {
159+ t.Fatal("reload cancelled acquisition")
160+ }
161+ handle(t, p, now, ports.LockConfirmed{Generation: 2})
162+ if !p.protected {
163+ t.Fatal("reload lost lock generation")
164+ }
165+ bad := next
166+ bad.SleepAfter = -1
167+ if _, err := p.Handle(now, ports.Reload{Config: bad}); err == nil {
168+ t.Fatal("bad config committed")
169+ }
170+ if p.cfg != next {
171+ t.Fatal("partial reload")
172+ }
173+ handle(t, p, now, ports.Activity{})
174+ if !p.protected {
175+ t.Fatal("activity unlock")
176+ }
177+}
178+
179+func TestBeforeSleepHonorsLockDisabled(t *testing.T) {
180+ now := time.Unix(100, 0)
181+ for _, enabled := range []bool{false, true} {
182+ cfg := ports.Defaults()
183+ if !enabled {
184+ cfg.LockAfter = 0
185+ }
186+ p, _, _ := New(cfg, now)
187+ out := handle(t, p, now, ports.BeforeSleep{})
188+ if !enabled {
189+ if _, ok := out[0].(ports.SleepReady); !ok {
190+ t.Fatal(out)
191+ }
192+ if p.acquiring {
193+ t.Fatal("forced disabled lock")
194+ }
195+ } else {
196+ if _, ok := out[0].(ports.AcquireLock); !ok {
197+ t.Fatal(out)
198+ }
199+ out = handle(t, p, now, ports.LockConfirmed{Generation: 1})
200+ if _, ok := out[len(out)-1].(ports.SleepReady); !ok {
201+ t.Fatal(out)
202+ }
203+ }
204+ }
205+}
206+
207+func TestReloadRetainsUnchangedProxyRejectsReplacedProxyAndDoneFlags(t *testing.T) {
208+ now := time.Unix(100, 0)
209+ cfg := ports.Defaults()
210+ p, initial, _ := New(cfg, now)
211+ old := initial[0].(ports.ArmIdle)
212+ fadeID := old.Notifications[1].ID
213+ next := cfg
214+ next.FadeAfter = 8 * time.Minute
215+ out := handle(t, p, now.Add(time.Minute), ports.Reload{Config: next})
216+ if len(handle(t, p, now, ports.IdleDue{Generation: 1, Notification: fadeID, After: 6 * time.Minute})) != 0 {
217+ t.Fatal("replaced proxy delivered")
218+ }
219+ var newFade ports.IdleNotification
220+ for _, n := range out[0].(ports.ArmIdle).Notifications {
221+ if n.After == 8*time.Minute {
222+ newFade = n
223+ }
224+ }
225+ if got := actions(handle(t, p, now, ports.IdleDue{Generation: 2, Notification: newFade.ID, After: newFade.After})); !slices.Equal(got, []ports.Action{ports.Fade}) {
226+ t.Fatal(got)
227+ }
228+ next.FadeAfter = time.Minute
229+ handle(t, p, now, ports.Reload{Config: next})
230+ if !p.deadlines[ports.Fade].done {
231+ t.Fatal("reload reset completed fade")
232+ }
233+ if len(handle(t, p, now, ports.IdleDue{Generation: 2, Notification: newFade.ID, After: newFade.After})) != 0 {
234+ t.Fatal("done fade replayed")
235+ }
236+}
237+
238+func TestCompletedFadeDisableReenableRetiresOldNotification(t *testing.T) {
239+ now := time.Unix(100, 0)
240+ cfg := config([4]time.Duration{0, time.Second, 0, 0})
241+ p, initial, _ := New(cfg, now)
242+ proxy := initial[0].(ports.ArmIdle).Notifications[0]
243+ handle(t, p, now, ports.IdleDue{Generation: 1, Notification: proxy.ID, After: proxy.After})
244+ cfg.FadeAfter = 0
245+ handle(t, p, now, ports.Reload{Config: cfg})
246+ d := p.deadlines[ports.Fade]
247+ if d.after != 0 || !d.done || d.due || d.notification != 0 {
248+ t.Fatalf("disable metadata %+v", d)
249+ }
250+ cfg.FadeAfter = 2 * time.Second
251+ out := handle(t, p, now, ports.Reload{Config: cfg})
252+ if len(out[0].(ports.ArmIdle).Notifications) != 0 {
253+ t.Fatal("completed action rearmed")
254+ }
255+ if len(handle(t, p, now, ports.IdleDue{Generation: 1, Notification: proxy.ID, After: proxy.After})) != 0 {
256+ t.Fatal("old event replayed")
257+ }
258+ handle(t, p, now, ports.Activity{})
259+ if p.deadlines[ports.Fade].after != 2*time.Second || p.deadlines[ports.Fade].done {
260+ t.Fatal("next cycle ignored latest enablement")
261+ }
262+}
263+
264+func TestEqualAfterReloadKeepsOriginalProxySeparateFromRebasedDeadline(t *testing.T) {
265+ now := time.Unix(100, 0)
266+ cfg := config([4]time.Duration{time.Minute, 2 * time.Minute, 0, 0})
267+ p, initial, _ := New(cfg, now)
268+ old := initial[0].(ports.ArmIdle).Notifications[0]
269+ cfg.FadeAfter = time.Minute
270+ arm := handle(t, p, now.Add(30*time.Second), ports.Reload{Config: cfg})[0].(ports.ArmIdle)
271+ if len(arm.Notifications) != 2 || arm.Notifications[0].ID == arm.Notifications[1].ID {
272+ t.Fatal("unrelated clock origins incorrectly coalesced")
273+ }
274+ // Equal timeout values do not imply equal due instants: fade was rearmed
275+ // at reload while lock's existing notification stayed registered.
276+ if got := actions(handle(t, p, now, ports.IdleDue{Generation: 1, Notification: old.ID, After: old.After})); !slices.Equal(got, []ports.Action{ports.Lock}) {
277+ t.Fatal(got)
278+ }
279+ if got := actions(handle(t, p, now, ports.IdleDue{Generation: 2, Notification: arm.Notifications[1].ID, After: time.Minute})); !slices.Equal(got, []ports.Action{ports.Fade}) {
280+ t.Fatal(got)
281+ }
282+}
283+
284+func TestLockRequestedAcquiresOnceAndNeverUnlocks(t *testing.T) {
285+ now := time.Unix(100, 0)
286+ p, _, _ := New(config([4]time.Duration{0, 0, time.Minute, 0}), now)
287+ out := handle(t, p, now, ports.LockRequested{})
288+ if len(out) != 1 || out[0] != (ports.AcquireLock{Generation: 1}) {
289+ t.Fatalf("lock request %v", out)
290+ }
291+ if got := handle(t, p, now, ports.LockRequested{}); len(got) != 0 {
292+ t.Fatalf("duplicate acquisition %v", got)
293+ }
294+ handle(t, p, now, ports.LockConfirmed{Generation: 1})
295+ if got := handle(t, p, now, ports.LockRequested{}); len(got) != 0 || !p.State().Protected {
296+ t.Fatalf("protected lock re-requested %v", got)
297+ }
298+ handle(t, p, now, ports.Activity{})
299+ if !p.State().Protected {
300+ t.Fatal("activity released the lock")
301+ }
302+}
A internal/mocks/ports/visual_mock.go +575 -0
@@ -0,0 +1,575 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package portsmocks
6+
7+import (
8+ "context"
9+
10+ "github.com/bnema/neferafk/internal/ports"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewMockVisual creates a new instance of MockVisual. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewMockVisual(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *MockVisual {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &MockVisual{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// MockVisual is an autogenerated mock type for the Visual type
38+type MockVisual struct {
39+ mock.Mock
40+}
41+
42+type MockVisual_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *MockVisual) EXPECT() *MockVisual_Expecter {
47+ return &MockVisual_Expecter{mock: &_m.Mock}
48+}
49+
50+// Close provides a mock function for the type MockVisual
51+func (_mock *MockVisual) Close() error {
52+ ret := _mock.Called()
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for Close")
56+ }
57+
58+ var r0 error
59+ if returnFunc, ok := ret.Get(0).(func() error); ok {
60+ r0 = returnFunc()
61+ } else {
62+ r0 = ret.Error(0)
63+ }
64+ return r0
65+}
66+
67+// MockVisual_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close'
68+type MockVisual_Close_Call struct {
69+ *mock.Call
70+}
71+
72+// Close is a helper method to define mock.On call
73+func (_e *MockVisual_Expecter) Close() *MockVisual_Close_Call {
74+ return &MockVisual_Close_Call{Call: _e.mock.On("Close")}
75+}
76+
77+func (_c *MockVisual_Close_Call) Run(run func()) *MockVisual_Close_Call {
78+ _c.Call.Run(func(args mock.Arguments) {
79+ run()
80+ })
81+ return _c
82+}
83+
84+func (_c *MockVisual_Close_Call) Return(err error) *MockVisual_Close_Call {
85+ _c.Call.Return(err)
86+ return _c
87+}
88+
89+func (_c *MockVisual_Close_Call) RunAndReturn(run func() error) *MockVisual_Close_Call {
90+ _c.Call.Return(run)
91+ return _c
92+}
93+
94+// Detach provides a mock function for the type MockVisual
95+func (_mock *MockVisual) Detach() error {
96+ ret := _mock.Called()
97+
98+ if len(ret) == 0 {
99+ panic("no return value specified for Detach")
100+ }
101+
102+ var r0 error
103+ if returnFunc, ok := ret.Get(0).(func() error); ok {
104+ r0 = returnFunc()
105+ } else {
106+ r0 = ret.Error(0)
107+ }
108+ return r0
109+}
110+
111+// MockVisual_Detach_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Detach'
112+type MockVisual_Detach_Call struct {
113+ *mock.Call
114+}
115+
116+// Detach is a helper method to define mock.On call
117+func (_e *MockVisual_Expecter) Detach() *MockVisual_Detach_Call {
118+ return &MockVisual_Detach_Call{Call: _e.mock.On("Detach")}
119+}
120+
121+func (_c *MockVisual_Detach_Call) Run(run func()) *MockVisual_Detach_Call {
122+ _c.Call.Run(func(args mock.Arguments) {
123+ run()
124+ })
125+ return _c
126+}
127+
128+func (_c *MockVisual_Detach_Call) Return(err error) *MockVisual_Detach_Call {
129+ _c.Call.Return(err)
130+ return _c
131+}
132+
133+func (_c *MockVisual_Detach_Call) RunAndReturn(run func() error) *MockVisual_Detach_Call {
134+ _c.Call.Return(run)
135+ return _c
136+}
137+
138+// Fade provides a mock function for the type MockVisual
139+func (_mock *MockVisual) Fade(ctx context.Context, req ports.VisualFade) error {
140+ ret := _mock.Called(ctx, req)
141+
142+ if len(ret) == 0 {
143+ panic("no return value specified for Fade")
144+ }
145+
146+ var r0 error
147+ if returnFunc, ok := ret.Get(0).(func(context.Context, ports.VisualFade) error); ok {
148+ r0 = returnFunc(ctx, req)
149+ } else {
150+ r0 = ret.Error(0)
151+ }
152+ return r0
153+}
154+
155+// MockVisual_Fade_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Fade'
156+type MockVisual_Fade_Call struct {
157+ *mock.Call
158+}
159+
160+// Fade is a helper method to define mock.On call
161+// - ctx context.Context
162+// - req ports.VisualFade
163+func (_e *MockVisual_Expecter) Fade(ctx any, req any) *MockVisual_Fade_Call {
164+ return &MockVisual_Fade_Call{Call: _e.mock.On("Fade", ctx, req)}
165+}
166+
167+func (_c *MockVisual_Fade_Call) Run(run func(ctx context.Context, req ports.VisualFade)) *MockVisual_Fade_Call {
168+ _c.Call.Run(func(args mock.Arguments) {
169+ var arg0 context.Context
170+ if args[0] != nil {
171+ arg0 = args[0].(context.Context)
172+ }
173+ var arg1 ports.VisualFade
174+ if args[1] != nil {
175+ arg1 = args[1].(ports.VisualFade)
176+ }
177+ run(
178+ arg0,
179+ arg1,
180+ )
181+ })
182+ return _c
183+}
184+
185+func (_c *MockVisual_Fade_Call) Return(err error) *MockVisual_Fade_Call {
186+ _c.Call.Return(err)
187+ return _c
188+}
189+
190+func (_c *MockVisual_Fade_Call) RunAndReturn(run func(ctx context.Context, req ports.VisualFade) error) *MockVisual_Fade_Call {
191+ _c.Call.Return(run)
192+ return _c
193+}
194+
195+// Lock provides a mock function for the type MockVisual
196+func (_mock *MockVisual) Lock(ctx context.Context, req ports.VisualLock) error {
197+ ret := _mock.Called(ctx, req)
198+
199+ if len(ret) == 0 {
200+ panic("no return value specified for Lock")
201+ }
202+
203+ var r0 error
204+ if returnFunc, ok := ret.Get(0).(func(context.Context, ports.VisualLock) error); ok {
205+ r0 = returnFunc(ctx, req)
206+ } else {
207+ r0 = ret.Error(0)
208+ }
209+ return r0
210+}
211+
212+// MockVisual_Lock_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Lock'
213+type MockVisual_Lock_Call struct {
214+ *mock.Call
215+}
216+
217+// Lock is a helper method to define mock.On call
218+// - ctx context.Context
219+// - req ports.VisualLock
220+func (_e *MockVisual_Expecter) Lock(ctx any, req any) *MockVisual_Lock_Call {
221+ return &MockVisual_Lock_Call{Call: _e.mock.On("Lock", ctx, req)}
222+}
223+
224+func (_c *MockVisual_Lock_Call) Run(run func(ctx context.Context, req ports.VisualLock)) *MockVisual_Lock_Call {
225+ _c.Call.Run(func(args mock.Arguments) {
226+ var arg0 context.Context
227+ if args[0] != nil {
228+ arg0 = args[0].(context.Context)
229+ }
230+ var arg1 ports.VisualLock
231+ if args[1] != nil {
232+ arg1 = args[1].(ports.VisualLock)
233+ }
234+ run(
235+ arg0,
236+ arg1,
237+ )
238+ })
239+ return _c
240+}
241+
242+func (_c *MockVisual_Lock_Call) Return(err error) *MockVisual_Lock_Call {
243+ _c.Call.Return(err)
244+ return _c
245+}
246+
247+func (_c *MockVisual_Lock_Call) RunAndReturn(run func(ctx context.Context, req ports.VisualLock) error) *MockVisual_Lock_Call {
248+ _c.Call.Return(run)
249+ return _c
250+}
251+
252+// NewMockVisualLauncher creates a new instance of MockVisualLauncher. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
253+// The first argument is typically a *testing.T value.
254+func NewMockVisualLauncher(t interface {
255+ mock.TestingT
256+ Cleanup(func())
257+}) *MockVisualLauncher {
258+ if helper, ok := t.(interface{ Helper() }); ok {
259+ helper.Helper()
260+ }
261+
262+ mock := &MockVisualLauncher{}
263+ mock.Mock.Test(t)
264+
265+ t.Cleanup(func() {
266+ if helper, ok := t.(interface{ Helper() }); ok {
267+ helper.Helper()
268+ }
269+ mock.AssertExpectations(t)
270+ })
271+
272+ return mock
273+}
274+
275+// MockVisualLauncher is an autogenerated mock type for the VisualLauncher type
276+type MockVisualLauncher struct {
277+ mock.Mock
278+}
279+
280+type MockVisualLauncher_Expecter struct {
281+ mock *mock.Mock
282+}
283+
284+func (_m *MockVisualLauncher) EXPECT() *MockVisualLauncher_Expecter {
285+ return &MockVisualLauncher_Expecter{mock: &_m.Mock}
286+}
287+
288+// Launch provides a mock function for the type MockVisualLauncher
289+func (_mock *MockVisualLauncher) Launch(ctx context.Context, spawn ports.VisualSpawn) (ports.VisualSession, error) {
290+ ret := _mock.Called(ctx, spawn)
291+
292+ if len(ret) == 0 {
293+ panic("no return value specified for Launch")
294+ }
295+
296+ var r0 ports.VisualSession
297+ var r1 error
298+ if returnFunc, ok := ret.Get(0).(func(context.Context, ports.VisualSpawn) (ports.VisualSession, error)); ok {
299+ return returnFunc(ctx, spawn)
300+ }
301+ if returnFunc, ok := ret.Get(0).(func(context.Context, ports.VisualSpawn) ports.VisualSession); ok {
302+ r0 = returnFunc(ctx, spawn)
303+ } else {
304+ if ret.Get(0) != nil {
305+ r0 = ret.Get(0).(ports.VisualSession)
306+ }
307+ }
308+ if returnFunc, ok := ret.Get(1).(func(context.Context, ports.VisualSpawn) error); ok {
309+ r1 = returnFunc(ctx, spawn)
310+ } else {
311+ r1 = ret.Error(1)
312+ }
313+ return r0, r1
314+}
315+
316+// MockVisualLauncher_Launch_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Launch'
317+type MockVisualLauncher_Launch_Call struct {
318+ *mock.Call
319+}
320+
321+// Launch is a helper method to define mock.On call
322+// - ctx context.Context
323+// - spawn ports.VisualSpawn
324+func (_e *MockVisualLauncher_Expecter) Launch(ctx any, spawn any) *MockVisualLauncher_Launch_Call {
325+ return &MockVisualLauncher_Launch_Call{Call: _e.mock.On("Launch", ctx, spawn)}
326+}
327+
328+func (_c *MockVisualLauncher_Launch_Call) Run(run func(ctx context.Context, spawn ports.VisualSpawn)) *MockVisualLauncher_Launch_Call {
329+ _c.Call.Run(func(args mock.Arguments) {
330+ var arg0 context.Context
331+ if args[0] != nil {
332+ arg0 = args[0].(context.Context)
333+ }
334+ var arg1 ports.VisualSpawn
335+ if args[1] != nil {
336+ arg1 = args[1].(ports.VisualSpawn)
337+ }
338+ run(
339+ arg0,
340+ arg1,
341+ )
342+ })
343+ return _c
344+}
345+
346+func (_c *MockVisualLauncher_Launch_Call) Return(visualSession ports.VisualSession, err error) *MockVisualLauncher_Launch_Call {
347+ _c.Call.Return(visualSession, err)
348+ return _c
349+}
350+
351+func (_c *MockVisualLauncher_Launch_Call) RunAndReturn(run func(ctx context.Context, spawn ports.VisualSpawn) (ports.VisualSession, error)) *MockVisualLauncher_Launch_Call {
352+ _c.Call.Return(run)
353+ return _c
354+}
355+
356+// NewMockVisualSession creates a new instance of MockVisualSession. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
357+// The first argument is typically a *testing.T value.
358+func NewMockVisualSession(t interface {
359+ mock.TestingT
360+ Cleanup(func())
361+}) *MockVisualSession {
362+ if helper, ok := t.(interface{ Helper() }); ok {
363+ helper.Helper()
364+ }
365+
366+ mock := &MockVisualSession{}
367+ mock.Mock.Test(t)
368+
369+ t.Cleanup(func() {
370+ if helper, ok := t.(interface{ Helper() }); ok {
371+ helper.Helper()
372+ }
373+ mock.AssertExpectations(t)
374+ })
375+
376+ return mock
377+}
378+
379+// MockVisualSession is an autogenerated mock type for the VisualSession type
380+type MockVisualSession struct {
381+ mock.Mock
382+}
383+
384+type MockVisualSession_Expecter struct {
385+ mock *mock.Mock
386+}
387+
388+func (_m *MockVisualSession) EXPECT() *MockVisualSession_Expecter {
389+ return &MockVisualSession_Expecter{mock: &_m.Mock}
390+}
391+
392+// Close provides a mock function for the type MockVisualSession
393+func (_mock *MockVisualSession) Close() error {
394+ ret := _mock.Called()
395+
396+ if len(ret) == 0 {
397+ panic("no return value specified for Close")
398+ }
399+
400+ var r0 error
401+ if returnFunc, ok := ret.Get(0).(func() error); ok {
402+ r0 = returnFunc()
403+ } else {
404+ r0 = ret.Error(0)
405+ }
406+ return r0
407+}
408+
409+// MockVisualSession_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close'
410+type MockVisualSession_Close_Call struct {
411+ *mock.Call
412+}
413+
414+// Close is a helper method to define mock.On call
415+func (_e *MockVisualSession_Expecter) Close() *MockVisualSession_Close_Call {
416+ return &MockVisualSession_Close_Call{Call: _e.mock.On("Close")}
417+}
418+
419+func (_c *MockVisualSession_Close_Call) Run(run func()) *MockVisualSession_Close_Call {
420+ _c.Call.Run(func(args mock.Arguments) {
421+ run()
422+ })
423+ return _c
424+}
425+
426+func (_c *MockVisualSession_Close_Call) Return(err error) *MockVisualSession_Close_Call {
427+ _c.Call.Return(err)
428+ return _c
429+}
430+
431+func (_c *MockVisualSession_Close_Call) RunAndReturn(run func() error) *MockVisualSession_Close_Call {
432+ _c.Call.Return(run)
433+ return _c
434+}
435+
436+// Detach provides a mock function for the type MockVisualSession
437+func (_mock *MockVisualSession) Detach() error {
438+ ret := _mock.Called()
439+
440+ if len(ret) == 0 {
441+ panic("no return value specified for Detach")
442+ }
443+
444+ var r0 error
445+ if returnFunc, ok := ret.Get(0).(func() error); ok {
446+ r0 = returnFunc()
447+ } else {
448+ r0 = ret.Error(0)
449+ }
450+ return r0
451+}
452+
453+// MockVisualSession_Detach_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Detach'
454+type MockVisualSession_Detach_Call struct {
455+ *mock.Call
456+}
457+
458+// Detach is a helper method to define mock.On call
459+func (_e *MockVisualSession_Expecter) Detach() *MockVisualSession_Detach_Call {
460+ return &MockVisualSession_Detach_Call{Call: _e.mock.On("Detach")}
461+}
462+
463+func (_c *MockVisualSession_Detach_Call) Run(run func()) *MockVisualSession_Detach_Call {
464+ _c.Call.Run(func(args mock.Arguments) {
465+ run()
466+ })
467+ return _c
468+}
469+
470+func (_c *MockVisualSession_Detach_Call) Return(err error) *MockVisualSession_Detach_Call {
471+ _c.Call.Return(err)
472+ return _c
473+}
474+
475+func (_c *MockVisualSession_Detach_Call) RunAndReturn(run func() error) *MockVisualSession_Detach_Call {
476+ _c.Call.Return(run)
477+ return _c
478+}
479+
480+// Events provides a mock function for the type MockVisualSession
481+func (_mock *MockVisualSession) Events() <-chan ports.VisualEvent {
482+ ret := _mock.Called()
483+
484+ if len(ret) == 0 {
485+ panic("no return value specified for Events")
486+ }
487+
488+ var r0 <-chan ports.VisualEvent
489+ if returnFunc, ok := ret.Get(0).(func() <-chan ports.VisualEvent); ok {
490+ r0 = returnFunc()
491+ } else {
492+ if ret.Get(0) != nil {
493+ r0 = ret.Get(0).(<-chan ports.VisualEvent)
494+ }
495+ }
496+ return r0
497+}
498+
499+// MockVisualSession_Events_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Events'
500+type MockVisualSession_Events_Call struct {
501+ *mock.Call
502+}
503+
504+// Events is a helper method to define mock.On call
505+func (_e *MockVisualSession_Expecter) Events() *MockVisualSession_Events_Call {
506+ return &MockVisualSession_Events_Call{Call: _e.mock.On("Events")}
507+}
508+
509+func (_c *MockVisualSession_Events_Call) Run(run func()) *MockVisualSession_Events_Call {
510+ _c.Call.Run(func(args mock.Arguments) {
511+ run()
512+ })
513+ return _c
514+}
515+
516+func (_c *MockVisualSession_Events_Call) Return(visualEventCh <-chan ports.VisualEvent) *MockVisualSession_Events_Call {
517+ _c.Call.Return(visualEventCh)
518+ return _c
519+}
520+
521+func (_c *MockVisualSession_Events_Call) RunAndReturn(run func() <-chan ports.VisualEvent) *MockVisualSession_Events_Call {
522+ _c.Call.Return(run)
523+ return _c
524+}
525+
526+// Send provides a mock function for the type MockVisualSession
527+func (_mock *MockVisualSession) Send(cmd ports.VisualCommand) error {
528+ ret := _mock.Called(cmd)
529+
530+ if len(ret) == 0 {
531+ panic("no return value specified for Send")
532+ }
533+
534+ var r0 error
535+ if returnFunc, ok := ret.Get(0).(func(ports.VisualCommand) error); ok {
536+ r0 = returnFunc(cmd)
537+ } else {
538+ r0 = ret.Error(0)
539+ }
540+ return r0
541+}
542+
543+// MockVisualSession_Send_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Send'
544+type MockVisualSession_Send_Call struct {
545+ *mock.Call
546+}
547+
548+// Send is a helper method to define mock.On call
549+// - cmd ports.VisualCommand
550+func (_e *MockVisualSession_Expecter) Send(cmd any) *MockVisualSession_Send_Call {
551+ return &MockVisualSession_Send_Call{Call: _e.mock.On("Send", cmd)}
552+}
553+
554+func (_c *MockVisualSession_Send_Call) Run(run func(cmd ports.VisualCommand)) *MockVisualSession_Send_Call {
555+ _c.Call.Run(func(args mock.Arguments) {
556+ var arg0 ports.VisualCommand
557+ if args[0] != nil {
558+ arg0 = args[0].(ports.VisualCommand)
559+ }
560+ run(
561+ arg0,
562+ )
563+ })
564+ return _c
565+}
566+
567+func (_c *MockVisualSession_Send_Call) Return(err error) *MockVisualSession_Send_Call {
568+ _c.Call.Return(err)
569+ return _c
570+}
571+
572+func (_c *MockVisualSession_Send_Call) RunAndReturn(run func(cmd ports.VisualCommand) error) *MockVisualSession_Send_Call {
573+ _c.Call.Return(run)
574+ return _c
575+}
A internal/ports/auth.go +62 -0
@@ -0,0 +1,62 @@
1+package ports
2+
3+// Auth messages are private visual↔worker traffic, never policy-daemon events.
4+const (
5+ AuthMaxSecret = 512
6+ AuthMaxMetadata = 4096
7+)
8+
9+type AuthKind uint8
10+
11+const (
12+ AuthReady AuthKind = iota + 1
13+ AuthAttempt
14+ AuthPrompt
15+ AuthSecret
16+ AuthStatus
17+ AuthResult
18+ AuthCancel
19+)
20+
21+type AuthCode uint8
22+
23+const (
24+ AuthPassword AuthCode = iota + 1
25+ AuthPIN
26+ AuthUnavailable
27+ AuthDenied
28+ AuthSuccess
29+ AuthInvalid
30+ // AuthFallbackPassword means a selected PIN source was unavailable or
31+ // invalid and the visual must ask for an account password instead.
32+ AuthFallbackPassword
33+)
34+
35+type AuthFallback uint8
36+
37+const AuthFallbackToPassword AuthFallback = 1
38+
39+// Payload ownership transfers to the recipient, which must clear secrets.
40+// Every frame is scoped to a nonzero lock epoch. Ready alone uses attempt 0.
41+// Ready has no payload except AuthUnavailable may carry exactly one byte
42+// AuthFallbackToPassword when fallback was required but PAM is unavailable.
43+// Attempt/Secret have code 0 and bounded secret bytes. All other kinds have
44+// no payload; Prompt/Status use AuthPassword, Result uses success/denied/
45+// unavailable/invalid, Cancel uses AuthInvalid. Non-Ready attempts are nonzero.
46+type AuthFrame struct {
47+ Kind AuthKind
48+ Code AuthCode
49+ Generation uint64
50+ Attempt uint64
51+ Payload []byte
52+}
53+
54+// AuthBootstrap is delivered on a private anonymous pipe once per lock.
55+// EnvPIN is the privately captured startup environment value, never a lookup
56+// of the authentication worker's environment. Run consumes and wipes it.
57+type AuthBootstrap struct {
58+ Generation uint64
59+ PINSource PINSource
60+ PINReference string
61+ EnvPIN []byte
62+}
A internal/ports/config.go +106 -0
@@ -0,0 +1,106 @@
1+package ports
2+
3+import (
4+ "fmt"
5+ "strings"
6+ "time"
7+ "unicode"
8+ "unicode/utf8"
9+)
10+
11+const MaxDuration = 24 * time.Hour
12+
13+// Zero After disables only that action. No deadline implies another action.
14+type Config struct {
15+ LockAfter, FadeAfter, OffAfter, SleepAfter time.Duration
16+ FadeDuration time.Duration
17+ Auth AuthConfig
18+}
19+type AuthMode string
20+
21+const (
22+ AuthModePassword AuthMode = "password"
23+ AuthModePIN AuthMode = "pin"
24+)
25+
26+type PINSource string
27+
28+const (
29+ PINSourceEnv PINSource = "env"
30+ PINSourcePass PINSource = "pass"
31+)
32+
33+// Only metadata: values resolved by the visual/auth worker never reach policy.
34+// PIN mode always falls back to PAM when its selected source is unavailable or
35+// invalid. A wrong PIN is authentication failure, never a fallback trigger.
36+type AuthConfig struct {
37+ Mode AuthMode
38+ PINSource PINSource
39+ PINEnv, PINEntry string
40+}
41+
42+func Defaults() Config {
43+ return Config{LockAfter: 5 * time.Minute, FadeAfter: 6 * time.Minute, FadeDuration: 2 * time.Second, OffAfter: 7 * time.Minute, SleepAfter: 20 * time.Minute, Auth: AuthConfig{Mode: AuthModePassword}}
44+}
45+func (c Config) Validate() error {
46+ for _, d := range []time.Duration{c.LockAfter, c.FadeAfter, c.OffAfter, c.SleepAfter, c.FadeDuration} {
47+ if d < 0 || d > MaxDuration {
48+ return fmt.Errorf("duration outside 0..24h")
49+ }
50+ }
51+ a := c.Auth
52+ for _, r := range []string{string(a.Mode), string(a.PINSource), a.PINEnv, a.PINEntry} {
53+ if !utf8.ValidString(r) {
54+ return fmt.Errorf("auth metadata is not valid UTF-8")
55+ }
56+ }
57+ if a.Mode != AuthModePassword && a.Mode != AuthModePIN {
58+ return fmt.Errorf("unknown auth mode")
59+ }
60+ for _, r := range []string{a.PINEnv, a.PINEntry} {
61+ if len(r) > 4096 {
62+ return fmt.Errorf("auth metadata too large")
63+ }
64+ for _, c := range r {
65+ if unicode.IsControl(c) {
66+ return fmt.Errorf("control character in auth metadata")
67+ }
68+ }
69+ }
70+ if a.PINEnv != "" {
71+ for i, r := range a.PINEnv {
72+ if !(r == '_' || r >= 'A' && r <= 'Z' || r >= 'a' && r <= 'z' || i > 0 && r >= '0' && r <= '9') {
73+ return fmt.Errorf("invalid environment reference")
74+ }
75+ }
76+ }
77+ if a.PINEntry != "" {
78+ if strings.HasPrefix(a.PINEntry, "-") || strings.HasPrefix(a.PINEntry, "/") || strings.Contains(a.PINEntry, "\\") {
79+ return fmt.Errorf("invalid pass entry")
80+ }
81+ for _, part := range strings.Split(a.PINEntry, "/") {
82+ if part == "" || part == "." || part == ".." {
83+ return fmt.Errorf("invalid pass entry")
84+ }
85+ }
86+ }
87+ switch a.PINSource {
88+ case "":
89+ if a.Mode == AuthModePIN || a.PINEnv != "" || a.PINEntry != "" {
90+ return fmt.Errorf("PIN metadata requires source")
91+ }
92+ case PINSourceEnv:
93+ if a.PINEnv == "" || a.PINEntry != "" {
94+ return fmt.Errorf("env source requires only pin-env")
95+ }
96+ case PINSourcePass:
97+ if a.PINEntry == "" || a.PINEnv != "" {
98+ return fmt.Errorf("pass source requires only pin-entry")
99+ }
100+ default:
101+ return fmt.Errorf("unknown PIN source")
102+ }
103+ return nil
104+}
105+
106+type ConfigChanged struct{ Config Config }
A internal/ports/control.go +25 -0
@@ -0,0 +1,25 @@
1+package ports
2+
3+// Private local control (unix socket). Only Lock and Status exist: there is no
4+// unlock request and none may be added; unlock is authentication-only.
5+type ControlKind uint8
6+
7+const (
8+ ControlLock ControlKind = iota + 1
9+ ControlStatus
10+)
11+
12+// ControlRequest is delivered to the policy owner, which answers exactly once
13+// on Reply (buffered, capacity >= 1).
14+type ControlRequest struct {
15+ Kind ControlKind
16+ Reply chan<- ControlReply
17+}
18+
19+// ControlReply is a state snapshot after the request was applied. Lock returns
20+// as soon as acquisition was requested, not when it was confirmed.
21+type ControlReply struct {
22+ Generation Generation
23+ Acquiring, Protected, Faded, Off bool
24+ Err string
25+}
A internal/ports/policy.go +78 -0
@@ -0,0 +1,78 @@
1+package ports
2+
3+import "time"
4+
5+type Generation uint64
6+type Action uint8
7+
8+const (
9+ Lock Action = iota
10+ Fade
11+ OutputOff
12+ Sleep
13+)
14+
15+// Input contains no credentials. LockReleased is an authoritative visual-owner
16+// lifecycle result, never an activity/reload request to unlock.
17+type PolicyEvent interface{ policyEvent() }
18+type IdleDue struct {
19+ Generation Generation
20+ // Notification identifies an existing proxy across unrelated reloads.
21+ Notification uint64
22+ After time.Duration
23+}
24+type Activity struct{}
25+type Reload struct{ Config Config }
26+type LockConfirmed struct{ Generation Generation }
27+type LockReleased struct{ Generation Generation }
28+type BeforeSleep struct{}
29+
30+func (IdleDue) policyEvent() {}
31+func (Activity) policyEvent() {}
32+func (Reload) policyEvent() {}
33+func (LockConfirmed) policyEvent() {}
34+func (LockReleased) policyEvent() {}
35+func (BeforeSleep) policyEvent() {}
36+
37+// One notification per distinct timeout and registration origin; After is
38+// relative to registration. Equal timeout values after a reload may have
39+// different IDs: retaining an old origin must not rebase unchanged deadlines.
40+// Actions sharing one notification dispatch in Lock/Fade/OutputOff/Sleep order.
41+// Replacement destroys old proxies. Generation fences their queued events.
42+type IdleNotification struct {
43+ // ID is stable while this pending deadline is unchanged by reload.
44+ // Adapters retain that proxy (and its old admitting Generation), avoiding
45+ // recreation that would invent an exact current idle age.
46+ ID uint64
47+ After time.Duration
48+}
49+type PolicyEffect interface{ policyEffect() }
50+type ArmIdle struct {
51+ Generation Generation
52+ Notifications []IdleNotification
53+}
54+type AcquireLock struct{ Generation Generation }
55+type SetFade struct {
56+ Black bool
57+ Duration time.Duration
58+}
59+type SetOutputPower struct{ On bool }
60+type Suspend struct{}
61+
62+// SleepReady lets the system adapter release its delay inhibitor. No sleep is
63+// induced by this response; it acknowledges a BeforeSleep already in progress.
64+type SleepReady struct{}
65+
66+func (ArmIdle) policyEffect() {}
67+func (AcquireLock) policyEffect() {}
68+func (SetFade) policyEffect() {}
69+func (SetOutputPower) policyEffect() {}
70+func (Suspend) policyEffect() {}
71+func (SleepReady) policyEffect() {}
72+
73+// LockRequested asks policy to acquire the session lock now (logind Lock
74+// signal or the private control socket). It is idempotent while a lock is
75+// being acquired or held and never orders/forces any other action.
76+type LockRequested struct{}
77+
78+func (LockRequested) policyEvent() {}
A internal/ports/system.go +48 -0
@@ -0,0 +1,48 @@
1+package ports
2+
3+import "time"
4+
5+// System events are observations, never authentication/unlock authority.
6+type SystemEvent interface{ systemEvent() }
7+type SystemCapabilities struct {
8+ Session bool
9+ Suspend bool
10+ SuspendAuthorization string // yes, challenge, no, na
11+ DelayMax time.Duration
12+ Missing []string
13+}
14+type SystemCapabilityReport struct{ Capabilities SystemCapabilities }
15+type SystemLockRequested struct{}
16+
17+// SystemUnlockObserved must NEVER become LockReleased for an acquired locker.
18+type SystemUnlockObserved struct{}
19+
20+// MaxDelay is logind's configured maximum, NOT remaining time or a promise of
21+// an arbitrary grace period. ReceivedAt is local receipt, not sleep start.
22+type SleepPreparation struct {
23+ Cycle uint64
24+ Preparing bool
25+ ReceivedAt time.Time
26+ MaxDelay time.Duration
27+}
28+type SystemFailure struct {
29+ Operation string
30+ Err error
31+}
32+
33+func (SystemCapabilityReport) systemEvent() {}
34+func (SystemLockRequested) systemEvent() {}
35+func (SystemUnlockObserved) systemEvent() {}
36+func (SleepPreparation) systemEvent() {}
37+func (SystemFailure) systemEvent() {}
38+
39+type SystemCommand interface{ systemCommand() }
40+
41+// SystemSleepReady releases only the inhibitor for this preparation cycle.
42+type SystemSleepReady struct{ Cycle uint64 }
43+type SystemSuspend struct{}
44+
45+func (SystemSleepReady) systemCommand() {}
46+func (SystemSuspend) systemCommand() {}
47+
48+type SystemRequirements struct{ Session, Sleep bool }
A internal/ports/visual.go +104 -0
@@ -0,0 +1,104 @@
1+package ports
2+
3+import (
4+ "context"
5+ "time"
6+)
7+
8+// Visual is the policy daemon's only handle on the separate visual process
9+// (fade animation and ext-session-lock surfaces). The daemon never initialises
10+// Vulkan or fonts; an implementation spawns the visual lazily on first use.
11+//
12+// There is deliberately NO unlock method: only a successful authentication in
13+// the visual+auth-worker pair releases the lock, and the visual reports that
14+// as a LockReleased event. Activity, reload, logind Unlock and control-socket
15+// requests can never reach an unlock path through this port.
16+//
17+// Methods are called from the single policy owner goroutine only. Results and
18+// child lifecycle come back asynchronously as VisualEvent values on the events
19+// channel the implementation was constructed with (channel port; tested by
20+// sending messages).
21+type Visual interface {
22+ // Fade starts (Black) or reverses (!Black) the fade. A reveal with no
23+ // running visual is a no-op and never spawns a process. Idempotent. A
24+ // reveal never drops lock surfaces: it only undoes the fade animation.
25+ Fade(ctx context.Context, req VisualFade) error
26+ // Lock makes the visual acquire ext-session-lock for req.Generation,
27+ // spawning it if needed. Idempotent for a live visual already asked for
28+ // the same generation, and safe to call again after VisualExited to
29+ // re-spawn: a crashed lock visual leaves the compositor locked and the
30+ // respawn re-acquires. req.Auth.EnvPIN ownership transfers to the
31+ // implementation, which wipes it once written to the private pipe.
32+ Lock(ctx context.Context, req VisualLock) error
33+ // Close kills and reaps any visual process. Killing a lock visual leaves
34+ // the compositor locked; it never unlocks.
35+ Close() error
36+ // Detach closes only the daemon's pipe ends and leaves the child running.
37+ // The daemon uses it instead of Close when exiting while a lock is live:
38+ // the visual treats command-pipe EOF as "daemon gone", keeps its lock and
39+ // authentication UI, and exits only after a successful unlock.
40+ Detach() error
41+}
42+
43+// VisualCommand is daemon -> visual traffic on the private pipe.
44+type VisualCommand interface{ visualCommand() }
45+
46+// VisualFade: Duration is ports.Config.FadeDuration.
47+type VisualFade struct {
48+ Black bool
49+ Duration time.Duration
50+ Spawn VisualSpawn
51+}
52+
53+// VisualSpawn applies only when the request has to launch a process. OmitEnv
54+// names environment variables the child must not inherit (the configured PIN
55+// variable: its value travels solely in VisualLock.Auth.EnvPIN).
56+type VisualSpawn struct{ OmitEnv []string }
57+
58+// VisualLock carries auth metadata only (config + the privately captured
59+// startup PIN value when the env source is selected). Auth.Generation equals
60+// Generation and is the nonzero epoch scoping every auth frame. The visual,
61+// not the daemon, spawns the auth worker and forwards Auth to it.
62+type VisualLock struct {
63+ Generation Generation
64+ Auth AuthBootstrap
65+ Spawn VisualSpawn
66+}
67+
68+func (VisualFade) visualCommand() {}
69+func (VisualLock) visualCommand() {}
70+
71+// VisualEvent is visual -> daemon traffic (lifecycle results, never secrets).
72+// LockConfirmed and LockReleased are the authoritative lock lifecycle results
73+// and double as PolicyEvents; VisualExited is the final event of a process.
74+type VisualEvent interface{ visualEvent() }
75+
76+// VisualExited reports that the visual process is gone (crash, kill or normal
77+// exit after unlock/reveal). Err is nil for a clean zero exit. It is never an
78+// unlock: a lock without a prior LockReleased is still held by the compositor.
79+type VisualExited struct{ Err error }
80+
81+func (LockConfirmed) visualEvent() {}
82+func (LockReleased) visualEvent() {}
83+func (VisualExited) visualEvent() {}
84+
85+// VisualLauncher is the os/exec adapter seam: it starts one visual process
86+// with a private anonymous pipe pair (child fd 3 = commands in, fd 4 = events
87+// out) and returns its session, honouring spawn.OmitEnv.
88+type VisualLauncher interface {
89+ Launch(ctx context.Context, spawn VisualSpawn) (VisualSession, error)
90+}
91+
92+// VisualSession is one running visual process. Send is called from the owner
93+// goroutine while Events is read from another goroutine.
94+type VisualSession interface {
95+ // Send writes a command; it fails once the process is gone.
96+ Send(cmd VisualCommand) error
97+ // Events yields child events; the final one is VisualExited, then the
98+ // channel closes.
99+ Events() <-chan VisualEvent
100+ // Close kills (SIGKILL) and reaps the process. Idempotent.
101+ Close() error
102+ // Detach closes the daemon-side pipe ends and stops observing the child.
103+ Detach() error
104+}
A internal/ports/wayland.go +54 -0
@@ -0,0 +1,54 @@
1+package ports
2+
3+import "time"
4+
5+// Wayland commands contain no credentials or lock ownership. The visual owns
6+// ext-session-lock; this connection observes capabilities, idle and power only.
7+type WaylandCommand interface{ waylandCommand() }
8+type SetDeadlines struct {
9+ Generation uint64
10+ Deadlines []time.Duration
11+}
12+type OutputPower struct{ On bool }
13+
14+func (SetDeadlines) waylandCommand() {}
15+func (OutputPower) waylandCommand() {}
16+
17+type WaylandEvent interface{ waylandEvent() }
18+
19+// Proxy and Cycle identify one notification lifetime and its idle/resume cycle.
20+// After is a distinct deadline, not an inferred policy action. Core maps it.
21+type WaylandIdle struct {
22+ Generation, Proxy, Cycle uint64
23+ Seat uint32
24+ After time.Duration
25+}
26+type WaylandActivity struct {
27+ Proxy, Cycle uint64
28+ Seat uint32
29+}
30+type WaylandCapabilities struct{ Capabilities Capabilities }
31+type WaylandPowerFailed struct{ Output uint64 }
32+
33+func (WaylandIdle) waylandEvent() {}
34+func (WaylandActivity) waylandEvent() {}
35+func (WaylandCapabilities) waylandEvent() {}
36+func (WaylandPowerFailed) waylandEvent() {}
37+
38+type WaylandOutput struct {
39+ Lifetime uint64
40+ Global uint32
41+ Name string
42+ PowerSupported bool
43+}
44+type Capabilities struct {
45+ Globals map[string]uint32
46+ Seats []uint32
47+ Outputs []WaylandOutput
48+ Missing []string
49+}
50+
51+// Requirements describe enabled features, not a particular compositor brand.
52+type WaylandRequirements struct {
53+ Idle, InputWake, OutputPower, Lock, Visual bool
54+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 9

↑↓ moveenter openesc close