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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 := ¬ification{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 | + | } | |