feat(session-lock): add ext-session-lock with DRM output protection (#73)
* feat(session-lock): add ext-session-lock with DRM output protection * chore(deps): bump wlturbo to v0.5.0 * fix(session-lock): skip unlocked capture control on devices without sync-file export Lavapipe exports no sync files, so the Vulkan renderer refuses every protocol capture by design. The end-to-end test's positive control therefore failed in CI. Add Renderer.CaptureSupported and run the control only where the device can capture; every protected-capture refusal assertion still runs.
- commit
- 39f7a284d1a81ea0a1a7e3febe6fcd042a9f954a
- author
- bnema · · 2026-09-30 20:15 UTC
- committer
- GitHub · · 2026-09-30 20:15 UTC
- parent
- 2b17ac2
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
164 files · +14130 -942
M .hexcheck.yaml +10 -2
| @@ -2,7 +2,8 @@ version: 1 | |||||
| 2 | components: | 2 | components: | ||
| 3 | core: { role: core, paths: [internal/core/**] } | 3 | core: { role: core, paths: [internal/core/**] } | ||
| 4 | ports: { role: ports, paths: [internal/ports/**] } | 4 | ports: { role: ports, paths: [internal/ports/**] } | ||
| 5 | - | adapters: { role: adapter, paths: [internal/adapters/**, internal/logging/**] } | 5 | + | # sessionlock is the display owner's proof ledger, not core placement policy. |
| 6 | + | adapters: { role: adapter, paths: [internal/adapters/**, internal/logging/**, internal/sessionlock/**] } | |||
| 6 | # app is the composition root: it wires adapters to core, like cmd. | 7 | # app is the composition root: it wires adapters to core, like cmd. | ||
| 7 | entrypoints: { role: entrypoint, paths: [cmd/**, internal/app/**] } | 8 | entrypoints: { role: entrypoint, paths: [cmd/**, internal/app/**] } | ||
| 8 | generated: { role: ignore, paths: [internal/mocks/**] } | 9 | generated: { role: ignore, paths: [internal/mocks/**] } | ||
| @@ -15,10 +16,17 @@ rules: | |||||
| 15 | mocking: | 16 | mocking: | ||
| 16 | generatedMockPaths: [internal/mocks/**] | 17 | generatedMockPaths: [internal/mocks/**] | ||
| 17 | generatedMockNamePatterns: ['Mock{{Interface}}', '{{Interface}}Mock'] | 18 | generatedMockNamePatterns: ['Mock{{Interface}}', '{{Interface}}Mock'] | ||
| 19 | + | allow: | |||
| 20 | + | - rule: suspicious-business-logic-in-adapter | |||
| 21 | + | path: internal/adapters/wayland/seat.go | |||
| 22 | + | reason: Seat dispatch resolves live Wayland targets and enforces protected input delivery at the display owner. | |||
| 23 | + | - rule: suspicious-business-logic-in-adapter | |||
| 24 | + | path: internal/adapters/wayland/server.go | |||
| 25 | + | reason: Display command dispatch validates immutable security epochs before converting port messages to wire events. | |||
| 18 | ruleSettings: | 26 | ruleSettings: | ||
| 19 | missing-generated-mock-for-port: | 27 | missing-generated-mock-for-port: | ||
| 20 | # Sealed message unions are channel data, not ports; tested by sending messages (ADR 005). | 28 | # Sealed message unions are channel data, not ports; tested by sending messages (ADR 005). | ||
| 21 | - | excludePaths: [internal/ports/messages.go] | 29 | + | excludePaths: [internal/ports/messages.go, internal/ports/session_lock_messages.go, internal/ports/session_backend.go] |
| 22 | suspicious-business-logic-in-adapter: | 30 | suspicious-business-logic-in-adapter: | ||
| 23 | # app is the composition root; starting core and wiring channels is its job. | 31 | # app is the composition root; starting core and wiring channels is its job. | ||
| 24 | excludePaths: [internal/app/app.go] | 32 | excludePaths: [internal/app/app.go] | ||
M .mockery.yml +14 -0
| @@ -19,6 +19,8 @@ packages: | |||||
| 19 | OutputScaleStore: | 19 | OutputScaleStore: | ||
| 20 | Renderer: | 20 | Renderer: | ||
| 21 | Seat: | 21 | Seat: | ||
| 22 | + | SessionSecurity: | |||
| 23 | + | SessionSecurityController: | |||
| 22 | Ticker: | 24 | Ticker: | ||
| 23 | Timer: | 25 | Timer: | ||
| 24 | # Unexported adapter seams: mocks live next to the code, in test files. | 26 | # Unexported adapter seams: mocks live next to the code, in test files. | ||
| @@ -43,11 +45,23 @@ packages: | |||||
| 43 | dir: internal/adapters/wayland | 45 | dir: internal/adapters/wayland | ||
| 44 | pkgname: wayland | 46 | pkgname: wayland | ||
| 45 | filename: proc_env_mock_test.go | 47 | filename: proc_env_mock_test.go | ||
| 48 | + | acquirePoller: | |||
| 49 | + | config: | |||
| 50 | + | dir: internal/adapters/wayland | |||
| 51 | + | pkgname: wayland | |||
| 52 | + | filename: acquire_poller_mock_test.go | |||
| 46 | syncobjDevice: | 53 | syncobjDevice: | ||
| 47 | config: | 54 | config: | ||
| 48 | dir: internal/adapters/wayland | 55 | dir: internal/adapters/wayland | ||
| 49 | pkgname: wayland | 56 | pkgname: wayland | ||
| 50 | filename: syncobj_device_mock_test.go | 57 | filename: syncobj_device_mock_test.go | ||
| 58 | + | github.com/bnema/neferwl/internal/app: | |||
| 59 | + | interfaces: | |||
| 60 | + | securityLeaseCard: | |||
| 61 | + | config: | |||
| 62 | + | dir: internal/app | |||
| 63 | + | pkgname: app | |||
| 64 | + | filename: securitylease_mock_test.go | |||
| 51 | github.com/bnema/neferwl/internal/adapters/statefile: | 65 | github.com/bnema/neferwl/internal/adapters/statefile: | ||
| 52 | interfaces: | 66 | interfaces: | ||
| 53 | procTree: | 67 | procTree: | ||
M README.md +1 -0
| @@ -110,6 +110,7 @@ The file has one `key = value` per line, and `#` starts a comment. A missing fil | |||||
| 110 | 110 | ||||
| 111 | - [Configuration](docs/config.md): every key, action and default bind, and HDR. | 111 | - [Configuration](docs/config.md): every key, action and default bind, and HDR. | ||
| 112 | - [Desktop integration](docs/desktop.md): running commands, clipboard, input methods, X11 apps, idle and screen off, bars, and the state file for scripts. | 112 | - [Desktop integration](docs/desktop.md): running commands, clipboard, input methods, X11 apps, idle and screen off, bars, and the state file for scripts. | ||
| 113 | + | - [Session locking](docs/session-lock.md): external lockers, output confirmation, owner recovery and security limits. | |||
| 113 | - [Headless mode](docs/headless.md): run without a screen, take screenshots, play input scripts and test input methods. | 114 | - [Headless mode](docs/headless.md): run without a screen, take screenshots, play input scripts and test input methods. | ||
| 114 | - [Performance](docs/performance.md): how to profile, the allocation guards, and reference numbers. | 115 | - [Performance](docs/performance.md): how to profile, the allocation guards, and reference numbers. | ||
| 115 | 116 | ||||
M docs/desktop.md +5 -1
| @@ -68,6 +68,10 @@ xwayland = off # no X11 display | |||||
| 68 | 68 | ||||
| 69 | Do not start xwayland-satellite yourself with `startup`. Changing `xwayland` takes effect at the next start. | 69 | Do not start xwayland-satellite yourself with `startup`. Changing `xwayland` takes effect at the next start. | ||
| 70 | 70 | ||||
| 71 | + | ## Session locking | |||
| 72 | + | ||||
| 73 | + | External lockers use `ext-session-lock-v1`. NeferWL isolates desktop input and captures, covers every output, and stays protected if the locker dies. Authentication belongs to the locker; screen-off and a visual cover do not replace locking. See [Session locking](session-lock.md) for confirmation, recovery and security limits. | |||
| 74 | + | ||||
| 71 | ## Idle and screen off | 75 | ## Idle and screen off | ||
| 72 | 76 | ||||
| 73 | NeferWL supports `ext_idle_notifier_v1` and `zwlr_output_power_management_v1`, so [swayidle](https://github.com/swaywm/swayidle) and [wlopm](https://git.sr.ht/~leon_plickat/wlopm) turn the screens off after a delay. A window that inhibits idle, such as a video player or a game, keeps them on. | 77 | NeferWL supports `ext_idle_notifier_v1` and `zwlr_output_power_management_v1`, so [swayidle](https://github.com/swaywm/swayidle) and [wlopm](https://git.sr.ht/~leon_plickat/wlopm) turn the screens off after a delay. A window that inhibits idle, such as a video player or a game, keeps them on. | ||
| @@ -88,7 +92,7 @@ exec wlopm --off '*' | |||||
| 88 | 92 | ||||
| 89 | NeferWL supports `ext_workspace_manager_v1` for bars such as Waybar 0.13+ (`ext/workspaces`) and ironbar. Bars receive workspace updates and can switch workspaces without polling. | 93 | NeferWL supports `ext_workspace_manager_v1` for bars such as Waybar 0.13+ (`ext/workspaces`) and ironbar. Bars receive workspace updates and can switch workspaces without polling. | ||
| 90 | 94 | ||||
| 91 | - | NeferWL also supports `zwlr_foreign_toplevel_manager_v1`. Taskbars can list, focus, close and fullscreen windows, and notification daemons such as Dunst can detect fullscreen windows. Every client can see window titles and app IDs. | 95 | + | NeferWL also supports `zwlr_foreign_toplevel_manager_v1`. Taskbars can list, focus, close and fullscreen windows, and notification daemons such as Dunst can detect fullscreen windows. While unlocked, clients can see window titles and app IDs. Workspace and window inventory updates are withheld during session protection. |
| 92 | 96 | ||||
| 93 | ## State for scripts | 97 | ## State for scripts | ||
| 94 | 98 | ||||
M docs/headless.md +1 -1
| @@ -15,7 +15,7 @@ The compositor draws a red border around the visible recording target. An explic | |||||
| 15 | 15 | ||||
| 16 | Offscreen frame callbacks keep clients running, but no physical presentation feedback is invented for hidden windows. The child renderer and its readback storage retire when unused and idle, are recreated for session or frame-size changes, and close with the output. Clean capture can require two compositions to preserve the displayed z-order. | 16 | Offscreen frame callbacks keep clients running, but no physical presentation feedback is invented for hidden windows. The child renderer and its readback storage retire when unused and idle, are recreated for session or frame-size changes, and close with the output. Clean capture can require two compositions to preserve the displayed z-order. | ||
| 17 | 17 | ||||
| 18 | - | `/tmp/neferwl-shots/latest.png` shows the current frame, including the recording border and HUD. `--timeout 5s` stops NeferWL after 5 seconds. | 18 | + | `/tmp/neferwl-shots/latest.png` shows the current unlocked frame, including the recording border and HUD. During session protection, protocol captures are refused and debug PNG files stop updating; an existing file still contains an earlier unlocked frame. See [Session locking](session-lock.md). `--timeout 5s` stops NeferWL after 5 seconds. |
| 19 | 19 | ||||
| 20 | `--input <path>`, or `--input -` for stdin, plays an input script. One command per line: | 20 | `--input <path>`, or `--input -` for stdin, plays an input script. One command per line: | ||
| 21 | 21 | ||||
A docs/session-lock.md +35 -0
| @@ -0,0 +1,35 @@ | |||||
| 1 | + | # Session locking | |||
| 2 | + | ||||
| 3 | + | NeferWL supports `ext-session-lock-v1` with an external Wayland locker. The locker owns authentication and requests unlock after it succeeds; NeferWL owns input isolation and output protection. A fullscreen window, layer-shell cover or fade is not a session lock. | |||
| 4 | + | ||||
| 5 | + | ## Protection | |||
| 6 | + | ||||
| 7 | + | NeferWL accepts one live lock owner. From acceptance, it refuses desktop input, binds, activation, virtual-keyboard injection, input-method grabs, clipboard requests and capture admission. Queued desktop input from an earlier transition is discarded rather than relabelled. Protected scenes contain only an opaque compositor-owned black background and the owner's validated lock surfaces. Direct scanout, overlays, tearing and hardware cursors are disabled while protected. | |||
| 8 | + | ||||
| 9 | + | The locker receives `locked` only after every registered output lifetime has affirmative evidence of a protected frame or actual inactivity, the backend has revoked outstanding DRM leases, and admitted prelock captures have finished writing their destination buffers. Queued rendering, a timeout, renderer failure and loss of DRM master are not protection evidence. A lease that cannot be revoked keeps `locked` withheld. | |||
| 10 | + | ||||
| 11 | + | Lock surfaces receive exact logical dimensions through configure/acknowledge. Outputs without a mapped lock surface remain black. An output added during protection starts safe-first; stale frames or proofs from an earlier lock or connector lifetime cannot authorize it. Resume and recovery require fresh output protection rather than replaying a desktop framebuffer. | |||
| 12 | + | ||||
| 13 | + | ## Owner failure and unlock | |||
| 14 | + | ||||
| 15 | + | Destroying a lock surface, disconnecting the locker or encountering its protocol error does not unlock. NeferWL keeps protection and falls back to black. A new ordinary locker can take over after the previous owner disappears; a second live owner is refused. | |||
| 16 | + | ||||
| 17 | + | Only the current owner, after `locked`, may send `unlock_and_destroy`. A locker exiting afterward must perform a Wayland roundtrip before closing its connection. Closing before the server processes unlock can be treated as owner death, which keeps the session protected. Input held across a transition is cleared or quarantined until released, so password-held keys do not enter the desktop after unlock. | |||
| 18 | + | ||||
| 19 | + | Returning activity may wake powered-off outputs but never unlocks. There is no emergency unlock key or timeout-to-unlock. VT switching remains available; `Ctrl+Alt+Backspace` and the `--debug=input-keys` key log are suppressed while protected. | |||
| 20 | + | ||||
| 21 | + | ## Boundaries and limits | |||
| 22 | + | ||||
| 23 | + | - Password/PIN verification belongs to the locker. NeferWL does not implement PAM, store credentials, or authenticate the protocol's owner. | |||
| 24 | + | - A same-user process that controls the session or a legitimate lock owner is outside this isolation boundary. Root, hostile kernel/driver code and another active VT are not isolated by the compositor. | |||
| 25 | + | - Clipboard pipes already handed to clients before acquisition cannot be recalled. Later receive/set requests are denied, including requests on old offer objects. Information a client received before locking cannot be erased from that client. | |||
| 26 | + | - Captures admitted before acquisition may complete; `locked` waits for their copy/FD-write completion even if the requesting protocol object has been destroyed. New captures, including private recording and hidden-workspace capture, are denied. | |||
| 27 | + | - The script state file is not a security API and is not scrubbed by locking. Its existing file permissions and same-user access boundary remain unchanged. | |||
| 28 | + | - Native KMS calls and compositor GPU work can stall. Stalls never produce a successful protection acknowledgement; bounded lock latency is not guaranteed. Protected shutdown never restores saved desktop pixels, but disabling an output is best effort if KMS authority is lost. | |||
| 29 | + | - Refused or transient protected KMS commits keep the output registered and dark, and retry with a 50 ms to 2 s backoff. An output stops, and the locker loses it, only when the renderer fails to clear it or no image format at all is accepted. If the driver keeps refusing to disable an output, its previous content can stay lit; `locked` is still withheld. | |||
| 30 | + | ||||
| 31 | + | ## Verification | |||
| 32 | + | ||||
| 33 | + | Headless integration tests exercise two outputs, bufferless acquisition, locker input isolation, capture refusal, owner death/takeover and synchronized unlock. Headless inactivity means the software output is off; there is no physical display. Protected headless runs do not update debug PNG files or read back protected content; an existing PNG remains an earlier unlocked image, not a live protected screenshot. | |||
| 34 | + | ||||
| 35 | + | KMS tests use generated mocks to verify detachment, black clearing, fence ordering, failure handling and shutdown. They do not prove physical behavior on every GPU. Multi-monitor hotplug, DPMS, VT/resume and suspend require separately authorized isolated hardware testing before making hardware-specific guarantees. | |||
M examples/testime/main.go +16 -0
| @@ -14,6 +14,8 @@ import ( | |||||
| 14 | 14 | ||||
| 15 | "github.com/bnema/purego-libwayland/protocol/inputmethod" | 15 | "github.com/bnema/purego-libwayland/protocol/inputmethod" | ||
| 16 | "github.com/bnema/wlturbo" | 16 | "github.com/bnema/wlturbo" | ||
| 17 | + | clientcore "github.com/bnema/wlturbo/protocol/core" | |||
| 18 | + | clientime "github.com/bnema/wlturbo/protocol/inputmethod" | |||
| 17 | ) | 19 | ) | ||
| 18 | 20 | ||||
| 19 | func main() { | 21 | func main() { | ||
| @@ -40,6 +42,12 @@ type inputMethod struct { | |||||
| 40 | serve bool // an activation applied by done, not yet served | 42 | serve bool // an activation applied by done, not yet served | ||
| 41 | } | 43 | } | ||
| 42 | 44 | ||||
| 45 | + | // EventSignature delegates to the generated client schema, including events | |||
| 46 | + | // this example intentionally ignores and their descriptor ownership. | |||
| 47 | + | func (*inputMethod) EventSignature(op uint16) (string, bool) { | |||
| 48 | + | return (&clientime.InputMethod{}).EventSignature(op) | |||
| 49 | + | } | |||
| 50 | + | ||||
| 43 | func (m *inputMethod) Dispatch(e *wlturbo.Event) { | 51 | func (m *inputMethod) Dispatch(e *wlturbo.Event) { | ||
| 44 | switch uint32(e.Opcode) { | 52 | switch uint32(e.Opcode) { | ||
| 45 | case inputmethod.ZwpInputMethodV2EventActivate: | 53 | case inputmethod.ZwpInputMethodV2EventActivate: | ||
| @@ -65,6 +73,10 @@ type ignored struct{ wlturbo.BaseProxy } | |||||
| 65 | 73 | ||||
| 66 | func (*ignored) Dispatch(*wlturbo.Event) {} | 74 | func (*ignored) Dispatch(*wlturbo.Event) {} | ||
| 67 | 75 | ||||
| 76 | + | func (*ignored) EventSignature(op uint16) (string, bool) { | |||
| 77 | + | return (&clientcore.Seat{}).EventSignature(op) | |||
| 78 | + | } | |||
| 79 | + | ||||
| 68 | func (m *inputMethod) send(op uint32, args ...any) error { | 80 | func (m *inputMethod) send(op uint32, args ...any) error { | ||
| 69 | return m.c.SendRequest(m.ID(), uint16(op), args...) | 81 | return m.c.SendRequest(m.ID(), uint16(op), args...) | ||
| 70 | } | 82 | } | ||
| @@ -92,6 +104,8 @@ func run(preedit, commit string, delay time.Duration, count int) error { | |||||
| 92 | // wlturbo fails on events for unregistered objects: the seat sends some. | 104 | // wlturbo fails on events for unregistered objects: the seat sends some. | ||
| 93 | seatProxy := &ignored{} | 105 | seatProxy := &ignored{} | ||
| 94 | seatProxy.SetID(seat) | 106 | seatProxy.SetID(seat) | ||
| 107 | + | seatProxy.SetContext(c.Context()) | |||
| 108 | + | seatProxy.SetVersion(1) | |||
| 95 | c.Context().Register(seatProxy) | 109 | c.Context().Register(seatProxy) | ||
| 96 | manager, err := bind("zwp_input_method_manager_v2") | 110 | manager, err := bind("zwp_input_method_manager_v2") | ||
| 97 | if err != nil { | 111 | if err != nil { | ||
| @@ -99,6 +113,8 @@ func run(preedit, commit string, delay time.Duration, count int) error { | |||||
| 99 | } | 113 | } | ||
| 100 | m := &inputMethod{c: c} | 114 | m := &inputMethod{c: c} | ||
| 101 | m.SetID(c.AllocateID()) | 115 | m.SetID(c.AllocateID()) | ||
| 116 | + | m.SetContext(c.Context()) | |||
| 117 | + | m.SetVersion(1) | |||
| 102 | c.Context().Register(m) | 118 | c.Context().Register(m) | ||
| 103 | if err := c.SendRequest(manager, uint16(inputmethod.ZwpInputMethodManagerV2RequestGetInputMethod), seat, m.ID()); err != nil { | 119 | if err := c.SendRequest(manager, uint16(inputmethod.ZwpInputMethodManagerV2RequestGetInputMethod), seat, m.ID()); err != nil { | ||
| 104 | return err | 120 | return err | ||
M go.mod +1 -1
| @@ -6,7 +6,7 @@ require ( | |||||
| 6 | github.com/bnema/purego v0.13.0-bnema.1 | 6 | github.com/bnema/purego v0.13.0-bnema.1 | ||
| 7 | github.com/bnema/purego-libwayland v0.7.0 | 7 | github.com/bnema/purego-libwayland v0.7.0 | ||
| 8 | github.com/bnema/purego-vulkan v0.6.0 | 8 | github.com/bnema/purego-vulkan v0.6.0 | ||
| 9 | - | github.com/bnema/wlturbo v0.2.0 | 9 | + | github.com/bnema/wlturbo v0.5.0 |
| 10 | github.com/bnema/zerowrap v1.4.1 | 10 | github.com/bnema/zerowrap v1.4.1 | ||
| 11 | github.com/rs/zerolog v1.35.1 | 11 | github.com/rs/zerolog v1.35.1 | ||
| 12 | github.com/stretchr/testify v1.12.1 | 12 | github.com/stretchr/testify v1.12.1 | ||
M go.sum +2 -2 generated file: collapsed
M internal/adapters/capture/offscreen.go +16 -4
| @@ -346,6 +346,9 @@ func (p *Pipeline) SubmitHidden(s ports.Scene, surfaces map[ports.WindowID]ports | |||||
| 346 | switch { | 346 | switch { | ||
| 347 | case len(reqs) == 0: | 347 | case len(reqs) == 0: | ||
| 348 | return | 348 | return | ||
| 349 | + | case !p.captureAllowed(s.Security): | |||
| 350 | + | fail(ErrSecurityState, reqs) | |||
| 351 | + | return | |||
| 349 | case s.Off: | 352 | case s.Off: | ||
| 350 | fail(errors.New("output off"), reqs) | 353 | fail(errors.New("output off"), reqs) | ||
| 351 | return | 354 | return | ||
| @@ -391,6 +394,7 @@ func (p *Pipeline) SubmitHidden(s ports.Scene, surfaces map[ports.WindowID]ports | |||||
| 391 | return | 394 | return | ||
| 392 | } | 395 | } | ||
| 393 | o.r, o.p, o.w, o.h, o.session = r, NewPipeline(p.ctx, p.replies), w, h, s.Capture.Session | 396 | o.r, o.p, o.w, o.h, o.session = r, NewPipeline(p.ctx, p.replies), w, h, s.Capture.Session | ||
| 397 | + | o.p.Security = p.Security | |||
| 394 | } | 398 | } | ||
| 395 | // Request regions are physical pixels of the output; TargetRect is | 399 | // Request regions are physical pixels of the output; TargetRect is | ||
| 396 | // relative to the workspace viewport, so it cannot place them. The child | 400 | // relative to the workspace viewport, so it cannot place them. The child | ||
| @@ -417,13 +421,21 @@ func (p *Pipeline) SubmitHidden(s ports.Scene, surfaces map[ports.WindowID]ports | |||||
| 417 | child := *cs | 421 | child := *cs | ||
| 418 | child.Capture = &ports.SceneCapture{Excluded: s.Capture.Excluded} | 422 | child.Capture = &ports.SceneCapture{Excluded: s.Capture.Excluded} | ||
| 419 | child = o.clean.scene(child) | 423 | child = o.clean.scene(child) | ||
| 420 | - | done, err := o.r.Render(child, surfaces) | 424 | + | if !p.captureAllowed(s.Security) { |
| 421 | - | if err != nil { | 425 | + | fail(ErrSecurityState, reqs) |
| 422 | - | fail(fmt.Errorf("render workspace: %w", err), reqs) | |||
| 423 | return | 426 | return | ||
| 424 | } | 427 | } | ||
| 428 | + | done, err := o.r.Render(child, surfaces) | |||
| 429 | + | // This device's reads are independent of the display. Even a rejected | |||
| 430 | + | // capture retains its render fence/ceilings until GPU completion. | |||
| 425 | if done != nil { | 431 | if done != nil { | ||
| 426 | o.track(done, child, surfaces) | 432 | o.track(done, child, surfaces) | ||
| 427 | } | 433 | } | ||
| 428 | - | o.p.Submit(o.r, reqs) | 434 | + | if err != nil { |
| 435 | + | fail(fmt.Errorf("render workspace: %w", err), reqs) | |||
| 436 | + | return | |||
| 437 | + | } | |||
| 438 | + | // Immutable parent scene epoch, not the cleaner's rewritten child scene. | |||
| 439 | + | // SubmitScoped rechecks immediately before native BeginCapture. | |||
| 440 | + | o.p.SubmitScoped(s.Security, o.r, reqs) | |||
| 429 | } | 441 | } | ||
M internal/adapters/capture/pipeline.go +15 -3
| @@ -2,6 +2,7 @@ package capture | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "errors" | |||
| 5 | "fmt" | 6 | "fmt" | ||
| 6 | "sync" | 7 | "sync" | ||
| 7 | "time" | 8 | "time" | ||
| @@ -15,6 +16,9 @@ const MaxRequests = 8 | |||||
| 15 | 16 | ||||
| 16 | const pipelineSlots = 2 | 17 | const pipelineSlots = 2 | ||
| 17 | 18 | ||||
| 19 | + | // ErrSecurityState rejects capture admission across an epoch or while protected. | |||
| 20 | + | var ErrSecurityState = errors.New("capture security epoch changed or session protected") | |||
| 21 | + | ||||
| 18 | // batch remains owned by the worker until it is returned through completed. | 22 | // batch remains owned by the worker until it is returned through completed. | ||
| 19 | // Both batches and their request storage are reused for the output's lifetime. | 23 | // Both batches and their request storage are reused for the output's lifetime. | ||
| 20 | type batch struct { | 24 | type batch struct { | ||
| @@ -27,6 +31,8 @@ type batch struct { | |||||
| 27 | // Pipeline moves fence waiting and SHM copies off the renderer's owner. | 31 | // Pipeline moves fence waiting and SHM copies off the renderer's owner. | ||
| 28 | // Submit, Recycle and Close belong to that owner; one worker handles all jobs. | 32 | // Submit, Recycle and Close belong to that owner; one worker handles all jobs. | ||
| 29 | type Pipeline struct { | 33 | type Pipeline struct { | ||
| 34 | + | // Set before owner use; no mutation or worker access to this dependency. | |||
| 35 | + | Security ports.SessionSecurity | |||
| 30 | ctx context.Context | 36 | ctx context.Context | ||
| 31 | cancel context.CancelFunc | 37 | cancel context.CancelFunc | ||
| 32 | replies chan<- ports.CaptureDone | 38 | replies chan<- ports.CaptureDone | ||
| @@ -55,9 +61,9 @@ func (p *Pipeline) Recycle(b *batch, r ports.Renderer) { | |||||
| 55 | *b = batch{} | 61 | *b = batch{} | ||
| 56 | } | 62 | } | ||
| 57 | 63 | ||||
| 58 | - | // Submit consumes all requests. Saturation fails explicitly instead of | 64 | + | // SubmitScoped consumes requests with explicit immutable scene admission. The |
| 59 | - | // waiting, growing a queue, or starting another worker. | 65 | + | // final gate read precedes BeginCapture, not the render that produced pixels. |
| 60 | - | func (p *Pipeline) Submit(r ports.Renderer, requests []ports.CaptureRequest) { | 66 | + | func (p *Pipeline) SubmitScoped(state ports.SecurityState, r ports.Renderer, requests []ports.CaptureRequest) { |
| 61 | if len(requests) == 0 { | 67 | if len(requests) == 0 { | ||
| 62 | return | 68 | return | ||
| 63 | } | 69 | } | ||
| @@ -75,6 +81,8 @@ func (p *Pipeline) Submit(r ports.Renderer, requests []ports.CaptureRequest) { | |||||
| 75 | var err error | 81 | var err error | ||
| 76 | if b == nil { | 82 | if b == nil { | ||
| 77 | err = fmt.Errorf("capture pipeline full or stopped") | 83 | err = fmt.Errorf("capture pipeline full or stopped") | ||
| 84 | + | } else if !p.captureAllowed(state) { | |||
| 85 | + | err = ErrSecurityState | |||
| 78 | } else { | 86 | } else { | ||
| 79 | b.frame, err = r.BeginCapture() | 87 | b.frame, err = r.BeginCapture() | ||
| 80 | if err == nil && b.frame == nil { | 88 | if err == nil && b.frame == nil { | ||
| @@ -94,6 +102,10 @@ func (p *Pipeline) Submit(r ports.Renderer, requests []ports.CaptureRequest) { | |||||
| 94 | p.jobs <- b | 102 | p.jobs <- b | ||
| 95 | } | 103 | } | ||
| 96 | 104 | ||||
| 105 | + | func (p *Pipeline) captureAllowed(state ports.SecurityState) bool { | |||
| 106 | + | return p.Security == nil || !state.Protected && p.Security.Snapshot() == state | |||
| 107 | + | } | |||
| 108 | + | ||||
| 97 | func (p *Pipeline) reclaim(r ports.Renderer) { | 109 | func (p *Pipeline) reclaim(r ports.Renderer) { | ||
| 98 | for { | 110 | for { | ||
| 99 | select { | 111 | select { | ||
M internal/adapters/capture/pipeline_fence_test.go +2 -2
| @@ -40,7 +40,7 @@ func TestPipelineWaitsForFenceBeforeRead(t *testing.T) { | |||||
| 40 | replies := make(chan ports.CaptureDone, 1) | 40 | replies := make(chan ports.CaptureDone, 1) | ||
| 41 | p := NewPipeline(context.Background(), replies) | 41 | p := NewPipeline(context.Background(), replies) | ||
| 42 | q := pipelineRequest(t, 1) | 42 | q := pipelineRequest(t, 1) | ||
| 43 | - | p.Submit(r, []ports.CaptureRequest{q}) | 43 | + | p.SubmitScoped(ports.SecurityState{}, r, []ports.CaptureRequest{q}) |
| 44 | // Longer than one syncfile poll slice: the worker has polled at least once. | 44 | // Longer than one syncfile poll slice: the worker has polled at least once. | ||
| 45 | select { | 45 | select { | ||
| 46 | case <-entered: | 46 | case <-entered: | ||
| @@ -79,7 +79,7 @@ func TestPipelinePropagatesReadError(t *testing.T) { | |||||
| 79 | replies := make(chan ports.CaptureDone, 1) | 79 | replies := make(chan ports.CaptureDone, 1) | ||
| 80 | p := NewPipeline(context.Background(), replies) | 80 | p := NewPipeline(context.Background(), replies) | ||
| 81 | q := pipelineRequest(t, 1) | 81 | q := pipelineRequest(t, 1) | ||
| 82 | - | p.Submit(r, []ports.CaptureRequest{q}) | 82 | + | p.SubmitScoped(ports.SecurityState{}, r, []ports.CaptureRequest{q}) |
| 83 | done := awaitCapture(t, replies) | 83 | done := awaitCapture(t, replies) | ||
| 84 | if !errors.Is(done.Err, boom) || done.ID != q.ID { | 84 | if !errors.Is(done.Err, boom) || done.ID != q.ID { | ||
| 85 | t.Fatalf("result %+v", done) | 85 | t.Fatalf("result %+v", done) | ||
M internal/adapters/capture/pipeline_test.go +5 -5
| @@ -53,7 +53,7 @@ func TestPipelineCopiesOffOwnerAndRecycles(t *testing.T) { | |||||
| 53 | replies := make(chan ports.CaptureDone, 1) | 53 | replies := make(chan ports.CaptureDone, 1) | ||
| 54 | p := NewPipeline(context.Background(), replies) | 54 | p := NewPipeline(context.Background(), replies) | ||
| 55 | q := pipelineRequest(t, 1) | 55 | q := pipelineRequest(t, 1) | ||
| 56 | - | p.Submit(r, []ports.CaptureRequest{q}) | 56 | + | p.SubmitScoped(ports.SecurityState{}, r, []ports.CaptureRequest{q}) |
| 57 | select { | 57 | select { | ||
| 58 | case <-entered: | 58 | case <-entered: | ||
| 59 | case <-time.After(time.Second): | 59 | case <-time.After(time.Second): | ||
| @@ -92,10 +92,10 @@ func TestPipelineSaturationAndShutdownWithoutGPUCompletion(t *testing.T) { | |||||
| 92 | frame.EXPECT().Done().Return(read).Maybe() | 92 | frame.EXPECT().Done().Return(read).Maybe() | ||
| 93 | r.EXPECT().BeginCapture().Return(frame, nil).Once() | 93 | r.EXPECT().BeginCapture().Return(frame, nil).Once() | ||
| 94 | r.EXPECT().EndCapture(frame).Return().Once() | 94 | r.EXPECT().EndCapture(frame).Return().Once() | ||
| 95 | - | p.Submit(r, []ports.CaptureRequest{pipelineRequest(t, id)}) | 95 | + | p.SubmitScoped(ports.SecurityState{}, r, []ports.CaptureRequest{pipelineRequest(t, id)}) |
| 96 | } | 96 | } | ||
| 97 | q := pipelineRequest(t, 3) | 97 | q := pipelineRequest(t, 3) | ||
| 98 | - | p.Submit(r, []ports.CaptureRequest{q}) | 98 | + | p.SubmitScoped(ports.SecurityState{}, r, []ports.CaptureRequest{q}) |
| 99 | if done := awaitCapture(t, replies); done.ID != 3 || done.Err == nil { | 99 | if done := awaitCapture(t, replies); done.ID != 3 || done.Err == nil { | ||
| 100 | t.Fatalf("saturation result %+v", done) | 100 | t.Fatalf("saturation result %+v", done) | ||
| 101 | } | 101 | } | ||
| @@ -120,7 +120,7 @@ func TestPipelineBeginFailureClosesRequest(t *testing.T) { | |||||
| 120 | replies := make(chan ports.CaptureDone, 1) | 120 | replies := make(chan ports.CaptureDone, 1) | ||
| 121 | p := NewPipeline(context.Background(), replies) | 121 | p := NewPipeline(context.Background(), replies) | ||
| 122 | q := pipelineRequest(t, 1) | 122 | q := pipelineRequest(t, 1) | ||
| 123 | - | p.Submit(r, []ports.CaptureRequest{q}) | 123 | + | p.SubmitScoped(ports.SecurityState{}, r, []ports.CaptureRequest{q}) |
| 124 | if done := awaitCapture(t, replies); !errors.Is(done.Err, boom) { | 124 | if done := awaitCapture(t, replies); !errors.Is(done.Err, boom) { | ||
| 125 | t.Fatalf("result %+v", done) | 125 | t.Fatalf("result %+v", done) | ||
| 126 | } | 126 | } | ||
| @@ -138,7 +138,7 @@ func TestPipelineRejectsOversizedBatch(t *testing.T) { | |||||
| 138 | for i := range requests { | 138 | for i := range requests { | ||
| 139 | requests[i] = pipelineRequest(t, uint64(i)) | 139 | requests[i] = pipelineRequest(t, uint64(i)) | ||
| 140 | } | 140 | } | ||
| 141 | - | p.Submit(r, requests) | 141 | + | p.SubmitScoped(ports.SecurityState{}, r, requests) |
| 142 | for range requests { | 142 | for range requests { | ||
| 143 | if done := awaitCapture(t, replies); done.Err == nil { | 143 | if done := awaitCapture(t, replies); done.Err == nil { | ||
| 144 | t.Fatal("oversized batch accepted") | 144 | t.Fatal("oversized batch accepted") | ||
A internal/adapters/capture/security_test.go +92 -0
| @@ -0,0 +1,92 @@ | |||||
| 1 | + | package capture | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "fmt" | |||
| 7 | + | "image" | |||
| 8 | + | "os" | |||
| 9 | + | "sync/atomic" | |||
| 10 | + | "testing" | |||
| 11 | + | ||||
| 12 | + | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |||
| 13 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 14 | + | "github.com/stretchr/testify/mock" | |||
| 15 | + | "github.com/stretchr/testify/require" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | func TestSubmitScopedRejectsChangedAndProtectedEpoch(t *testing.T) { | |||
| 19 | + | for _, state := range []ports.SecurityState{{Generation: 4}, {Generation: 3, Protected: true}} { | |||
| 20 | + | t.Run(fmt.Sprintf("generation-%d-protected-%v", state.Generation, state.Protected), func(t *testing.T) { | |||
| 21 | + | replies := make(chan ports.CaptureDone, 2) | |||
| 22 | + | p := NewPipeline(context.Background(), replies) | |||
| 23 | + | security := portsmocks.NewMockSessionSecurity(t) | |||
| 24 | + | security.EXPECT().Snapshot().Return(state) | |||
| 25 | + | p.Security = security | |||
| 26 | + | r := portsmocks.NewMockRenderer(t) | |||
| 27 | + | q := pipelineRequest(t, 1) | |||
| 28 | + | file := q.Dst.File | |||
| 29 | + | p.SubmitScoped(ports.SecurityState{Generation: 3}, r, []ports.CaptureRequest{q}) | |||
| 30 | + | p.Close(r) | |||
| 31 | + | require.Len(t, replies, 1) | |||
| 32 | + | require.ErrorIs(t, (<-replies).Err, ErrSecurityState) | |||
| 33 | + | r.AssertNotCalled(t, "BeginCapture") | |||
| 34 | + | _, err := file.Stat() | |||
| 35 | + | require.ErrorIs(t, err, os.ErrClosed) | |||
| 36 | + | }) | |||
| 37 | + | } | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | func TestSubmitHiddenEngageDuringRenderRejectsCopyRetainsReads(t *testing.T) { | |||
| 41 | + | replies := make(chan ports.CaptureDone, 4) | |||
| 42 | + | p := NewPipeline(context.Background(), replies) | |||
| 43 | + | security := portsmocks.NewMockSessionSecurity(t) | |||
| 44 | + | var engaged atomic.Bool | |||
| 45 | + | security.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { | |||
| 46 | + | if engaged.Load() { | |||
| 47 | + | return ports.SecurityState{Generation: 1, Protected: true} | |||
| 48 | + | } | |||
| 49 | + | return ports.SecurityState{} | |||
| 50 | + | }) | |||
| 51 | + | p.Security = security | |||
| 52 | + | child := portsmocks.NewMockRenderer(t) | |||
| 53 | + | f, w, err := os.Pipe() | |||
| 54 | + | require.NoError(t, err) | |||
| 55 | + | t.Cleanup(func() { f.Close(); w.Close() }) | |||
| 56 | + | child.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) { | |||
| 57 | + | engaged.Store(true) | |||
| 58 | + | return f, nil | |||
| 59 | + | }).Once() | |||
| 60 | + | child.EXPECT().Close().Return().Once() | |||
| 61 | + | p.EnableOffscreen(func(int, int) (ports.Renderer, error) { return child, nil }) | |||
| 62 | + | s := hiddenScene() | |||
| 63 | + | s.CaptureScene.Windows = []ports.SceneWindow{{ID: 5}} | |||
| 64 | + | surfaces := map[ports.WindowID]ports.SurfaceContent{5: {ID: 5, Seq: 8}} | |||
| 65 | + | reqs := []ports.CaptureRequest{hiddenRequest(t, 1, image.Rect(0, 0, 200, 100)), hiddenRequest(t, 2, image.Rect(0, 0, 200, 100))} | |||
| 66 | + | files := []*os.File{reqs[0].Dst.File, reqs[1].Dst.File} | |||
| 67 | + | p.SubmitHidden(s, surfaces, reqs) | |||
| 68 | + | child.AssertNotCalled(t, "BeginCapture") | |||
| 69 | + | require.True(t, p.HiddenReading(), "rejected native capture still owns GPU reads") | |||
| 70 | + | seen, reads, limited := p.CapHiddenSeen(map[ports.WindowID]uint64{5: 9}) | |||
| 71 | + | require.True(t, limited) | |||
| 72 | + | require.Equal(t, uint64(8), seen[5]) | |||
| 73 | + | require.Equal(t, uint64(8), reads[5]) | |||
| 74 | + | for range 2 { | |||
| 75 | + | reply := awaitCapture(t, replies) | |||
| 76 | + | require.ErrorIs(t, reply.Err, ErrSecurityState) | |||
| 77 | + | } | |||
| 78 | + | for _, file := range files { | |||
| 79 | + | _, err := file.Stat() | |||
| 80 | + | require.ErrorIs(t, err, os.ErrClosed) | |||
| 81 | + | } | |||
| 82 | + | require.Empty(t, replies, "exactly one failure reply for each request credit") | |||
| 83 | + | _, err = f.Stat() | |||
| 84 | + | require.NoError(t, err, "render fence stays owned until signalling") | |||
| 85 | + | _, err = w.Write([]byte{1}) | |||
| 86 | + | require.NoError(t, err) | |||
| 87 | + | require.False(t, p.HiddenReading()) | |||
| 88 | + | _, err = f.Stat() | |||
| 89 | + | require.True(t, errors.Is(err, os.ErrClosed), "signalled child fence closes") | |||
| 90 | + | p.Close(nil) | |||
| 91 | + | require.Empty(t, replies, "closing rejected pipeline creates no duplicate reply") | |||
| 92 | + | } | |||
A internal/adapters/drm/amdgpu_rules_test.go +413 -0
| @@ -0,0 +1,413 @@ | |||||
| 1 | + | package drm | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "sync" | |||
| 7 | + | "sync/atomic" | |||
| 8 | + | "testing" | |||
| 9 | + | "time" | |||
| 10 | + | ||||
| 11 | + | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |||
| 12 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 13 | + | "github.com/stretchr/testify/mock" | |||
| 14 | + | "golang.org/x/sys/unix" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | // Connector HDR property IDs and values of the amdgpu rule tests. | |||
| 18 | + | const ( | |||
| 19 | + | pHDRMeta, pColorspace, pMaxBPC = 20, 21, 22 | |||
| 20 | + | colorspaceBT2020 = 9 | |||
| 21 | + | ) | |||
| 22 | + | ||||
| 23 | + | // Blob lifetime is part of the model: attach feeds createBlob/destroyBlob | |||
| 24 | + | // mocks into the live set. | |||
| 25 | + | type amdgpuModel struct { | |||
| 26 | + | t *testing.T | |||
| 27 | + | mu sync.Mutex | |||
| 28 | + | live map[uint32]bool | |||
| 29 | + | next uint32 | |||
| 30 | + | rule func(*atomicReq, uint32) error | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | // newAmdgpuModel starts from a lit desktop whose mode blob 99 is live. | |||
| 34 | + | func newAmdgpuModel(t *testing.T) *amdgpuModel { | |||
| 35 | + | t.Helper() | |||
| 36 | + | m := &amdgpuModel{t: t, live: map[uint32]bool{99: true}, next: 100} | |||
| 37 | + | m.rule = amdgpuRuleWith(t, m.isLive) | |||
| 38 | + | return m | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func (m *amdgpuModel) isLive(blob uint32) bool { | |||
| 42 | + | m.mu.Lock() | |||
| 43 | + | defer m.mu.Unlock() | |||
| 44 | + | return m.live[blob] | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | // attach makes k create ids from 100 up and destroy only live blobs. With | |||
| 48 | + | // strict ids, each is expected to be destroyed exactly once and no other | |||
| 49 | + | // destroy is allowed; without, any live blob may be destroyed. | |||
| 50 | + | func (m *amdgpuModel) attach(k *mockkms, strict ...uint32) { | |||
| 51 | + | k.EXPECT().createBlob(mock.Anything).RunAndReturn(func([]byte) (uint32, error) { | |||
| 52 | + | m.mu.Lock() | |||
| 53 | + | defer m.mu.Unlock() | |||
| 54 | + | id := m.next | |||
| 55 | + | m.next++ | |||
| 56 | + | m.live[id] = true | |||
| 57 | + | return id, nil | |||
| 58 | + | }).Maybe() | |||
| 59 | + | destroy := func(id uint32) error { | |||
| 60 | + | m.mu.Lock() | |||
| 61 | + | defer m.mu.Unlock() | |||
| 62 | + | if !m.live[id] { | |||
| 63 | + | m.t.Errorf("amdgpu: destroyBlob(%d) of an unknown or destroyed blob", id) | |||
| 64 | + | return unix.ENOENT | |||
| 65 | + | } | |||
| 66 | + | delete(m.live, id) | |||
| 67 | + | return nil | |||
| 68 | + | } | |||
| 69 | + | if len(strict) == 0 { | |||
| 70 | + | k.EXPECT().destroyBlob(mock.Anything).RunAndReturn(destroy).Maybe() | |||
| 71 | + | } | |||
| 72 | + | for _, id := range strict { | |||
| 73 | + | k.EXPECT().destroyBlob(id).RunAndReturn(destroy).Once() | |||
| 74 | + | } | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | // amdgpuRule returns a commit rule encoding the atomic_check constraints of | |||
| 78 | + | // amdgpu_dm that real hardware applied to the protected requests (EINVAL on | |||
| 79 | + | // kernel 7.2, RX/DP+HDMI). The rule tracks the committed state, starting from | |||
| 80 | + | // a lit desktop: CRTC enabled and active, primary/cursor/overlay/stray | |||
| 81 | + | // planes attached, connector routed. | |||
| 82 | + | // | |||
| 83 | + | // - a CRTC with MODE_ID set (enabled) needs its primary plane attached, | |||
| 84 | + | // even with ACTIVE=0 (drm_atomic_helper_check_crtc_primary_plane); | |||
| 85 | + | // - MODE_ID, connector CRTC_ID and ACTIVE agree: an active or routed CRTC | |||
| 86 | + | // is enabled, an enabled one is routed; | |||
| 87 | + | // - a plane's FB_ID and CRTC_ID are both set or both clear, and only on an | |||
| 88 | + | // enabled CRTC; | |||
| 89 | + | // - the active primary plane covers the whole mode (no partial src/dst); | |||
| 90 | + | // - HDR_OUTPUT_METADATA needs the BT.2020 Colorspace; | |||
| 91 | + | // - MODE_ID must name a live blob, and a change of MODE_ID, ACTIVE or the | |||
| 92 | + | // connector CRTC_ID is a modeset that needs the ALLOW_MODESET flag. | |||
| 93 | + | // | |||
| 94 | + | // amdgpuRule has no blob model: any MODE_ID is live. | |||
| 95 | + | func amdgpuRule(t *testing.T) func(*atomicReq, uint32) error { | |||
| 96 | + | t.Helper() | |||
| 97 | + | return amdgpuRuleWith(t, func(uint32) bool { return true }) | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | func amdgpuRuleWith(t *testing.T, live func(uint32) bool) func(*atomicReq, uint32) error { | |||
| 101 | + | t.Helper() | |||
| 102 | + | state := map[[2]uint32]uint64{ | |||
| 103 | + | {tCrtc, pMode}: 99, {tCrtc, pActive}: 1, {tConn, pConnCrtc}: tCrtc, | |||
| 104 | + | } | |||
| 105 | + | for _, p := range []uint32{tPrimary, tCursor, tOverlay, 53} { | |||
| 106 | + | state[[2]uint32{p, pFB}], state[[2]uint32{p, pCrtcID}] = 70, tCrtc | |||
| 107 | + | } | |||
| 108 | + | return func(req *atomicReq, flags uint32) error { | |||
| 109 | + | next := make(map[[2]uint32]uint64, len(state)) | |||
| 110 | + | for k, v := range state { | |||
| 111 | + | next[k] = v | |||
| 112 | + | } | |||
| 113 | + | for i, obj := range req.objs { | |||
| 114 | + | for _, p := range req.props[i] { | |||
| 115 | + | next[[2]uint32{obj, p.prop}] = p.val | |||
| 116 | + | } | |||
| 117 | + | } | |||
| 118 | + | get := func(obj, prop uint32) uint64 { return next[[2]uint32{obj, prop}] } | |||
| 119 | + | if mode := get(tCrtc, pMode); mode != 0 && !live(uint32(mode)) { | |||
| 120 | + | t.Logf("amdgpu: MODE_ID %d names an unknown or destroyed blob", mode) | |||
| 121 | + | return unix.EINVAL | |||
| 122 | + | } | |||
| 123 | + | for _, k := range [...][2]uint32{{tCrtc, pMode}, {tCrtc, pActive}, {tConn, pConnCrtc}} { | |||
| 124 | + | if next[k] != state[k] && flags&atomicAllowModes == 0 { | |||
| 125 | + | t.Logf("amdgpu: modeset change of %v without ALLOW_MODESET", k) | |||
| 126 | + | return unix.EINVAL | |||
| 127 | + | } | |||
| 128 | + | } | |||
| 129 | + | enabled, active := get(tCrtc, pMode) != 0, get(tCrtc, pActive) == 1 | |||
| 130 | + | if active && !enabled || (get(tConn, pConnCrtc) != 0) != enabled { | |||
| 131 | + | t.Logf("amdgpu: ACTIVE/MODE_ID/connector routing disagree: %v", next) | |||
| 132 | + | return unix.EINVAL | |||
| 133 | + | } | |||
| 134 | + | for _, p := range []uint32{tPrimary, tCursor, tOverlay, 53} { | |||
| 135 | + | fb, crtc := get(p, pFB), get(p, pCrtcID) | |||
| 136 | + | if (fb != 0) != (crtc != 0) || crtc != 0 && (!enabled || crtc != tCrtc) { | |||
| 137 | + | t.Logf("amdgpu: plane %d fb=%d crtc=%d enabled=%v", p, fb, crtc, enabled) | |||
| 138 | + | return unix.EINVAL | |||
| 139 | + | } | |||
| 140 | + | } | |||
| 141 | + | if enabled && get(tPrimary, pFB) == 0 { | |||
| 142 | + | t.Log("amdgpu: enabled CRTC without primary plane") | |||
| 143 | + | return unix.EINVAL | |||
| 144 | + | } | |||
| 145 | + | if active { | |||
| 146 | + | for _, c := range [...]struct { | |||
| 147 | + | prop uint32 | |||
| 148 | + | want uint64 | |||
| 149 | + | }{{10, 0}, {11, 0}, {12, 200 << 16}, {13, 100 << 16}, {14, 0}, {15, 0}, {16, 200}, {17, 100}} { | |||
| 150 | + | if get(tPrimary, c.prop) != c.want { | |||
| 151 | + | t.Logf("amdgpu: primary prop %d = %d, want full-screen %d", c.prop, get(tPrimary, c.prop), c.want) | |||
| 152 | + | return unix.EINVAL | |||
| 153 | + | } | |||
| 154 | + | } | |||
| 155 | + | } | |||
| 156 | + | if get(tConn, pHDRMeta) != 0 && get(tConn, pColorspace) != colorspaceBT2020 { | |||
| 157 | + | t.Log("amdgpu: HDR metadata without BT.2020 colorspace") | |||
| 158 | + | return unix.EINVAL | |||
| 159 | + | } | |||
| 160 | + | if flags&atomicTestOnly == 0 { | |||
| 161 | + | state = next | |||
| 162 | + | } | |||
| 163 | + | return nil | |||
| 164 | + | } | |||
| 165 | + | } | |||
| 166 | + | ||||
| 167 | + | func enableTestHDR(o *Output) { | |||
| 168 | + | o.hdrOn, o.hdrBlob = true, 55 | |||
| 169 | + | o.hdrProps = connectorHDRProps{Metadata: pHDRMeta, Colorspace: pColorspace, MaxBPC: pMaxBPC, BT2020Value: colorspaceBT2020, MaxBPCValue: 8, HasDefault: true} | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | func TestAmdgpuRuleRejectsWhatHardwareRejected(t *testing.T) { | |||
| 173 | + | for name, build := range map[string]func(*atomicReq){ | |||
| 174 | + | // The shape that failed on hardware: inactive, still enabled, no planes. | |||
| 175 | + | "enabled CRTC without primary": func(r *atomicReq) { | |||
| 176 | + | r.set(tCrtc, pActive, 0) | |||
| 177 | + | for _, p := range []uint32{tPrimary, tCursor, tOverlay, 53} { | |||
| 178 | + | r.set(p, pFB, 0) | |||
| 179 | + | r.set(p, pCrtcID, 0) | |||
| 180 | + | } | |||
| 181 | + | }, | |||
| 182 | + | "plane on disabled CRTC": func(r *atomicReq) { | |||
| 183 | + | r.set(tCrtc, pActive, 0) | |||
| 184 | + | r.set(tCrtc, pMode, 0) | |||
| 185 | + | r.set(tConn, pConnCrtc, 0) | |||
| 186 | + | }, | |||
| 187 | + | "connector routed to disabled CRTC": func(r *atomicReq) { | |||
| 188 | + | r.set(tCrtc, pActive, 0) | |||
| 189 | + | r.set(tCrtc, pMode, 0) | |||
| 190 | + | for _, p := range []uint32{tPrimary, tCursor, tOverlay, 53} { | |||
| 191 | + | r.set(p, pFB, 0) | |||
| 192 | + | r.set(p, pCrtcID, 0) | |||
| 193 | + | } | |||
| 194 | + | }, | |||
| 195 | + | "cursor with CRTC but no FB": func(r *atomicReq) { r.set(tCursor, pFB, 0) }, | |||
| 196 | + | "partial primary": func(r *atomicReq) { r.set(tPrimary, 16, 100) }, | |||
| 197 | + | "unknown MODE_ID": func(r *atomicReq) { r.set(tCrtc, pMode, 123) }, | |||
| 198 | + | "HDR metadata without colorspace": func(r *atomicReq) { | |||
| 199 | + | r.set(tConn, pHDRMeta, 55) | |||
| 200 | + | }, | |||
| 201 | + | } { | |||
| 202 | + | t.Run(name, func(t *testing.T) { | |||
| 203 | + | req := &atomicReq{} | |||
| 204 | + | build(req) | |||
| 205 | + | if err := amdgpuRule(t)(req, atomicAllowModes); err == nil { | |||
| 206 | + | t.Fatal("rule accepted a request amdgpu refuses") | |||
| 207 | + | } | |||
| 208 | + | }) | |||
| 209 | + | } | |||
| 210 | + | } | |||
| 211 | + | ||||
| 212 | + | // Every protected disable/activation request must pass the amdgpu rules, with | |||
| 213 | + | // HDR on and off, and leave the state dark then black-only. | |||
| 214 | + | func TestProtectedRequestsAcceptedByAmdgpuRules(t *testing.T) { | |||
| 215 | + | for _, hdr := range []bool{false, true} { | |||
| 216 | + | name := map[bool]string{false: "sdr", true: "hdr"}[hdr] | |||
| 217 | + | t.Run(name, func(t *testing.T) { | |||
| 218 | + | m := newAmdgpuModel(t) | |||
| 219 | + | o, k, commits, _ := testOutputRules(t, m.rule) | |||
| 220 | + | m.attach(k, 99) | |||
| 221 | + | if hdr { | |||
| 222 | + | enableTestHDR(o) | |||
| 223 | + | } | |||
| 224 | + | o.modeBlob = 99 | |||
| 225 | + | o.overlay = &plane{id: tOverlay, props: planeProps} | |||
| 226 | + | o.stray = []*plane{o.overlay, {id: 53, props: planeProps}} | |||
| 227 | + | k.EXPECT().objProps(uint32(53), uint32(objPlane)).Return(map[string][2]uint64{"CRTC_ID": {pCrtcID, tCrtc}}, nil) | |||
| 228 | + | r := portsmocks.NewMockRenderer(t) | |||
| 229 | + | r.EXPECT().UseTarget(0).Return().Once() | |||
| 230 | + | r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil).Once() | |||
| 231 | + | if err := o.protectedModeset(context.Background(), r); err != nil { | |||
| 232 | + | t.Fatalf("amdgpu refused the protected transaction: %v", err) | |||
| 233 | + | } | |||
| 234 | + | if len(*commits) != 2 { | |||
| 235 | + | t.Fatalf("commits %d", len(*commits)) | |||
| 236 | + | } | |||
| 237 | + | act := (*commits)[1] | |||
| 238 | + | if fb, _ := act.req.value(tPrimary, pFB); fb != 71 && fb != 70 { | |||
| 239 | + | t.Fatalf("activated fb %d", fb) | |||
| 240 | + | } | |||
| 241 | + | if crtc, ok := act.req.value(tCursor, pCrtcID); !ok || crtc != 0 { | |||
| 242 | + | t.Fatalf("cursor not explicitly detached: %d %v", crtc, ok) | |||
| 243 | + | } | |||
| 244 | + | if fb, ok := act.req.value(tCursor, pFB); !ok || fb != 0 { | |||
| 245 | + | t.Fatalf("cursor fb not explicitly detached: %d %v", fb, ok) | |||
| 246 | + | } | |||
| 247 | + | if o.off || !o.protected { | |||
| 248 | + | t.Fatalf("off=%v protected=%v", o.off, o.protected) | |||
| 249 | + | } | |||
| 250 | + | // The desktop mode blob is destroyed once by the disable; the | |||
| 251 | + | // activation's blob is the only live one and o.modeBlob owns it. | |||
| 252 | + | if o.modeBlob != 100 || !m.isLive(100) || m.isLive(99) { | |||
| 253 | + | t.Fatalf("modeBlob %d live100=%v live99=%v", o.modeBlob, m.isLive(100), m.isLive(99)) | |||
| 254 | + | } | |||
| 255 | + | }) | |||
| 256 | + | } | |||
| 257 | + | } | |||
| 258 | + | ||||
| 259 | + | func TestAmdgpuRuleRejectsModesetWithoutAllowModes(t *testing.T) { | |||
| 260 | + | for name, build := range map[string]func(*atomicReq){ | |||
| 261 | + | "MODE_ID": func(r *atomicReq) { r.set(tCrtc, pMode, 99) }, | |||
| 262 | + | "ACTIVE": func(r *atomicReq) { r.set(tCrtc, pActive, 0) }, | |||
| 263 | + | "connector CRTC_ID": func(r *atomicReq) { r.set(tConn, pConnCrtc, 0) }, | |||
| 264 | + | } { | |||
| 265 | + | t.Run(name, func(t *testing.T) { | |||
| 266 | + | m := newAmdgpuModel(t) | |||
| 267 | + | m.live[98] = true | |||
| 268 | + | req := &atomicReq{} | |||
| 269 | + | build(req) | |||
| 270 | + | if name == "MODE_ID" { | |||
| 271 | + | req.set(tCrtc, pMode, 98) | |||
| 272 | + | } | |||
| 273 | + | if err := m.rule(req, 0); err == nil { | |||
| 274 | + | t.Fatal("rule accepted a modeset change without ALLOW_MODESET") | |||
| 275 | + | } | |||
| 276 | + | }) | |||
| 277 | + | } | |||
| 278 | + | } | |||
| 279 | + | ||||
| 280 | + | func TestAmdgpuRuleRejectsDestroyedModeBlob(t *testing.T) { | |||
| 281 | + | m := newAmdgpuModel(t) | |||
| 282 | + | req := &atomicReq{} | |||
| 283 | + | req.set(tCrtc, pMode, 99) | |||
| 284 | + | for _, c := range [...]struct{ prop, val uint32 }{{12, 200 << 16}, {13, 100 << 16}, {16, 200}, {17, 100}} { | |||
| 285 | + | req.set(tPrimary, c.prop, uint64(c.val)) | |||
| 286 | + | } | |||
| 287 | + | if err := m.rule(req, atomicAllowModes); err != nil { | |||
| 288 | + | t.Fatalf("live blob rejected: %v", err) | |||
| 289 | + | } | |||
| 290 | + | m.mu.Lock() | |||
| 291 | + | delete(m.live, 99) | |||
| 292 | + | m.mu.Unlock() | |||
| 293 | + | if err := m.rule(req, atomicAllowModes); err == nil { | |||
| 294 | + | t.Fatal("rule accepted a destroyed MODE_ID blob") | |||
| 295 | + | } | |||
| 296 | + | } | |||
| 297 | + | ||||
| 298 | + | func TestProtectedResumeAcceptedByAmdgpuRules(t *testing.T) { | |||
| 299 | + | m := newAmdgpuModel(t) | |||
| 300 | + | o, k, commits, _ := testOutputRules(t, m.rule) | |||
| 301 | + | m.attach(k) | |||
| 302 | + | o.modeBlob = 99 | |||
| 303 | + | o.protected = true | |||
| 304 | + | o.overlay = &plane{id: tOverlay, props: planeProps} | |||
| 305 | + | o.stray = []*plane{o.overlay, {id: 53, props: planeProps}} | |||
| 306 | + | k.EXPECT().objProps(uint32(53), uint32(objPlane)).Return(map[string][2]uint64{"CRTC_ID": {pCrtcID, tCrtc}}, nil) | |||
| 307 | + | if err := o.modeset(); err != nil { // resume while protected: dark, not stale | |||
| 308 | + | t.Fatalf("amdgpu refused protected inactive modeset: %v", err) | |||
| 309 | + | } | |||
| 310 | + | if !o.off || len(*commits) != 1 { | |||
| 311 | + | t.Fatalf("off=%v commits=%d", o.off, len(*commits)) | |||
| 312 | + | } | |||
| 313 | + | if v, ok := (*commits)[0].req.value(tCrtc, pMode); !ok || v != 0 { | |||
| 314 | + | t.Fatal("protected disable left the CRTC enabled") | |||
| 315 | + | } | |||
| 316 | + | } | |||
| 317 | + | ||||
| 318 | + | // The legacy request is refused by the rule; the output stays registered and | |||
| 319 | + | // protected, retries with bounded backoff and never reports proof. | |||
| 320 | + | func TestProtectedRefusalKeepsOutputProtectedAndBacksOff(t *testing.T) { | |||
| 321 | + | o, k, commits, _ := testOutputRules(t, func(r *atomicReq, flags uint32) error { | |||
| 322 | + | if v, ok := r.value(tCrtc, pActive); ok && v == 1 { | |||
| 323 | + | return unix.EINVAL | |||
| 324 | + | } | |||
| 325 | + | return nil | |||
| 326 | + | }) | |||
| 327 | + | k.EXPECT().createBlob(mock.Anything).Return(100, nil) | |||
| 328 | + | k.EXPECT().destroyBlob(mock.Anything).Return(nil).Maybe() | |||
| 329 | + | r := portsmocks.NewMockRenderer(t) | |||
| 330 | + | r.EXPECT().UseTarget(0).Return().Once() | |||
| 331 | + | r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil).Once() | |||
| 332 | + | err := o.protectedModeset(context.Background(), r) | |||
| 333 | + | if err == nil || !retryableProtected(err) || !errorsIsEINVAL(err) { | |||
| 334 | + | t.Fatalf("refusal result %v", err) | |||
| 335 | + | } | |||
| 336 | + | if !o.protected || !o.off || o.back != 0 || len(*commits) != 2 { | |||
| 337 | + | t.Fatalf("protected=%v off=%v back=%d commits=%d", o.protected, o.off, o.back, len(*commits)) | |||
| 338 | + | } | |||
| 339 | + | var enabled = true | |||
| 340 | + | if !o.commitFailed(err, &enabled) || !enabled || !o.protectBackoffActive() { | |||
| 341 | + | t.Fatal("refusal not retryable with backoff") | |||
| 342 | + | } | |||
| 343 | + | d := time.Duration(0) | |||
| 344 | + | for range 10 { | |||
| 345 | + | d = nextProtectBackoff(d) | |||
| 346 | + | } | |||
| 347 | + | if d != protectRetryMax || nextProtectBackoff(0) != protectRetryMin { | |||
| 348 | + | t.Fatalf("backoff not bounded: %v", d) | |||
| 349 | + | } | |||
| 350 | + | } | |||
| 351 | + | ||||
| 352 | + | func errorsIsEINVAL(err error) bool { return err != nil && errors.Is(err, unix.EINVAL) } | |||
| 353 | + | ||||
| 354 | + | // Run: activation refused twice with EINVAL. The output keeps running, stays | |||
| 355 | + | // dark, and only the later accepted black activation produces proof. | |||
| 356 | + | func TestRunProtectedActivationRetriesWithoutStoppingOrEarlyProof(t *testing.T) { | |||
| 357 | + | var activations, refusals atomic.Int32 | |||
| 358 | + | o, k, _, _ := testOutputRules(t, func(r *atomicReq, flags uint32) error { | |||
| 359 | + | if v, ok := r.value(tCrtc, pActive); ok && v == 1 && flags&atomicTestOnly == 0 { | |||
| 360 | + | activations.Add(1) | |||
| 361 | + | if refusals.Add(1) <= 2 { | |||
| 362 | + | return unix.EINVAL | |||
| 363 | + | } | |||
| 364 | + | } | |||
| 365 | + | return nil | |||
| 366 | + | }) | |||
| 367 | + | o.cursor = nil | |||
| 368 | + | var state atomic.Uint64 | |||
| 369 | + | state.Store(3) | |||
| 370 | + | securityGate(t, o, &state) | |||
| 371 | + | events := make(chan ports.SecurityBackendEvent, 32) | |||
| 372 | + | o.SecurityEvents = events | |||
| 373 | + | r := portsmocks.NewMockRenderer(t) | |||
| 374 | + | buf := func() ports.DMABuf { | |||
| 375 | + | f, w := protectionFence(t, false) | |||
| 376 | + | w.Close() | |||
| 377 | + | return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}} | |||
| 378 | + | } | |||
| 379 | + | r.EXPECT().SetHDR(float64(0)).Return().Maybe() | |||
| 380 | + | r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once() | |||
| 381 | + | r.EXPECT().UseTarget(mock.Anything).Return() | |||
| 382 | + | r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil) | |||
| 383 | + | r.EXPECT().Close().Return().Once() | |||
| 384 | + | k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil) | |||
| 385 | + | k.EXPECT().createBlob(mock.Anything).Return(99, nil) | |||
| 386 | + | k.EXPECT().destroyBlob(mock.Anything).Return(nil).Maybe() | |||
| 387 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |||
| 388 | + | defer cancel() | |||
| 389 | + | done := make(chan error, 1) | |||
| 390 | + | go func() { | |||
| 391 | + | done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, nil, nil, nil, nil, make(chan ports.OutputPresented, 8), nil, nil) | |||
| 392 | + | }() | |||
| 393 | + | for { | |||
| 394 | + | select { | |||
| 395 | + | case ev := <-events: | |||
| 396 | + | if _, ok := ev.(ports.SecurityOutputProof); !ok { | |||
| 397 | + | continue | |||
| 398 | + | } | |||
| 399 | + | if n := activations.Load(); n < 3 { | |||
| 400 | + | t.Fatalf("proof after %d activation attempts; refused ones are no evidence", n) | |||
| 401 | + | } | |||
| 402 | + | cancel() | |||
| 403 | + | if err := <-done; err != nil { | |||
| 404 | + | t.Fatalf("output stopped: %v", err) | |||
| 405 | + | } | |||
| 406 | + | return | |||
| 407 | + | case err := <-done: | |||
| 408 | + | t.Fatalf("output stopped on refused protected commit: %v", err) | |||
| 409 | + | case <-ctx.Done(): | |||
| 410 | + | t.Fatal("no proof after retries") | |||
| 411 | + | } | |||
| 412 | + | } | |||
| 413 | + | } | |||
M internal/adapters/drm/capture_session_test.go +1 -1
| @@ -295,7 +295,7 @@ func TestHiddenWorkspaceChildKeepsDirectScanout(t *testing.T) { | |||||
| 295 | pipeline.Close(display) | 295 | pipeline.Close(display) | ||
| 296 | } | 296 | } | ||
| 297 | 297 | ||||
| 298 | - | // GO-002: child holds do not expire, so a finished child read must lift its | 298 | + | // Child holds do not expire, so a finished child read must lift its |
| 299 | // hold even while the display's own commit never completes. The display's | 299 | // hold even while the display's own commit never completes. The display's | ||
| 300 | // Seen stays at the last safe value and the output is not stopped. | 300 | // Seen stays at the last safe value and the output is not stopped. | ||
| 301 | func TestRunChildHoldLiftedWhileDisplayCommitStalls(t *testing.T) { | 301 | func TestRunChildHoldLiftedWhileDisplayCommitStalls(t *testing.T) { | ||
M internal/adapters/drm/frame_lifecycle.go +1 -0
| @@ -26,6 +26,7 @@ type frameLifecycle struct { | |||||
| 26 | 26 | ||||
| 27 | // pendingFrame is what the pending frame commit shows. | 27 | // pendingFrame is what the pending frame commit shows. | ||
| 28 | type pendingFrame struct { | 28 | type pendingFrame struct { | ||
| 29 | + | security ports.SecurityState // admission epoch checked again at KMS boundary | |||
| 29 | frame bool | 30 | frame bool | ||
| 30 | queued uint64 | 31 | queued uint64 | ||
| 31 | zeroCopy ports.WindowID // window shown without composition | 32 | zeroCopy ports.WindowID // window shown without composition | ||
M internal/adapters/drm/frame_submit.go +49 -4
| @@ -23,9 +23,15 @@ func (e renderError) Unwrap() error { return e.err } | |||||
| 23 | // without the overlay; a renderError stops the output; other errors are | 23 | // without the overlay; a renderError stops the output; other errors are | ||
| 24 | // commit errors for commitFailed. | 24 | // commit errors for commitFailed. | ||
| 25 | func (o *Output) submitFrame(ctx context.Context, r ports.Renderer, scene ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, seen map[ports.WindowID]uint64, requests []ports.CaptureRequest, pipeline *capture.Pipeline) (direct bool, err error) { | 25 | func (o *Output) submitFrame(ctx context.Context, r ports.Renderer, scene ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, seen map[ports.WindowID]uint64, requests []ports.CaptureRequest, pipeline *capture.Pipeline) (direct bool, err error) { | ||
| 26 | + | if !o.sceneCurrent(scene) { | |||
| 27 | + | return false, errSecurityScene | |||
| 28 | + | } | |||
| 29 | + | if o.protected { | |||
| 30 | + | return false, o.submitProtectedFrame(ctx, r, scene, surfaces, seen) | |||
| 31 | + | } | |||
| 26 | // shows is what the frame puts on screen: presentation feedback of | 32 | // shows is what the frame puts on screen: presentation feedback of | ||
| 27 | // windows it does not draw is discarded, not presented. | 33 | // windows it does not draw is discarded, not presented. | ||
| 28 | - | f := pendingFrame{shows: o.shownBy(scene, seen)} | 34 | + | f := pendingFrame{security: scene.Security, shows: o.shownBy(scene, seen)} |
| 29 | o.wantContent(scene, surfaces) | 35 | o.wantContent(scene, surfaces) | ||
| 30 | // Clean requests that cannot be served (their session is not the | 36 | // Clean requests that cannot be served (their session is not the | ||
| 31 | // scene's) are failed here, never answered from the displayed frame. | 37 | // scene's) are failed here, never answered from the displayed frame. | ||
| @@ -33,6 +39,9 @@ func (o *Output) submitFrame(ctx context.Context, r ports.Renderer, scene ports. | |||||
| 33 | pipeline.Retire(scene) | 39 | pipeline.Retire(scene) | ||
| 34 | hiddenClean := len(clean) > 0 && scene.CaptureScene != nil | 40 | hiddenClean := len(clean) > 0 && scene.CaptureScene != nil | ||
| 35 | if hiddenClean { | 41 | if hiddenClean { | ||
| 42 | + | if !o.sceneCurrent(scene) || o.protected { | |||
| 43 | + | return false, errSecurityScene | |||
| 44 | + | } | |||
| 36 | // A hidden workspace: the child renderer draws it, whatever the display | 45 | // A hidden workspace: the child renderer draws it, whatever the display | ||
| 37 | // does (scanout included). Failures answer the requests and never stop | 46 | // does (scanout included). Failures answer the requests and never stop | ||
| 38 | // the output. | 47 | // the output. | ||
| @@ -50,9 +59,13 @@ func (o *Output) submitFrame(ctx context.Context, r ports.Renderer, scene ports. | |||||
| 50 | // force composition: scanout and the overlay plane would skip them. The | 59 | // force composition: scanout and the overlay plane would skip them. The | ||
| 51 | // hidden-workspace child needs none, so it keeps direct scanout. | 60 | // hidden-workspace child needs none, so it keeps direct scanout. | ||
| 52 | decision := o.decideFrame(scene, surfaces, len(normal) > 0 || len(clean) > 0 || capture.BorderVisible(scene)) | 61 | decision := o.decideFrame(scene, surfaces, len(normal) > 0 || len(clean) > 0 || capture.BorderVisible(scene)) | ||
| 62 | + | if !o.sceneCurrent(scene) || o.protected { | |||
| 63 | + | decision.overlay.close() | |||
| 64 | + | return false, errSecurityScene | |||
| 65 | + | } | |||
| 53 | if decision.fb != 0 { | 66 | if decision.fb != 0 { | ||
| 54 | c := decision.content | 67 | c := decision.content | ||
| 55 | - | if direct, err = o.commitScanoutRect(decision.fb, c, pendingFrame{shows: o.directShownBy(c.ID, seen[c.ID])}, decision.rect); direct { | 68 | + | if direct, err = o.commitScanoutRect(decision.fb, c, pendingFrame{security: scene.Security, shows: o.directShownBy(c.ID, seen[c.ID])}, decision.rect); direct { |
| 56 | return true, err | 69 | return true, err | ||
| 57 | } | 70 | } | ||
| 58 | decision = o.composeFrame(scene, surfaces) | 71 | decision = o.composeFrame(scene, surfaces) | ||
| @@ -65,12 +78,28 @@ func (o *Output) submitFrame(ctx context.Context, r ports.Renderer, scene ports. | |||||
| 65 | // Cost: a second composition on frames with a clean request. | 78 | // Cost: a second composition on frames with a clean request. | ||
| 66 | cdone, cerr := r.Render(pipeline.CleanScene(composed), surfaces) | 79 | cdone, cerr := r.Render(pipeline.CleanScene(composed), surfaces) | ||
| 67 | if cerr != nil { | 80 | if cerr != nil { | ||
| 81 | + | o.holdRead(cdone) | |||
| 82 | + | if cdone != nil { | |||
| 83 | + | cdone.Close() | |||
| 84 | + | } | |||
| 85 | + | ov.close() | |||
| 68 | return false, renderError{cerr} | 86 | return false, renderError{cerr} | ||
| 69 | } | 87 | } | ||
| 88 | + | // A clean composition is not capture admission. Engage may have | |||
| 89 | + | // happened while Render submitted GPU work; retain its client reads | |||
| 90 | + | // and leave the request credit with the caller on rejection. | |||
| 91 | + | if !o.sceneCurrent(scene) || o.protected { | |||
| 92 | + | o.holdRead(cdone) | |||
| 93 | + | if cdone != nil { | |||
| 94 | + | cdone.Close() | |||
| 95 | + | } | |||
| 96 | + | ov.close() | |||
| 97 | + | return false, errSecurityScene | |||
| 98 | + | } | |||
| 70 | if cdone != nil { | 99 | if cdone != nil { | ||
| 71 | cdone.Close() | 100 | cdone.Close() | ||
| 72 | } | 101 | } | ||
| 73 | - | pipeline.Submit(r, clean) | 102 | + | pipeline.SubmitScoped(scene.Security, r, clean) |
| 74 | // Ownership moved to the worker (or an immediate failure reply). | 103 | // Ownership moved to the worker (or an immediate failure reply). | ||
| 75 | clear(clean) | 104 | clear(clean) | ||
| 76 | } | 105 | } | ||
| @@ -90,14 +119,30 @@ func (o *Output) submitFrame(ctx context.Context, r ports.Renderer, scene ports. | |||||
| 90 | return false, renderError{rerr} | 119 | return false, renderError{rerr} | ||
| 91 | } | 120 | } | ||
| 92 | } | 121 | } | ||
| 122 | + | if !o.sceneCurrent(scene) || o.protected { | |||
| 123 | + | o.holdRead(done) | |||
| 124 | + | if done != nil { | |||
| 125 | + | done.Close() | |||
| 126 | + | } | |||
| 127 | + | ov.close() | |||
| 128 | + | return false, errSecurityScene | |||
| 129 | + | } | |||
| 93 | if len(normal) > 0 { | 130 | if len(normal) > 0 { | ||
| 94 | // Scanout and the overlay plane were skipped for this frame. | 131 | // Scanout and the overlay plane were skipped for this frame. | ||
| 95 | o.log.Debug().Str("connector", o.conn.name).Int("captures", len(normal)).Msg("capture frame composed") | 132 | o.log.Debug().Str("connector", o.conn.name).Int("captures", len(normal)).Msg("capture frame composed") | ||
| 96 | - | pipeline.Submit(r, normal) | 133 | + | pipeline.SubmitScoped(scene.Security, r, normal) |
| 97 | // Ownership moved to the worker (or an immediate failure reply). | 134 | // Ownership moved to the worker (or an immediate failure reply). | ||
| 98 | // A later commit panic must not fail the same requests again. | 135 | // A later commit panic must not fail the same requests again. | ||
| 99 | clear(normal) | 136 | clear(normal) | ||
| 100 | } | 137 | } | ||
| 138 | + | if !o.sceneCurrent(scene) || o.protected { | |||
| 139 | + | o.holdRead(done) | |||
| 140 | + | if done != nil { | |||
| 141 | + | done.Close() | |||
| 142 | + | } | |||
| 143 | + | ov.close() | |||
| 144 | + | return false, errSecurityScene | |||
| 145 | + | } | |||
| 101 | // The overlay buffer is on screen like a scanned-out one: it is | 146 | // The overlay buffer is on screen like a scanned-out one: it is | ||
| 102 | // reported shown, so it is not released under the plane. | 147 | // reported shown, so it is not released under the plane. | ||
| 103 | f.queued, f.zeroCopy, f.composed = ov.buf, ov.id, true | 148 | f.queued, f.zeroCopy, f.composed = ov.buf, ov.id, true | ||
M internal/adapters/drm/output.go +324 -34
| @@ -26,6 +26,17 @@ import ( | |||||
| 26 | // it with EBUSY (it retries soon), and an event that never comes is | 26 | // it with EBUSY (it retries soon), and an event that never comes is | ||
| 27 | // bounded (stuckTimeout, then a modeset). | 27 | // bounded (stuckTimeout, then a modeset). | ||
| 28 | type Output struct { | 28 | type Output struct { | ||
| 29 | + | // Set before Run. The backend owns instance registration and wake routing. | |||
| 30 | + | Security ports.SessionSecurity | |||
| 31 | + | SecurityChanges <-chan ports.SecurityState | |||
| 32 | + | Instance ports.OutputInstance | |||
| 33 | + | SecurityEvents chan<- ports.SecurityBackendEvent | |||
| 34 | + | securityState ports.SecurityState | |||
| 35 | + | securityPrepared bool | |||
| 36 | + | securityInvalid bool | |||
| 37 | + | inactiveOnClose bool // affirmative terminal KMS result, owned by Close | |||
| 38 | + | runContext context.Context // bounds compositor clear waits during Run startup | |||
| 39 | + | ||||
| 29 | k kms | 40 | k kms | ||
| 30 | flipped <-chan flipEvent // commit events of this CRTC, from Card.ReadEvents | 41 | flipped <-chan flipEvent // commit events of this CRTC, from Card.ReadEvents | ||
| 31 | crtc uint32 | 42 | crtc uint32 | ||
| @@ -130,6 +141,14 @@ type Output struct { | |||||
| 130 | // off: the CRTC is inactive for it. Every modeset (resume, recovery) | 141 | // off: the CRTC is inactive for it. Every modeset (resume, recovery) | ||
| 131 | // follows wantOff, so a display turned off never lights up. | 142 | // follows wantOff, so a display turned off never lights up. | ||
| 132 | wantOff, off bool | 143 | wantOff, off bool | ||
| 144 | + | // protected is an output-owned latch. Ordinary modesets must not expose | |||
| 145 | + | // a previous desktop target while it is set; only protectedModeset may | |||
| 146 | + | // activate a target after clearing it and waiting for the GPU. | |||
| 147 | + | protected bool | |||
| 148 | + | // protectBackoff is the current retry delay after a refused protected | |||
| 149 | + | // commit; protectNotBefore the earliest next attempt (zero: none). | |||
| 150 | + | protectBackoff time.Duration | |||
| 151 | + | protectNotBefore time.Time | |||
| 133 | // kind is how the images were made. | 152 | // kind is how the images were made. | ||
| 134 | kind imageKind | 153 | kind imageKind | ||
| 135 | // formats receives the direct scanout formats after each modeset; | 154 | // formats receives the direct scanout formats after each modeset; | ||
| @@ -383,12 +402,58 @@ func (o *Output) primaryRectProps(req *atomicReq, fb uint32, rect planeRect) { | |||||
| 383 | // other plane on the CRTC; needed at start and after every VT resume. | 402 | // other plane on the CRTC; needed at start and after every VT resume. | ||
| 384 | // It blocks until the kernel applied it. | 403 | // It blocks until the kernel applied it. | ||
| 385 | func (o *Output) modeset() error { | 404 | func (o *Output) modeset() error { | ||
| 405 | + | o.observeSecurity() | |||
| 406 | + | return o.modesetImage(o.fbs[1-o.back], !o.wantOff && !o.protected) | |||
| 407 | + | } | |||
| 408 | + | ||||
| 409 | + | // modesetImage is called with an active protected image only by | |||
| 410 | + | // protectedModeset, after its clear fence has signalled. | |||
| 411 | + | func (o *Output) modesetImage(fb uint32, active bool) error { | |||
| 412 | + | if o.protected && o.frame.pendingCommit() { | |||
| 413 | + | return errProtectionPending | |||
| 414 | + | } | |||
| 415 | + | if o.protected && !active { | |||
| 416 | + | if err := o.disableProtected(); err != nil { | |||
| 417 | + | return err | |||
| 418 | + | } | |||
| 419 | + | o.frame.resetAfterModeset() | |||
| 420 | + | o.planeRect = fullPlaneRect(o.Width(), o.Height()) | |||
| 421 | + | o.signalReady() | |||
| 422 | + | return nil | |||
| 423 | + | } | |||
| 386 | blob, err := o.modeBlobFor(o.mode) | 424 | blob, err := o.modeBlobFor(o.mode) | ||
| 387 | if err != nil { | 425 | if err != nil { | ||
| 426 | + | if o.protected { | |||
| 427 | + | return protectedCommitError{err} | |||
| 428 | + | } | |||
| 388 | return err | 429 | return err | ||
| 389 | } | 430 | } | ||
| 390 | - | if err := o.k.commit(o.modesetReq(blob, !o.wantOff), atomicAllowModes, 0); err != nil { | 431 | + | var req *atomicReq |
| 432 | + | if o.protected { | |||
| 433 | + | req = o.modesetBaseReq(blob, active) | |||
| 434 | + | if err := o.detachProtectedPlanes(req); err != nil { | |||
| 435 | + | _ = o.k.destroyBlob(blob) | |||
| 436 | + | return protectedCommitError{err} | |||
| 437 | + | } | |||
| 438 | + | if active { | |||
| 439 | + | o.primaryProps(req, fb) | |||
| 440 | + | } | |||
| 441 | + | } else { | |||
| 442 | + | req = o.modesetReq(blob, active) | |||
| 443 | + | } | |||
| 444 | + | // Do not admit a desktop-front modeset after an engage observed while | |||
| 445 | + | // constructing the request. Protected activation only contains black. | |||
| 446 | + | wasProtected := o.protected | |||
| 447 | + | o.observeSecurity() | |||
| 448 | + | if active && !wasProtected && o.protected { | |||
| 449 | + | _ = o.k.destroyBlob(blob) | |||
| 450 | + | return errSecurityScene | |||
| 451 | + | } | |||
| 452 | + | if err := o.k.commit(req, atomicAllowModes, 0); err != nil { | |||
| 391 | _ = o.k.destroyBlob(blob) | 453 | _ = o.k.destroyBlob(blob) | ||
| 454 | + | if o.protected { | |||
| 455 | + | return protectedCommitError{fmt.Errorf("modeset: %w", err)} | |||
| 456 | + | } | |||
| 392 | return fmt.Errorf("modeset: %w", err) | 457 | return fmt.Errorf("modeset: %w", err) | ||
| 393 | } | 458 | } | ||
| 394 | // Only now is nothing of ours pending or on screen: on failure the | 459 | // Only now is nothing of ours pending or on screen: on failure the | ||
| @@ -403,7 +468,7 @@ func (o *Output) modeset() error { | |||||
| 403 | _ = o.k.destroyBlob(o.modeBlob) | 468 | _ = o.k.destroyBlob(o.modeBlob) | ||
| 404 | } | 469 | } | ||
| 405 | o.modeBlob = blob | 470 | o.modeBlob = blob | ||
| 406 | - | o.vrrOn, o.vrrGame, o.overlayOn, o.off = false, false, 0, o.wantOff | 471 | + | o.vrrOn, o.vrrGame, o.overlayOn, o.off = false, false, 0, !active |
| 407 | // The gap and the traced flip interval belonged to the old state. | 472 | // The gap and the traced flip interval belonged to the old state. | ||
| 408 | o.flipGapUntil, o.lastFlipAt = time.Time{}, 0 | 473 | o.flipGapUntil, o.lastFlipAt = time.Time{}, 0 | ||
| 409 | o.contentValue, o.contentWanted = o.contentValues[0], o.contentValues[0] | 474 | o.contentValue, o.contentWanted = o.contentValues[0], o.contentValues[0] | ||
| @@ -413,16 +478,22 @@ func (o *Output) modeset() error { | |||||
| 413 | o.cursor.screen, o.cursor.flying = 0, false | 478 | o.cursor.screen, o.cursor.flying = 0, false | ||
| 414 | } | 479 | } | ||
| 415 | o.log.Info().Str("connector", o.conn.name).Bool("off", o.off).Msg("modeset") | 480 | o.log.Info().Str("connector", o.conn.name).Bool("off", o.off).Msg("modeset") | ||
| 416 | - | if !o.off { | 481 | + | if !o.off && !o.protected { |
| 417 | - | // An inactive CRTC tells nothing about the cursor plane. | 482 | + | // An inactive CRTC tells nothing about the cursor plane. Protected |
| 483 | + | // activation keeps it detached; do not test desktop cursor images. | |||
| 418 | o.testCursor() | 484 | o.testCursor() | ||
| 419 | } | 485 | } | ||
| 420 | o.sendFormats() | 486 | o.sendFormats() | ||
| 487 | + | o.signalReady() | |||
| 488 | + | return nil | |||
| 489 | + | } | |||
| 490 | + | ||||
| 491 | + | // signalReady reports the first successful modeset once. | |||
| 492 | + | func (o *Output) signalReady() { | |||
| 421 | if o.ready != nil && !o.readySent { | 493 | if o.ready != nil && !o.readySent { | ||
| 422 | o.ready <- nil | 494 | o.ready <- nil | ||
| 423 | o.readySent = true | 495 | o.readySent = true | ||
| 424 | } | 496 | } | ||
| 425 | - | return nil | |||
| 426 | } | 497 | } | ||
| 427 | 498 | ||||
| 428 | // powerOff turns the display off: the CRTC goes inactive and keeps its | 499 | // powerOff turns the display off: the CRTC goes inactive and keeps its | ||
| @@ -513,9 +584,8 @@ func (o *Output) cursorOff(err error) { | |||||
| 513 | o.log.Warn().Err(err).Str("connector", o.conn.name).Msg("cursor plane refused; hardware cursor off") | 584 | o.log.Warn().Err(err).Str("connector", o.conn.name).Msg("cursor plane refused; hardware cursor off") | ||
| 514 | } | 585 | } | ||
| 515 | 586 | ||||
| 516 | - | // modesetReq is the modeset of the front image with mode blob; active | 587 | + | // modesetBaseReq sets the CRTC and connector without attaching any plane. |
| 517 | - | // false keeps the display off (output power). | 588 | + | func (o *Output) modesetBaseReq(blob uint32, active bool) *atomicReq { |
| 518 | - | func (o *Output) modesetReq(blob uint32, active bool) *atomicReq { | |||
| 519 | req := &atomicReq{} | 589 | req := &atomicReq{} | ||
| 520 | req.set(o.crtc, o.crtcProps["MODE_ID"], uint64(blob)) | 590 | req.set(o.crtc, o.crtcProps["MODE_ID"], uint64(blob)) | ||
| 521 | req.set(o.crtc, o.crtcProps["ACTIVE"], boolValue(active)) | 591 | req.set(o.crtc, o.crtcProps["ACTIVE"], boolValue(active)) | ||
| @@ -523,6 +593,13 @@ func (o *Output) modesetReq(blob uint32, active bool) *atomicReq { | |||||
| 523 | req.set(o.conn.id, o.contentProp, o.contentValues[0]) | 593 | req.set(o.conn.id, o.contentProp, o.contentValues[0]) | ||
| 524 | req.set(o.conn.id, o.connCrtc, uint64(o.crtc)) | 594 | req.set(o.conn.id, o.connCrtc, uint64(o.crtc)) | ||
| 525 | o.hdrConnectorProps(req, o.hdrOn) | 595 | o.hdrConnectorProps(req, o.hdrOn) | ||
| 596 | + | return req | |||
| 597 | + | } | |||
| 598 | + | ||||
| 599 | + | // modesetReq is the unlocked modeset of the front image with mode blob; | |||
| 600 | + | // active false keeps the display off (output power). | |||
| 601 | + | func (o *Output) modesetReq(blob uint32, active bool) *atomicReq { | |||
| 602 | + | req := o.modesetBaseReq(blob, active) | |||
| 526 | o.primaryProps(req, o.fbs[1-o.back]) | 603 | o.primaryProps(req, o.fbs[1-o.back]) | ||
| 527 | if o.cursor != nil { | 604 | if o.cursor != nil { | ||
| 528 | o.cursor.props(req, o.crtc, cursorState{}) | 605 | o.cursor.props(req, o.crtc, cursorState{}) | ||
| @@ -634,10 +711,17 @@ func (o *Output) commitWith(fb uint32, fence *os.File, async bool, vrr bool, f p | |||||
| 634 | } | 711 | } | ||
| 635 | 712 | ||||
| 636 | func (o *Output) commitWithRect(fb uint32, fence *os.File, async bool, vrr bool, f pendingFrame, ov overlayWin, rect planeRect) error { | 713 | func (o *Output) commitWithRect(fb uint32, fence *os.File, async bool, vrr bool, f pendingFrame, ov overlayWin, rect planeRect) error { | ||
| 714 | + | o.observeSecurity() | |||
| 715 | + | if o.Security != nil && (f.security != o.securityState || o.protected && !o.securityPrepared) { | |||
| 716 | + | return errSecurityScene | |||
| 717 | + | } | |||
| 637 | cur := cursorState{} | 718 | cur := cursorState{} | ||
| 638 | - | if o.cursor != nil { | 719 | + | if o.cursor != nil && !o.protected { |
| 639 | cur = o.cursor.desired() | 720 | cur = o.cursor.desired() | ||
| 640 | } | 721 | } | ||
| 722 | + | if o.protected { | |||
| 723 | + | async, vrr, ov = false, false, overlayWin{} | |||
| 724 | + | } | |||
| 641 | if async && (vrr != o.vrrOn || o.cursor != nil && cur != o.cursor.applied || fence != nil && !o.asyncFence || ov.buf != o.overlayOn || rect != o.planeRect || o.contentWanted != o.contentValue) { | 725 | if async && (vrr != o.vrrOn || o.cursor != nil && cur != o.cursor.applied || fence != nil && !o.asyncFence || ov.buf != o.overlayOn || rect != o.planeRect || o.contentWanted != o.contentValue) { | ||
| 642 | async = false | 726 | async = false | ||
| 643 | } | 727 | } | ||
| @@ -660,6 +744,12 @@ func (o *Output) commitWithRect(fb uint32, fence *os.File, async bool, vrr bool, | |||||
| 660 | if fence != nil { | 744 | if fence != nil { | ||
| 661 | req.set(o.primary.id, o.primary.prop("IN_FENCE_FD"), uint64(fence.Fd())) | 745 | req.set(o.primary.id, o.primary.prop("IN_FENCE_FD"), uint64(fence.Fd())) | ||
| 662 | } | 746 | } | ||
| 747 | + | // Admission linearizes at this last defensive snapshot. An already | |||
| 748 | + | // admitted native ioctl can complete, but precedes the black proof. | |||
| 749 | + | o.observeSecurity() | |||
| 750 | + | if o.Security != nil && (f.security != o.securityState || o.protected && !o.securityPrepared) { | |||
| 751 | + | return errSecurityScene | |||
| 752 | + | } | |||
| 663 | // The kernel takes its own reference on the fence. | 753 | // The kernel takes its own reference on the fence. | ||
| 664 | if err := o.k.commit(req, flags, o.frame.userData(userFrame)); err != nil { | 754 | if err := o.k.commit(req, flags, o.frame.userData(userFrame)); err != nil { | ||
| 665 | if !async && cur.on && errors.Is(err, unix.EINVAL) && o.cursorRefused() { | 755 | if !async && cur.on && errors.Is(err, unix.EINVAL) && o.cursorRefused() { | ||
| @@ -735,6 +825,10 @@ func (o *Output) cursorRefused() bool { | |||||
| 735 | 825 | ||||
| 736 | // commitState commits a cursor or VRR change without a new frame. | 826 | // commitState commits a cursor or VRR change without a new frame. | ||
| 737 | func (o *Output) commitState(vrr bool) error { | 827 | func (o *Output) commitState(vrr bool) error { | ||
| 828 | + | o.observeSecurity() | |||
| 829 | + | if o.protected { | |||
| 830 | + | return errSecurityScene | |||
| 831 | + | } | |||
| 738 | req := &atomicReq{} | 832 | req := &atomicReq{} | ||
| 739 | cur := cursorState{} | 833 | cur := cursorState{} | ||
| 740 | // A move back to the applied state holds nothing. | 834 | // A move back to the applied state holds nothing. | ||
| @@ -757,6 +851,10 @@ func (o *Output) commitState(vrr bool) error { | |||||
| 757 | if len(req.objs) == 0 { | 851 | if len(req.objs) == 0 { | ||
| 758 | return nil | 852 | return nil | ||
| 759 | } | 853 | } | ||
| 854 | + | o.observeSecurity() | |||
| 855 | + | if o.protected { | |||
| 856 | + | return errSecurityScene | |||
| 857 | + | } | |||
| 760 | if err := o.k.commit(req, atomicNonblock|flipEventFlag, o.frame.userData(userState)); err != nil { | 858 | if err := o.k.commit(req, atomicNonblock|flipEventFlag, o.frame.userData(userState)); err != nil { | ||
| 761 | if o.overlayConflict(err, o.overlayOn) { | 859 | if o.overlayConflict(err, o.overlayOn) { | ||
| 762 | // The next frame is composed without the overlay. | 860 | // The next frame is composed without the overlay. | ||
| @@ -864,9 +962,13 @@ func (o *Output) setAsync(on bool) { | |||||
| 864 | } | 962 | } | ||
| 865 | } | 963 | } | ||
| 866 | 964 | ||||
| 867 | - | // Close restores the CRTC state found at startup, when it had a mode, and | 965 | + | // Close frees buffers and disables scanout. Without a security gate it |
| 868 | - | // frees buffers. | 966 | + | // retains legacy restoration of the CRTC state found at startup; a wired |
| 967 | + | // gate never admits saved desktop pixels, even if its snapshot is unlocked. | |||
| 869 | func (o *Output) Close() { | 968 | func (o *Output) Close() { | ||
| 969 | + | o.inactiveOnClose = false | |||
| 970 | + | // Close may run after Run stopped, without receiving the engage wake. | |||
| 971 | + | o.observeSecurity() | |||
| 870 | req := &atomicReq{} | 972 | req := &atomicReq{} | ||
| 871 | if o.cursor != nil { | 973 | if o.cursor != nil { | ||
| 872 | o.cursor.props(req, o.crtc, cursorState{}) | 974 | o.cursor.props(req, o.crtc, cursorState{}) | ||
| @@ -878,7 +980,7 @@ func (o *Output) Close() { | |||||
| 878 | var blob uint32 | 980 | var blob uint32 | ||
| 879 | p := o.primary | 981 | p := o.primary | ||
| 880 | restored := false | 982 | restored := false | ||
| 881 | - | if s.crtcID != 0 && s.modeValid != 0 && s.fbID != 0 { | 983 | + | if o.Security == nil && !o.protected && s.crtcID != 0 && s.modeValid != 0 && s.fbID != 0 { |
| 882 | if b, err := o.modeBlobFor(s.mode); err == nil { | 984 | if b, err := o.modeBlobFor(s.mode); err == nil { | ||
| 883 | blob, restored = b, true | 985 | blob, restored = b, true | ||
| 884 | req.set(o.crtc, o.crtcProps["MODE_ID"], uint64(b)) | 986 | req.set(o.crtc, o.crtcProps["MODE_ID"], uint64(b)) | ||
| @@ -904,6 +1006,16 @@ func (o *Output) Close() { | |||||
| 904 | req.set(p.id, p.prop("CRTC_ID"), 0) | 1006 | req.set(p.id, p.prop("CRTC_ID"), 0) | ||
| 905 | req.set(o.conn.id, o.connCrtc, 0) | 1007 | req.set(o.conn.id, o.connCrtc, 0) | ||
| 906 | } | 1008 | } | ||
| 1009 | + | // Only the validated detachment path can certify terminal inactivity. | |||
| 1010 | + | // Legacy nil-gate restoration/disable remains best effort, not evidence. | |||
| 1011 | + | detached := false | |||
| 1012 | + | if o.protected || o.Security != nil { | |||
| 1013 | + | if err := o.detachProtectedPlanes(req); err != nil { | |||
| 1014 | + | o.log.Warn().Err(err).Str("connector", o.conn.name).Msg("protected close plane detachment") | |||
| 1015 | + | } else { | |||
| 1016 | + | detached = o.primary != nil && o.crtc != 0 && o.crtcProps["ACTIVE"] != 0 | |||
| 1017 | + | } | |||
| 1018 | + | } | |||
| 907 | // A live metadata blob means an HDR modeset may still be on screen, even | 1019 | // A live metadata blob means an HDR modeset may still be on screen, even | ||
| 908 | // after a failed SDR fallback (hdrOn is then false): it is freed only once | 1020 | // after a failed SDR fallback (hdrOn is then false): it is freed only once | ||
| 909 | // an SDR modeset succeeds. | 1021 | // an SDR modeset succeeds. | ||
| @@ -916,8 +1028,12 @@ func (o *Output) Close() { | |||||
| 916 | ev = o.log.Warn() | 1028 | ev = o.log.Warn() | ||
| 917 | } | 1029 | } | ||
| 918 | ev.Err(err).Str("connector", o.conn.name).Bool("hdr", hdrShown).Msg("restore crtc") | 1030 | ev.Err(err).Str("connector", o.conn.name).Bool("hdr", hdrShown).Msg("restore crtc") | ||
| 919 | - | } else if hdrShown { | 1031 | + | } else { |
| 920 | - | o.log.Info().Str("connector", o.conn.name).Msg("HDR10 off") | 1032 | + | active, hasActive := req.value(o.crtc, o.crtcProps["ACTIVE"]) |
| 1033 | + | o.inactiveOnClose = !restored && detached && hasActive && active == 0 | |||
| 1034 | + | if hdrShown { | |||
| 1035 | + | o.log.Info().Str("connector", o.conn.name).Msg("HDR10 off") | |||
| 1036 | + | } | |||
| 921 | } | 1037 | } | ||
| 922 | if blob != 0 { | 1038 | if blob != 0 { | ||
| 923 | _ = o.k.destroyBlob(blob) | 1039 | _ = o.k.destroyBlob(blob) | ||
| @@ -940,6 +1056,12 @@ func (o *Output) Close() { | |||||
| 940 | o.dropClientFBs(time.Now(), true) | 1056 | o.dropClientFBs(time.Now(), true) | ||
| 941 | } | 1057 | } | ||
| 942 | 1058 | ||||
| 1059 | + | // InactiveOnClose reports affirmative terminal inactivity, not cached power | |||
| 1060 | + | // state. Call only after Close's owner has finished and a done channel (or | |||
| 1061 | + | // equivalent owner-completion synchronization) has been observed. It is not | |||
| 1062 | + | // safe to read concurrently with the output owner; no shared mutation is added. | |||
| 1063 | + | func (o *Output) InactiveOnClose() bool { return o.inactiveOnClose } | |||
| 1064 | + | ||||
| 943 | // Run renders scenes and commits them until ctx ends. active reports seat | 1065 | // Run renders scenes and commits them until ctx ends. active reports seat | ||
| 944 | // enable/disable. What the output shows and read is reported on presented. | 1066 | // enable/disable. What the output shows and read is reported on presented. | ||
| 945 | func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Renderer, error), loadCursor CursorLoader, active <-chan bool, scenes <-chan ports.Scene, contents <-chan ports.SurfaceContent, cursor <-chan ports.CursorChange, presented chan<- ports.OutputPresented, captures <-chan ports.CaptureRequest, captured chan<- ports.CaptureDone) (runErr error) { | 1067 | func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Renderer, error), loadCursor CursorLoader, active <-chan bool, scenes <-chan ports.Scene, contents <-chan ports.SurfaceContent, cursor <-chan ports.CursorChange, presented chan<- ports.OutputPresented, captures <-chan ports.CaptureRequest, captured chan<- ports.CaptureDone) (runErr error) { | ||
| @@ -956,6 +1078,8 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||||
| 956 | panic(p) | 1078 | panic(p) | ||
| 957 | } | 1079 | } | ||
| 958 | }() | 1080 | }() | ||
| 1081 | + | o.runContext = ctx | |||
| 1082 | + | o.observeSecurity() | |||
| 959 | r, err := newRenderer(o.Width(), o.Height()) | 1083 | r, err := newRenderer(o.Width(), o.Height()) | ||
| 960 | if err != nil { | 1084 | if err != nil { | ||
| 961 | return fmt.Errorf("create renderer: %w", err) | 1085 | return fmt.Errorf("create renderer: %w", err) | ||
| @@ -978,9 +1102,15 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||||
| 978 | // While switched away they are made on the first enable. | 1102 | // While switched away they are made on the first enable. | ||
| 979 | if enabled { | 1103 | if enabled { | ||
| 980 | if err := o.showImages(r, imagesDriver, nil); err != nil { | 1104 | if err := o.showImages(r, imagesDriver, nil); err != nil { | ||
| 981 | - | return err | 1105 | + | // While protected, a recoverable KMS failure keeps the output |
| 1106 | + | // registered and dark; the loop retries with bounded backoff. | |||
| 1107 | + | if !o.protected || !o.commitFailed(err, &enabled) { | |||
| 1108 | + | return err | |||
| 1109 | + | } | |||
| 1110 | + | } | |||
| 1111 | + | if !o.protected { | |||
| 1112 | + | o.probeAsync(r) | |||
| 982 | } | 1113 | } | ||
| 983 | - | o.probeAsync(r) | |||
| 984 | } | 1114 | } | ||
| 985 | surfaces := make(map[ports.WindowID]ports.SurfaceContent) | 1115 | surfaces := make(map[ports.WindowID]ports.SurfaceContent) | ||
| 986 | var scene ports.Scene | 1116 | var scene ports.Scene | ||
| @@ -992,6 +1122,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||||
| 992 | requests := requestStorage[:0] | 1122 | requests := requestStorage[:0] | ||
| 993 | ctx, cancelCaptures := context.WithCancel(ctx) | 1123 | ctx, cancelCaptures := context.WithCancel(ctx) | ||
| 994 | pipeline := capture.NewPipeline(ctx, captured) | 1124 | pipeline := capture.NewPipeline(ctx, captured) | ||
| 1125 | + | pipeline.Security = o.Security | |||
| 995 | if o.NewCaptureRenderer != nil { | 1126 | if o.NewCaptureRenderer != nil { | ||
| 996 | pipeline.EnableOffscreen(o.NewCaptureRenderer) | 1127 | pipeline.EnableOffscreen(o.NewCaptureRenderer) | ||
| 997 | o.capHidden = pipeline.CapHiddenSeen | 1128 | o.capHidden = pipeline.CapHiddenSeen | ||
| @@ -1024,6 +1155,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||||
| 1024 | retryTimer := time.NewTimer(time.Hour) | 1155 | retryTimer := time.NewTimer(time.Hour) | ||
| 1025 | vrrTimer := time.NewTimer(time.Hour) | 1156 | vrrTimer := time.NewTimer(time.Hour) | ||
| 1026 | cursorTimer := time.NewTimer(time.Hour) | 1157 | cursorTimer := time.NewTimer(time.Hour) | ||
| 1158 | + | protectTimer := time.NewTimer(time.Hour) | |||
| 1027 | o.frame.startTimer() | 1159 | o.frame.startTimer() | ||
| 1028 | gapTimer := time.NewTimer(time.Hour) | 1160 | gapTimer := time.NewTimer(time.Hour) | ||
| 1029 | gapTimer.Stop() | 1161 | gapTimer.Stop() | ||
| @@ -1031,6 +1163,8 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||||
| 1031 | retryTimer.Stop() | 1163 | retryTimer.Stop() | ||
| 1032 | vrrTimer.Stop() | 1164 | vrrTimer.Stop() | ||
| 1033 | cursorTimer.Stop() | 1165 | cursorTimer.Stop() | ||
| 1166 | + | protectTimer.Stop() | |||
| 1167 | + | defer protectTimer.Stop() | |||
| 1034 | defer retryTimer.Stop() | 1168 | defer retryTimer.Stop() | ||
| 1035 | defer vrrTimer.Stop() | 1169 | defer vrrTimer.Stop() | ||
| 1036 | defer cursorTimer.Stop() | 1170 | defer cursorTimer.Stop() | ||
| @@ -1044,7 +1178,42 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||||
| 1044 | if o.cursor != nil { | 1178 | if o.cursor != nil { | ||
| 1045 | cursorWake = o.cursor.wake | 1179 | cursorWake = o.cursor.wake | ||
| 1046 | } | 1180 | } | ||
| 1181 | + | securityChanges := o.SecurityChanges | |||
| 1047 | for { | 1182 | for { | ||
| 1183 | + | if o.observeSecurity() || haveScene && !o.sceneCurrent(scene) { | |||
| 1184 | + | scene, haveScene, dirty = ports.Scene{}, false, false | |||
| 1185 | + | want, cursorScale = ports.CursorChange{}, -1 | |||
| 1186 | + | if o.cursor != nil { | |||
| 1187 | + | o.cursor.image, o.cursor.later = false, nil | |||
| 1188 | + | } | |||
| 1189 | + | } | |||
| 1190 | + | if o.protected && len(requests) > 0 { | |||
| 1191 | + | for _, q := range requests { | |||
| 1192 | + | capture.Fail(ctx, q, fmt.Errorf("session protected"), captured) | |||
| 1193 | + | } | |||
| 1194 | + | clear(requests) | |||
| 1195 | + | requests = requestStorage[:0] | |||
| 1196 | + | } | |||
| 1197 | + | if o.protected && enabled && !o.frame.pendingCommit() && !o.securityPrepared && !o.protectBackoffActive() { | |||
| 1198 | + | if err := o.prepareSecurity(ctx, r); err != nil { | |||
| 1199 | + | if ctx.Err() != nil { | |||
| 1200 | + | return nil | |||
| 1201 | + | } | |||
| 1202 | + | if !o.commitFailed(err, &enabled) { | |||
| 1203 | + | return err | |||
| 1204 | + | } | |||
| 1205 | + | } | |||
| 1206 | + | // Recheck immediately: a takeover during the clear needs its own | |||
| 1207 | + | // transaction, not another scene or wake to make progress. | |||
| 1208 | + | if enabled && !o.frame.pendingCommit() && !o.securityPrepared && !o.protectBackoffActive() { | |||
| 1209 | + | continue | |||
| 1210 | + | } | |||
| 1211 | + | } | |||
| 1212 | + | if o.securityInvalid { | |||
| 1213 | + | if err := o.sendSecurityInvalid(ctx); err != nil { | |||
| 1214 | + | return nil | |||
| 1215 | + | } | |||
| 1216 | + | } | |||
| 1048 | readWait := false | 1217 | readWait := false | ||
| 1049 | if reportDirty || o.capped { | 1218 | if reportDirty || o.capped { | ||
| 1050 | published := false | 1219 | published := false | ||
| @@ -1084,7 +1253,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||||
| 1084 | } | 1253 | } | ||
| 1085 | // A held cursor move commits alone if no frame comes in time. | 1254 | // A held cursor move commits alone if no frame comes in time. | ||
| 1086 | var cursorDue <-chan time.Time | 1255 | var cursorDue <-chan time.Time | ||
| 1087 | - | if enabled && o.cursorHeld && !o.frame.pendingCommit() { | 1256 | + | if enabled && !o.protected && o.cursorHeld && !o.frame.pendingCommit() { |
| 1088 | cursorTimer.Reset(max(0, cursorMinInterval-time.Since(o.lastFrame))) | 1257 | cursorTimer.Reset(max(0, cursorMinInterval-time.Since(o.lastFrame))) | ||
| 1089 | cursorDue = cursorTimer.C | 1258 | cursorDue = cursorTimer.C | ||
| 1090 | } else { | 1259 | } else { | ||
| @@ -1098,17 +1267,37 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||||
| 1098 | } else { | 1267 | } else { | ||
| 1099 | gapTimer.Stop() | 1268 | gapTimer.Stop() | ||
| 1100 | } | 1269 | } | ||
| 1270 | + | // A refused protected commit is retried after a bounded backoff. | |||
| 1271 | + | var protectDue <-chan time.Time | |||
| 1272 | + | if o.protected && enabled && !o.securityPrepared && o.protectBackoffActive() { | |||
| 1273 | + | protectTimer.Reset(max(0, time.Until(o.protectNotBefore))) | |||
| 1274 | + | protectDue = protectTimer.C | |||
| 1275 | + | } else { | |||
| 1276 | + | protectTimer.Stop() | |||
| 1277 | + | } | |||
| 1101 | // A commit whose event never comes must not stop the output. | 1278 | // A commit whose event never comes must not stop the output. | ||
| 1102 | stuck := o.frame.wait(enabled) | 1279 | stuck := o.frame.wait(enabled) | ||
| 1103 | stateDirty := false | 1280 | stateDirty := false | ||
| 1104 | select { | 1281 | select { | ||
| 1282 | + | case _, ok := <-securityChanges: | |||
| 1283 | + | if !ok { | |||
| 1284 | + | securityChanges = nil | |||
| 1285 | + | } | |||
| 1286 | + | continue // gate is authoritative, not the queued wake payload | |||
| 1105 | case <-retry: | 1287 | case <-retry: | ||
| 1106 | continue | 1288 | continue | ||
| 1289 | + | case <-protectDue: | |||
| 1290 | + | continue | |||
| 1107 | case <-stuck: | 1291 | case <-stuck: | ||
| 1292 | + | o.observeSecurity() | |||
| 1108 | if !o.frame.deadlineReached(time.Now()) { | 1293 | if !o.frame.deadlineReached(time.Now()) { | ||
| 1109 | continue // waits for a fence | 1294 | continue // waits for a fence | ||
| 1110 | } | 1295 | } | ||
| 1111 | if o.expire() { | 1296 | if o.expire() { | ||
| 1297 | + | if o.protected { | |||
| 1298 | + | o.invalidateSecurity() | |||
| 1299 | + | continue // fresh black transaction at the top of the loop | |||
| 1300 | + | } | |||
| 1112 | if err := o.modeset(); err != nil { | 1301 | if err := o.modeset(); err != nil { | ||
| 1113 | if !o.commitFailed(err, &enabled) { | 1302 | if !o.commitFailed(err, &enabled) { | ||
| 1114 | return fmt.Errorf("recovery modeset: %w", err) | 1303 | return fmt.Errorf("recovery modeset: %w", err) | ||
| @@ -1135,7 +1324,8 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||||
| 1135 | pipeline.Retire(scene) | 1324 | pipeline.Retire(scene) | ||
| 1136 | continue | 1325 | continue | ||
| 1137 | case q := <-captures: | 1326 | case q := <-captures: | ||
| 1138 | - | if !enabled || o.off || o.wantOff { | 1327 | + | o.observeSecurity() |
| 1328 | + | if o.protected || !enabled || o.off || o.wantOff { | |||
| 1139 | capture.Fail(ctx, q, fmt.Errorf("output off"), captured) | 1329 | capture.Fail(ctx, q, fmt.Errorf("output off"), captured) | ||
| 1140 | } else if len(requests) == cap(requests) { | 1330 | } else if len(requests) == cap(requests) { | ||
| 1141 | capture.Fail(ctx, q, fmt.Errorf("output capture batch full"), captured) | 1331 | capture.Fail(ctx, q, fmt.Errorf("output capture batch full"), captured) | ||
| @@ -1144,22 +1334,37 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||||
| 1144 | dirty = haveScene | 1334 | dirty = haveScene | ||
| 1145 | } | 1335 | } | ||
| 1146 | case on := <-active: | 1336 | case on := <-active: | ||
| 1337 | + | o.observeSecurity() | |||
| 1338 | + | if o.protected { | |||
| 1339 | + | o.invalidateSecurity() | |||
| 1340 | + | enabled = on | |||
| 1341 | + | if on && o.fbs[0] == 0 { | |||
| 1342 | + | if err := o.showImages(r, imagesDriver, nil); err != nil { | |||
| 1343 | + | if !o.commitFailed(err, &enabled) { | |||
| 1344 | + | return err | |||
| 1345 | + | } | |||
| 1346 | + | } | |||
| 1347 | + | } | |||
| 1348 | + | continue | |||
| 1349 | + | } | |||
| 1147 | // Modeset on every enable: a fast disable+enable can coalesce to one true. | 1350 | // Modeset on every enable: a fast disable+enable can coalesce to one true. | ||
| 1148 | if on { | 1351 | if on { | ||
| 1352 | + | enabled = true // this enable may supersede a previous master loss | |||
| 1149 | var err error | 1353 | var err error | ||
| 1150 | if o.fbs[0] == 0 { | 1354 | if o.fbs[0] == 0 { | ||
| 1151 | err = o.showImages(r, imagesDriver, nil) | 1355 | err = o.showImages(r, imagesDriver, nil) | ||
| 1152 | } else { | 1356 | } else { | ||
| 1153 | err = o.modeset() | 1357 | err = o.modeset() | ||
| 1154 | } | 1358 | } | ||
| 1155 | - | if lostMaster(err) { | |||
| 1156 | - | // The seat took DRM master back: wait for the next enable. | |||
| 1157 | - | o.log.Warn().Err(err).Msg("resume") | |||
| 1158 | - | continue | |||
| 1159 | - | } | |||
| 1160 | if err != nil { | 1359 | if err != nil { | ||
| 1161 | - | o.log.Error().Err(err).Msg("resume") | 1360 | + | if !o.commitFailed(err, &enabled) { |
| 1162 | - | return err | 1361 | + | return err |
| 1362 | + | } | |||
| 1363 | + | continue // epoch retry or wait disabled for master reacquisition | |||
| 1364 | + | } | |||
| 1365 | + | o.observeSecurity() | |||
| 1366 | + | if o.protected { | |||
| 1367 | + | continue | |||
| 1163 | } | 1368 | } | ||
| 1164 | o.probeAsync(r) | 1369 | o.probeAsync(r) | ||
| 1165 | dirty = haveScene | 1370 | dirty = haveScene | ||
| @@ -1168,18 +1373,29 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||||
| 1168 | enabled = on | 1373 | enabled = on | ||
| 1169 | o.log.Info().Bool("enabled", on).Msg("output") | 1374 | o.log.Info().Bool("enabled", on).Msg("output") | ||
| 1170 | case ev := <-o.flipped: | 1375 | case ev := <-o.flipped: | ||
| 1376 | + | o.observeSecurity() | |||
| 1171 | if !o.completed(ev, seen) { | 1377 | if !o.completed(ev, seen) { | ||
| 1172 | continue // stale event from before a modeset | 1378 | continue // stale event from before a modeset | ||
| 1173 | } | 1379 | } | ||
| 1174 | stateDirty = true | 1380 | stateDirty = true | ||
| 1175 | - | if o.cursor != nil { | 1381 | + | if o.cursor != nil && !o.protected { |
| 1176 | // An image that waited for a free slot loads now. | 1382 | // An image that waited for a free slot loads now. | ||
| 1177 | if _, err := o.cursor.flushLater(r); err != nil { | 1383 | if _, err := o.cursor.flushLater(r); err != nil { | ||
| 1178 | o.log.Warn().Err(err).Msg("cursor") | 1384 | o.log.Warn().Err(err).Msg("cursor") | ||
| 1179 | } | 1385 | } | ||
| 1180 | } | 1386 | } | ||
| 1181 | case s := <-scenes: | 1387 | case s := <-scenes: | ||
| 1182 | - | if o.cursor != nil && loadCursor != nil && s.Scale != cursorScale { | 1388 | + | o.observeSecurity() |
| 1389 | + | if !o.sceneCurrent(s) { | |||
| 1390 | + | continue | |||
| 1391 | + | } | |||
| 1392 | + | if o.protected { | |||
| 1393 | + | s.CaptureScene = nil | |||
| 1394 | + | if o.wantOff != s.Off { | |||
| 1395 | + | o.invalidateSecurity() | |||
| 1396 | + | } | |||
| 1397 | + | } | |||
| 1398 | + | if !o.protected && o.cursor != nil && loadCursor != nil && s.Scale != cursorScale { | |||
| 1183 | cursorScale = s.Scale | 1399 | cursorScale = s.Scale | ||
| 1184 | o.setCursor(r, loadCursor, want, s.Scale) | 1400 | o.setCursor(r, loadCursor, want, s.Scale) | ||
| 1185 | stateDirty = true | 1401 | stateDirty = true | ||
| @@ -1187,6 +1403,10 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||||
| 1187 | scene, haveScene, dirty = s, true, true | 1403 | scene, haveScene, dirty = s, true, true | ||
| 1188 | o.wantOff = s.Off | 1404 | o.wantOff = s.Off | ||
| 1189 | case c := <-cursor: | 1405 | case c := <-cursor: | ||
| 1406 | + | o.observeSecurity() | |||
| 1407 | + | if o.protected { | |||
| 1408 | + | continue | |||
| 1409 | + | } | |||
| 1190 | want = c | 1410 | want = c | ||
| 1191 | // Before the first scene the scale is unknown: loaded then. | 1411 | // Before the first scene the scale is unknown: loaded then. | ||
| 1192 | if o.cursor != nil && loadCursor != nil && cursorScale > 0 { | 1412 | if o.cursor != nil && loadCursor != nil && cursorScale > 0 { | ||
| @@ -1221,7 +1441,17 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||||
| 1221 | } | 1441 | } | ||
| 1222 | ev.Msg("stats") | 1442 | ev.Msg("stats") | ||
| 1223 | } | 1443 | } | ||
| 1224 | - | if len(requests) > 0 && (!enabled || o.off || o.wantOff) { | 1444 | + | o.observeSecurity() |
| 1445 | + | if haveScene && !o.sceneCurrent(scene) { | |||
| 1446 | + | continue | |||
| 1447 | + | } | |||
| 1448 | + | if o.protected && o.wantOff != o.off { | |||
| 1449 | + | o.invalidateSecurity() | |||
| 1450 | + | } | |||
| 1451 | + | if o.protected && !o.securityPrepared { | |||
| 1452 | + | continue | |||
| 1453 | + | } | |||
| 1454 | + | if len(requests) > 0 && (o.protected || !enabled || o.off || o.wantOff) { | |||
| 1225 | for _, q := range requests { | 1455 | for _, q := range requests { | ||
| 1226 | capture.Fail(ctx, q, fmt.Errorf("output off"), captured) | 1456 | capture.Fail(ctx, q, fmt.Errorf("output off"), captured) | ||
| 1227 | } | 1457 | } | ||
| @@ -1248,8 +1478,11 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||||
| 1248 | continue | 1478 | continue | ||
| 1249 | } | 1479 | } | ||
| 1250 | if !dirty || !haveScene { | 1480 | if !dirty || !haveScene { | ||
| 1251 | - | if stateDirty { | 1481 | + | if stateDirty && !o.protected { |
| 1252 | err := o.commitState(o.stateVRR()) | 1482 | err := o.commitState(o.stateVRR()) | ||
| 1483 | + | if errors.Is(err, errSecurityScene) { | |||
| 1484 | + | continue | |||
| 1485 | + | } | |||
| 1253 | if errors.Is(err, errOverlayDropped) { | 1486 | if errors.Is(err, errOverlayDropped) { | ||
| 1254 | dirty = haveScene | 1487 | dirty = haveScene | ||
| 1255 | continue | 1488 | continue | ||
| @@ -1266,11 +1499,14 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||||
| 1266 | } | 1499 | } | ||
| 1267 | o.flipGapUntil = time.Time{} | 1500 | o.flipGapUntil = time.Time{} | ||
| 1268 | } | 1501 | } | ||
| 1502 | + | if !o.sceneCurrent(scene) { | |||
| 1503 | + | continue | |||
| 1504 | + | } | |||
| 1269 | start := time.Now() | 1505 | start := time.Now() | ||
| 1270 | direct, err := o.submitFrame(ctx, r, scene, surfaces, seen, requests, pipeline) | 1506 | direct, err := o.submitFrame(ctx, r, scene, surfaces, seen, requests, pipeline) | ||
| 1271 | var fatal renderError | 1507 | var fatal renderError | ||
| 1272 | - | if errors.As(err, &fatal) { | 1508 | + | if errors.As(err, &fatal) || errors.Is(err, errSecurityScene) { |
| 1273 | - | // A failed render did not hand these requests to the worker. | 1509 | + | // A failed render or epoch rejection did not hand these requests to the worker. |
| 1274 | for _, q := range requests { | 1510 | for _, q := range requests { | ||
| 1275 | if capture.Handed(q) { | 1511 | if capture.Handed(q) { | ||
| 1276 | continue // already owned by the worker, or answered | 1512 | continue // already owned by the worker, or answered | ||
| @@ -1284,6 +1520,9 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||||
| 1284 | return err | 1520 | return err | ||
| 1285 | } | 1521 | } | ||
| 1286 | if err != nil { | 1522 | if err != nil { | ||
| 1523 | + | if errors.Is(err, errSecurityScene) { | |||
| 1524 | + | continue | |||
| 1525 | + | } | |||
| 1287 | if errors.Is(err, errOverlayDropped) { | 1526 | if errors.Is(err, errOverlayDropped) { | ||
| 1288 | dirty = true | 1527 | dirty = true | ||
| 1289 | continue | 1528 | continue | ||
| @@ -1307,13 +1546,29 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||||
| 1307 | // lost mid-switch (the seat disable follows). | 1546 | // lost mid-switch (the seat disable follows). | ||
| 1308 | func (o *Output) commitFailed(err error, enabled *bool) bool { | 1547 | func (o *Output) commitFailed(err error, enabled *bool) bool { | ||
| 1309 | switch { | 1548 | switch { | ||
| 1549 | + | case errors.Is(err, errSecurityScene), errors.Is(err, errProtectionPending): | |||
| 1550 | + | return true // retry at the owner boundary, never certify stale work | |||
| 1310 | case errors.Is(err, unix.EBUSY): | 1551 | case errors.Is(err, unix.EBUSY): | ||
| 1311 | // Any CRTC event can unblock a refusal; absent one, retry on time. | 1552 | // Any CRTC event can unblock a refusal; absent one, retry on time. | ||
| 1312 | if o.frame.busy(time.Now()) { | 1553 | if o.frame.busy(time.Now()) { | ||
| 1313 | o.log.Info().Err(err).Str("connector", o.conn.name).Msg("commit busy") | 1554 | o.log.Info().Err(err).Str("connector", o.conn.name).Msg("commit busy") | ||
| 1314 | } | 1555 | } | ||
| 1556 | + | if o.protected && !o.securityPrepared { | |||
| 1557 | + | o.deferProtection() // bounded: a busy protected attempt never spins | |||
| 1558 | + | } | |||
| 1559 | + | return true | |||
| 1560 | + | case retryableProtected(err): | |||
| 1561 | + | // Protection stays latched and the output stays registered, dark. | |||
| 1562 | + | // Never stop the output: that would remove it from Wayland while the | |||
| 1563 | + | // session stays locked. Retry later; no proof is sent meanwhile. | |||
| 1564 | + | o.deferProtection() | |||
| 1565 | + | o.log.Warn().Err(err).Str("connector", o.conn.name).Dur("retry_in", o.protectBackoff).Msg("protected commit refused; output stays protected, retrying") | |||
| 1315 | return true | 1566 | return true | ||
| 1316 | case lostMaster(err): | 1567 | case lostMaster(err): | ||
| 1568 | + | o.observeSecurity() | |||
| 1569 | + | if o.protected { | |||
| 1570 | + | o.invalidateSecurity() | |||
| 1571 | + | } | |||
| 1317 | o.log.Warn().Err(err).Msg("commit refused") | 1572 | o.log.Warn().Err(err).Msg("commit refused") | ||
| 1318 | *enabled = false | 1573 | *enabled = false | ||
| 1319 | return true | 1574 | return true | ||
| @@ -1370,7 +1625,8 @@ func (o *Output) completed(ev flipEvent, seen map[ports.WindowID]uint64) bool { | |||||
| 1370 | 1625 | ||||
| 1371 | // setCursor loads a cursor for scale into the cursor images. | 1626 | // setCursor loads a cursor for scale into the cursor images. | ||
| 1372 | func (o *Output) setCursor(r ports.Renderer, load CursorLoader, c ports.CursorChange, scale float64) { | 1627 | func (o *Output) setCursor(r ports.Renderer, load CursorLoader, c ports.CursorChange, scale float64) { | ||
| 1373 | - | if o.cursor.off { | 1628 | + | o.observeSecurity() |
| 1629 | + | if o.protected || o.cursor.off { | |||
| 1374 | return | 1630 | return | ||
| 1375 | } | 1631 | } | ||
| 1376 | img, err := load(c, scale, o.cursor.Limit()) | 1632 | img, err := load(c, scale, o.cursor.Limit()) | ||
| @@ -1486,6 +1742,21 @@ func (o *Output) showImages(r ports.Renderer, kind imageKind, cause error) error | |||||
| 1486 | r.SetHDR(float64(o.hdrSettings.SDRBrightness)) | 1742 | r.SetHDR(float64(o.hdrSettings.SDRBrightness)) | ||
| 1487 | err = o.showImageKind(r, imagesDriver, nil) | 1743 | err = o.showImageKind(r, imagesDriver, nil) | ||
| 1488 | } | 1744 | } | ||
| 1745 | + | var pe protectedCommitError | |||
| 1746 | + | if errors.As(err, &pe) { | |||
| 1747 | + | // A protected KMS failure is not an HDR failure: images exist and | |||
| 1748 | + | // the owner retries the protected modeset. Never fall back to SDR | |||
| 1749 | + | // or latch hdrFailed for it. | |||
| 1750 | + | if created { | |||
| 1751 | + | if oldBlob != 0 { | |||
| 1752 | + | _ = o.k.destroyBlob(o.hdrBlob) | |||
| 1753 | + | o.hdrBlob = oldBlob | |||
| 1754 | + | } else { | |||
| 1755 | + | o.hdrBlobData = meta | |||
| 1756 | + | } | |||
| 1757 | + | } | |||
| 1758 | + | return err | |||
| 1759 | + | } | |||
| 1489 | if err == nil { | 1760 | if err == nil { | ||
| 1490 | if created { | 1761 | if created { | ||
| 1491 | o.hdrBlobData = meta | 1762 | o.hdrBlobData = meta | ||
| @@ -1530,7 +1801,19 @@ func (o *Output) showImageKind(r ports.Renderer, kind imageKind, cause error) er | |||||
| 1530 | } | 1801 | } | ||
| 1531 | o.kind = got | 1802 | o.kind = got | ||
| 1532 | if err = o.testModeset(); err == nil { | 1803 | if err = o.testModeset(); err == nil { | ||
| 1533 | - | return o.modeset() | 1804 | + | err = o.modeset() |
| 1805 | + | if err != nil && o.protected && !errors.Is(err, errSecurityScene) && !errors.Is(err, errProtectionPending) && !errors.As(err, new(protectedCommitError)) { | |||
| 1806 | + | err = protectedCommitError{err} | |||
| 1807 | + | } | |||
| 1808 | + | // A protected failure is not an image refusal: never fall back to | |||
| 1809 | + | // another image kind or SDR for it. The caller hands it to the | |||
| 1810 | + | // bounded retry (commitFailed). | |||
| 1811 | + | return err | |||
| 1812 | + | } | |||
| 1813 | + | if o.protected && !refused(err) { | |||
| 1814 | + | // EBUSY, lost master or a blob failure of the test: the images | |||
| 1815 | + | // are fine; retry the protected modeset later. | |||
| 1816 | + | return protectedCommitError{err} | |||
| 1534 | } | 1817 | } | ||
| 1535 | if !refused(err) || got == imagesLinear { | 1818 | if !refused(err) || got == imagesLinear { | ||
| 1536 | o.freeImages() | 1819 | o.freeImages() | ||
| @@ -1558,6 +1841,9 @@ func lostMaster(err error) bool { | |||||
| 1558 | func (o *Output) setupImages(r ports.Renderer, kind imageKind, cause error) (imageKind, error) { | 1841 | func (o *Output) setupImages(r ports.Renderer, kind imageKind, cause error) (imageKind, error) { | ||
| 1559 | err := cause | 1842 | err := cause | ||
| 1560 | for ; kind <= imagesLinear; kind++ { | 1843 | for ; kind <= imagesLinear; kind++ { | ||
| 1844 | + | if o.runContext != nil && o.runContext.Err() != nil { | |||
| 1845 | + | return kind, o.runContext.Err() | |||
| 1846 | + | } | |||
| 1561 | mods := []uint64(nil) | 1847 | mods := []uint64(nil) | ||
| 1562 | if o.hdrOn { | 1848 | if o.hdrOn { | ||
| 1563 | for _, f := range o.primary.formats { | 1849 | for _, f := range o.primary.formats { | ||
| @@ -1611,7 +1897,11 @@ func (o *Output) exportImages(r ports.Renderer, mods []uint64) error { | |||||
| 1611 | var done *os.File | 1897 | var done *os.File | ||
| 1612 | if done, err = r.Render(ports.Scene{Background: "#000000"}, nil); done != nil { | 1898 | if done, err = r.Render(ports.Scene{Background: "#000000"}, nil); done != nil { | ||
| 1613 | // The modeset is a blocking commit: wait for the clear. | 1899 | // The modeset is a blocking commit: wait for the clear. | ||
| 1614 | - | err = errors.Join(err, syncfile.Wait(context.Background(), done)) | 1900 | + | waitCtx := o.runContext |
| 1901 | + | if waitCtx == nil { | |||
| 1902 | + | waitCtx = context.Background() | |||
| 1903 | + | } | |||
| 1904 | + | err = errors.Join(err, syncfile.Wait(waitCtx, done)) | |||
| 1615 | done.Close() | 1905 | done.Close() | ||
| 1616 | } | 1906 | } | ||
| 1617 | } | 1907 | } | ||
M internal/adapters/drm/output_test.go +11 -0
| @@ -44,6 +44,12 @@ func testOutput(t *testing.T, errs ...error) (*Output, *mockkms, *[]commitRec) { | |||||
| 44 | // testOutputMu is testOutput with the mutex that guards the recorded | 44 | // testOutputMu is testOutput with the mutex that guards the recorded | ||
| 45 | // commits (Run commits on its goroutine). | 45 | // commits (Run commits on its goroutine). | ||
| 46 | func testOutputMu(t *testing.T, errs ...error) (*Output, *mockkms, *[]commitRec, *sync.Mutex) { | 46 | func testOutputMu(t *testing.T, errs ...error) (*Output, *mockkms, *[]commitRec, *sync.Mutex) { | ||
| 47 | + | return testOutputRules(t, nil, errs...) | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // testOutputRules is testOutputMu with an optional driver rule: a non-nil | |||
| 51 | + | // rule sees every commit (under the commit mutex) and may refuse it. | |||
| 52 | + | func testOutputRules(t *testing.T, rule func(*atomicReq, uint32) error, errs ...error) (*Output, *mockkms, *[]commitRec, *sync.Mutex) { | |||
| 47 | k := newMockkms(t) | 53 | k := newMockkms(t) | ||
| 48 | var commits []commitRec | 54 | var commits []commitRec | ||
| 49 | commitMu := &sync.Mutex{} | 55 | commitMu := &sync.Mutex{} | ||
| @@ -56,6 +62,11 @@ func testOutputMu(t *testing.T, errs ...error) (*Output, *mockkms, *[]commitRec, | |||||
| 56 | c.req.props = append(c.req.props, append([]propValue(nil), ps...)) | 62 | c.req.props = append(c.req.props, append([]propValue(nil), ps...)) | ||
| 57 | } | 63 | } | ||
| 58 | commits = append(commits, c) | 64 | commits = append(commits, c) | ||
| 65 | + | if rule != nil { | |||
| 66 | + | if err := rule(r, flags); err != nil { | |||
| 67 | + | return err | |||
| 68 | + | } | |||
| 69 | + | } | |||
| 59 | if len(errs) > 0 { | 70 | if len(errs) > 0 { | ||
| 60 | err := errs[0] | 71 | err := errs[0] | ||
| 61 | errs = errs[1:] | 72 | errs = errs[1:] | ||
A internal/adapters/drm/protection.go +206 -0
| @@ -0,0 +1,206 @@ | |||||
| 1 | + | package drm | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "fmt" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/neferwl/internal/adapters/syncfile" | |||
| 10 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 11 | + | "golang.org/x/sys/unix" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // errProtectionPending asks the output owner to retry after the outstanding | |||
| 15 | + | // commit ends. In particular, never queue a blocking disable behind a client | |||
| 16 | + | // acquire fence: that fence need not signal in bounded time. | |||
| 17 | + | var errProtectionPending = errors.New("output protection: commit pending") | |||
| 18 | + | ||||
| 19 | + | // detachProtectedPlanes detaches every plane owned by this output, and stray | |||
| 20 | + | // planes currently on its CRTC (not planes belonging to another output). A | |||
| 21 | + | // failed read is not evidence that a stray plane is detached. | |||
| 22 | + | func (o *Output) detachProtectedPlanes(req *atomicReq) error { | |||
| 23 | + | var err error | |||
| 24 | + | owned := make(map[uint32]bool) | |||
| 25 | + | detach := func(p *plane) { | |||
| 26 | + | if p == nil { | |||
| 27 | + | return | |||
| 28 | + | } | |||
| 29 | + | // atomicReq.set silently skips zero property IDs. Partial cached | |||
| 30 | + | // metadata therefore cannot prove detachment of a previous desktop. | |||
| 31 | + | if p.id == 0 || p.prop("FB_ID") == 0 || p.prop("CRTC_ID") == 0 { | |||
| 32 | + | err = errors.Join(err, fmt.Errorf("protected plane %d: incomplete detachment properties", p.id)) | |||
| 33 | + | return | |||
| 34 | + | } | |||
| 35 | + | req.set(p.id, p.prop("FB_ID"), 0) | |||
| 36 | + | req.set(p.id, p.prop("CRTC_ID"), 0) | |||
| 37 | + | } | |||
| 38 | + | detachOwned := func(p *plane) { | |||
| 39 | + | if p != nil { | |||
| 40 | + | owned[p.id] = true | |||
| 41 | + | detach(p) | |||
| 42 | + | } | |||
| 43 | + | } | |||
| 44 | + | detachOwned(o.primary) | |||
| 45 | + | if o.cursor != nil { | |||
| 46 | + | detachOwned(o.cursor.plane) | |||
| 47 | + | } | |||
| 48 | + | detachOwned(o.overlay) | |||
| 49 | + | for _, p := range o.stray { | |||
| 50 | + | // pickOverlay leaves the owned overlay in stray. Do not turn a | |||
| 51 | + | // redundant query failure into refusal of known-owned detachment. | |||
| 52 | + | if owned[p.id] { | |||
| 53 | + | continue | |||
| 54 | + | } | |||
| 55 | + | props, readErr := o.k.objProps(p.id, objPlane) | |||
| 56 | + | if readErr != nil { | |||
| 57 | + | err = errors.Join(err, fmt.Errorf("read protected plane %d: %w", p.id, readErr)) | |||
| 58 | + | continue | |||
| 59 | + | } | |||
| 60 | + | crtc, ok := props["CRTC_ID"] | |||
| 61 | + | if !ok { | |||
| 62 | + | err = errors.Join(err, fmt.Errorf("protected plane %d: missing CRTC_ID", p.id)) | |||
| 63 | + | continue | |||
| 64 | + | } | |||
| 65 | + | if crtc[1] == uint64(o.crtc) { | |||
| 66 | + | detach(p) | |||
| 67 | + | } | |||
| 68 | + | } | |||
| 69 | + | return err | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | // disableProtected is the atomic disable of a protected output. It is a full | |||
| 73 | + | // disable: ACTIVE=0, MODE_ID=0, connector CRTC_ID=0, every plane detached, VRR | |||
| 74 | + | // and HDR connector state off. amdgpu (amdgpu_dm, via | |||
| 75 | + | // drm_atomic_helper_check_crtc_primary_plane) rejects with EINVAL a CRTC that | |||
| 76 | + | // stays enabled (MODE_ID set) without its primary plane, even with ACTIVE=0, | |||
| 77 | + | // so "inactive but every plane detached" is not a valid request. The other | |||
| 78 | + | // valid shape, ACTIVE=0 with the primary still attached, would keep a desktop | |||
| 79 | + | // framebuffer referenced by the CRTC until a later activation replaced it. A | |||
| 80 | + | // full disable leaves nothing of the desktop attached and needs no blob; the | |||
| 81 | + | // activation that follows is a full modeset either way. It is the same | |||
| 82 | + | // request as Close's disable, which this hardware accepts. | |||
| 83 | + | func (o *Output) disableProtected() error { | |||
| 84 | + | req := &atomicReq{} | |||
| 85 | + | req.set(o.crtc, o.crtcProps["ACTIVE"], 0) | |||
| 86 | + | req.set(o.crtc, o.crtcProps["MODE_ID"], 0) | |||
| 87 | + | req.set(o.crtc, o.vrrProp, 0) | |||
| 88 | + | req.set(o.conn.id, o.connCrtc, 0) | |||
| 89 | + | o.hdrConnectorProps(req, false) | |||
| 90 | + | if err := o.detachProtectedPlanes(req); err != nil { | |||
| 91 | + | return protectedCommitError{err} | |||
| 92 | + | } | |||
| 93 | + | if err := o.k.commit(req, atomicAllowModes, 0); err != nil { | |||
| 94 | + | return protectedCommitError{fmt.Errorf("disable protected output: %w", err)} | |||
| 95 | + | } | |||
| 96 | + | if o.modeBlob != 0 { | |||
| 97 | + | _ = o.k.destroyBlob(o.modeBlob) // MODE_ID is 0 now: nothing references it | |||
| 98 | + | o.modeBlob = 0 | |||
| 99 | + | } | |||
| 100 | + | o.off, o.vrrOn, o.vrrGame, o.overlayOn = true, false, false, 0 | |||
| 101 | + | o.sendFormats() | |||
| 102 | + | o.shown, o.queued = 0, 0 | |||
| 103 | + | if o.cursor != nil { | |||
| 104 | + | o.cursor.applied = cursorState{} | |||
| 105 | + | o.cursor.screen, o.cursor.flying = 0, false | |||
| 106 | + | } | |||
| 107 | + | return nil | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | // protectedCommitError marks a recoverable failure of the protected disable or | |||
| 111 | + | // activation: a refused KMS commit, unverifiable plane state, a mode blob or a | |||
| 112 | + | // clear fence that failed. The output owner keeps the output, stays protected | |||
| 113 | + | // and retries with bounded backoff instead of stopping (see commitFailed). A | |||
| 114 | + | // renderer error is not wrapped: it stays fatal. | |||
| 115 | + | type protectedCommitError struct{ error } | |||
| 116 | + | ||||
| 117 | + | func (e protectedCommitError) Unwrap() error { return e.error } | |||
| 118 | + | ||||
| 119 | + | // retryableProtected reports a refused protected KMS commit worth retrying | |||
| 120 | + | // later. Busy and lost-master errors keep their own handling. | |||
| 121 | + | func retryableProtected(err error) bool { | |||
| 122 | + | var pe protectedCommitError | |||
| 123 | + | return errors.As(err, &pe) && !lostMaster(err) && !errors.Is(err, unix.EBUSY) | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | const ( | |||
| 127 | + | protectRetryMin = 50 * time.Millisecond | |||
| 128 | + | protectRetryMax = 2 * time.Second | |||
| 129 | + | ) | |||
| 130 | + | ||||
| 131 | + | // deferProtection schedules the next protected attempt after a refusal. | |||
| 132 | + | func (o *Output) deferProtection() { | |||
| 133 | + | o.protectBackoff = nextProtectBackoff(o.protectBackoff) | |||
| 134 | + | o.protectNotBefore = time.Now().Add(o.protectBackoff) | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | // protectBackoffActive reports a pending retry delay. | |||
| 138 | + | func (o *Output) protectBackoffActive() bool { | |||
| 139 | + | return !o.protectNotBefore.IsZero() && time.Now().Before(o.protectNotBefore) | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | // protectSucceeded clears the retry state. | |||
| 143 | + | func (o *Output) protectSucceeded() { o.protectBackoff, o.protectNotBefore = 0, time.Time{} } | |||
| 144 | + | ||||
| 145 | + | // nextProtectBackoff doubles the delay, bounded by protectRetryMax. | |||
| 146 | + | func nextProtectBackoff(d time.Duration) time.Duration { | |||
| 147 | + | if d < protectRetryMin { | |||
| 148 | + | return protectRetryMin | |||
| 149 | + | } | |||
| 150 | + | return min(2*d, protectRetryMax) | |||
| 151 | + | } | |||
| 152 | + | ||||
| 153 | + | // protectedModeset is an internal output-owner primitive, not session-lock | |||
| 154 | + | // readiness or a protocol entry point. It latches protection even on failure. | |||
| 155 | + | // First stop scanout with a blocking full disable, then clear an unscanned | |||
| 156 | + | // target and wait for the clear to complete before activating that exact FB. | |||
| 157 | + | func (o *Output) protectedModeset(ctx context.Context, r ports.Renderer) error { | |||
| 158 | + | o.protected = true | |||
| 159 | + | if o.frame.pendingCommit() { | |||
| 160 | + | return errProtectionPending | |||
| 161 | + | } | |||
| 162 | + | if err := ctx.Err(); err != nil { | |||
| 163 | + | return err | |||
| 164 | + | } | |||
| 165 | + | if err := o.disableProtected(); err != nil { | |||
| 166 | + | return err | |||
| 167 | + | } | |||
| 168 | + | // Do not dropRead here: an earlier render whose KMS commit failed may | |||
| 169 | + | // still read client buffers. KMS detachment is not a GPU completion fence. | |||
| 170 | + | if err := ctx.Err(); err != nil { | |||
| 171 | + | return err | |||
| 172 | + | } | |||
| 173 | + | r.UseTarget(o.back) | |||
| 174 | + | fence, err := r.Render(ports.Scene{Seq: 0, Background: "#000000"}, nil) | |||
| 175 | + | if err != nil { | |||
| 176 | + | if fence != nil { | |||
| 177 | + | fence.Close() | |||
| 178 | + | } | |||
| 179 | + | return fmt.Errorf("clear protected output: %w", err) | |||
| 180 | + | } | |||
| 181 | + | if fence != nil { | |||
| 182 | + | err := syncfile.Wait(ctx, fence) | |||
| 183 | + | fence.Close() | |||
| 184 | + | if err != nil { | |||
| 185 | + | err = fmt.Errorf("wait protected clear: %w", err) | |||
| 186 | + | if ctx.Err() != nil { | |||
| 187 | + | return err | |||
| 188 | + | } | |||
| 189 | + | return protectedCommitError{err} | |||
| 190 | + | } | |||
| 191 | + | } | |||
| 192 | + | if err := ctx.Err(); err != nil { | |||
| 193 | + | return err | |||
| 194 | + | } | |||
| 195 | + | if o.wantOff { | |||
| 196 | + | o.protectSucceeded() | |||
| 197 | + | o.signalReady() | |||
| 198 | + | return nil // the blocking disable is the proof; do not activate or flip | |||
| 199 | + | } | |||
| 200 | + | if err := o.modesetImage(o.fbs[o.back], true); err != nil { | |||
| 201 | + | return err | |||
| 202 | + | } | |||
| 203 | + | o.back = 1 - o.back | |||
| 204 | + | o.protectSucceeded() | |||
| 205 | + | return nil | |||
| 206 | + | } | |||
A internal/adapters/drm/protection_close_test.go +26 -0
| @@ -0,0 +1,26 @@ | |||||
| 1 | + | package drm | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/stretchr/testify/mock" | |||
| 7 | + | "golang.org/x/sys/unix" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestProtectedCloseUnreadableStrayStillDisablesWithoutRestore(t *testing.T) { | |||
| 11 | + | o, k, commits := testOutput(t) | |||
| 12 | + | o.protected = true | |||
| 13 | + | o.saved = modeCrtc{crtcID: tCrtc, fbID: 123, modeValid: 1, mode: o.mode} | |||
| 14 | + | o.stray = []*plane{{id: 53, props: planeProps}, {id: 54, props: planeProps}} | |||
| 15 | + | k.EXPECT().objProps(uint32(53), uint32(objPlane)).Return(nil, unix.EACCES).Once() | |||
| 16 | + | k.EXPECT().objProps(uint32(54), uint32(objPlane)).Return(map[string][2]uint64{"CRTC_ID": {pCrtcID, tCrtc}}, nil).Once() | |||
| 17 | + | k.EXPECT().rmFB(mock.Anything).Return(nil) | |||
| 18 | + | o.Close() // best effort, no readiness or protection ACK | |||
| 19 | + | if len(*commits) != 1 || !o.protected { | |||
| 20 | + | t.Fatal("query failure skipped disable or dropped protection") | |||
| 21 | + | } | |||
| 22 | + | checkProtectedDisable(t, (*commits)[0], tPrimary, tCursor, 54) | |||
| 23 | + | if _, ok := (*commits)[0].req.value(53, pFB); ok { | |||
| 24 | + | t.Fatal("claimed ownership of unreadable stray") | |||
| 25 | + | } | |||
| 26 | + | } | |||
A internal/adapters/drm/protection_fence_test.go +96 -0
| @@ -0,0 +1,96 @@ | |||||
| 1 | + | package drm | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "os" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |||
| 11 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 12 | + | "github.com/stretchr/testify/mock" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | func TestProtectedModesetWaitsForClearFence(t *testing.T) { | |||
| 16 | + | o, k, commits, mu := testOutputMu(t) | |||
| 17 | + | k.EXPECT().createBlob(mock.Anything).Return(99, nil).Once() | |||
| 18 | + | r := portsmocks.NewMockRenderer(t) | |||
| 19 | + | f, w := protectionFence(t, false) | |||
| 20 | + | rendered := make(chan struct{}) | |||
| 21 | + | r.EXPECT().UseTarget(0).Return().Once() | |||
| 22 | + | r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) { | |||
| 23 | + | close(rendered) | |||
| 24 | + | return f, nil | |||
| 25 | + | }).Once() | |||
| 26 | + | ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) | |||
| 27 | + | defer cancel() | |||
| 28 | + | done := make(chan error, 1) | |||
| 29 | + | go func() { done <- o.protectedModeset(ctx, r) }() | |||
| 30 | + | <-rendered | |||
| 31 | + | // The fence remains unreadable; activation must not be queued behind it. | |||
| 32 | + | select { | |||
| 33 | + | case err := <-done: | |||
| 34 | + | t.Fatalf("returned before clear fence signalled: %v", err) | |||
| 35 | + | case <-time.After(20 * time.Millisecond): | |||
| 36 | + | } | |||
| 37 | + | mu.Lock() | |||
| 38 | + | n := len(*commits) | |||
| 39 | + | mu.Unlock() | |||
| 40 | + | if n != 1 { | |||
| 41 | + | t.Errorf("committed activation before fence signalled: %d commits", n) | |||
| 42 | + | } | |||
| 43 | + | if _, err := w.Write([]byte{1}); err != nil { | |||
| 44 | + | t.Fatal(err) | |||
| 45 | + | } | |||
| 46 | + | if err := <-done; err != nil { | |||
| 47 | + | t.Fatal(err) | |||
| 48 | + | } | |||
| 49 | + | if len(*commits) != 2 || o.back != 1 || o.off { | |||
| 50 | + | t.Fatal("signalled clear did not activate") | |||
| 51 | + | } | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func TestProtectedModesetPreservesUncommittedGPUReads(t *testing.T) { | |||
| 55 | + | for _, fail := range []bool{false, true} { | |||
| 56 | + | t.Run(map[bool]string{false: "success", true: "clear-failure"}[fail], func(t *testing.T) { | |||
| 57 | + | o, k, _ := testOutput(t) | |||
| 58 | + | clientFence, _ := protectionFence(t, false) | |||
| 59 | + | o.holdRead(clientFence) | |||
| 60 | + | owned := o.readFences[0] | |||
| 61 | + | r := portsmocks.NewMockRenderer(t) | |||
| 62 | + | r.EXPECT().UseTarget(0).Return().Once() | |||
| 63 | + | clearFence, _ := protectionFence(t, true) | |||
| 64 | + | var renderErr error | |||
| 65 | + | if fail { | |||
| 66 | + | renderErr = errors.New("clear failed") | |||
| 67 | + | } else { | |||
| 68 | + | k.EXPECT().createBlob(mock.Anything).Return(99, nil).Once() | |||
| 69 | + | } | |||
| 70 | + | r.EXPECT().Render(mock.Anything, mock.Anything).Return(clearFence, renderErr).Once() | |||
| 71 | + | err := o.protectedModeset(context.Background(), r) | |||
| 72 | + | if (err != nil) != fail { | |||
| 73 | + | t.Fatalf("clear result: %v", err) | |||
| 74 | + | } | |||
| 75 | + | if o.readDone() || len(o.readFences) != 1 || o.readFences[0] != owned { | |||
| 76 | + | t.Fatal("KMS detachment released an uncommitted GPU read") | |||
| 77 | + | } | |||
| 78 | + | if _, err := owned.Stat(); err != nil { | |||
| 79 | + | t.Fatalf("owned client read fence closed: %v", err) | |||
| 80 | + | } | |||
| 81 | + | o.dropRead() // test cleanup only | |||
| 82 | + | }) | |||
| 83 | + | } | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | func TestProtectedCloseCommitFailureDoesNotRestoreDesktop(t *testing.T) { | |||
| 87 | + | o, k, commits := testOutput(t, errors.New("no DRM master")) | |||
| 88 | + | o.protected = true | |||
| 89 | + | o.saved = modeCrtc{crtcID: tCrtc, fbID: 123, modeValid: 1, mode: o.mode} | |||
| 90 | + | k.EXPECT().rmFB(mock.Anything).Return(nil) | |||
| 91 | + | o.Close() | |||
| 92 | + | if len(*commits) != 1 || !o.protected { | |||
| 93 | + | t.Fatal("failed close dropped protection or retried restore") | |||
| 94 | + | } | |||
| 95 | + | checkProtectedDisable(t, (*commits)[0], tPrimary, tCursor) | |||
| 96 | + | } | |||
A internal/adapters/drm/protection_retry_test.go +293 -0
| @@ -0,0 +1,293 @@ | |||||
| 1 | + | package drm | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "os" | |||
| 7 | + | "sync/atomic" | |||
| 8 | + | "testing" | |||
| 9 | + | "time" | |||
| 10 | + | ||||
| 11 | + | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |||
| 12 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 13 | + | "github.com/stretchr/testify/mock" | |||
| 14 | + | "golang.org/x/sys/unix" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | // runProtectedStartup runs o with the session already protected. k and r are | |||
| 18 | + | // set up with permissive renderer/KMS expectations; inject holds the failure | |||
| 19 | + | // under test. | |||
| 20 | + | func runProtectedStartup(t *testing.T, o *Output, k *mockkms, renders func() (*os.File, error)) (<-chan ports.SecurityBackendEvent, <-chan error, context.CancelFunc) { | |||
| 21 | + | t.Helper() | |||
| 22 | + | o.cursor = nil | |||
| 23 | + | var state atomic.Uint64 | |||
| 24 | + | state.Store(3) | |||
| 25 | + | securityGate(t, o, &state) | |||
| 26 | + | events := make(chan ports.SecurityBackendEvent, 32) | |||
| 27 | + | o.SecurityEvents = events | |||
| 28 | + | r := portsmocks.NewMockRenderer(t) | |||
| 29 | + | buf := func() ports.DMABuf { | |||
| 30 | + | f, w := protectionFence(t, false) | |||
| 31 | + | w.Close() | |||
| 32 | + | return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}} | |||
| 33 | + | } | |||
| 34 | + | r.EXPECT().SetHDR(float64(0)).Return().Maybe() | |||
| 35 | + | r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once() | |||
| 36 | + | r.EXPECT().UseTarget(mock.Anything).Return().Maybe() | |||
| 37 | + | r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) { | |||
| 38 | + | return renders() | |||
| 39 | + | }).Maybe() | |||
| 40 | + | r.EXPECT().Close().Return().Once() | |||
| 41 | + | k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil).Maybe() | |||
| 42 | + | k.EXPECT().rmFB(mock.Anything).Return(nil).Maybe() | |||
| 43 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |||
| 44 | + | done := make(chan error, 1) | |||
| 45 | + | go func() { | |||
| 46 | + | done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, nil, nil, nil, nil, make(chan ports.OutputPresented, 8), nil, nil) | |||
| 47 | + | }() | |||
| 48 | + | return events, done, cancel | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | func waitProof(t *testing.T, events <-chan ports.SecurityBackendEvent, done <-chan error) { | |||
| 52 | + | t.Helper() | |||
| 53 | + | timeout := time.After(4 * time.Second) | |||
| 54 | + | for { | |||
| 55 | + | select { | |||
| 56 | + | case ev := <-events: | |||
| 57 | + | if _, ok := ev.(ports.SecurityOutputProof); ok { | |||
| 58 | + | return | |||
| 59 | + | } | |||
| 60 | + | case err := <-done: | |||
| 61 | + | t.Fatalf("output stopped while protected: %v", err) | |||
| 62 | + | case <-timeout: | |||
| 63 | + | t.Fatal("no proof after retry") | |||
| 64 | + | } | |||
| 65 | + | } | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | // A protected startup whose first KMS attempt is refused (EBUSY, | |||
| 69 | + | // or a swallowed EINVAL refusal) keeps the output, backs off from that first | |||
| 70 | + | // refusal, and proves only after the accepted retry. | |||
| 71 | + | func TestProtectedStartupRefusalRetriesWithBackoffFromFirstRefusal(t *testing.T) { | |||
| 72 | + | for _, errno := range []error{unix.EBUSY, unix.EINVAL} { | |||
| 73 | + | t.Run(errno.Error(), func(t *testing.T) { | |||
| 74 | + | // Commits: test-only modeset, then the blocking disable (refused). | |||
| 75 | + | o, k, commits, mu := testOutputMu(t, nil, errno) | |||
| 76 | + | k.EXPECT().createBlob(mock.Anything).Return(99, nil).Maybe() | |||
| 77 | + | k.EXPECT().destroyBlob(mock.Anything).Return(nil).Maybe() | |||
| 78 | + | events, done, cancel := runProtectedStartup(t, o, k, func() (*os.File, error) { return nil, nil }) | |||
| 79 | + | defer cancel() | |||
| 80 | + | waitProof(t, events, done) | |||
| 81 | + | cancel() | |||
| 82 | + | if err := <-done; err != nil { | |||
| 83 | + | t.Fatalf("output stopped: %v", err) | |||
| 84 | + | } | |||
| 85 | + | mu.Lock() | |||
| 86 | + | defer mu.Unlock() | |||
| 87 | + | var real []commitRec | |||
| 88 | + | for _, c := range *commits { | |||
| 89 | + | if c.flags&atomicTestOnly == 0 { | |||
| 90 | + | real = append(real, c) | |||
| 91 | + | } | |||
| 92 | + | } | |||
| 93 | + | if len(real) < 3 { | |||
| 94 | + | t.Fatalf("real commits %d", len(real)) | |||
| 95 | + | } | |||
| 96 | + | if gap := real[1].at.Sub(real[0].at); gap < protectRetryMin { | |||
| 97 | + | t.Fatalf("retry after %v of the first refusal, want >= %v", gap, protectRetryMin) | |||
| 98 | + | } | |||
| 99 | + | }) | |||
| 100 | + | } | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | // Lost master at protected startup keeps the output running, dark, | |||
| 104 | + | // without proof; it follows the lost-master invalidate path. | |||
| 105 | + | func TestProtectedStartupLostMasterStaysRunningWithoutProof(t *testing.T) { | |||
| 106 | + | for _, errno := range []error{unix.EACCES, unix.EPERM} { | |||
| 107 | + | t.Run(errno.Error(), func(t *testing.T) { | |||
| 108 | + | o, k, _, _ := testOutputMu(t, nil, errno) | |||
| 109 | + | k.EXPECT().createBlob(mock.Anything).Return(99, nil).Maybe() | |||
| 110 | + | k.EXPECT().destroyBlob(mock.Anything).Return(nil).Maybe() | |||
| 111 | + | events, done, cancel := runProtectedStartup(t, o, k, func() (*os.File, error) { return nil, nil }) | |||
| 112 | + | defer cancel() | |||
| 113 | + | select { | |||
| 114 | + | case err := <-done: | |||
| 115 | + | t.Fatalf("output stopped on lost master: %v", err) | |||
| 116 | + | case ev := <-events: | |||
| 117 | + | if _, ok := ev.(ports.SecurityOutputProof); ok { | |||
| 118 | + | t.Fatal("proof after lost master") | |||
| 119 | + | } | |||
| 120 | + | case <-time.After(300 * time.Millisecond): | |||
| 121 | + | } | |||
| 122 | + | cancel() | |||
| 123 | + | if err := <-done; err != nil { | |||
| 124 | + | t.Fatalf("output stopped: %v", err) | |||
| 125 | + | } | |||
| 126 | + | if o.securityPrepared || !o.protected { | |||
| 127 | + | t.Fatalf("prepared=%v protected=%v", o.securityPrepared, o.protected) | |||
| 128 | + | } | |||
| 129 | + | }) | |||
| 130 | + | } | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | // Startup failures of detachment, mode blob or clear fence keep | |||
| 134 | + | // the output and recover through the bounded retry; proof only after success. | |||
| 135 | + | func TestProtectedStartupRecoverableFailuresRetry(t *testing.T) { | |||
| 136 | + | for _, stage := range []string{"detach", "blob", "fence"} { | |||
| 137 | + | t.Run(stage, func(t *testing.T) { | |||
| 138 | + | o, k, _, _ := testOutputMu(t) | |||
| 139 | + | var failures, blobs, objs, renders atomic.Int32 | |||
| 140 | + | switch stage { | |||
| 141 | + | case "detach": | |||
| 142 | + | o.stray = []*plane{{id: 53, props: planeProps}} | |||
| 143 | + | k.EXPECT().objProps(uint32(53), uint32(objPlane)).RunAndReturn(func(uint32, uint32) (map[string][2]uint64, error) { | |||
| 144 | + | // 1: test modeset request (error ignored); 2: first detach. | |||
| 145 | + | if objs.Add(1) == 2 { | |||
| 146 | + | failures.Add(1) | |||
| 147 | + | return nil, unix.EIO | |||
| 148 | + | } | |||
| 149 | + | return map[string][2]uint64{"CRTC_ID": {pCrtcID, 0}}, nil | |||
| 150 | + | }).Maybe() | |||
| 151 | + | k.EXPECT().createBlob(mock.Anything).Return(99, nil).Maybe() | |||
| 152 | + | case "blob": | |||
| 153 | + | k.EXPECT().createBlob(mock.Anything).RunAndReturn(func([]byte) (uint32, error) { | |||
| 154 | + | if blobs.Add(1) == 2 { // 1: test modeset; 2: activation | |||
| 155 | + | failures.Add(1) | |||
| 156 | + | return 0, unix.ENOMEM | |||
| 157 | + | } | |||
| 158 | + | return 99, nil | |||
| 159 | + | }).Maybe() | |||
| 160 | + | case "fence": | |||
| 161 | + | k.EXPECT().createBlob(mock.Anything).Return(99, nil).Maybe() | |||
| 162 | + | } | |||
| 163 | + | k.EXPECT().destroyBlob(mock.Anything).Return(nil).Maybe() | |||
| 164 | + | events, done, cancel := runProtectedStartup(t, o, k, func() (*os.File, error) { | |||
| 165 | + | // Renders 1-2 initialise the images; 3 is the first protected clear. | |||
| 166 | + | if stage != "fence" || renders.Add(1) != 3 { | |||
| 167 | + | return nil, nil | |||
| 168 | + | } | |||
| 169 | + | failures.Add(1) | |||
| 170 | + | f, w := protectionFence(t, false) | |||
| 171 | + | w.Close() // HUP without POLLIN is an error, not completion | |||
| 172 | + | return f, nil | |||
| 173 | + | }) | |||
| 174 | + | defer cancel() | |||
| 175 | + | waitProof(t, events, done) | |||
| 176 | + | if failures.Load() == 0 { | |||
| 177 | + | t.Fatal("failure was not injected before proof") | |||
| 178 | + | } | |||
| 179 | + | cancel() | |||
| 180 | + | if err := <-done; err != nil { | |||
| 181 | + | t.Fatalf("output stopped: %v", err) | |||
| 182 | + | } | |||
| 183 | + | }) | |||
| 184 | + | } | |||
| 185 | + | } | |||
| 186 | + | ||||
| 187 | + | // Recoverable preparation failures are retryable protected errors | |||
| 188 | + | // handled by commitFailed with backoff; a renderer error stays fatal. | |||
| 189 | + | func TestProtectedPreparationFailuresRetryableRenderFatal(t *testing.T) { | |||
| 190 | + | boom := errors.New("render broke") | |||
| 191 | + | for _, stage := range []string{"stray-read", "stray-missing", "blob", "fence", "render"} { | |||
| 192 | + | t.Run(stage, func(t *testing.T) { | |||
| 193 | + | o, k, _ := testOutput(t) | |||
| 194 | + | r := portsmocks.NewMockRenderer(t) | |||
| 195 | + | switch stage { | |||
| 196 | + | case "stray-read", "stray-missing": | |||
| 197 | + | o.stray = []*plane{{id: 53, props: planeProps}} | |||
| 198 | + | var err error | |||
| 199 | + | props := map[string][2]uint64{} | |||
| 200 | + | if stage == "stray-read" { | |||
| 201 | + | err = unix.EIO | |||
| 202 | + | } | |||
| 203 | + | k.EXPECT().objProps(uint32(53), uint32(objPlane)).Return(props, err).Once() | |||
| 204 | + | case "blob": | |||
| 205 | + | k.EXPECT().createBlob(mock.Anything).Return(0, unix.ENOMEM).Once() | |||
| 206 | + | r.EXPECT().UseTarget(0).Return().Once() | |||
| 207 | + | r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil).Once() | |||
| 208 | + | case "fence": | |||
| 209 | + | f, w := protectionFence(t, false) | |||
| 210 | + | w.Close() | |||
| 211 | + | r.EXPECT().UseTarget(0).Return().Once() | |||
| 212 | + | r.EXPECT().Render(mock.Anything, mock.Anything).Return(f, nil).Once() | |||
| 213 | + | case "render": | |||
| 214 | + | r.EXPECT().UseTarget(0).Return().Once() | |||
| 215 | + | r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, boom).Once() | |||
| 216 | + | } | |||
| 217 | + | o.protected = true | |||
| 218 | + | err := o.protectedModeset(context.Background(), r) | |||
| 219 | + | if err == nil { | |||
| 220 | + | t.Fatal("failure reported success") | |||
| 221 | + | } | |||
| 222 | + | enabled := true | |||
| 223 | + | handled := o.commitFailed(err, &enabled) | |||
| 224 | + | if stage == "render" { | |||
| 225 | + | if retryableProtected(err) || handled { | |||
| 226 | + | t.Fatalf("renderer error must stay fatal: retryable=%v handled=%v", retryableProtected(err), handled) | |||
| 227 | + | } | |||
| 228 | + | return | |||
| 229 | + | } | |||
| 230 | + | if !retryableProtected(err) || !handled || !enabled || !o.protectBackoffActive() || o.securityPrepared { | |||
| 231 | + | t.Fatalf("not retried with backoff: %v", err) | |||
| 232 | + | } | |||
| 233 | + | }) | |||
| 234 | + | } | |||
| 235 | + | } | |||
| 236 | + | ||||
| 237 | + | // A protected EBUSY/lost-master/detach error from showImages is a | |||
| 238 | + | // protected KMS error: it never triggers the HDR->SDR fallback or hdrFailed. | |||
| 239 | + | func TestProtectedShowImagesHDRKeepsHDRForKMSErrors(t *testing.T) { | |||
| 240 | + | for _, errno := range []error{unix.EBUSY, unix.EACCES, unix.EPERM, unix.EINVAL, nil} { | |||
| 241 | + | name := "detach" | |||
| 242 | + | if errno != nil { | |||
| 243 | + | name = errno.Error() | |||
| 244 | + | } | |||
| 245 | + | t.Run(name, func(t *testing.T) { | |||
| 246 | + | var errs []error | |||
| 247 | + | if errno != nil { | |||
| 248 | + | errs = []error{nil, errno} // HDR test commit, then the disable | |||
| 249 | + | } | |||
| 250 | + | o, k, _ := testOutput(t, errs...) | |||
| 251 | + | o.cursor = nil | |||
| 252 | + | o.protected = true | |||
| 253 | + | o.hdr = hdrCapability{Capable: true} | |||
| 254 | + | o.hdrSettings = HDRSettings{Enabled: true, SDRBrightness: 203} | |||
| 255 | + | o.hdrProps = connectorHDRProps{Metadata: 100, Colorspace: 101, MaxBPC: 102, BT2020Value: 7, HasDefault: true, MaxBPCValue: 8} | |||
| 256 | + | o.primary.formats = []ports.DMABufFormat{{Format: fourccXR30, Modifier: 0}} | |||
| 257 | + | if errno == nil { | |||
| 258 | + | o.primary.props = map[string]uint32{"CRTC_ID": pCrtcID} // incomplete: detach fails | |||
| 259 | + | } | |||
| 260 | + | r := portsmocks.NewMockRenderer(t) | |||
| 261 | + | buf := func() ports.DMABuf { | |||
| 262 | + | f, w := protectionFence(t, false) | |||
| 263 | + | w.Close() | |||
| 264 | + | return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}} | |||
| 265 | + | } | |||
| 266 | + | r.EXPECT().SetHDR(float64(203)).Return().Once() | |||
| 267 | + | r.EXPECT().UseTarget(mock.Anything).Return().Maybe() | |||
| 268 | + | r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil).Maybe() | |||
| 269 | + | r.EXPECT().ExportTargets(2, []uint64{0}).Return([]ports.DMABuf{buf(), buf()}, nil).Once() | |||
| 270 | + | k.EXPECT().addFB(mock.Anything, uint32(fourccXR30)).Return(70, nil).Maybe() | |||
| 271 | + | k.EXPECT().createBlob(mock.Anything).Return(321, nil).Maybe() | |||
| 272 | + | k.EXPECT().destroyBlob(mock.Anything).Return(nil).Maybe() | |||
| 273 | + | err := o.showImages(r, imagesDriver, nil) | |||
| 274 | + | if err == nil { | |||
| 275 | + | t.Fatal("protected KMS failure reported success") | |||
| 276 | + | } | |||
| 277 | + | enabled := true | |||
| 278 | + | lost := errno != nil && lostMaster(errno) | |||
| 279 | + | if !o.commitFailed(err, &enabled) { | |||
| 280 | + | t.Fatalf("protected failure not handled: %v", err) | |||
| 281 | + | } | |||
| 282 | + | if o.hdrFailed || !o.hdrOn { | |||
| 283 | + | t.Fatalf("HDR fell back: failed=%v on=%v", o.hdrFailed, o.hdrOn) | |||
| 284 | + | } | |||
| 285 | + | if lost == enabled { | |||
| 286 | + | t.Fatalf("lost master=%v enabled=%v", lost, enabled) | |||
| 287 | + | } | |||
| 288 | + | if !o.protected || o.securityPrepared { | |||
| 289 | + | t.Fatal("protection dropped or certified") | |||
| 290 | + | } | |||
| 291 | + | }) | |||
| 292 | + | } | |||
| 293 | + | } | |||
A internal/adapters/drm/protection_review_test.go +130 -0
| @@ -0,0 +1,130 @@ | |||||
| 1 | + | package drm | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "maps" | |||
| 7 | + | "os" | |||
| 8 | + | "testing" | |||
| 9 | + | ||||
| 10 | + | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |||
| 11 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 12 | + | "github.com/stretchr/testify/mock" | |||
| 13 | + | "golang.org/x/sys/unix" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | func TestProtectedModesetPartialPlaneMetadataFailsClosed(t *testing.T) { | |||
| 17 | + | for _, role := range []string{"primary", "cursor", "overlay", "stray"} { | |||
| 18 | + | for _, missing := range []string{"FB_ID", "CRTC_ID"} { | |||
| 19 | + | t.Run(role+"/"+missing, func(t *testing.T) { | |||
| 20 | + | o, k, commits := testOutput(t) | |||
| 21 | + | props := maps.Clone(planeProps) | |||
| 22 | + | props[missing] = 0 | |||
| 23 | + | p := &plane{id: tOverlay, props: props, crtc: tCrtc} | |||
| 24 | + | switch role { | |||
| 25 | + | case "primary": | |||
| 26 | + | o.primary.props = props | |||
| 27 | + | case "cursor": | |||
| 28 | + | o.cursor.plane.props = props | |||
| 29 | + | case "overlay": | |||
| 30 | + | o.overlay = p | |||
| 31 | + | o.stray = []*plane{p} // pickOverlay keeps the plane in stray. | |||
| 32 | + | case "stray": | |||
| 33 | + | o.stray = []*plane{p} // old master's overlay may still be here. | |||
| 34 | + | k.EXPECT().objProps(uint32(tOverlay), uint32(objPlane)).Return(map[string][2]uint64{"CRTC_ID": {pCrtcID, tCrtc}}, nil).Once() | |||
| 35 | + | } | |||
| 36 | + | r := portsmocks.NewMockRenderer(t) // no GPU calls permitted | |||
| 37 | + | if err := o.protectedModeset(context.Background(), r); err == nil { | |||
| 38 | + | t.Fatal("partial metadata reported protection success") | |||
| 39 | + | } | |||
| 40 | + | if !o.protected || len(*commits) != 0 || o.off || o.back != 0 { | |||
| 41 | + | t.Fatal("unverified detachment committed, flipped or claimed off") | |||
| 42 | + | } | |||
| 43 | + | }) | |||
| 44 | + | } | |||
| 45 | + | } | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | func TestProtectedModesetSkipsOwnedStrayDuplicates(t *testing.T) { | |||
| 49 | + | o, k, commits := testOutput(t) | |||
| 50 | + | o.overlay = &plane{id: tOverlay, props: planeProps} | |||
| 51 | + | o.stray = []*plane{o.overlay, o.primary, o.cursor.plane} | |||
| 52 | + | // Even if a fresh query would fail, cached owned-plane IDs suffice. | |||
| 53 | + | // No owned plane may be queried, during disable or activation. | |||
| 54 | + | k.EXPECT().objProps(mock.Anything, uint32(objPlane)).Return(nil, unix.EACCES).Maybe() | |||
| 55 | + | k.EXPECT().createBlob(mock.Anything).Return(99, nil).Once() | |||
| 56 | + | r := portsmocks.NewMockRenderer(t) | |||
| 57 | + | r.EXPECT().UseTarget(0).Return().Once() | |||
| 58 | + | r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil).Once() | |||
| 59 | + | if err := o.protectedModeset(context.Background(), r); err != nil { | |||
| 60 | + | t.Fatal(err) | |||
| 61 | + | } | |||
| 62 | + | k.AssertNotCalled(t, "objProps", mock.Anything, mock.Anything) | |||
| 63 | + | if len(*commits) != 2 { | |||
| 64 | + | t.Fatalf("commits %d", len(*commits)) | |||
| 65 | + | } | |||
| 66 | + | checkProtectedDisable(t, (*commits)[0], tPrimary, tCursor, tOverlay) | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | func TestProtectedModesetDisableSendsInactiveFormats(t *testing.T) { | |||
| 70 | + | for _, stage := range []string{"disable-failure", "clear-failure", "cancel", "off", "activate"} { | |||
| 71 | + | t.Run(stage, func(t *testing.T) { | |||
| 72 | + | var failures []error | |||
| 73 | + | if stage == "disable-failure" { | |||
| 74 | + | failures = []error{unix.EACCES} | |||
| 75 | + | } | |||
| 76 | + | o, k, _ := testOutput(t, failures...) | |||
| 77 | + | o.device, o.hdrOn = 42, true | |||
| 78 | + | feedback := make(chan ports.OutputFormats, 2) | |||
| 79 | + | o.formats = feedback | |||
| 80 | + | r := portsmocks.NewMockRenderer(t) | |||
| 81 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 82 | + | defer cancel() | |||
| 83 | + | if stage != "disable-failure" { | |||
| 84 | + | r.EXPECT().UseTarget(0).Return().Once() | |||
| 85 | + | r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) { | |||
| 86 | + | // Disable feedback must precede GPU writes, not just return. | |||
| 87 | + | select { | |||
| 88 | + | case f := <-feedback: | |||
| 89 | + | if f.Output != o.conn.name || f.Device != 42 || len(f.Formats) != 0 || f.HDR != nil || !o.off { | |||
| 90 | + | t.Fatalf("not inactive feedback: %+v off=%v", f, o.off) | |||
| 91 | + | } | |||
| 92 | + | default: | |||
| 93 | + | t.Fatal("successful disable sent no inactive feedback") | |||
| 94 | + | } | |||
| 95 | + | if stage == "clear-failure" { | |||
| 96 | + | return nil, errors.New("clear failed") | |||
| 97 | + | } | |||
| 98 | + | if stage == "cancel" { | |||
| 99 | + | cancel() | |||
| 100 | + | } | |||
| 101 | + | return nil, nil | |||
| 102 | + | }).Once() | |||
| 103 | + | } | |||
| 104 | + | if stage == "off" { | |||
| 105 | + | o.wantOff = true | |||
| 106 | + | } | |||
| 107 | + | if stage == "activate" { | |||
| 108 | + | k.EXPECT().createBlob(mock.Anything).Return(99, nil).Once() | |||
| 109 | + | } | |||
| 110 | + | err := o.protectedModeset(ctx, r) | |||
| 111 | + | wantErr := stage == "disable-failure" || stage == "clear-failure" || stage == "cancel" | |||
| 112 | + | if (err != nil) != wantErr { | |||
| 113 | + | t.Fatalf("result %v", err) | |||
| 114 | + | } | |||
| 115 | + | if stage == "activate" { | |||
| 116 | + | select { | |||
| 117 | + | case f := <-feedback: | |||
| 118 | + | if f.HDR == nil || o.off { | |||
| 119 | + | t.Fatalf("activation did not send active feedback: %+v", f) | |||
| 120 | + | } | |||
| 121 | + | default: | |||
| 122 | + | t.Fatal("missing active feedback after activation") | |||
| 123 | + | } | |||
| 124 | + | } | |||
| 125 | + | if len(feedback) != 0 { | |||
| 126 | + | t.Fatal("unexpected feedback without a successful state transition") | |||
| 127 | + | } | |||
| 128 | + | }) | |||
| 129 | + | } | |||
| 130 | + | } | |||
A internal/adapters/drm/protection_test.go +289 -0
| @@ -0,0 +1,289 @@ | |||||
| 1 | + | package drm | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "os" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |||
| 11 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 12 | + | "github.com/stretchr/testify/mock" | |||
| 13 | + | "golang.org/x/sys/unix" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | func protectionFence(t *testing.T, signalled bool) (*os.File, *os.File) { | |||
| 17 | + | t.Helper() | |||
| 18 | + | f, w, err := os.Pipe() | |||
| 19 | + | if err != nil { | |||
| 20 | + | t.Fatal(err) | |||
| 21 | + | } | |||
| 22 | + | t.Cleanup(func() { f.Close(); w.Close() }) | |||
| 23 | + | if signalled { | |||
| 24 | + | if _, err := w.Write([]byte{1}); err != nil { | |||
| 25 | + | t.Fatal(err) | |||
| 26 | + | } | |||
| 27 | + | } | |||
| 28 | + | return f, w | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | func checkProtectedDisable(t *testing.T, c commitRec, planes ...uint32) { | |||
| 32 | + | t.Helper() | |||
| 33 | + | if c.flags != atomicAllowModes || c.user != 0 { | |||
| 34 | + | t.Fatalf("not blocking modeset: flags=%#x user=%d", c.flags, c.user) | |||
| 35 | + | } | |||
| 36 | + | if active, ok := c.req.value(tCrtc, pActive); !ok || active != 0 { | |||
| 37 | + | t.Fatalf("CRTC still active: %d %v", active, ok) | |||
| 38 | + | } | |||
| 39 | + | // Full disable: no CRTC enabled without its primary plane (amdgpu rule). | |||
| 40 | + | if v, ok := c.req.value(tCrtc, pMode); !ok || v != 0 { | |||
| 41 | + | t.Fatalf("CRTC still enabled: MODE_ID %d %v", v, ok) | |||
| 42 | + | } | |||
| 43 | + | if v, ok := c.req.value(tConn, pConnCrtc); !ok || v != 0 { | |||
| 44 | + | t.Fatalf("connector still routed: CRTC_ID %d %v", v, ok) | |||
| 45 | + | } | |||
| 46 | + | for _, p := range planes { | |||
| 47 | + | for _, prop := range []uint32{pFB, pCrtcID} { | |||
| 48 | + | if v, ok := c.req.value(p, prop); !ok || v != 0 { | |||
| 49 | + | t.Fatalf("plane %d prop %d still attached: %d %v", p, prop, v, ok) | |||
| 50 | + | } | |||
| 51 | + | } | |||
| 52 | + | } | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | // Adapted from the parent's draft: detach, clear, wait, then activate only | |||
| 56 | + | // the cleared target, with no nonblocking commit or desktop cursor test. | |||
| 57 | + | func TestProtectedModesetClearsBeforeActivation(t *testing.T) { | |||
| 58 | + | o, k, commits := testOutput(t) | |||
| 59 | + | o.back = 1 // exercise the other target, not just startup's back=0 | |||
| 60 | + | o.overlay = &plane{id: tOverlay, props: planeProps} | |||
| 61 | + | o.stray = []*plane{o.overlay, {id: 53, props: planeProps}, {id: 54, props: planeProps}} | |||
| 62 | + | k.EXPECT().objProps(uint32(53), uint32(objPlane)).Return(map[string][2]uint64{"CRTC_ID": {pCrtcID, tCrtc}}, nil) | |||
| 63 | + | k.EXPECT().objProps(uint32(54), uint32(objPlane)).Return(map[string][2]uint64{"CRTC_ID": {pCrtcID, 99}}, nil) | |||
| 64 | + | r := portsmocks.NewMockRenderer(t) | |||
| 65 | + | r.EXPECT().UseTarget(1).Return().Once() | |||
| 66 | + | f, _ := protectionFence(t, true) | |||
| 67 | + | // Blob creation is part of activation and must occur after fence close. | |||
| 68 | + | k.EXPECT().createBlob(mock.Anything).RunAndReturn(func([]byte) (uint32, error) { | |||
| 69 | + | if _, err := f.Stat(); !errors.Is(err, os.ErrClosed) { | |||
| 70 | + | t.Fatalf("activation before clear fence close: %v", err) | |||
| 71 | + | } | |||
| 72 | + | return 99, nil | |||
| 73 | + | }).Once() | |||
| 74 | + | r.EXPECT().Render(ports.Scene{Seq: 0, Background: "#000000"}, map[ports.WindowID]ports.SurfaceContent(nil)).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) { | |||
| 75 | + | if len(*commits) != 1 { | |||
| 76 | + | t.Fatalf("clear before disable: %d commits", len(*commits)) | |||
| 77 | + | } | |||
| 78 | + | checkProtectedDisable(t, (*commits)[0], tPrimary, tCursor, tOverlay, 53) | |||
| 79 | + | if _, ok := (*commits)[0].req.value(54, pFB); ok { | |||
| 80 | + | t.Fatal("detached another output's plane") | |||
| 81 | + | } | |||
| 82 | + | return f, nil | |||
| 83 | + | }).Once() | |||
| 84 | + | if err := o.protectedModeset(context.Background(), r); err != nil { | |||
| 85 | + | t.Fatal(err) | |||
| 86 | + | } | |||
| 87 | + | if len(*commits) != 2 { | |||
| 88 | + | t.Fatalf("commits %d", len(*commits)) | |||
| 89 | + | } | |||
| 90 | + | last := (*commits)[1] | |||
| 91 | + | if last.flags != atomicAllowModes || last.user != 0 { | |||
| 92 | + | t.Fatal("activation was not blocking") | |||
| 93 | + | } | |||
| 94 | + | if fb, _ := last.req.value(tPrimary, pFB); fb != 71 { | |||
| 95 | + | t.Fatalf("activated fb %d, want cleared 71", fb) | |||
| 96 | + | } | |||
| 97 | + | if active, _ := last.req.value(tCrtc, pActive); active != 1 { | |||
| 98 | + | t.Fatal("not activated") | |||
| 99 | + | } | |||
| 100 | + | if o.back != 0 || !o.protected || o.off { | |||
| 101 | + | t.Fatalf("back=%d protected=%v off=%v", o.back, o.protected, o.off) | |||
| 102 | + | } | |||
| 103 | + | if _, err := f.Stat(); !errors.Is(err, os.ErrClosed) { | |||
| 104 | + | t.Fatalf("fence not closed: %v", err) | |||
| 105 | + | } | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | func TestProtectedModesetFailureNeverActivates(t *testing.T) { | |||
| 109 | + | for _, stage := range []string{"disable", "render", "fence", "blob", "activate"} { | |||
| 110 | + | t.Run(stage, func(t *testing.T) { | |||
| 111 | + | boom := errors.New("refused") | |||
| 112 | + | var failures []error | |||
| 113 | + | if stage == "disable" { | |||
| 114 | + | failures = []error{unix.EACCES} | |||
| 115 | + | } | |||
| 116 | + | if stage == "activate" { | |||
| 117 | + | failures = []error{nil, unix.EPERM} | |||
| 118 | + | } | |||
| 119 | + | o, k, commits := testOutput(t, failures...) | |||
| 120 | + | r := portsmocks.NewMockRenderer(t) | |||
| 121 | + | var fence *os.File | |||
| 122 | + | if stage != "disable" { | |||
| 123 | + | r.EXPECT().UseTarget(0).Return().Once() | |||
| 124 | + | f, w := protectionFence(t, stage != "fence") | |||
| 125 | + | fence = f | |||
| 126 | + | if stage == "fence" { | |||
| 127 | + | w.Close() // HUP without POLLIN is not fence completion. | |||
| 128 | + | } | |||
| 129 | + | var renderErr error | |||
| 130 | + | if stage == "render" { | |||
| 131 | + | renderErr = boom | |||
| 132 | + | } | |||
| 133 | + | r.EXPECT().Render(mock.Anything, mock.Anything).Return(fence, renderErr).Once() | |||
| 134 | + | } | |||
| 135 | + | if stage == "activate" { | |||
| 136 | + | k.EXPECT().createBlob(mock.Anything).Return(99, nil).Once() | |||
| 137 | + | k.EXPECT().destroyBlob(uint32(99)).Return(nil).Once() | |||
| 138 | + | } | |||
| 139 | + | if stage == "blob" { | |||
| 140 | + | k.EXPECT().createBlob(mock.Anything).Return(0, boom).Once() | |||
| 141 | + | } | |||
| 142 | + | if err := o.protectedModeset(context.Background(), r); err == nil { | |||
| 143 | + | t.Fatal("failure reported success") | |||
| 144 | + | } | |||
| 145 | + | if !o.protected || o.back != 0 { | |||
| 146 | + | t.Fatal("failure dropped protection or flipped target") | |||
| 147 | + | } | |||
| 148 | + | if stage != "activate" && len(*commits) != 1 { | |||
| 149 | + | t.Fatalf("activated after %s failure", stage) | |||
| 150 | + | } | |||
| 151 | + | if stage != "disable" && !o.off { | |||
| 152 | + | t.Fatal("disabled output considered active") | |||
| 153 | + | } | |||
| 154 | + | if fence != nil { | |||
| 155 | + | if _, err := fence.Stat(); !errors.Is(err, os.ErrClosed) { | |||
| 156 | + | t.Fatalf("fence not closed: %v", err) | |||
| 157 | + | } | |||
| 158 | + | } | |||
| 159 | + | }) | |||
| 160 | + | } | |||
| 161 | + | } | |||
| 162 | + | ||||
| 163 | + | func TestProtectedResumeWithoutClearStaysInactive(t *testing.T) { | |||
| 164 | + | o, _, commits := testOutput(t) | |||
| 165 | + | o.protected = true | |||
| 166 | + | if err := o.modeset(); err != nil { | |||
| 167 | + | t.Fatal(err) | |||
| 168 | + | } | |||
| 169 | + | checkProtectedDisable(t, (*commits)[0], tPrimary, tCursor) | |||
| 170 | + | if !o.off { | |||
| 171 | + | t.Fatal("resume exposed stale target") | |||
| 172 | + | } | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | func TestProtectedCloseNeverRestoresSavedDesktop(t *testing.T) { | |||
| 176 | + | o, k, commits := testOutput(t) | |||
| 177 | + | o.protected = true | |||
| 178 | + | o.saved = modeCrtc{crtcID: tCrtc, fbID: 123, modeValid: 1, mode: o.mode} | |||
| 179 | + | o.overlay = &plane{id: tOverlay, props: planeProps} | |||
| 180 | + | o.stray = []*plane{o.overlay, {id: 53, props: planeProps}, {id: 54, props: planeProps}} | |||
| 181 | + | k.EXPECT().objProps(uint32(53), uint32(objPlane)).Return(map[string][2]uint64{"CRTC_ID": {pCrtcID, tCrtc}}, nil).Once() | |||
| 182 | + | k.EXPECT().objProps(uint32(54), uint32(objPlane)).Return(map[string][2]uint64{"CRTC_ID": {pCrtcID, 99}}, nil).Once() | |||
| 183 | + | k.EXPECT().rmFB(mock.Anything).Return(nil) | |||
| 184 | + | o.Close() | |||
| 185 | + | if len(*commits) != 1 { | |||
| 186 | + | t.Fatalf("commits %d", len(*commits)) | |||
| 187 | + | } | |||
| 188 | + | checkProtectedDisable(t, (*commits)[0], tPrimary, tCursor, tOverlay, 53) | |||
| 189 | + | if _, ok := (*commits)[0].req.value(54, pFB); ok { | |||
| 190 | + | t.Fatal("close detached another output's plane") | |||
| 191 | + | } | |||
| 192 | + | } | |||
| 193 | + | ||||
| 194 | + | func TestProtectedModesetPendingDoesNotWaitForClientFence(t *testing.T) { | |||
| 195 | + | o, _, commits := testOutput(t) | |||
| 196 | + | f, _ := protectionFence(t, false) | |||
| 197 | + | o.frame.begin(pendingFrame{fences: []*os.File{f}}, time.Now()) | |||
| 198 | + | r := portsmocks.NewMockRenderer(t) | |||
| 199 | + | for _, call := range []func() error{ | |||
| 200 | + | func() error { return o.protectedModeset(context.Background(), r) }, | |||
| 201 | + | o.modeset, | |||
| 202 | + | } { | |||
| 203 | + | if err := call(); !errors.Is(err, errProtectionPending) { | |||
| 204 | + | t.Fatalf("pending result: %v", err) | |||
| 205 | + | } | |||
| 206 | + | } | |||
| 207 | + | if !o.protected || len(*commits) != 0 || !o.frame.pendingCommit() { | |||
| 208 | + | t.Fatal("pending protection touched KMS or dropped pending state") | |||
| 209 | + | } | |||
| 210 | + | if _, err := f.Stat(); err != nil { | |||
| 211 | + | t.Fatalf("client fence ownership dropped: %v", err) | |||
| 212 | + | } | |||
| 213 | + | } | |||
| 214 | + | ||||
| 215 | + | func TestProtectedModesetCanceled(t *testing.T) { | |||
| 216 | + | for _, before := range []bool{true, false} { | |||
| 217 | + | t.Run(map[bool]string{true: "before-disable", false: "during-wait"}[before], func(t *testing.T) { | |||
| 218 | + | o, _, commits := testOutput(t) | |||
| 219 | + | r := portsmocks.NewMockRenderer(t) | |||
| 220 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 221 | + | defer cancel() | |||
| 222 | + | var fence *os.File | |||
| 223 | + | if before { | |||
| 224 | + | cancel() | |||
| 225 | + | } else { | |||
| 226 | + | fence, _ = protectionFence(t, false) | |||
| 227 | + | r.EXPECT().UseTarget(0).Return().Once() | |||
| 228 | + | r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) { | |||
| 229 | + | cancel() | |||
| 230 | + | return fence, nil | |||
| 231 | + | }).Once() | |||
| 232 | + | } | |||
| 233 | + | if err := o.protectedModeset(ctx, r); !errors.Is(err, context.Canceled) { | |||
| 234 | + | t.Fatalf("cancel result: %v", err) | |||
| 235 | + | } | |||
| 236 | + | want := 1 | |||
| 237 | + | if before { | |||
| 238 | + | want = 0 | |||
| 239 | + | } | |||
| 240 | + | if len(*commits) != want || !o.protected || o.back != 0 { | |||
| 241 | + | t.Fatal("cancel activated or dropped latch") | |||
| 242 | + | } | |||
| 243 | + | if fence != nil { | |||
| 244 | + | if !o.off { | |||
| 245 | + | t.Fatal("canceled clear left output marked active") | |||
| 246 | + | } | |||
| 247 | + | if _, err := fence.Stat(); !errors.Is(err, os.ErrClosed) { | |||
| 248 | + | t.Fatalf("cancel did not close fence: %v", err) | |||
| 249 | + | } | |||
| 250 | + | } | |||
| 251 | + | }) | |||
| 252 | + | } | |||
| 253 | + | } | |||
| 254 | + | ||||
| 255 | + | func TestProtectedModesetPreservesPowerOff(t *testing.T) { | |||
| 256 | + | o, _, commits := testOutput(t) | |||
| 257 | + | o.wantOff = true | |||
| 258 | + | r := portsmocks.NewMockRenderer(t) | |||
| 259 | + | r.EXPECT().UseTarget(0).Return().Once() | |||
| 260 | + | f, _ := protectionFence(t, true) | |||
| 261 | + | r.EXPECT().Render(ports.Scene{Seq: 0, Background: "#000000"}, map[ports.WindowID]ports.SurfaceContent(nil)).Return(f, nil).Once() | |||
| 262 | + | if err := o.protectedModeset(context.Background(), r); err != nil { | |||
| 263 | + | t.Fatal(err) | |||
| 264 | + | } | |||
| 265 | + | if len(*commits) != 1 || !o.wantOff || !o.off || !o.protected || o.back != 0 { | |||
| 266 | + | t.Fatal("off proof activated output, flipped target or changed power intent") | |||
| 267 | + | } | |||
| 268 | + | checkProtectedDisable(t, (*commits)[0], tPrimary, tCursor) | |||
| 269 | + | } | |||
| 270 | + | ||||
| 271 | + | func TestProtectedModesetUnreadableStrayFailsClosed(t *testing.T) { | |||
| 272 | + | for _, missing := range []bool{false, true} { | |||
| 273 | + | t.Run(map[bool]string{false: "read-error", true: "missing-property"}[missing], func(t *testing.T) { | |||
| 274 | + | o, k, commits := testOutput(t) | |||
| 275 | + | o.stray = []*plane{{id: tOverlay, props: planeProps}} | |||
| 276 | + | var err error | |||
| 277 | + | if !missing { | |||
| 278 | + | err = unix.EACCES | |||
| 279 | + | } | |||
| 280 | + | k.EXPECT().objProps(uint32(tOverlay), uint32(objPlane)).Return(map[string][2]uint64{}, err).Once() | |||
| 281 | + | if err := o.protectedModeset(context.Background(), portsmocks.NewMockRenderer(t)); err == nil { | |||
| 282 | + | t.Fatal("unverified stray plane reported success") | |||
| 283 | + | } | |||
| 284 | + | if !o.protected || len(*commits) != 0 { | |||
| 285 | + | t.Fatal("unverified detachment touched KMS or dropped latch") | |||
| 286 | + | } | |||
| 287 | + | }) | |||
| 288 | + | } | |||
| 289 | + | } | |||
A internal/adapters/drm/security.go +149 -0
| @@ -0,0 +1,149 @@ | |||||
| 1 | + | package drm | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | var errSecurityScene = errors.New("scene security epoch changed or output unprepared") | |||
| 12 | + | ||||
| 13 | + | // observeSecurity is output-owner only. The channel wakes; the coherent gate | |||
| 14 | + | // decides admission, including shutdown paths that never received the wake. | |||
| 15 | + | func (o *Output) observeSecurity() bool { | |||
| 16 | + | if o.Security == nil { | |||
| 17 | + | return false | |||
| 18 | + | } | |||
| 19 | + | state := o.Security.Snapshot() | |||
| 20 | + | if state == o.securityState { | |||
| 21 | + | return false | |||
| 22 | + | } | |||
| 23 | + | o.securityState = state | |||
| 24 | + | o.securityPrepared = false | |||
| 25 | + | o.protected = state.Protected | |||
| 26 | + | o.protectSucceeded() // a new epoch gets an immediate first attempt | |||
| 27 | + | if o.cursor != nil { | |||
| 28 | + | o.cursor.image, o.cursor.later = false, nil | |||
| 29 | + | } | |||
| 30 | + | return true | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | func (o *Output) sceneCurrent(scene ports.Scene) bool { | |||
| 34 | + | o.observeSecurity() | |||
| 35 | + | return o.Security == nil || scene.Security == o.securityState | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | func (o *Output) invalidateSecurity() { | |||
| 39 | + | o.securityPrepared = false | |||
| 40 | + | if o.protected { | |||
| 41 | + | o.securityInvalid = true | |||
| 42 | + | } | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | func (o *Output) securityEvent(ctx context.Context, event ports.SecurityBackendEvent) error { | |||
| 46 | + | if o.SecurityEvents == nil || o.Instance == 0 { | |||
| 47 | + | return nil | |||
| 48 | + | } | |||
| 49 | + | select { | |||
| 50 | + | case o.SecurityEvents <- event: | |||
| 51 | + | return nil | |||
| 52 | + | case <-ctx.Done(): | |||
| 53 | + | return ctx.Err() | |||
| 54 | + | } | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | func (o *Output) sendSecurityInvalid(ctx context.Context) error { | |||
| 58 | + | if !o.securityInvalid { | |||
| 59 | + | return nil | |||
| 60 | + | } | |||
| 61 | + | state := o.securityState | |||
| 62 | + | if err := o.securityEvent(ctx, ports.SecurityOutputInvalidated{Generation: state.Generation, Instance: o.Instance}); err != nil { | |||
| 63 | + | return err | |||
| 64 | + | } | |||
| 65 | + | o.securityInvalid = false | |||
| 66 | + | return nil | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | func (o *Output) prepareSecurity(ctx context.Context, r ports.Renderer) error { | |||
| 70 | + | o.observeSecurity() | |||
| 71 | + | if !o.protected { | |||
| 72 | + | return nil | |||
| 73 | + | } | |||
| 74 | + | if err := o.sendSecurityInvalid(ctx); err != nil { | |||
| 75 | + | return err | |||
| 76 | + | } | |||
| 77 | + | state := o.securityState | |||
| 78 | + | if err := o.protectedModeset(ctx, r); err != nil { | |||
| 79 | + | return err | |||
| 80 | + | } | |||
| 81 | + | // A gate transition during the blocking clear cannot certify a new epoch. | |||
| 82 | + | if o.observeSecurity() || o.securityState != state { | |||
| 83 | + | return nil | |||
| 84 | + | } | |||
| 85 | + | o.securityPrepared = true | |||
| 86 | + | kind := ports.ProtectionProtectedFrame | |||
| 87 | + | if o.off { | |||
| 88 | + | kind = ports.ProtectionInactiveOutput | |||
| 89 | + | } | |||
| 90 | + | // Separate non-merging channel; presentation feedback is not evidence. | |||
| 91 | + | // Backpressure may outlive an epoch. Revalidate on each bounded send | |||
| 92 | + | // attempt; the ledger also rejects a transition racing the final send. | |||
| 93 | + | if o.SecurityEvents == nil || o.Instance == 0 { | |||
| 94 | + | return nil | |||
| 95 | + | } | |||
| 96 | + | timer := time.NewTimer(time.Millisecond) | |||
| 97 | + | defer timer.Stop() | |||
| 98 | + | proof := ports.SecurityOutputProof{Proof: ports.OutputProtection{Generation: state.Generation, Instance: o.Instance, Kind: kind}} | |||
| 99 | + | for { | |||
| 100 | + | if o.observeSecurity() || o.securityState != state { | |||
| 101 | + | return nil | |||
| 102 | + | } | |||
| 103 | + | select { | |||
| 104 | + | case o.SecurityEvents <- proof: | |||
| 105 | + | return nil | |||
| 106 | + | case <-ctx.Done(): | |||
| 107 | + | return ctx.Err() | |||
| 108 | + | case <-timer.C: | |||
| 109 | + | timer.Reset(time.Millisecond) | |||
| 110 | + | } | |||
| 111 | + | } | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | // Protected frames never enter client-fence KMS paths or capture pipelines. | |||
| 115 | + | // Locker content is composed only after black preparation for this epoch. | |||
| 116 | + | func (o *Output) submitProtectedFrame(ctx context.Context, r ports.Renderer, scene ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, seen map[ports.WindowID]uint64) error { | |||
| 117 | + | if !o.securityPrepared || !o.sceneCurrent(scene) { | |||
| 118 | + | return errSecurityScene | |||
| 119 | + | } | |||
| 120 | + | scene.CaptureScene, scene.Capture = nil, nil | |||
| 121 | + | r.UseTarget(o.back) | |||
| 122 | + | done, err := r.Render(scene, surfaces) | |||
| 123 | + | if err != nil { | |||
| 124 | + | if done != nil { | |||
| 125 | + | done.Close() | |||
| 126 | + | } | |||
| 127 | + | return renderError{err} | |||
| 128 | + | } | |||
| 129 | + | // A transition while rendering must not put the obsolete scene on KMS. | |||
| 130 | + | if !o.sceneCurrent(scene) || !o.protected || !o.securityPrepared { | |||
| 131 | + | o.holdRead(done) | |||
| 132 | + | if done != nil { | |||
| 133 | + | done.Close() | |||
| 134 | + | } | |||
| 135 | + | return errSecurityScene | |||
| 136 | + | } | |||
| 137 | + | f := pendingFrame{security: scene.Security, frame: true, composed: true, shows: o.shownBy(scene, seen)} | |||
| 138 | + | err = o.commitWith(o.fbs[o.back], done, false, false, f, overlayWin{}) | |||
| 139 | + | if err != nil { | |||
| 140 | + | o.holdRead(done) | |||
| 141 | + | } | |||
| 142 | + | if done != nil { | |||
| 143 | + | done.Close() | |||
| 144 | + | } | |||
| 145 | + | if err == nil { | |||
| 146 | + | o.back = 1 - o.back | |||
| 147 | + | } | |||
| 148 | + | return err | |||
| 149 | + | } | |||
A internal/adapters/drm/security_capture_test.go +102 -0
| @@ -0,0 +1,102 @@ | |||||
| 1 | + | package drm | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "os" | |||
| 7 | + | "sync/atomic" | |||
| 8 | + | "testing" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/neferwl/internal/adapters/capture" | |||
| 11 | + | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |||
| 12 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 13 | + | "github.com/stretchr/testify/mock" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | func TestSecurityEngageDuringCleanRenderRejectsCaptureTransfer(t *testing.T) { | |||
| 17 | + | o, _, commits := testOutput(t) | |||
| 18 | + | o.cursor = nil | |||
| 19 | + | var state atomic.Uint64 | |||
| 20 | + | securityGate(t, o, &state) | |||
| 21 | + | r := portsmocks.NewMockRenderer(t) | |||
| 22 | + | f, _ := protectionFence(t, false) | |||
| 23 | + | r.EXPECT().UseTarget(0).Return().Once() | |||
| 24 | + | r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) { | |||
| 25 | + | state.Store(3) | |||
| 26 | + | return f, nil | |||
| 27 | + | }).Once() | |||
| 28 | + | replies := make(chan ports.CaptureDone, 2) | |||
| 29 | + | pipeline := capture.NewPipeline(context.Background(), replies) | |||
| 30 | + | requests := []ports.CaptureRequest{sessionRequest(t, 1, true), sessionRequest(t, 2, false)} | |||
| 31 | + | files := []*os.File{requests[0].Dst.File, requests[1].Dst.File} | |||
| 32 | + | _, err := o.submitFrame(context.Background(), r, drmSessionScene(), nil, nil, requests, pipeline) | |||
| 33 | + | if !errors.Is(err, errSecurityScene) || len(*commits) != 0 { | |||
| 34 | + | t.Fatalf("capture admission result: %v", err) | |||
| 35 | + | } | |||
| 36 | + | if len(o.readFences) != 1 || o.readDone() { | |||
| 37 | + | t.Fatal("rejected clean render dropped GPU reads") | |||
| 38 | + | } | |||
| 39 | + | if _, err := f.Stat(); !errors.Is(err, os.ErrClosed) { | |||
| 40 | + | t.Fatal("returned clean fence not closed") | |||
| 41 | + | } | |||
| 42 | + | // Match Run's remaining-credit rejection: nothing was transferred to the | |||
| 43 | + | // worker, each request is failed once, and every owned FD is closed. | |||
| 44 | + | for _, q := range requests { | |||
| 45 | + | if capture.Handed(q) { | |||
| 46 | + | t.Fatal("obsolete capture transferred credit") | |||
| 47 | + | } | |||
| 48 | + | capture.Fail(context.Background(), q, err, replies) | |||
| 49 | + | } | |||
| 50 | + | pipeline.Close(r) | |||
| 51 | + | r.AssertNotCalled(t, "BeginCapture") | |||
| 52 | + | if len(replies) != 2 { | |||
| 53 | + | t.Fatalf("capture replies %d", len(replies)) | |||
| 54 | + | } | |||
| 55 | + | for range 2 { | |||
| 56 | + | if reply := <-replies; !errors.Is(reply.Err, errSecurityScene) { | |||
| 57 | + | t.Fatalf("reply %+v", reply) | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | for _, file := range files { | |||
| 61 | + | if _, err := file.Stat(); !errors.Is(err, os.ErrClosed) { | |||
| 62 | + | t.Fatal("capture FD not closed") | |||
| 63 | + | } | |||
| 64 | + | } | |||
| 65 | + | o.dropRead() | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | func TestSecurityHiddenAdmissionRechecksBeforeNativeStart(t *testing.T) { | |||
| 69 | + | o, _, _ := testOutput(t) | |||
| 70 | + | o.cursor = nil | |||
| 71 | + | security := portsmocks.NewMockSessionSecurity(t) | |||
| 72 | + | security.EXPECT().Snapshot().Return(ports.SecurityState{}).Once() | |||
| 73 | + | security.EXPECT().Snapshot().Return(ports.SecurityState{Generation: 1, Protected: true}) | |||
| 74 | + | o.Security = security | |||
| 75 | + | r := portsmocks.NewMockRenderer(t) | |||
| 76 | + | child := portsmocks.NewMockRenderer(t) | |||
| 77 | + | replies := make(chan ports.CaptureDone, 1) | |||
| 78 | + | pipeline := capture.NewPipeline(context.Background(), replies) | |||
| 79 | + | pipeline.EnableOffscreen(func(int, int) (ports.Renderer, error) { | |||
| 80 | + | t.Fatal("obsolete hidden capture created child") | |||
| 81 | + | return child, nil | |||
| 82 | + | }) | |||
| 83 | + | s := drmSessionScene() | |||
| 84 | + | s.CaptureScene = &ports.Scene{OutputWidth: 200, OutputHeight: 100, Scale: 1} | |||
| 85 | + | requests := []ports.CaptureRequest{sessionRequest(t, 1, true)} | |||
| 86 | + | file := requests[0].Dst.File | |||
| 87 | + | _, err := o.submitFrame(context.Background(), r, s, nil, nil, requests, pipeline) | |||
| 88 | + | if !errors.Is(err, errSecurityScene) || capture.Handed(requests[0]) { | |||
| 89 | + | t.Fatalf("hidden admission result %v", err) | |||
| 90 | + | } | |||
| 91 | + | capture.Fail(context.Background(), requests[0], err, replies) | |||
| 92 | + | pipeline.Close(r) | |||
| 93 | + | if len(replies) != 1 || (<-replies).ID != 1 { | |||
| 94 | + | t.Fatal("hidden request credit not failed once") | |||
| 95 | + | } | |||
| 96 | + | if _, err := file.Stat(); !errors.Is(err, os.ErrClosed) { | |||
| 97 | + | t.Fatal("hidden FD not closed") | |||
| 98 | + | } | |||
| 99 | + | r.AssertNotCalled(t, "BeginCapture") | |||
| 100 | + | child.AssertNotCalled(t, "Render", mock.Anything, mock.Anything) | |||
| 101 | + | child.AssertNotCalled(t, "BeginCapture") | |||
| 102 | + | } | |||
A internal/adapters/drm/security_close_test.go +74 -0
| @@ -0,0 +1,74 @@ | |||||
| 1 | + | package drm | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "maps" | |||
| 5 | + | "sync/atomic" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/stretchr/testify/mock" | |||
| 9 | + | "golang.org/x/sys/unix" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func TestInactiveOnCloseRequiresSuccessfulValidatedDisable(t *testing.T) { | |||
| 13 | + | for _, stage := range []string{"success", "commit-failure", "query-failure", "partial-primary", "partial-cursor", "partial-overlay", "partial-stray", "missing-active"} { | |||
| 14 | + | t.Run(stage, func(t *testing.T) { | |||
| 15 | + | var failures []error | |||
| 16 | + | if stage == "commit-failure" { | |||
| 17 | + | failures = []error{unix.EACCES} | |||
| 18 | + | } | |||
| 19 | + | o, k, commits := testOutput(t, failures...) | |||
| 20 | + | var state atomic.Uint64 | |||
| 21 | + | securityGate(t, o, &state) // production gate, even unlocked: disable | |||
| 22 | + | o.off = true // stale cached state is never terminal evidence | |||
| 23 | + | o.inactiveOnClose = true // reset any earlier result at entry | |||
| 24 | + | o.saved = modeCrtc{crtcID: tCrtc, fbID: 123, modeValid: 1, mode: o.mode} | |||
| 25 | + | o.overlay = &plane{id: tOverlay, props: planeProps} | |||
| 26 | + | stray := &plane{id: 53, props: planeProps} | |||
| 27 | + | o.stray = []*plane{o.overlay, stray} | |||
| 28 | + | var readErr error | |||
| 29 | + | if stage == "query-failure" { | |||
| 30 | + | readErr = unix.EACCES | |||
| 31 | + | } | |||
| 32 | + | k.EXPECT().objProps(uint32(53), uint32(objPlane)).Return(map[string][2]uint64{"CRTC_ID": {pCrtcID, tCrtc}}, readErr).Once() | |||
| 33 | + | partial := func(p *plane) { p.props = maps.Clone(planeProps); p.props["FB_ID"] = 0 } | |||
| 34 | + | switch stage { | |||
| 35 | + | case "partial-primary": | |||
| 36 | + | partial(o.primary) | |||
| 37 | + | case "partial-cursor": | |||
| 38 | + | partial(o.cursor.plane) | |||
| 39 | + | case "partial-overlay": | |||
| 40 | + | partial(o.overlay) | |||
| 41 | + | case "partial-stray": | |||
| 42 | + | partial(stray) | |||
| 43 | + | case "missing-active": | |||
| 44 | + | o.crtcProps = maps.Clone(o.crtcProps) | |||
| 45 | + | o.crtcProps["ACTIVE"] = 0 | |||
| 46 | + | } | |||
| 47 | + | k.EXPECT().rmFB(mock.Anything).Return(nil) | |||
| 48 | + | done := make(chan struct{}) | |||
| 49 | + | go func() { o.Close(); close(done) }() | |||
| 50 | + | <-done // accessor is only read after owner-completion synchronization | |||
| 51 | + | if got := o.InactiveOnClose(); got != (stage == "success") { | |||
| 52 | + | t.Fatalf("inactiveOnClose=%v stage=%s", got, stage) | |||
| 53 | + | } | |||
| 54 | + | if len(*commits) != 1 { | |||
| 55 | + | t.Fatalf("commits %d", len(*commits)) | |||
| 56 | + | } | |||
| 57 | + | if stage == "success" { | |||
| 58 | + | checkProtectedDisable(t, (*commits)[0], tPrimary, tCursor, tOverlay, 53) | |||
| 59 | + | } | |||
| 60 | + | }) | |||
| 61 | + | } | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | func TestInactiveOnCloseSavedRestoreNeverCertifiesRemoval(t *testing.T) { | |||
| 65 | + | o, k, _ := testOutput(t) | |||
| 66 | + | o.saved = modeCrtc{crtcID: tCrtc, fbID: 123, modeValid: 1, mode: o.mode} | |||
| 67 | + | k.EXPECT().createBlob(mock.Anything).Return(99, nil).Once() | |||
| 68 | + | k.EXPECT().destroyBlob(uint32(99)).Return(nil).Once() | |||
| 69 | + | k.EXPECT().rmFB(mock.Anything).Return(nil) | |||
| 70 | + | o.Close() | |||
| 71 | + | if o.InactiveOnClose() { | |||
| 72 | + | t.Fatal("legacy saved desktop restoration certified inactivity") | |||
| 73 | + | } | |||
| 74 | + | } | |||
A internal/adapters/drm/security_commit_test.go +22 -0
| @@ -0,0 +1,22 @@ | |||||
| 1 | + | package drm | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "sync/atomic" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestSecurityFinalCommitSnapshotRejectsEngage(t *testing.T) { | |||
| 12 | + | o, _, commits := testOutput(t) | |||
| 13 | + | var state atomic.Uint64 | |||
| 14 | + | securityGate(t, o, &state) | |||
| 15 | + | // The frame was admitted in unlocked generation zero; before the final | |||
| 16 | + | // commit boundary a lock engages. No client fence is handed to KMS. | |||
| 17 | + | state.Store(3) | |||
| 18 | + | err := o.commitWith(70, nil, true, true, pendingFrame{security: ports.SecurityState{}}, overlayWin{}) | |||
| 19 | + | if !errors.Is(err, errSecurityScene) || len(*commits) != 0 || !o.protected { | |||
| 20 | + | t.Fatalf("obsolete commit admitted: %v commits=%d", err, len(*commits)) | |||
| 21 | + | } | |||
| 22 | + | } | |||
A internal/adapters/drm/security_recovery_test.go +131 -0
| @@ -0,0 +1,131 @@ | |||||
| 1 | + | package drm | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "os" | |||
| 6 | + | "sync/atomic" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |||
| 11 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 12 | + | "github.com/stretchr/testify/mock" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // A missing flip event is recoverable only after the real acquire fence | |||
| 16 | + | // signals. Recovery revokes earlier proof before a fresh blocking black proof. | |||
| 17 | + | func TestSecurityStuckRecoveryInvalidatesBeforeFreshProof(t *testing.T) { | |||
| 18 | + | o, k, commits, mu := testOutputMu(t) | |||
| 19 | + | o.cursor, o.tearing = nil, false | |||
| 20 | + | o.frame.stuckAfter = 40 * time.Millisecond | |||
| 21 | + | o.flipped = make(chan flipEvent) // the event is intentionally lost | |||
| 22 | + | var state atomic.Uint64 | |||
| 23 | + | state.Store(3) | |||
| 24 | + | securityGate(t, o, &state) | |||
| 25 | + | events := make(chan ports.SecurityBackendEvent, 8) | |||
| 26 | + | o.SecurityEvents = events | |||
| 27 | + | r := portsmocks.NewMockRenderer(t) | |||
| 28 | + | buf := func() ports.DMABuf { | |||
| 29 | + | f, w := protectionFence(t, false) | |||
| 30 | + | w.Close() | |||
| 31 | + | return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}} | |||
| 32 | + | } | |||
| 33 | + | r.EXPECT().SetHDR(float64(0)).Return().Maybe() | |||
| 34 | + | r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once() | |||
| 35 | + | r.EXPECT().UseTarget(mock.Anything).Return() | |||
| 36 | + | fence, signal := protectionFence(t, false) | |||
| 37 | + | var renderedOnce atomic.Bool | |||
| 38 | + | rendered := make(chan struct{}, 1) | |||
| 39 | + | r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) (*os.File, error) { | |||
| 40 | + | if s.Seq == 12 { | |||
| 41 | + | if renderedOnce.CompareAndSwap(false, true) { | |||
| 42 | + | select { | |||
| 43 | + | case rendered <- struct{}{}: | |||
| 44 | + | default: | |||
| 45 | + | } | |||
| 46 | + | return fence, nil | |||
| 47 | + | } | |||
| 48 | + | return nil, nil | |||
| 49 | + | } | |||
| 50 | + | if s.Background != "#000000" || s.Seq != 0 || len(s.Windows) != 0 { | |||
| 51 | + | t.Error("recovery did not compose exact black") | |||
| 52 | + | } | |||
| 53 | + | return nil, nil | |||
| 54 | + | }) | |||
| 55 | + | r.EXPECT().Close().Return().Once() | |||
| 56 | + | k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil) | |||
| 57 | + | k.EXPECT().createBlob(mock.Anything).Return(99, nil) | |||
| 58 | + | k.EXPECT().destroyBlob(mock.Anything).Return(nil).Maybe() | |||
| 59 | + | ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) | |||
| 60 | + | defer cancel() | |||
| 61 | + | scenes := make(chan ports.Scene, 1) | |||
| 62 | + | done := make(chan error, 1) | |||
| 63 | + | go func() { | |||
| 64 | + | done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, nil, scenes, nil, nil, make(chan ports.OutputPresented, 8), nil, nil) | |||
| 65 | + | }() | |||
| 66 | + | receive := func() ports.SecurityBackendEvent { | |||
| 67 | + | t.Helper() | |||
| 68 | + | select { | |||
| 69 | + | case event := <-events: | |||
| 70 | + | return event | |||
| 71 | + | case <-ctx.Done(): | |||
| 72 | + | t.Fatal("missing recovery security event") | |||
| 73 | + | return nil | |||
| 74 | + | } | |||
| 75 | + | } | |||
| 76 | + | initial := receive() | |||
| 77 | + | if p, ok := initial.(ports.SecurityOutputProof); !ok || p.Proof != (ports.OutputProtection{Generation: 1, Instance: 77, Kind: ports.ProtectionProtectedFrame}) { | |||
| 78 | + | t.Fatalf("initial proof %+v", initial) | |||
| 79 | + | } | |||
| 80 | + | scenes <- ports.Scene{Seq: 12, Security: ports.SecurityState{Generation: 1, Protected: true}} | |||
| 81 | + | select { | |||
| 82 | + | case <-rendered: | |||
| 83 | + | case <-ctx.Done(): | |||
| 84 | + | t.Fatal("protected frame not submitted") | |||
| 85 | + | } | |||
| 86 | + | waitFor(t, func() bool { | |||
| 87 | + | mu.Lock() | |||
| 88 | + | defer mu.Unlock() | |||
| 89 | + | for _, c := range *commits { | |||
| 90 | + | if c.user&3 == userFrame { | |||
| 91 | + | return true | |||
| 92 | + | } | |||
| 93 | + | } | |||
| 94 | + | return false | |||
| 95 | + | }) | |||
| 96 | + | select { | |||
| 97 | + | case event := <-events: | |||
| 98 | + | t.Fatalf("unsignalled acquire fence forged recovery evidence: %+v", event) | |||
| 99 | + | case <-time.After(3 * o.frame.stuckAfter): | |||
| 100 | + | } | |||
| 101 | + | if _, err := signal.Write([]byte{1}); err != nil { | |||
| 102 | + | t.Fatal(err) | |||
| 103 | + | } | |||
| 104 | + | if event := receive(); event != (ports.SecurityOutputInvalidated{Generation: 1, Instance: 77}) { | |||
| 105 | + | t.Fatalf("fresh proof preceded recovery invalidation: %+v", event) | |||
| 106 | + | } | |||
| 107 | + | if event := receive(); event != initial { | |||
| 108 | + | t.Fatalf("fresh recovery proof changed lifetime/epoch: %+v", event) | |||
| 109 | + | } | |||
| 110 | + | cancel() | |||
| 111 | + | if err := <-done; err != nil { | |||
| 112 | + | t.Fatal(err) | |||
| 113 | + | } | |||
| 114 | + | mu.Lock() | |||
| 115 | + | defer mu.Unlock() | |||
| 116 | + | var frameIndex int | |||
| 117 | + | for i, c := range *commits { | |||
| 118 | + | if c.user&3 == userFrame { | |||
| 119 | + | frameIndex = i | |||
| 120 | + | break | |||
| 121 | + | } | |||
| 122 | + | } | |||
| 123 | + | if frameIndex == 0 || len(*commits) < frameIndex+3 { | |||
| 124 | + | t.Fatal("no actual blocking black recovery transaction") | |||
| 125 | + | } | |||
| 126 | + | checkProtectedDisable(t, (*commits)[frameIndex+1], tPrimary) | |||
| 127 | + | activate := (*commits)[frameIndex+2] | |||
| 128 | + | if active, ok := activate.req.value(tCrtc, pActive); !ok || active != 1 || activate.flags != atomicAllowModes { | |||
| 129 | + | t.Fatal("recovery proof did not follow blocking activation") | |||
| 130 | + | } | |||
| 131 | + | } | |||
A internal/adapters/drm/security_resume_test.go +96 -0
| @@ -0,0 +1,96 @@ | |||||
| 1 | + | package drm | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "os" | |||
| 6 | + | "sync/atomic" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |||
| 11 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 12 | + | "github.com/stretchr/testify/mock" | |||
| 13 | + | "golang.org/x/sys/unix" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | func TestSecurityUnlockedResumeUsesOwnerRetry(t *testing.T) { | |||
| 17 | + | for _, stage := range []string{"engage-during-request", "master-loss"} { | |||
| 18 | + | t.Run(stage, func(t *testing.T) { | |||
| 19 | + | var failures []error | |||
| 20 | + | if stage == "master-loss" { | |||
| 21 | + | failures = []error{unix.EACCES} | |||
| 22 | + | } | |||
| 23 | + | o, k, commits, mu := testOutputMu(t, failures...) | |||
| 24 | + | o.cursor, o.tearing = nil, false | |||
| 25 | + | var state atomic.Uint64 | |||
| 26 | + | securityGate(t, o, &state) | |||
| 27 | + | events := make(chan ports.SecurityBackendEvent, 4) | |||
| 28 | + | o.SecurityEvents = events | |||
| 29 | + | active := make(chan bool, 2) | |||
| 30 | + | active <- false // retain existing images; first modeset is resume | |||
| 31 | + | wake := make(chan ports.SecurityState, 1) | |||
| 32 | + | o.SecurityChanges = wake | |||
| 33 | + | r := portsmocks.NewMockRenderer(t) | |||
| 34 | + | r.EXPECT().UseTarget(0).Return().Once() | |||
| 35 | + | r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) (*os.File, error) { | |||
| 36 | + | if s.Seq != 0 || s.Background != "#000000" || len(s.Windows) != 0 { | |||
| 37 | + | t.Fatal("resume rendered desktop") | |||
| 38 | + | } | |||
| 39 | + | return nil, nil | |||
| 40 | + | }).Once() | |||
| 41 | + | r.EXPECT().Close().Return().Once() | |||
| 42 | + | var blobs atomic.Int32 | |||
| 43 | + | k.EXPECT().createBlob(mock.Anything).RunAndReturn(func([]byte) (uint32, error) { | |||
| 44 | + | if blobs.Add(1) == 1 && stage == "engage-during-request" { | |||
| 45 | + | state.Store(3) | |||
| 46 | + | } | |||
| 47 | + | return 99, nil | |||
| 48 | + | }) | |||
| 49 | + | k.EXPECT().destroyBlob(mock.Anything).Return(nil).Maybe() | |||
| 50 | + | ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) | |||
| 51 | + | defer cancel() | |||
| 52 | + | done := make(chan error, 1) | |||
| 53 | + | go func() { | |||
| 54 | + | done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, active, nil, nil, nil, nil, nil, nil) | |||
| 55 | + | }() | |||
| 56 | + | active <- true | |||
| 57 | + | if stage == "master-loss" { | |||
| 58 | + | waitFor(t, func() bool { mu.Lock(); defer mu.Unlock(); return len(*commits) == 1 }) | |||
| 59 | + | state.Store(3) | |||
| 60 | + | wake <- ports.SecurityState{} | |||
| 61 | + | select { | |||
| 62 | + | case ev := <-events: | |||
| 63 | + | if _, ok := ev.(ports.SecurityOutputProof); ok { | |||
| 64 | + | t.Fatal("master loss forged proof") | |||
| 65 | + | } | |||
| 66 | + | case <-time.After(20 * time.Millisecond): | |||
| 67 | + | } | |||
| 68 | + | select { | |||
| 69 | + | case err := <-done: | |||
| 70 | + | t.Fatalf("resume stopped owner: %v", err) | |||
| 71 | + | default: | |||
| 72 | + | } | |||
| 73 | + | active <- true // only real seat reacquisition permits preparation | |||
| 74 | + | } | |||
| 75 | + | for { | |||
| 76 | + | select { | |||
| 77 | + | case ev := <-events: | |||
| 78 | + | if p, ok := ev.(ports.SecurityOutputProof); ok { | |||
| 79 | + | if p.Proof.Generation != 1 || p.Proof.Instance != 77 { | |||
| 80 | + | t.Fatalf("proof %+v", p) | |||
| 81 | + | } | |||
| 82 | + | cancel() | |||
| 83 | + | if err := <-done; err != nil { | |||
| 84 | + | t.Fatal(err) | |||
| 85 | + | } | |||
| 86 | + | return | |||
| 87 | + | } | |||
| 88 | + | case err := <-done: | |||
| 89 | + | t.Fatalf("resume ended before fresh proof: %v", err) | |||
| 90 | + | case <-ctx.Done(): | |||
| 91 | + | t.Fatal("missing fresh resume proof") | |||
| 92 | + | } | |||
| 93 | + | } | |||
| 94 | + | }) | |||
| 95 | + | } | |||
| 96 | + | } | |||
A internal/adapters/drm/security_run_test.go +131 -0
| @@ -0,0 +1,131 @@ | |||||
| 1 | + | package drm | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "os" | |||
| 6 | + | "sync/atomic" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |||
| 11 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 12 | + | "github.com/stretchr/testify/mock" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | func TestSecurityRunWakePendingResumeAndOff(t *testing.T) { | |||
| 16 | + | o, k, commits, mu := testOutputMu(t) | |||
| 17 | + | o.cursor, o.tearing = nil, false | |||
| 18 | + | var state atomic.Uint64 | |||
| 19 | + | securityGate(t, o, &state) | |||
| 20 | + | events := make(chan ports.SecurityBackendEvent, 8) | |||
| 21 | + | o.SecurityEvents = events | |||
| 22 | + | wake := make(chan ports.SecurityState, 1) | |||
| 23 | + | o.SecurityChanges = wake | |||
| 24 | + | active := make(chan bool, 2) | |||
| 25 | + | flips := make(chan flipEvent, 2) | |||
| 26 | + | o.flipped = flips | |||
| 27 | + | scenes := make(chan ports.Scene, 2) | |||
| 28 | + | r := portsmocks.NewMockRenderer(t) | |||
| 29 | + | buf := func() ports.DMABuf { | |||
| 30 | + | f, w := protectionFence(t, false) | |||
| 31 | + | w.Close() | |||
| 32 | + | return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}} | |||
| 33 | + | } | |||
| 34 | + | r.EXPECT().SetHDR(float64(0)).Return().Maybe() | |||
| 35 | + | r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once() | |||
| 36 | + | r.EXPECT().UseTarget(mock.Anything).Return() | |||
| 37 | + | black := make(chan struct{}, 8) | |||
| 38 | + | desktop := make(chan struct{}, 2) | |||
| 39 | + | r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) (*os.File, error) { | |||
| 40 | + | if s.Seq == 99 { | |||
| 41 | + | desktop <- struct{}{} | |||
| 42 | + | } else { | |||
| 43 | + | black <- struct{}{} | |||
| 44 | + | } | |||
| 45 | + | return nil, nil | |||
| 46 | + | }) | |||
| 47 | + | r.EXPECT().Close().Return().Once() | |||
| 48 | + | k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil) | |||
| 49 | + | k.EXPECT().createBlob(mock.Anything).Return(99, nil) | |||
| 50 | + | k.EXPECT().destroyBlob(mock.Anything).Return(nil).Maybe() | |||
| 51 | + | ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) | |||
| 52 | + | defer cancel() | |||
| 53 | + | done := make(chan error, 1) | |||
| 54 | + | go func() { | |||
| 55 | + | done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, active, scenes, nil, nil, make(chan ports.OutputPresented, 8), nil, nil) | |||
| 56 | + | }() | |||
| 57 | + | scenes <- ports.Scene{Seq: 99, Scale: 1} | |||
| 58 | + | select { | |||
| 59 | + | case <-desktop: | |||
| 60 | + | case <-ctx.Done(): | |||
| 61 | + | t.Fatal("no desktop frame") | |||
| 62 | + | } | |||
| 63 | + | var frame commitRec | |||
| 64 | + | waitFor(t, func() bool { | |||
| 65 | + | mu.Lock() | |||
| 66 | + | defer mu.Unlock() | |||
| 67 | + | for _, c := range *commits { | |||
| 68 | + | if c.user&3 == userFrame { | |||
| 69 | + | frame = c | |||
| 70 | + | return true | |||
| 71 | + | } | |||
| 72 | + | } | |||
| 73 | + | return false | |||
| 74 | + | }) | |||
| 75 | + | state.Store(3) | |||
| 76 | + | wake <- ports.SecurityState{} // stale payload: read gate, not payload | |||
| 77 | + | select { | |||
| 78 | + | case ev := <-events: | |||
| 79 | + | t.Fatalf("proof while old frame pending: %+v", ev) | |||
| 80 | + | case <-time.After(20 * time.Millisecond): | |||
| 81 | + | } | |||
| 82 | + | flips <- eventOf(frame) | |||
| 83 | + | proof := func(kind ports.ProtectionKind) { | |||
| 84 | + | t.Helper() | |||
| 85 | + | for { | |||
| 86 | + | select { | |||
| 87 | + | case ev := <-events: | |||
| 88 | + | if p, ok := ev.(ports.SecurityOutputProof); ok { | |||
| 89 | + | if p.Proof != (ports.OutputProtection{Generation: 1, Instance: 77, Kind: kind}) { | |||
| 90 | + | t.Fatalf("proof %+v", p) | |||
| 91 | + | } | |||
| 92 | + | return | |||
| 93 | + | } | |||
| 94 | + | case <-ctx.Done(): | |||
| 95 | + | t.Fatal("missing security proof") | |||
| 96 | + | } | |||
| 97 | + | } | |||
| 98 | + | } | |||
| 99 | + | proof(ports.ProtectionProtectedFrame) | |||
| 100 | + | scenes <- ports.Scene{Seq: 99} // held desktop scene cannot be replayed | |||
| 101 | + | select { | |||
| 102 | + | case <-desktop: | |||
| 103 | + | t.Fatal("stale desktop rendered under protection") | |||
| 104 | + | case <-time.After(20 * time.Millisecond): | |||
| 105 | + | } | |||
| 106 | + | active <- false | |||
| 107 | + | select { | |||
| 108 | + | case ev := <-events: | |||
| 109 | + | if _, ok := ev.(ports.SecurityOutputInvalidated); !ok { | |||
| 110 | + | t.Fatalf("disable did not invalidate before resume: %+v", ev) | |||
| 111 | + | } | |||
| 112 | + | case <-ctx.Done(): | |||
| 113 | + | t.Fatal("missing disable invalidation") | |||
| 114 | + | } | |||
| 115 | + | active <- true | |||
| 116 | + | select { | |||
| 117 | + | case ev := <-events: | |||
| 118 | + | if _, ok := ev.(ports.SecurityOutputInvalidated); !ok { | |||
| 119 | + | t.Fatalf("resume proof preceded invalidation: %+v", ev) | |||
| 120 | + | } | |||
| 121 | + | case <-ctx.Done(): | |||
| 122 | + | t.Fatal("missing resume invalidation") | |||
| 123 | + | } | |||
| 124 | + | proof(ports.ProtectionProtectedFrame) // reacquire requires a fresh black transaction | |||
| 125 | + | scenes <- ports.Scene{Security: ports.SecurityState{Generation: 1, Protected: true}, Off: true} | |||
| 126 | + | proof(ports.ProtectionInactiveOutput) | |||
| 127 | + | cancel() | |||
| 128 | + | if err := <-done; err != nil { | |||
| 129 | + | t.Fatal(err) | |||
| 130 | + | } | |||
| 131 | + | } | |||
A internal/adapters/drm/security_startup_test.go +127 -0
| @@ -0,0 +1,127 @@ | |||||
| 1 | + | package drm | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "os" | |||
| 7 | + | "sync/atomic" | |||
| 8 | + | "testing" | |||
| 9 | + | "time" | |||
| 10 | + | ||||
| 11 | + | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |||
| 12 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 13 | + | "github.com/stretchr/testify/mock" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | func TestSecurityProtectedStartupBlackProofAndCaptureRejection(t *testing.T) { | |||
| 17 | + | o, k, commits, mu := testOutputMu(t) | |||
| 18 | + | o.cursor = nil | |||
| 19 | + | var state atomic.Uint64 | |||
| 20 | + | state.Store(3) | |||
| 21 | + | securityGate(t, o, &state) | |||
| 22 | + | events := make(chan ports.SecurityBackendEvent, 2) | |||
| 23 | + | o.SecurityEvents = events | |||
| 24 | + | r := portsmocks.NewMockRenderer(t) | |||
| 25 | + | buf := func() ports.DMABuf { | |||
| 26 | + | f, w := protectionFence(t, false) | |||
| 27 | + | w.Close() | |||
| 28 | + | return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}} | |||
| 29 | + | } | |||
| 30 | + | r.EXPECT().SetHDR(float64(0)).Return().Maybe() | |||
| 31 | + | r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once() | |||
| 32 | + | r.EXPECT().UseTarget(mock.Anything).Return() | |||
| 33 | + | r.EXPECT().Render(ports.Scene{Background: "#000000"}, map[ports.WindowID]ports.SurfaceContent(nil)).Return(nil, nil).Times(3) | |||
| 34 | + | r.EXPECT().Close().Return().Once() | |||
| 35 | + | k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil) | |||
| 36 | + | k.EXPECT().createBlob(mock.Anything).Return(99, nil) | |||
| 37 | + | k.EXPECT().destroyBlob(mock.Anything).Return(nil).Maybe() | |||
| 38 | + | captures := make(chan ports.CaptureRequest, 1) | |||
| 39 | + | captured := make(chan ports.CaptureDone, 1) | |||
| 40 | + | scenes := make(chan ports.Scene, 1) | |||
| 41 | + | scenes <- ports.Scene{Seq: 99} // never render stale desktop after startup | |||
| 42 | + | ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) | |||
| 43 | + | defer cancel() | |||
| 44 | + | done := make(chan error, 1) | |||
| 45 | + | go func() { | |||
| 46 | + | done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, nil, scenes, nil, nil, nil, captures, captured) | |||
| 47 | + | }() | |||
| 48 | + | select { | |||
| 49 | + | case ev := <-events: | |||
| 50 | + | if p, ok := ev.(ports.SecurityOutputProof); !ok || p.Proof.Generation != 1 || p.Proof.Instance != 77 || p.Proof.Kind != ports.ProtectionProtectedFrame { | |||
| 51 | + | t.Fatalf("proof %+v", ev) | |||
| 52 | + | } | |||
| 53 | + | case <-ctx.Done(): | |||
| 54 | + | t.Fatal("no startup black proof") | |||
| 55 | + | } | |||
| 56 | + | mu.Lock() | |||
| 57 | + | for i, c := range *commits { | |||
| 58 | + | if c.flags&atomicTestOnly != 0 { | |||
| 59 | + | continue | |||
| 60 | + | } | |||
| 61 | + | active, _ := c.req.value(tCrtc, pActive) | |||
| 62 | + | if active != 0 && i != len(*commits)-1 { | |||
| 63 | + | t.Error("startup exposed an active target before protected transaction") | |||
| 64 | + | } | |||
| 65 | + | } | |||
| 66 | + | mu.Unlock() | |||
| 67 | + | f, w := protectionFence(t, false) | |||
| 68 | + | w.Close() | |||
| 69 | + | captures <- ports.CaptureRequest{ID: 9, Dst: ports.SHMBuffer{File: f}} | |||
| 70 | + | select { | |||
| 71 | + | case reply := <-captured: | |||
| 72 | + | if reply.ID != 9 || reply.Err == nil { | |||
| 73 | + | t.Fatalf("capture result %+v", reply) | |||
| 74 | + | } | |||
| 75 | + | case <-ctx.Done(): | |||
| 76 | + | t.Fatal("protected capture not failed") | |||
| 77 | + | } | |||
| 78 | + | cancel() | |||
| 79 | + | if err := <-done; err != nil { | |||
| 80 | + | t.Fatal(err) | |||
| 81 | + | } | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | func TestSecurityStartupClearWaitUsesRunContext(t *testing.T) { | |||
| 85 | + | o, k, _ := testOutput(t) | |||
| 86 | + | o.cursor = nil | |||
| 87 | + | var state atomic.Uint64 | |||
| 88 | + | state.Store(3) | |||
| 89 | + | securityGate(t, o, &state) | |||
| 90 | + | r := portsmocks.NewMockRenderer(t) | |||
| 91 | + | buf := func() ports.DMABuf { | |||
| 92 | + | f, w := protectionFence(t, false) | |||
| 93 | + | w.Close() | |||
| 94 | + | return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}} | |||
| 95 | + | } | |||
| 96 | + | r.EXPECT().SetHDR(float64(0)).Return().Maybe() | |||
| 97 | + | r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once() | |||
| 98 | + | r.EXPECT().ExportTargets(0, []uint64(nil)).Return(nil, nil).Once() | |||
| 99 | + | r.EXPECT().UseTarget(0).Return().Once() | |||
| 100 | + | fence, _ := protectionFence(t, false) | |||
| 101 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 102 | + | defer cancel() | |||
| 103 | + | r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) { | |||
| 104 | + | cancel() | |||
| 105 | + | return fence, nil | |||
| 106 | + | }).Once() | |||
| 107 | + | r.EXPECT().Close().Return().Once() | |||
| 108 | + | k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil) | |||
| 109 | + | k.EXPECT().rmFB(mock.Anything).Return(nil) | |||
| 110 | + | err := o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, nil, nil, nil, nil, nil, nil, nil) | |||
| 111 | + | if !errors.Is(err, context.Canceled) { | |||
| 112 | + | t.Fatalf("startup cancellation: %v", err) | |||
| 113 | + | } | |||
| 114 | + | if _, err := fence.Stat(); !errors.Is(err, os.ErrClosed) { | |||
| 115 | + | t.Fatal("startup fence leaked") | |||
| 116 | + | } | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | func TestSecurityWiredUnlockedCloseNeverRestoresSavedFB(t *testing.T) { | |||
| 120 | + | o, k, commits := testOutput(t) | |||
| 121 | + | var state atomic.Uint64 | |||
| 122 | + | securityGate(t, o, &state) | |||
| 123 | + | o.saved = modeCrtc{crtcID: tCrtc, fbID: 123, modeValid: 1, mode: o.mode} | |||
| 124 | + | k.EXPECT().rmFB(mock.Anything).Return(nil) | |||
| 125 | + | o.Close() // no protected snapshot needed: saved restoration is never admitted | |||
| 126 | + | checkProtectedDisable(t, (*commits)[0], tPrimary, tCursor) | |||
| 127 | + | } | |||
A internal/adapters/drm/security_test.go +170 -0
| @@ -0,0 +1,170 @@ | |||||
| 1 | + | package drm | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "os" | |||
| 7 | + | "sync/atomic" | |||
| 8 | + | "testing" | |||
| 9 | + | "time" | |||
| 10 | + | ||||
| 11 | + | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |||
| 12 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 13 | + | "github.com/stretchr/testify/mock" | |||
| 14 | + | "golang.org/x/sys/unix" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | // A generated mock through the real ports interface, using a coherent atomic | |||
| 18 | + | // test state so transitions can race with the output owner without data races. | |||
| 19 | + | func securityGate(t *testing.T, o *Output, state *atomic.Uint64) { | |||
| 20 | + | k := portsmocks.NewMockSessionSecurity(t) | |||
| 21 | + | k.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { | |||
| 22 | + | v := state.Load() | |||
| 23 | + | return ports.SecurityState{Generation: ports.LockGeneration(v >> 1), Protected: v&1 != 0} | |||
| 24 | + | }) | |||
| 25 | + | o.Security = k | |||
| 26 | + | o.Instance = 77 | |||
| 27 | + | o.SecurityEvents = make(chan ports.SecurityBackendEvent, 8) | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | func TestSecurityPreparationProofAndFailure(t *testing.T) { | |||
| 31 | + | for _, stage := range []string{"success", "off", "disable", "render", "pending", "changed"} { | |||
| 32 | + | t.Run(stage, func(t *testing.T) { | |||
| 33 | + | var failures []error | |||
| 34 | + | if stage == "disable" { | |||
| 35 | + | failures = []error{unix.EACCES} | |||
| 36 | + | } | |||
| 37 | + | o, k, commits := testOutput(t, failures...) | |||
| 38 | + | var state atomic.Uint64 | |||
| 39 | + | state.Store(3) | |||
| 40 | + | securityGate(t, o, &state) | |||
| 41 | + | events := make(chan ports.SecurityBackendEvent, 8) | |||
| 42 | + | o.SecurityEvents = events | |||
| 43 | + | r := portsmocks.NewMockRenderer(t) | |||
| 44 | + | if stage == "pending" { | |||
| 45 | + | f, _ := protectionFence(t, false) | |||
| 46 | + | o.frame.begin(pendingFrame{fences: []*os.File{f}}, time.Now()) | |||
| 47 | + | } else if stage != "disable" { | |||
| 48 | + | r.EXPECT().UseTarget(0).Return().Once() | |||
| 49 | + | r.EXPECT().Render(ports.Scene{Background: "#000000"}, map[ports.WindowID]ports.SurfaceContent(nil)).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) { | |||
| 50 | + | if stage == "changed" { | |||
| 51 | + | state.Store(5) | |||
| 52 | + | } | |||
| 53 | + | if stage == "render" { | |||
| 54 | + | return nil, errors.New("render refused") | |||
| 55 | + | } | |||
| 56 | + | return nil, nil | |||
| 57 | + | }).Once() | |||
| 58 | + | if stage != "off" && stage != "render" { | |||
| 59 | + | k.EXPECT().createBlob(mock.Anything).Return(99, nil).Once() | |||
| 60 | + | } | |||
| 61 | + | } | |||
| 62 | + | o.wantOff = stage == "off" | |||
| 63 | + | err := o.prepareSecurity(context.Background(), r) | |||
| 64 | + | wantErr := stage == "disable" || stage == "render" || stage == "pending" | |||
| 65 | + | if (err != nil) != wantErr { | |||
| 66 | + | t.Fatalf("result %v", err) | |||
| 67 | + | } | |||
| 68 | + | if stage == "success" || stage == "off" { | |||
| 69 | + | select { | |||
| 70 | + | case ev := <-events: | |||
| 71 | + | proof, ok := ev.(ports.SecurityOutputProof) | |||
| 72 | + | kind := ports.ProtectionProtectedFrame | |||
| 73 | + | if stage == "off" { | |||
| 74 | + | kind = ports.ProtectionInactiveOutput | |||
| 75 | + | } | |||
| 76 | + | if !ok || proof.Proof != (ports.OutputProtection{Generation: 1, Instance: 77, Kind: kind}) { | |||
| 77 | + | t.Fatalf("proof %+v", ev) | |||
| 78 | + | } | |||
| 79 | + | default: | |||
| 80 | + | t.Fatal("missing proof") | |||
| 81 | + | } | |||
| 82 | + | } else if len(events) != 0 || o.securityPrepared { | |||
| 83 | + | t.Fatal("failure or changed epoch certified protection") | |||
| 84 | + | } | |||
| 85 | + | if stage == "pending" && len(*commits) != 0 { | |||
| 86 | + | t.Fatal("pending client fence queued blocking KMS") | |||
| 87 | + | } | |||
| 88 | + | }) | |||
| 89 | + | } | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | func TestSecurityRejectsStaleAcquireReleaseScenes(t *testing.T) { | |||
| 93 | + | o, _, commits := testOutput(t) | |||
| 94 | + | var state atomic.Uint64 | |||
| 95 | + | securityGate(t, o, &state) | |||
| 96 | + | r := portsmocks.NewMockRenderer(t) | |||
| 97 | + | state.Store(3) | |||
| 98 | + | if _, err := o.submitFrame(context.Background(), r, ports.Scene{}, nil, nil, nil, nil); !errors.Is(err, errSecurityScene) { | |||
| 99 | + | t.Fatalf("old desktop admitted: %v", err) | |||
| 100 | + | } | |||
| 101 | + | state.Store(4) | |||
| 102 | + | if _, err := o.submitFrame(context.Background(), r, ports.Scene{Security: ports.SecurityState{Generation: 1, Protected: true}}, nil, nil, nil, nil); !errors.Is(err, errSecurityScene) { | |||
| 103 | + | t.Fatalf("old lock admitted on release: %v", err) | |||
| 104 | + | } | |||
| 105 | + | if len(*commits) != 0 { | |||
| 106 | + | t.Fatal("stale epoch committed") | |||
| 107 | + | } | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | func TestSecurityProtectedCompositionDetachesCursorAndOverlay(t *testing.T) { | |||
| 111 | + | o, _, commits := testOutput(t) | |||
| 112 | + | var state atomic.Uint64 | |||
| 113 | + | state.Store(3) | |||
| 114 | + | securityGate(t, o, &state) | |||
| 115 | + | o.observeSecurity() | |||
| 116 | + | o.securityPrepared = true | |||
| 117 | + | o.cursor.image = true | |||
| 118 | + | o.cursor.Move(8, 8) | |||
| 119 | + | o.overlay = &plane{id: tOverlay, props: planeProps} | |||
| 120 | + | s, c := fullscreenScene() | |||
| 121 | + | s.Security = ports.SecurityState{Generation: 1, Protected: true} | |||
| 122 | + | s.CaptureScene = &ports.Scene{} | |||
| 123 | + | r := portsmocks.NewMockRenderer(t) | |||
| 124 | + | r.EXPECT().UseTarget(0).Return().Once() | |||
| 125 | + | r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(scene ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) (*os.File, error) { | |||
| 126 | + | if scene.CaptureScene != nil { | |||
| 127 | + | t.Fatal("hidden capture crossed protected path") | |||
| 128 | + | } | |||
| 129 | + | return nil, nil | |||
| 130 | + | }).Once() | |||
| 131 | + | direct, err := o.submitFrame(context.Background(), r, s, c, nil, nil, nil) | |||
| 132 | + | if err != nil || direct { | |||
| 133 | + | t.Fatalf("direct=%v err=%v", direct, err) | |||
| 134 | + | } | |||
| 135 | + | last := (*commits)[0] | |||
| 136 | + | if last.flags&flipAsyncFlag != 0 { | |||
| 137 | + | t.Fatal("protected tearing") | |||
| 138 | + | } | |||
| 139 | + | for _, plane := range []uint32{tCursor, tOverlay} { | |||
| 140 | + | if fb, _ := last.req.value(plane, pFB); fb != 0 { | |||
| 141 | + | t.Fatalf("desktop plane %d attached", plane) | |||
| 142 | + | } | |||
| 143 | + | } | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | func TestSecurityCloseReadsGateAfterOwnerStopped(t *testing.T) { | |||
| 147 | + | o, k, commits := testOutput(t) | |||
| 148 | + | var state atomic.Uint64 | |||
| 149 | + | securityGate(t, o, &state) | |||
| 150 | + | o.saved = modeCrtc{crtcID: tCrtc, fbID: 123, modeValid: 1, mode: o.mode} | |||
| 151 | + | state.Store(3) // no wake or owner loop observes it | |||
| 152 | + | k.EXPECT().rmFB(mock.Anything).Return(nil) | |||
| 153 | + | o.Close() | |||
| 154 | + | checkProtectedDisable(t, (*commits)[0], tPrimary, tCursor) | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | func TestSecurityCommitStateReadsGateBeforeCursorCommit(t *testing.T) { | |||
| 158 | + | o, _, commits := testOutput(t) | |||
| 159 | + | var state atomic.Uint64 | |||
| 160 | + | securityGate(t, o, &state) | |||
| 161 | + | o.cursor.image = true | |||
| 162 | + | o.cursor.Move(10, 10) | |||
| 163 | + | state.Store(3) | |||
| 164 | + | if err := o.commitState(true); !errors.Is(err, errSecurityScene) { | |||
| 165 | + | t.Fatalf("cursor result: %v", err) | |||
| 166 | + | } | |||
| 167 | + | if len(*commits) != 0 || !o.protected { | |||
| 168 | + | t.Fatal("cursor committed before owner observed lock") | |||
| 169 | + | } | |||
| 170 | + | } | |||
A internal/adapters/drm/security_transition_test.go +90 -0
| @@ -0,0 +1,90 @@ | |||||
| 1 | + | package drm | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "os" | |||
| 7 | + | "sync/atomic" | |||
| 8 | + | "testing" | |||
| 9 | + | "time" | |||
| 10 | + | ||||
| 11 | + | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |||
| 12 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 13 | + | "github.com/stretchr/testify/mock" | |||
| 14 | + | "golang.org/x/sys/unix" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | func TestSecurityTransitionDuringDesktopRenderPreservesReadFence(t *testing.T) { | |||
| 18 | + | o, _, commits := testOutput(t) | |||
| 19 | + | o.cursor, o.scanout = nil, false | |||
| 20 | + | var state atomic.Uint64 | |||
| 21 | + | securityGate(t, o, &state) | |||
| 22 | + | r := portsmocks.NewMockRenderer(t) | |||
| 23 | + | f, _ := protectionFence(t, false) | |||
| 24 | + | r.EXPECT().UseTarget(0).Return().Once() | |||
| 25 | + | r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) { | |||
| 26 | + | state.Store(3) | |||
| 27 | + | return f, nil | |||
| 28 | + | }).Once() | |||
| 29 | + | _, err := o.submitFrame(context.Background(), r, ports.Scene{}, nil, nil, nil, nil) | |||
| 30 | + | if !errors.Is(err, errSecurityScene) || len(*commits) != 0 || o.readDone() { | |||
| 31 | + | t.Fatalf("old rendered frame committed or read fence dropped: %v", err) | |||
| 32 | + | } | |||
| 33 | + | o.dropRead() | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | func TestSecurityMasterLossInvalidatesWithoutProof(t *testing.T) { | |||
| 37 | + | o, _, _ := testOutput(t) | |||
| 38 | + | var state atomic.Uint64 | |||
| 39 | + | state.Store(3) | |||
| 40 | + | securityGate(t, o, &state) | |||
| 41 | + | events := make(chan ports.SecurityBackendEvent, 2) | |||
| 42 | + | o.SecurityEvents = events | |||
| 43 | + | o.observeSecurity() | |||
| 44 | + | o.securityPrepared = true | |||
| 45 | + | enabled := true | |||
| 46 | + | if !o.commitFailed(unix.EACCES, &enabled) || enabled || o.securityPrepared { | |||
| 47 | + | t.Fatal("master loss retained proof") | |||
| 48 | + | } | |||
| 49 | + | if err := o.sendSecurityInvalid(context.Background()); err != nil { | |||
| 50 | + | t.Fatal(err) | |||
| 51 | + | } | |||
| 52 | + | ev := <-events | |||
| 53 | + | if invalid, ok := ev.(ports.SecurityOutputInvalidated); !ok || invalid.Generation != 1 || invalid.Instance != 77 { | |||
| 54 | + | t.Fatalf("invalid event %+v", ev) | |||
| 55 | + | } | |||
| 56 | + | if len(events) != 0 { | |||
| 57 | + | t.Fatal("master loss forged inactive proof") | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func TestSecurityProofBackpressureRechecksEpoch(t *testing.T) { | |||
| 62 | + | o, k, _ := testOutput(t) | |||
| 63 | + | var state atomic.Uint64 | |||
| 64 | + | state.Store(3) | |||
| 65 | + | securityGate(t, o, &state) | |||
| 66 | + | events := make(chan ports.SecurityBackendEvent) // no consumer until transition | |||
| 67 | + | o.SecurityEvents = events | |||
| 68 | + | r := portsmocks.NewMockRenderer(t) | |||
| 69 | + | cleared := make(chan struct{}) | |||
| 70 | + | r.EXPECT().UseTarget(0).Return().Once() | |||
| 71 | + | r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil).Once() | |||
| 72 | + | k.EXPECT().createBlob(mock.Anything).RunAndReturn(func([]byte) (uint32, error) { close(cleared); return 99, nil }).Once() | |||
| 73 | + | ctx, cancel := context.WithTimeout(context.Background(), time.Second) | |||
| 74 | + | defer cancel() | |||
| 75 | + | done := make(chan error, 1) | |||
| 76 | + | go func() { done <- o.prepareSecurity(ctx, r) }() | |||
| 77 | + | <-cleared | |||
| 78 | + | state.Store(5) | |||
| 79 | + | if err := <-done; err != nil { | |||
| 80 | + | t.Fatal(err) | |||
| 81 | + | } | |||
| 82 | + | select { | |||
| 83 | + | case ev := <-events: | |||
| 84 | + | t.Fatalf("obsolete proof emitted: %+v", ev) | |||
| 85 | + | default: | |||
| 86 | + | } | |||
| 87 | + | if o.securityPrepared { | |||
| 88 | + | t.Fatal("obsolete proof retained") | |||
| 89 | + | } | |||
| 90 | + | } | |||
M internal/adapters/headless/capture_session_test.go +1 -1
| @@ -249,7 +249,7 @@ func TestRenderErrorDoesNotAnswerHandedRequestsAgain(t *testing.T) { | |||||
| 249 | require.Equal(t, []uint64{1}, ids, "request 1 answered once by its worker, no pseudo request 0") | 249 | require.Equal(t, []uint64{1}, ids, "request 1 answered once by its worker, no pseudo request 0") | ||
| 250 | } | 250 | } | ||
| 251 | 251 | ||||
| 252 | - | // GO-003: an unchanged child-hold publication reuses its immutable snapshots; | 252 | + | // An unchanged child-hold publication reuses its immutable snapshots; |
| 253 | // a change clones, and earlier reports keep their maps. | 253 | // a change clones, and earlier reports keep their maps. | ||
| 254 | func TestHoldSnapshotsCloneOnlyOnChange(t *testing.T) { | 254 | func TestHoldSnapshotsCloneOnlyOnChange(t *testing.T) { | ||
| 255 | var h holdSnapshots | 255 | var h holdSnapshots | ||
M internal/adapters/headless/cursor.go +5 -1
| @@ -37,9 +37,13 @@ func (c *Cursor) set(img ports.CursorImage) { | |||||
| 37 | 37 | ||||
| 38 | // draw blends the premultiplied cursor over dst with its hotspot at (x, y). | 38 | // draw blends the premultiplied cursor over dst with its hotspot at (x, y). | ||
| 39 | func (c *Cursor) draw(dst *image.RGBA) { | 39 | func (c *Cursor) draw(dst *image.RGBA) { | ||
| 40 | + | c.drawSecure(dst, nil) | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | func (c *Cursor) drawSecure(dst *image.RGBA, security ports.SessionSecurity) { | |||
| 40 | c.mu.Lock() | 44 | c.mu.Lock() | ||
| 41 | defer c.mu.Unlock() | 45 | defer c.mu.Unlock() | ||
| 42 | - | if c.hidden { | 46 | + | if c.hidden || security != nil && security.Snapshot().Protected { |
| 43 | return | 47 | return | ||
| 44 | } | 48 | } | ||
| 45 | img := c.img | 49 | img := c.img | ||
M internal/adapters/headless/output.go +262 -20
| @@ -19,6 +19,12 @@ import ( | |||||
| 19 | ) | 19 | ) | ||
| 20 | 20 | ||||
| 21 | type Options struct { | 21 | type Options struct { | ||
| 22 | + | // Security is the defensive gate. SecurityChanges wakes an idle owner; | |||
| 23 | + | // the snapshot, not the queued notification payload, is authoritative. | |||
| 24 | + | Security ports.SessionSecurity | |||
| 25 | + | SecurityChanges <-chan ports.SecurityState | |||
| 26 | + | Instance ports.OutputInstance | |||
| 27 | + | SecurityEvents chan<- ports.SecurityBackendEvent | |||
| 22 | // Cursor, when set, is drawn into screenshots with the image from | 28 | // Cursor, when set, is drawn into screenshots with the image from | ||
| 23 | // LoadCursor at the scene scale. | 29 | // LoadCursor at the scene scale. | ||
| 24 | Cursor *Cursor | 30 | Cursor *Cursor | ||
| @@ -83,6 +89,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||||
| 83 | requests := requestStorage[:0] | 89 | requests := requestStorage[:0] | ||
| 84 | ctx, cancelCaptures := context.WithCancel(ctx) | 90 | ctx, cancelCaptures := context.WithCancel(ctx) | ||
| 85 | pipeline := capture.NewPipeline(ctx, opts.Captured) | 91 | pipeline := capture.NewPipeline(ctx, opts.Captured) | ||
| 92 | + | pipeline.Security = opts.Security | |||
| 86 | if opts.NewCaptureRenderer != nil { | 93 | if opts.NewCaptureRenderer != nil { | ||
| 87 | pipeline.EnableOffscreen(opts.NewCaptureRenderer) | 94 | pipeline.EnableOffscreen(opts.NewCaptureRenderer) | ||
| 88 | } | 95 | } | ||
| @@ -97,7 +104,10 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||||
| 97 | } | 104 | } | ||
| 98 | for { | 105 | for { | ||
| 99 | select { | 106 | select { | ||
| 100 | - | case q := <-incoming: | 107 | + | case q, ok := <-incoming: |
| 108 | + | if !ok { | |||
| 109 | + | return | |||
| 110 | + | } | |||
| 101 | capture.Fail(ctx, q, fmt.Errorf("output stopped"), opts.Captured) | 111 | capture.Fail(ctx, q, fmt.Errorf("output stopped"), opts.Captured) | ||
| 102 | default: | 112 | default: | ||
| 103 | return | 113 | return | ||
| @@ -110,6 +120,77 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||||
| 110 | var holds holdSnapshots | 120 | var holds holdSnapshots | ||
| 111 | // pending is a report the channel could not take, retried soon. | 121 | // pending is a report the channel could not take, retried soon. | ||
| 112 | var pending *ports.OutputPresented | 122 | var pending *ports.OutputPresented | ||
| 123 | + | var security ports.SecurityState | |||
| 124 | + | securityKnown := false | |||
| 125 | + | failRequests := func(reason string) { | |||
| 126 | + | for _, q := range requests { | |||
| 127 | + | if !capture.Handed(q) { | |||
| 128 | + | capture.Fail(ctx, q, fmt.Errorf("%s", reason), opts.Captured) | |||
| 129 | + | } | |||
| 130 | + | } | |||
| 131 | + | clear(requests) | |||
| 132 | + | requests = requestStorage[:0] | |||
| 133 | + | } | |||
| 134 | + | // A transition is its own frame, never a locker-buffer-dependent frame. | |||
| 135 | + | // Keep its epoch through the fence wait and report only actual completion. | |||
| 136 | + | checkSecurity := func() error { | |||
| 137 | + | if opts.Security == nil { | |||
| 138 | + | return nil | |||
| 139 | + | } | |||
| 140 | + | for { | |||
| 141 | + | next := opts.Security.Snapshot() | |||
| 142 | + | if securityKnown && next == security { | |||
| 143 | + | return nil | |||
| 144 | + | } | |||
| 145 | + | // No hardware scanout exists: an already applied Off scene is | |||
| 146 | + | // confirmed software inactivity, independent of its old epoch. | |||
| 147 | + | inactive := haveScene && scene.Off | |||
| 148 | + | security, securityKnown = next, true | |||
| 149 | + | pending = nil | |||
| 150 | + | haveScene, dirty = false, false | |||
| 151 | + | if !security.Protected { | |||
| 152 | + | continue | |||
| 153 | + | } | |||
| 154 | + | black := ports.Scene{Security: security, Output: opts.Name, OutputWidth: opts.Width, OutputHeight: opts.Height, Scale: 1, Background: "#000000"} | |||
| 155 | + | black.Off = inactive | |||
| 156 | + | scene = black | |||
| 157 | + | kind := ports.ProtectionInactiveOutput | |||
| 158 | + | if !inactive { | |||
| 159 | + | kind = ports.ProtectionProtectedFrame | |||
| 160 | + | done, err := r.Render(black, nil) | |||
| 161 | + | if err != nil { | |||
| 162 | + | return fmt.Errorf("render protection: %w", err) | |||
| 163 | + | } | |||
| 164 | + | if done != nil { | |||
| 165 | + | err = syncfile.Wait(ctx, done) | |||
| 166 | + | _ = done.Close() | |||
| 167 | + | if err != nil { | |||
| 168 | + | return fmt.Errorf("protection fence: %w", err) | |||
| 169 | + | } | |||
| 170 | + | } | |||
| 171 | + | } | |||
| 172 | + | if opts.Security.Snapshot() != security { | |||
| 173 | + | continue | |||
| 174 | + | } | |||
| 175 | + | if opts.SecurityEvents != nil { | |||
| 176 | + | proof := ports.SecurityOutputProof{Proof: ports.OutputProtection{Generation: security.Generation, Instance: opts.Instance, Kind: kind}} | |||
| 177 | + | select { | |||
| 178 | + | case opts.SecurityEvents <- proof: | |||
| 179 | + | case <-ctx.Done(): | |||
| 180 | + | return ctx.Err() | |||
| 181 | + | } | |||
| 182 | + | } | |||
| 183 | + | // Capture reply backpressure must not postpone the first black | |||
| 184 | + | // completion/proof. Capture drainage is a separate parent barrier. | |||
| 185 | + | failRequests("session protected") | |||
| 186 | + | } | |||
| 187 | + | } | |||
| 188 | + | acceptScene := func(s ports.Scene) { | |||
| 189 | + | if opts.Security != nil && (s.Security != security || s.Security != opts.Security.Snapshot()) { | |||
| 190 | + | return | |||
| 191 | + | } | |||
| 192 | + | scene, haveScene, dirty = s, true, true | |||
| 193 | + | } | |||
| 113 | update := func(c ports.SurfaceContent) { | 194 | update := func(c ports.SurfaceContent) { | ||
| 114 | seen[c.ID] = max(seen[c.ID], c.Seq) | 195 | seen[c.ID] = max(seen[c.ID], c.Seq) | ||
| 115 | dirty = dirty || capture.Shows(scene, c.ID) | 196 | dirty = dirty || capture.Shows(scene, c.ID) | ||
| @@ -129,6 +210,12 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||||
| 129 | if ctx.Err() != nil { | 210 | if ctx.Err() != nil { | ||
| 130 | return nil | 211 | return nil | ||
| 131 | } | 212 | } | ||
| 213 | + | if err := checkSecurity(); err != nil { | |||
| 214 | + | if ctx.Err() != nil { | |||
| 215 | + | return nil | |||
| 216 | + | } | |||
| 217 | + | return err | |||
| 218 | + | } | |||
| 132 | var retry <-chan time.Time | 219 | var retry <-chan time.Time | ||
| 133 | if pending != nil { | 220 | if pending != nil { | ||
| 134 | retry = time.After(time.Millisecond) | 221 | retry = time.After(time.Millisecond) | ||
| @@ -136,15 +223,54 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||||
| 136 | select { | 223 | select { | ||
| 137 | case <-ctx.Done(): | 224 | case <-ctx.Done(): | ||
| 138 | return nil | 225 | return nil | ||
| 226 | + | case _, ok := <-opts.SecurityChanges: | |||
| 227 | + | if !ok { | |||
| 228 | + | opts.SecurityChanges = nil | |||
| 229 | + | } | |||
| 230 | + | if err := checkSecurity(); err != nil { | |||
| 231 | + | if ctx.Err() != nil { | |||
| 232 | + | return nil | |||
| 233 | + | } | |||
| 234 | + | return err | |||
| 235 | + | } | |||
| 236 | + | continue | |||
| 139 | case b := <-pipeline.Completed(): | 237 | case b := <-pipeline.Completed(): | ||
| 238 | + | // The completed token has left the pipeline's queue. Return its | |||
| 239 | + | // lease before any transition path can error or block. | |||
| 140 | pipeline.Recycle(b, r) | 240 | pipeline.Recycle(b, r) | ||
| 241 | + | if err := checkSecurity(); err != nil { | |||
| 242 | + | if ctx.Err() != nil { | |||
| 243 | + | return nil | |||
| 244 | + | } | |||
| 245 | + | return err | |||
| 246 | + | } | |||
| 141 | continue | 247 | continue | ||
| 142 | case b := <-pipeline.HiddenCompleted(): | 248 | case b := <-pipeline.HiddenCompleted(): | ||
| 143 | pipeline.RecycleHidden(b) | 249 | pipeline.RecycleHidden(b) | ||
| 250 | + | if err := checkSecurity(); err != nil { | |||
| 251 | + | if ctx.Err() != nil { | |||
| 252 | + | return nil | |||
| 253 | + | } | |||
| 254 | + | return err | |||
| 255 | + | } | |||
| 144 | pipeline.Retire(scene) | 256 | pipeline.Retire(scene) | ||
| 145 | continue | 257 | continue | ||
| 146 | - | case q := <-incoming: | 258 | + | case q, ok := <-incoming: |
| 147 | - | if scene.Off { | 259 | + | if !ok { |
| 260 | + | incoming = nil | |||
| 261 | + | continue | |||
| 262 | + | } | |||
| 263 | + | if err := checkSecurity(); err != nil { | |||
| 264 | + | // q has left incoming but is not yet owned by requests/workers. | |||
| 265 | + | capture.Fail(ctx, q, fmt.Errorf("output stopped"), opts.Captured) | |||
| 266 | + | if ctx.Err() != nil { | |||
| 267 | + | return nil | |||
| 268 | + | } | |||
| 269 | + | return err | |||
| 270 | + | } | |||
| 271 | + | if opts.protected() { | |||
| 272 | + | capture.Fail(ctx, q, fmt.Errorf("session protected"), opts.Captured) | |||
| 273 | + | } else if scene.Off { | |||
| 148 | capture.Fail(ctx, q, fmt.Errorf("output off"), opts.Captured) | 274 | capture.Fail(ctx, q, fmt.Errorf("output off"), opts.Captured) | ||
| 149 | } else if len(requests) == cap(requests) { | 275 | } else if len(requests) == cap(requests) { | ||
| 150 | capture.Fail(ctx, q, fmt.Errorf("output capture batch full"), opts.Captured) | 276 | capture.Fail(ctx, q, fmt.Errorf("output capture batch full"), opts.Captured) | ||
| @@ -153,9 +279,23 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||||
| 153 | dirty = haveScene | 279 | dirty = haveScene | ||
| 154 | } | 280 | } | ||
| 155 | case <-retry: | 281 | case <-retry: | ||
| 156 | - | pending = opts.send(pending) | 282 | + | if err := checkSecurity(); err != nil { |
| 283 | + | if ctx.Err() != nil { | |||
| 284 | + | return nil | |||
| 285 | + | } | |||
| 286 | + | return err | |||
| 287 | + | } | |||
| 288 | + | if pending != nil { | |||
| 289 | + | pending = opts.send(pending) | |||
| 290 | + | } | |||
| 157 | continue | 291 | continue | ||
| 158 | case <-trim.C(): | 292 | case <-trim.C(): | ||
| 293 | + | if err := checkSecurity(); err != nil { | |||
| 294 | + | if ctx.Err() != nil { | |||
| 295 | + | return nil | |||
| 296 | + | } | |||
| 297 | + | return err | |||
| 298 | + | } | |||
| 159 | // An idle output renders nothing: free what windows that left | 299 | // An idle output renders nothing: free what windows that left | ||
| 160 | // it held, and the child of an ended session. | 300 | // it held, and the child of an ended session. | ||
| 161 | pipeline.Retire(scene) | 301 | pipeline.Retire(scene) | ||
| @@ -168,14 +308,36 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||||
| 168 | scenes = nil | 308 | scenes = nil | ||
| 169 | continue | 309 | continue | ||
| 170 | } | 310 | } | ||
| 171 | - | scene, haveScene, dirty = s, true, true | 311 | + | if err := checkSecurity(); err != nil { |
| 312 | + | if ctx.Err() != nil { | |||
| 313 | + | return nil | |||
| 314 | + | } | |||
| 315 | + | return err | |||
| 316 | + | } | |||
| 317 | + | acceptScene(s) | |||
| 172 | case c, ok := <-contents: | 318 | case c, ok := <-contents: | ||
| 173 | if !ok { | 319 | if !ok { | ||
| 174 | contents = nil | 320 | contents = nil | ||
| 175 | continue | 321 | continue | ||
| 176 | } | 322 | } | ||
| 323 | + | if err := checkSecurity(); err != nil { | |||
| 324 | + | if ctx.Err() != nil { | |||
| 325 | + | return nil | |||
| 326 | + | } | |||
| 327 | + | return err | |||
| 328 | + | } | |||
| 177 | update(c) | 329 | update(c) | ||
| 178 | - | case c := <-cursor: | 330 | + | case c, ok := <-cursor: |
| 331 | + | if !ok { | |||
| 332 | + | cursor = nil | |||
| 333 | + | continue | |||
| 334 | + | } | |||
| 335 | + | if err := checkSecurity(); err != nil { | |||
| 336 | + | if ctx.Err() != nil { | |||
| 337 | + | return nil | |||
| 338 | + | } | |||
| 339 | + | return err | |||
| 340 | + | } | |||
| 179 | // The cursor is only drawn into screenshots: no new frame. | 341 | // The cursor is only drawn into screenshots: no new frame. | ||
| 180 | want, cursorScale = c, -1 | 342 | want, cursorScale = c, -1 | ||
| 181 | } | 343 | } | ||
| @@ -187,18 +349,39 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||||
| 187 | if !ok { | 349 | if !ok { | ||
| 188 | scenes = nil | 350 | scenes = nil | ||
| 189 | } else { | 351 | } else { | ||
| 190 | - | scene, haveScene, dirty = s, true, true | 352 | + | if err := checkSecurity(); err != nil { |
| 353 | + | if ctx.Err() != nil { | |||
| 354 | + | return nil | |||
| 355 | + | } | |||
| 356 | + | return err | |||
| 357 | + | } | |||
| 358 | + | acceptScene(s) | |||
| 191 | } | 359 | } | ||
| 192 | case c, ok := <-contents: | 360 | case c, ok := <-contents: | ||
| 193 | if !ok { | 361 | if !ok { | ||
| 194 | contents = nil | 362 | contents = nil | ||
| 195 | } else { | 363 | } else { | ||
| 364 | + | if err := checkSecurity(); err != nil { | |||
| 365 | + | if ctx.Err() != nil { | |||
| 366 | + | return nil | |||
| 367 | + | } | |||
| 368 | + | return err | |||
| 369 | + | } | |||
| 196 | update(c) | 370 | update(c) | ||
| 197 | } | 371 | } | ||
| 198 | default: | 372 | default: | ||
| 199 | break drain | 373 | break drain | ||
| 200 | } | 374 | } | ||
| 201 | } | 375 | } | ||
| 376 | + | if err := checkSecurity(); err != nil { | |||
| 377 | + | if ctx.Err() != nil { | |||
| 378 | + | return nil | |||
| 379 | + | } | |||
| 380 | + | return err | |||
| 381 | + | } | |||
| 382 | + | if opts.protected() { | |||
| 383 | + | failRequests("session protected") | |||
| 384 | + | } | |||
| 202 | if scene.Off && len(requests) > 0 { | 385 | if scene.Off && len(requests) > 0 { | ||
| 203 | for _, q := range requests { | 386 | for _, q := range requests { | ||
| 204 | capture.Fail(ctx, q, fmt.Errorf("output off"), opts.Captured) | 387 | capture.Fail(ctx, q, fmt.Errorf("output off"), opts.Captured) | ||
| @@ -213,14 +396,25 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||||
| 213 | continue | 396 | continue | ||
| 214 | } | 397 | } | ||
| 215 | dirty = false | 398 | dirty = false | ||
| 216 | - | if opts.Cursor != nil && opts.LoadCursor != nil && scene.Scale != cursorScale { | 399 | + | if !opts.protected() && opts.Cursor != nil && opts.LoadCursor != nil && scene.Scale != cursorScale { |
| 217 | cursorScale = scene.Scale | 400 | cursorScale = scene.Scale | ||
| 218 | if img, err := opts.LoadCursor(want, scene.Scale, 256); err == nil { | 401 | if img, err := opts.LoadCursor(want, scene.Scale, 256); err == nil { | ||
| 219 | - | opts.Cursor.set(img) | 402 | + | if !opts.protected() { |
| 403 | + | opts.Cursor.set(img) | |||
| 404 | + | } | |||
| 220 | } else { | 405 | } else { | ||
| 221 | opts.Log.Warn().Err(err).Msg("cursor") | 406 | opts.Log.Warn().Err(err).Msg("cursor") | ||
| 222 | } | 407 | } | ||
| 223 | } | 408 | } | ||
| 409 | + | if err := checkSecurity(); err != nil { | |||
| 410 | + | if ctx.Err() != nil { | |||
| 411 | + | return nil | |||
| 412 | + | } | |||
| 413 | + | return err | |||
| 414 | + | } | |||
| 415 | + | if !haveScene { | |||
| 416 | + | continue | |||
| 417 | + | } | |||
| 224 | start := time.Now() | 418 | start := time.Now() | ||
| 225 | // Clean requests of a session the scene does not carry are failed | 419 | // Clean requests of a session the scene does not carry are failed | ||
| 226 | // here. The others draw the clean frame first (same renderer, queue | 420 | // here. The others draw the clean frame first (same renderer, queue | ||
| @@ -230,6 +424,10 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||||
| 230 | if len(clean) > 0 && scene.CaptureScene != nil { | 424 | if len(clean) > 0 && scene.CaptureScene != nil { | ||
| 231 | // A hidden workspace: a child renderer draws it. The display's | 425 | // A hidden workspace: a child renderer draws it. The display's | ||
| 232 | // fences do not cover the child, so wait for it before reporting. | 426 | // fences do not cover the child, so wait for it before reporting. | ||
| 427 | + | if opts.Security != nil && opts.Security.Snapshot() != scene.Security { | |||
| 428 | + | failRequests("security epoch changed") | |||
| 429 | + | continue | |||
| 430 | + | } | |||
| 233 | pipeline.SubmitHidden(scene, surfaces, clean) | 431 | pipeline.SubmitHidden(scene, surfaces, clean) | ||
| 234 | clear(clean) | 432 | clear(clean) | ||
| 235 | // A slow child may outlive Wayland's stale-report timeout even | 433 | // A slow child may outlive Wayland's stale-report timeout even | ||
| @@ -252,16 +450,38 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||||
| 252 | return fmt.Errorf("workspace frame fence: %w", err) | 450 | return fmt.Errorf("workspace frame fence: %w", err) | ||
| 253 | } | 451 | } | ||
| 254 | } else if len(clean) > 0 { | 452 | } else if len(clean) > 0 { | ||
| 255 | - | cdone, err := r.Render(pipeline.CleanScene(scene), surfaces) | 453 | + | if opts.Security != nil && opts.Security.Snapshot() != scene.Security { |
| 256 | - | if err != nil { | 454 | + | failRequests("security epoch changed") |
| 257 | - | return fmt.Errorf("render clean frame: %w", err) | 455 | + | continue |
| 258 | } | 456 | } | ||
| 457 | + | cdone, renderErr := r.Render(pipeline.CleanScene(scene), surfaces) | |||
| 259 | if cdone != nil { | 458 | if cdone != nil { | ||
| 260 | - | cdone.Close() | 459 | + | // Clean composition reads client buffers even when the displayed |
| 460 | + | // frame is skipped by a gate/off transition. Own and wait its | |||
| 461 | + | // fence before any Seen report or protection check can proceed. | |||
| 462 | + | waitErr := syncfile.Wait(ctx, cdone) | |||
| 463 | + | _ = cdone.Close() | |||
| 464 | + | if waitErr != nil { | |||
| 465 | + | if ctx.Err() != nil { | |||
| 466 | + | return nil | |||
| 467 | + | } | |||
| 468 | + | return fmt.Errorf("clean frame fence: %w", waitErr) | |||
| 469 | + | } | |||
| 470 | + | } | |||
| 471 | + | if renderErr != nil { | |||
| 472 | + | return fmt.Errorf("render clean frame: %w", renderErr) | |||
| 473 | + | } | |||
| 474 | + | if opts.Security != nil && opts.Security.Snapshot() != scene.Security { | |||
| 475 | + | failRequests("security epoch changed") | |||
| 476 | + | continue | |||
| 261 | } | 477 | } | ||
| 262 | - | pipeline.Submit(r, clean) | 478 | + | pipeline.SubmitScoped(scene.Security, r, clean) |
| 263 | clear(clean) | 479 | clear(clean) | ||
| 264 | } | 480 | } | ||
| 481 | + | if opts.Security != nil && opts.Security.Snapshot() != scene.Security { | |||
| 482 | + | failRequests("security epoch changed") | |||
| 483 | + | continue | |||
| 484 | + | } | |||
| 265 | done, err := r.Render(scene, surfaces) | 485 | done, err := r.Render(scene, surfaces) | ||
| 266 | if err != nil { | 486 | if err != nil { | ||
| 267 | return fmt.Errorf("render frame: %w", err) | 487 | return fmt.Errorf("render frame: %w", err) | ||
| @@ -281,24 +501,36 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||||
| 281 | if len(normal) > 0 { | 501 | if len(normal) > 0 { | ||
| 282 | opts.Log.Debug().Str("output", opts.Name).Int("captures", len(normal)).Msg("capture frame composed") | 502 | opts.Log.Debug().Str("output", opts.Name).Int("captures", len(normal)).Msg("capture frame composed") | ||
| 283 | } | 503 | } | ||
| 284 | - | pipeline.Submit(r, normal) | 504 | + | // Do not capture or report a frame that crossed a transition during |
| 505 | + | // Render/fence wait. The next loop establishes the new black proof. | |||
| 506 | + | if opts.Security != nil && opts.Security.Snapshot() != scene.Security { | |||
| 507 | + | failRequests("security epoch changed") | |||
| 508 | + | continue | |||
| 509 | + | } | |||
| 510 | + | pipeline.SubmitScoped(scene.Security, r, normal) | |||
| 285 | clear(requests) | 511 | clear(requests) | ||
| 286 | requests = requestStorage[:0] | 512 | requests = requestStorage[:0] | ||
| 287 | frame++ | 513 | frame++ | ||
| 288 | pending = opts.flipped(pending, seen, scene, surfaces) | 514 | pending = opts.flipped(pending, seen, scene, surfaces) | ||
| 289 | - | if opts.ScreenshotDir != "" { | 515 | + | if opts.ScreenshotDir != "" && !opts.protected() { |
| 290 | shot := r.Pixels() | 516 | shot := r.Pixels() | ||
| 517 | + | if opts.protected() || opts.Security != nil && opts.Security.Snapshot() != scene.Security { | |||
| 518 | + | continue | |||
| 519 | + | } | |||
| 291 | if shot == nil { | 520 | if shot == nil { | ||
| 292 | opts.Log.Debug().Str("component", "headless").Str("output", opts.Name).Int("frame", frame).Msg("screenshot readback unavailable; frame skipped") | 521 | opts.Log.Debug().Str("component", "headless").Str("output", opts.Name).Int("frame", frame).Msg("screenshot readback unavailable; frame skipped") | ||
| 293 | continue | 522 | continue | ||
| 294 | } | 523 | } | ||
| 295 | if opts.Cursor != nil { | 524 | if opts.Cursor != nil { | ||
| 296 | - | opts.Cursor.draw(shot) | 525 | + | opts.Cursor.drawSecure(shot, opts.Security) |
| 526 | + | } | |||
| 527 | + | if opts.protected() || opts.Security != nil && opts.Security.Snapshot() != scene.Security { | |||
| 528 | + | continue | |||
| 297 | } | 529 | } | ||
| 298 | - | if err := writePNG(filepath.Join(opts.ScreenshotDir, fmt.Sprintf("frame-%06d.png", frame)), shot); err != nil { | 530 | + | if err := writePNGSecure(filepath.Join(opts.ScreenshotDir, fmt.Sprintf("frame-%06d.png", frame)), shot, opts.Security, scene.Security); err != nil { |
| 299 | return fmt.Errorf("screenshot: %w", err) | 531 | return fmt.Errorf("screenshot: %w", err) | ||
| 300 | } | 532 | } | ||
| 301 | - | if err := writePNG(filepath.Join(opts.ScreenshotDir, "latest.png"), shot); err != nil { | 533 | + | if err := writePNGSecure(filepath.Join(opts.ScreenshotDir, "latest.png"), shot, opts.Security, scene.Security); err != nil { |
| 302 | return fmt.Errorf("latest screenshot: %w", err) | 534 | return fmt.Errorf("latest screenshot: %w", err) | ||
| 303 | } | 535 | } | ||
| 304 | } | 536 | } | ||
| @@ -324,7 +556,14 @@ func (h *holdSnapshots) report(output string, seen, reads map[ports.WindowID]uin | |||||
| 324 | return ports.OutputPresented{Output: output, Seen: h.seen, ChildReads: h.reads} | 556 | return ports.OutputPresented{Output: output, Seen: h.seen, ChildReads: h.reads} | ||
| 325 | } | 557 | } | ||
| 326 | 558 | ||||
| 327 | - | func writePNG(path string, img *image.RGBA) error { | 559 | + | func (opts Options) protected() bool { |
| 560 | + | return opts.Security != nil && opts.Security.Snapshot().Protected | |||
| 561 | + | } | |||
| 562 | + | ||||
| 563 | + | func writePNGSecure(path string, img *image.RGBA, security ports.SessionSecurity, state ports.SecurityState) error { | |||
| 564 | + | if security != nil && (state.Protected || security.Snapshot() != state) { | |||
| 565 | + | return nil | |||
| 566 | + | } | |||
| 328 | f, err := os.CreateTemp(filepath.Dir(path), ".frame-*.png") | 567 | f, err := os.CreateTemp(filepath.Dir(path), ".frame-*.png") | ||
| 329 | if err != nil { | 568 | if err != nil { | ||
| 330 | return err | 569 | return err | ||
| @@ -337,6 +576,9 @@ func writePNG(path string, img *image.RGBA) error { | |||||
| 337 | if err := f.Close(); err != nil { | 576 | if err := f.Close(); err != nil { | ||
| 338 | return err | 577 | return err | ||
| 339 | } | 578 | } | ||
| 579 | + | if security != nil && security.Snapshot() != state { | |||
| 580 | + | return nil | |||
| 581 | + | } | |||
| 340 | return os.Rename(f.Name(), path) | 582 | return os.Rename(f.Name(), path) | ||
| 341 | } | 583 | } | ||
| 342 | 584 | ||||
M internal/adapters/headless/output_test.go +1 -1
| @@ -193,7 +193,7 @@ func TestWritePNGAtomic(t *testing.T) { | |||||
| 193 | img := image.NewRGBA(image.Rect(0, 0, 1, 1)) | 193 | img := image.NewRGBA(image.Rect(0, 0, 1, 1)) | ||
| 194 | for _, name := range []string{"frame-000001.png", "latest.png"} { | 194 | for _, name := range []string{"frame-000001.png", "latest.png"} { | ||
| 195 | path := filepath.Join(dir, name) | 195 | path := filepath.Join(dir, name) | ||
| 196 | - | if err := writePNG(path, img); err != nil { | 196 | + | if err := writePNGSecure(path, img, nil, ports.SecurityState{}); err != nil { |
| 197 | t.Fatal(err) | 197 | t.Fatal(err) | ||
| 198 | } | 198 | } | ||
| 199 | f, err := os.Open(path) | 199 | f, err := os.Open(path) | ||
A internal/adapters/headless/security_capture_test.go +164 -0
| @@ -0,0 +1,164 @@ | |||||
| 1 | + | package headless | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "image" | |||
| 7 | + | "os" | |||
| 8 | + | "sync/atomic" | |||
| 9 | + | "testing" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/neferwl/internal/adapters/capture" | |||
| 12 | + | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |||
| 13 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 14 | + | "github.com/stretchr/testify/mock" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | // Change the gate AFTER the output's post-Render check returns the admitting | |||
| 18 | + | // state. SubmitScoped must close this last gap before native BeginCapture. | |||
| 19 | + | func TestScopedCaptureClosesPostRenderAdmissionGap(t *testing.T) { | |||
| 20 | + | for _, clean := range []bool{false, true} { | |||
| 21 | + | name := "normal" | |||
| 22 | + | if clean { | |||
| 23 | + | name = "clean" | |||
| 24 | + | } | |||
| 25 | + | t.Run(name, func(t *testing.T) { | |||
| 26 | + | admitted := ports.SecurityState{Generation: 2} | |||
| 27 | + | protected := ports.SecurityState{Generation: 3, Protected: true} | |||
| 28 | + | var state atomic.Value | |||
| 29 | + | state.Store(admitted) | |||
| 30 | + | var arm atomic.Bool | |||
| 31 | + | gate := portsmocks.NewMockSessionSecurity(t) | |||
| 32 | + | gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { | |||
| 33 | + | before := state.Load().(ports.SecurityState) | |||
| 34 | + | if arm.CompareAndSwap(true, false) { | |||
| 35 | + | state.Store(protected) | |||
| 36 | + | } | |||
| 37 | + | return before | |||
| 38 | + | }) | |||
| 39 | + | r := portsmocks.NewMockRenderer(t) | |||
| 40 | + | r.EXPECT().Close().Return().Once() | |||
| 41 | + | frameRendered := make(chan struct{}, 1) | |||
| 42 | + | proof := make(chan ports.SecurityBackendEvent, 1) | |||
| 43 | + | r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) (*os.File, error) { | |||
| 44 | + | if s.Security == protected { | |||
| 45 | + | if s.Seq != 0 || len(s.Windows) != 0 { | |||
| 46 | + | t.Errorf("bad protection frame %+v", s) | |||
| 47 | + | } | |||
| 48 | + | return nil, nil | |||
| 49 | + | } | |||
| 50 | + | if s.Security != admitted { | |||
| 51 | + | t.Errorf("wrong render epoch %+v", s) | |||
| 52 | + | } | |||
| 53 | + | arm.Store(true) | |||
| 54 | + | frameRendered <- struct{}{} | |||
| 55 | + | return nil, nil | |||
| 56 | + | }).Twice() | |||
| 57 | + | // BeginCapture has no expectation: any native export fails the test. | |||
| 58 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 59 | + | done := make(chan error, 1) | |||
| 60 | + | scenes := make(chan ports.Scene, 1) | |||
| 61 | + | incoming := make(chan ports.CaptureRequest) | |||
| 62 | + | replies := make(chan ports.CaptureDone, 2) | |||
| 63 | + | go func() { | |||
| 64 | + | done <- Run(ctx, Options{Security: gate, SecurityEvents: proof, Instance: 8, Width: 2, Height: 2, Captured: replies, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, incoming) | |||
| 65 | + | }() | |||
| 66 | + | dst := sessionCaptureFile(t) | |||
| 67 | + | incoming <- ports.CaptureRequest{ID: 91, Clean: clean, Session: 7, Region: image.Rect(0, 0, 2, 2), Width: 2, Height: 2, Stride: 8, Format: 1, Dst: dst} | |||
| 68 | + | scene := ports.Scene{Security: admitted, Seq: 2, Scale: 1, OutputWidth: 2, OutputHeight: 2} | |||
| 69 | + | if clean { | |||
| 70 | + | scene = sessionScene() | |||
| 71 | + | scene.Security = admitted | |||
| 72 | + | scene.Scale = 1 | |||
| 73 | + | } | |||
| 74 | + | scenes <- scene | |||
| 75 | + | securityReceive(t, frameRendered) | |||
| 76 | + | result := securityReceive(t, replies) | |||
| 77 | + | if result.ID != 91 || !errors.Is(result.Err, capture.ErrSecurityState) { | |||
| 78 | + | t.Fatalf("scoped rejection missing: %+v", result) | |||
| 79 | + | } | |||
| 80 | + | if _, err := dst.File.Stat(); !errors.Is(err, os.ErrClosed) { | |||
| 81 | + | t.Fatalf("destination not closed: %v", err) | |||
| 82 | + | } | |||
| 83 | + | securityReceive(t, proof) | |||
| 84 | + | cancel() | |||
| 85 | + | if err := securityReceive(t, done); err != nil { | |||
| 86 | + | t.Fatal(err) | |||
| 87 | + | } | |||
| 88 | + | select { | |||
| 89 | + | case extra := <-replies: | |||
| 90 | + | t.Fatalf("duplicate reply %+v", extra) | |||
| 91 | + | default: | |||
| 92 | + | } | |||
| 93 | + | }) | |||
| 94 | + | } | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | func TestTransitionDuringHeadlessRenderWaitRetainsFenceAndRejectsCapture(t *testing.T) { | |||
| 98 | + | admitted := ports.SecurityState{Generation: 2} | |||
| 99 | + | locked := ports.SecurityState{Generation: 3, Protected: true} | |||
| 100 | + | var epoch atomic.Value | |||
| 101 | + | epoch.Store(admitted) | |||
| 102 | + | gate := portsmocks.NewMockSessionSecurity(t) | |||
| 103 | + | gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return epoch.Load().(ports.SecurityState) }) | |||
| 104 | + | fence, signal, err := os.Pipe() | |||
| 105 | + | if err != nil { | |||
| 106 | + | t.Fatal(err) | |||
| 107 | + | } | |||
| 108 | + | defer signal.Close() | |||
| 109 | + | r := portsmocks.NewMockRenderer(t) | |||
| 110 | + | r.EXPECT().Close().Return().Once() | |||
| 111 | + | rendered := make(chan struct{}, 1) | |||
| 112 | + | r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) (*os.File, error) { | |||
| 113 | + | if s.Security == admitted { | |||
| 114 | + | epoch.Store(locked) | |||
| 115 | + | rendered <- struct{}{} | |||
| 116 | + | return fence, nil | |||
| 117 | + | } | |||
| 118 | + | return nil, nil | |||
| 119 | + | }).Twice() | |||
| 120 | + | scenes := make(chan ports.Scene, 1) | |||
| 121 | + | incoming := make(chan ports.CaptureRequest) | |||
| 122 | + | replies := make(chan ports.CaptureDone, 2) | |||
| 123 | + | proof := make(chan ports.SecurityBackendEvent, 1) | |||
| 124 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 125 | + | done := make(chan error, 1) | |||
| 126 | + | go func() { | |||
| 127 | + | done <- Run(ctx, Options{Security: gate, SecurityEvents: proof, Instance: 8, Captured: replies, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, incoming) | |||
| 128 | + | }() | |||
| 129 | + | dst := sessionCaptureFile(t) | |||
| 130 | + | incoming <- ports.CaptureRequest{ID: 92, Dst: dst} | |||
| 131 | + | scenes <- ports.Scene{Security: admitted, Seq: 2} | |||
| 132 | + | securityReceive(t, rendered) | |||
| 133 | + | if _, err := fence.Stat(); err != nil { | |||
| 134 | + | t.Fatal("pending renderer read fence closed early") | |||
| 135 | + | } | |||
| 136 | + | select { | |||
| 137 | + | case p := <-proof: | |||
| 138 | + | t.Fatalf("proof on stalled old render %+v", p) | |||
| 139 | + | default: | |||
| 140 | + | } | |||
| 141 | + | if _, err := signal.Write([]byte{1}); err != nil { | |||
| 142 | + | t.Fatal(err) | |||
| 143 | + | } | |||
| 144 | + | result := securityReceive(t, replies) | |||
| 145 | + | if result.ID != 92 || result.Err == nil { | |||
| 146 | + | t.Fatal(result) | |||
| 147 | + | } | |||
| 148 | + | securityReceive(t, proof) | |||
| 149 | + | cancel() | |||
| 150 | + | if err := securityReceive(t, done); err != nil { | |||
| 151 | + | t.Fatal(err) | |||
| 152 | + | } | |||
| 153 | + | if _, err := fence.Stat(); !errors.Is(err, os.ErrClosed) { | |||
| 154 | + | t.Fatal("completed render fence leaked") | |||
| 155 | + | } | |||
| 156 | + | if _, err := dst.File.Stat(); !errors.Is(err, os.ErrClosed) { | |||
| 157 | + | t.Fatal("rejected capture FD leaked") | |||
| 158 | + | } | |||
| 159 | + | select { | |||
| 160 | + | case extra := <-replies: | |||
| 161 | + | t.Fatalf("duplicate reply %+v", extra) | |||
| 162 | + | default: | |||
| 163 | + | } | |||
| 164 | + | } | |||
A internal/adapters/headless/security_clean_fence_test.go +143 -0
| @@ -0,0 +1,143 @@ | |||||
| 1 | + | package headless | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "image" | |||
| 7 | + | "os" | |||
| 8 | + | "sync/atomic" | |||
| 9 | + | "testing" | |||
| 10 | + | "time" | |||
| 11 | + | ||||
| 12 | + | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |||
| 13 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 14 | + | "github.com/stretchr/testify/mock" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | func TestCleanFenceRetainsReadsAcrossEngageAndOff(t *testing.T) { | |||
| 18 | + | for _, finish := range []string{"signal", "cancel", "render-error"} { | |||
| 19 | + | t.Run(finish, func(t *testing.T) { | |||
| 20 | + | admitted := ports.SecurityState{Generation: 2} | |||
| 21 | + | protected := ports.SecurityState{Generation: 3, Protected: true} | |||
| 22 | + | var epoch atomic.Value | |||
| 23 | + | epoch.Store(admitted) | |||
| 24 | + | gate := portsmocks.NewMockSessionSecurity(t) | |||
| 25 | + | gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return epoch.Load().(ports.SecurityState) }) | |||
| 26 | + | fence, signal, err := os.Pipe() | |||
| 27 | + | if err != nil { | |||
| 28 | + | t.Fatal(err) | |||
| 29 | + | } | |||
| 30 | + | defer signal.Close() | |||
| 31 | + | r := portsmocks.NewMockRenderer(t) | |||
| 32 | + | r.EXPECT().Close().Return().Once() | |||
| 33 | + | rendered := make(chan struct{}, 1) | |||
| 34 | + | releaseRender := make(chan struct{}) | |||
| 35 | + | failure := errors.New("clean render failed after submission") | |||
| 36 | + | r.EXPECT().Render(mock.MatchedBy(func(s ports.Scene) bool { return s.Security == admitted && s.Seq == 0 }), mock.Anything).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) { | |||
| 37 | + | rendered <- struct{}{} | |||
| 38 | + | <-releaseRender | |||
| 39 | + | if finish == "render-error" { | |||
| 40 | + | return fence, failure | |||
| 41 | + | } | |||
| 42 | + | return fence, nil | |||
| 43 | + | }).Once() | |||
| 44 | + | if finish == "signal" { | |||
| 45 | + | r.EXPECT().Render(mock.MatchedBy(func(s ports.Scene) bool { return s.Security == protected && s.Seq == 0 }), mock.Anything).Return(nil, nil).Once() | |||
| 46 | + | } | |||
| 47 | + | // Native capture and Pixels have no expectations; neither is admitted. | |||
| 48 | + | scenes := make(chan ports.Scene, 2) | |||
| 49 | + | contents := make(chan ports.SurfaceContent, 1) | |||
| 50 | + | incoming := make(chan ports.CaptureRequest) | |||
| 51 | + | presented := make(chan ports.OutputPresented, 8) | |||
| 52 | + | proofs := make(chan ports.SecurityBackendEvent, 2) | |||
| 53 | + | replies := make(chan ports.CaptureDone, 2) | |||
| 54 | + | changes := make(chan ports.SecurityState, 1) | |||
| 55 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 56 | + | defer cancel() | |||
| 57 | + | done := make(chan error, 1) | |||
| 58 | + | go func() { | |||
| 59 | + | done <- Run(ctx, Options{Security: gate, SecurityChanges: changes, SecurityEvents: proofs, Instance: 7, Width: 2, Height: 2, Presented: presented, Captured: replies, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, contents, nil, incoming) | |||
| 60 | + | }() | |||
| 61 | + | dst := sessionCaptureFile(t) | |||
| 62 | + | incoming <- ports.CaptureRequest{ID: 99, Clean: true, Session: 7, Region: image.Rect(0, 0, 2, 2), Width: 2, Height: 2, Stride: 8, Format: 1, Dst: dst} | |||
| 63 | + | contents <- ports.SurfaceContent{ID: 1, Seq: 8, SHM: &ports.SHMBuffer{Pool: 1}} | |||
| 64 | + | scene := sessionScene() | |||
| 65 | + | scene.Security = admitted | |||
| 66 | + | scene.Scale = 1 | |||
| 67 | + | scene.OutputWidth = 2 | |||
| 68 | + | scene.OutputHeight = 2 | |||
| 69 | + | scenes <- scene | |||
| 70 | + | securityReceive(t, rendered) | |||
| 71 | + | // Prior non-render reports are outside this read lifetime; establish the | |||
| 72 | + | // cut at clean GPU submission before exercising transition/off handling. | |||
| 73 | + | for len(presented) > 0 { | |||
| 74 | + | <-presented | |||
| 75 | + | } | |||
| 76 | + | epoch.Store(protected) | |||
| 77 | + | scenes <- ports.Scene{Security: protected, Off: true} | |||
| 78 | + | changes <- protected | |||
| 79 | + | close(releaseRender) | |||
| 80 | + | // A bounded observation interval catches the original immediate fence | |||
| 81 | + | // close/report path without assuming a polling schedule. | |||
| 82 | + | select { | |||
| 83 | + | case report := <-presented: | |||
| 84 | + | t.Fatalf("GPU reads reported before clean fence: %+v", report) | |||
| 85 | + | case <-time.After(150 * time.Millisecond): | |||
| 86 | + | } | |||
| 87 | + | if _, err := fence.Stat(); err != nil { | |||
| 88 | + | t.Fatalf("unsignalled clean read fence closed: %v", err) | |||
| 89 | + | } | |||
| 90 | + | select { | |||
| 91 | + | case proof := <-proofs: | |||
| 92 | + | t.Fatalf("protection path bypassed pending clean reads: %+v", proof) | |||
| 93 | + | default: | |||
| 94 | + | } | |||
| 95 | + | if finish == "cancel" { | |||
| 96 | + | cancel() | |||
| 97 | + | } else { | |||
| 98 | + | if _, err := signal.Write([]byte{1}); err != nil { | |||
| 99 | + | t.Fatal(err) | |||
| 100 | + | } | |||
| 101 | + | } | |||
| 102 | + | if finish == "signal" { | |||
| 103 | + | securityReceive(t, proofs) | |||
| 104 | + | cancel() | |||
| 105 | + | } | |||
| 106 | + | runErr := securityReceive(t, done) | |||
| 107 | + | if finish == "render-error" { | |||
| 108 | + | if !errors.Is(runErr, failure) { | |||
| 109 | + | t.Fatal(runErr) | |||
| 110 | + | } | |||
| 111 | + | } else if runErr != nil { | |||
| 112 | + | t.Fatal(runErr) | |||
| 113 | + | } | |||
| 114 | + | if _, err := fence.Stat(); !errors.Is(err, os.ErrClosed) { | |||
| 115 | + | t.Fatalf("clean fence leaked: %v", err) | |||
| 116 | + | } | |||
| 117 | + | reply := securityReceive(t, replies) | |||
| 118 | + | if reply.ID != 99 || reply.Err == nil { | |||
| 119 | + | t.Fatal(reply) | |||
| 120 | + | } | |||
| 121 | + | if _, err := dst.File.Stat(); !errors.Is(err, os.ErrClosed) { | |||
| 122 | + | t.Fatal("capture destination leaked") | |||
| 123 | + | } | |||
| 124 | + | select { | |||
| 125 | + | case extra := <-replies: | |||
| 126 | + | t.Fatalf("duplicate capture reply %+v", extra) | |||
| 127 | + | default: | |||
| 128 | + | } | |||
| 129 | + | if finish != "signal" { | |||
| 130 | + | select { | |||
| 131 | + | case proof := <-proofs: | |||
| 132 | + | t.Fatalf("cancel/error falsely proved protection: %+v", proof) | |||
| 133 | + | default: | |||
| 134 | + | } | |||
| 135 | + | select { | |||
| 136 | + | case report := <-presented: | |||
| 137 | + | t.Fatalf("cancel/error falsely reported reads: %+v", report) | |||
| 138 | + | default: | |||
| 139 | + | } | |||
| 140 | + | } | |||
| 141 | + | }) | |||
| 142 | + | } | |||
| 143 | + | } | |||
A internal/adapters/headless/security_test.go +370 -0
| @@ -0,0 +1,370 @@ | |||||
| 1 | + | package headless | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "image" | |||
| 7 | + | "os" | |||
| 8 | + | "path/filepath" | |||
| 9 | + | "sync/atomic" | |||
| 10 | + | "testing" | |||
| 11 | + | "time" | |||
| 12 | + | ||||
| 13 | + | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |||
| 14 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 15 | + | "github.com/stretchr/testify/mock" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | func securityReceive[T any](t *testing.T, ch <-chan T) T { | |||
| 19 | + | t.Helper() | |||
| 20 | + | select { | |||
| 21 | + | case v := <-ch: | |||
| 22 | + | return v | |||
| 23 | + | case <-time.After(time.Second): | |||
| 24 | + | t.Fatal("timed out") | |||
| 25 | + | var z T | |||
| 26 | + | return z | |||
| 27 | + | } | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | func TestSecurityInitialBlackFenceBeforeLockerAndNoReadback(t *testing.T) { | |||
| 31 | + | state := ports.SecurityState{Generation: 7, Protected: true} | |||
| 32 | + | gate := portsmocks.NewMockSessionSecurity(t) | |||
| 33 | + | gate.EXPECT().Snapshot().Return(state) | |||
| 34 | + | r := portsmocks.NewMockRenderer(t) | |||
| 35 | + | r.EXPECT().Close().Return().Once() | |||
| 36 | + | fence, signal, err := os.Pipe() | |||
| 37 | + | if err != nil { | |||
| 38 | + | t.Fatal(err) | |||
| 39 | + | } | |||
| 40 | + | defer signal.Close() | |||
| 41 | + | rendered := make(chan ports.Scene, 4) | |||
| 42 | + | r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, c map[ports.WindowID]ports.SurfaceContent) (*os.File, error) { | |||
| 43 | + | if s.Seq == 0 { | |||
| 44 | + | if s.Background != "#000000" || s.Security != state || len(s.Windows) != 0 || len(s.Layers) != 0 || s.Capture != nil || s.CaptureScene != nil || c != nil { | |||
| 45 | + | t.Errorf("unsafe initial black: %+v surfaces %v", s, c) | |||
| 46 | + | } | |||
| 47 | + | rendered <- s | |||
| 48 | + | return fence, nil | |||
| 49 | + | } | |||
| 50 | + | if s.Security != state || s.Seq != 2 { | |||
| 51 | + | t.Errorf("stale desktop rendered: %+v", s) | |||
| 52 | + | } | |||
| 53 | + | rendered <- s | |||
| 54 | + | return nil, nil | |||
| 55 | + | }).Twice() | |||
| 56 | + | // No Pixels/BeginCapture expectation: either call fails this test. | |||
| 57 | + | scenes := make(chan ports.Scene, 3) | |||
| 58 | + | scenes <- ports.Scene{Seq: 1, Windows: []ports.SceneWindow{{ID: 1}}} | |||
| 59 | + | scenes <- ports.Scene{Security: state, Seq: 2, Background: "#000000", Windows: []ports.SceneWindow{{ID: 10, Fullscreen: true, Rect: ports.Rect{W: 2, H: 2}}}} | |||
| 60 | + | contents := make(chan ports.SurfaceContent, 1) | |||
| 61 | + | acquire, acquireSignal, err := os.Pipe() | |||
| 62 | + | if err != nil { | |||
| 63 | + | t.Fatal(err) | |||
| 64 | + | } | |||
| 65 | + | defer acquire.Close() | |||
| 66 | + | defer acquireSignal.Close() | |||
| 67 | + | contents <- ports.SurfaceContent{ID: 10, SHM: &ports.SHMBuffer{Pool: 10}, Acquire: acquire} | |||
| 68 | + | proof := make(chan ports.SecurityBackendEvent, 4) | |||
| 69 | + | replies := make(chan ports.CaptureDone, 4) | |||
| 70 | + | requests := make(chan ports.CaptureRequest, 2) | |||
| 71 | + | dst, err := os.CreateTemp(t.TempDir(), "capture") | |||
| 72 | + | if err != nil { | |||
| 73 | + | t.Fatal(err) | |||
| 74 | + | } | |||
| 75 | + | requests <- ports.CaptureRequest{ID: 44, Dst: ports.SHMBuffer{File: dst}} | |||
| 76 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 77 | + | done := make(chan error, 1) | |||
| 78 | + | dir := t.TempDir() | |||
| 79 | + | go func() { | |||
| 80 | + | done <- Run(ctx, Options{Security: gate, Instance: 9, SecurityEvents: proof, Width: 2, Height: 2, Name: "H", ScreenshotDir: dir, Cursor: &Cursor{}, Captured: replies, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, contents, nil, requests) | |||
| 81 | + | }() | |||
| 82 | + | t.Cleanup(func() { | |||
| 83 | + | cancel() | |||
| 84 | + | if err := securityReceive(t, done); err != nil { | |||
| 85 | + | t.Error(err) | |||
| 86 | + | } | |||
| 87 | + | }) | |||
| 88 | + | securityReceive(t, rendered) | |||
| 89 | + | select { | |||
| 90 | + | case p := <-proof: | |||
| 91 | + | t.Fatalf("proof before fence: %+v", p) | |||
| 92 | + | default: | |||
| 93 | + | } | |||
| 94 | + | if _, err := signal.Write([]byte{1}); err != nil { | |||
| 95 | + | t.Fatal(err) | |||
| 96 | + | } | |||
| 97 | + | p := securityReceive(t, proof).(ports.SecurityOutputProof) | |||
| 98 | + | if p.Proof != (ports.OutputProtection{Generation: 7, Instance: 9, Kind: ports.ProtectionProtectedFrame}) { | |||
| 99 | + | t.Fatal(p) | |||
| 100 | + | } | |||
| 101 | + | securityReceive(t, rendered) | |||
| 102 | + | result := securityReceive(t, replies) | |||
| 103 | + | if result.ID != 44 || result.Err == nil { | |||
| 104 | + | t.Fatal(result) | |||
| 105 | + | } | |||
| 106 | + | if _, err := dst.Stat(); err == nil { | |||
| 107 | + | t.Fatal("capture destination not closed") | |||
| 108 | + | } | |||
| 109 | + | if err := dst.Close(); err == nil { | |||
| 110 | + | t.Fatal("destination was still open") | |||
| 111 | + | } | |||
| 112 | + | select { | |||
| 113 | + | case result := <-replies: | |||
| 114 | + | t.Fatalf("duplicate failure: %+v", result) | |||
| 115 | + | default: | |||
| 116 | + | } | |||
| 117 | + | files, err := os.ReadDir(dir) | |||
| 118 | + | if err != nil || len(files) != 0 { | |||
| 119 | + | t.Fatalf("protected screenshot files: %v %v", files, err) | |||
| 120 | + | } | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | func TestSecurityChangesWakeIdleAndRejectQueuedDesktop(t *testing.T) { | |||
| 124 | + | var state atomic.Value | |||
| 125 | + | state.Store(ports.SecurityState{}) | |||
| 126 | + | gate := portsmocks.NewMockSessionSecurity(t) | |||
| 127 | + | gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return state.Load().(ports.SecurityState) }) | |||
| 128 | + | r := portsmocks.NewMockRenderer(t) | |||
| 129 | + | r.EXPECT().Close().Return().Once() | |||
| 130 | + | rendered := make(chan ports.Scene, 4) | |||
| 131 | + | desktopFinished := make(chan struct{}) | |||
| 132 | + | r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, c map[ports.WindowID]ports.SurfaceContent) (*os.File, error) { | |||
| 133 | + | if s.Seq != 0 { | |||
| 134 | + | rendered <- s | |||
| 135 | + | <-desktopFinished | |||
| 136 | + | return nil, nil | |||
| 137 | + | } | |||
| 138 | + | if len(s.Windows) != 0 || c != nil || !s.Security.Protected { | |||
| 139 | + | t.Errorf("bad transition black %+v", s) | |||
| 140 | + | } | |||
| 141 | + | rendered <- s | |||
| 142 | + | return nil, nil | |||
| 143 | + | }).Twice() | |||
| 144 | + | scenes := make(chan ports.Scene, 2) | |||
| 145 | + | scenes <- ports.Scene{Seq: 1, Windows: []ports.SceneWindow{{ID: 1}}} | |||
| 146 | + | changes := make(chan ports.SecurityState, 1) | |||
| 147 | + | proofs := make(chan ports.SecurityBackendEvent, 2) | |||
| 148 | + | presented := make(chan ports.OutputPresented, 4) | |||
| 149 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 150 | + | done := make(chan error, 1) | |||
| 151 | + | go func() { | |||
| 152 | + | done <- Run(ctx, Options{Security: gate, SecurityChanges: changes, Instance: 3, SecurityEvents: proofs, Presented: presented, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, nil) | |||
| 153 | + | }() | |||
| 154 | + | t.Cleanup(func() { | |||
| 155 | + | cancel() | |||
| 156 | + | if err := securityReceive(t, done); err != nil { | |||
| 157 | + | t.Error(err) | |||
| 158 | + | } | |||
| 159 | + | }) | |||
| 160 | + | securityReceive(t, rendered) | |||
| 161 | + | locked := ports.SecurityState{Generation: 1, Protected: true} | |||
| 162 | + | state.Store(locked) | |||
| 163 | + | scenes <- ports.Scene{Seq: 99, Windows: []ports.SceneWindow{{ID: 1}}} | |||
| 164 | + | changes <- locked | |||
| 165 | + | close(desktopFinished) | |||
| 166 | + | s := securityReceive(t, rendered) | |||
| 167 | + | if s.Security != locked || s.Seq != 0 { | |||
| 168 | + | t.Fatal(s) | |||
| 169 | + | } | |||
| 170 | + | securityReceive(t, proofs) | |||
| 171 | + | select { | |||
| 172 | + | case p := <-presented: | |||
| 173 | + | if p.Flip != nil { | |||
| 174 | + | t.Fatalf("stale render reported: %+v", p) | |||
| 175 | + | } | |||
| 176 | + | default: | |||
| 177 | + | } | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | func TestSecurityDedicatedChangeWakesWithoutScene(t *testing.T) { | |||
| 181 | + | var state atomic.Value | |||
| 182 | + | state.Store(ports.SecurityState{}) | |||
| 183 | + | gate := portsmocks.NewMockSessionSecurity(t) | |||
| 184 | + | gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return state.Load().(ports.SecurityState) }) | |||
| 185 | + | r := portsmocks.NewMockRenderer(t) | |||
| 186 | + | r.EXPECT().Close().Return().Once() | |||
| 187 | + | r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil).Once() | |||
| 188 | + | changes := make(chan ports.SecurityState, 1) | |||
| 189 | + | proof := make(chan ports.SecurityBackendEvent, 1) | |||
| 190 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 191 | + | done := make(chan error, 1) | |||
| 192 | + | go func() { | |||
| 193 | + | done <- Run(ctx, Options{Security: gate, SecurityChanges: changes, Instance: 5, SecurityEvents: proof, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, nil, nil, nil, nil) | |||
| 194 | + | }() | |||
| 195 | + | locked := ports.SecurityState{Generation: 2, Protected: true} | |||
| 196 | + | state.Store(locked) | |||
| 197 | + | changes <- locked | |||
| 198 | + | securityReceive(t, proof) | |||
| 199 | + | cancel() | |||
| 200 | + | if err := securityReceive(t, done); err != nil { | |||
| 201 | + | t.Fatal(err) | |||
| 202 | + | } | |||
| 203 | + | } | |||
| 204 | + | ||||
| 205 | + | func TestSecurityCursorAndPNGSuppressed(t *testing.T) { | |||
| 206 | + | gate := portsmocks.NewMockSessionSecurity(t) | |||
| 207 | + | state := ports.SecurityState{Generation: 1, Protected: true} | |||
| 208 | + | gate.EXPECT().Snapshot().Return(state) | |||
| 209 | + | c := &Cursor{} | |||
| 210 | + | c.set(ports.CursorImage{W: 1, H: 1, Pixels: []byte{0, 0, 255, 255}}) | |||
| 211 | + | c.Move(0, 0) | |||
| 212 | + | img := image.NewRGBA(image.Rect(0, 0, 1, 1)) | |||
| 213 | + | c.drawSecure(img, gate) | |||
| 214 | + | if img.Pix[0] != 0 { | |||
| 215 | + | t.Fatal("protected cursor drawn") | |||
| 216 | + | } | |||
| 217 | + | path := filepath.Join(t.TempDir(), "shot.png") | |||
| 218 | + | if err := writePNGSecure(path, img, gate, ports.SecurityState{}); err != nil { | |||
| 219 | + | t.Fatal(err) | |||
| 220 | + | } | |||
| 221 | + | if _, err := os.Stat(path); !os.IsNotExist(err) { | |||
| 222 | + | t.Fatal("stale screenshot delivered") | |||
| 223 | + | } | |||
| 224 | + | } | |||
| 225 | + | ||||
| 226 | + | func TestSecurityQueuedCaptureFailedOnceAndProofCancellation(t *testing.T) { | |||
| 227 | + | var state atomic.Value | |||
| 228 | + | state.Store(ports.SecurityState{}) | |||
| 229 | + | gate := portsmocks.NewMockSessionSecurity(t) | |||
| 230 | + | gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return state.Load().(ports.SecurityState) }) | |||
| 231 | + | r := portsmocks.NewMockRenderer(t) | |||
| 232 | + | r.EXPECT().Close().Return().Once() | |||
| 233 | + | black := make(chan struct{}) | |||
| 234 | + | r.EXPECT().Render(mock.Anything, mock.Anything).Run(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) { close(black) }).Return(nil, nil).Once() | |||
| 235 | + | incoming := make(chan ports.CaptureRequest) | |||
| 236 | + | replies := make(chan ports.CaptureDone, 2) | |||
| 237 | + | changes := make(chan ports.SecurityState, 1) | |||
| 238 | + | // No receiver: proof delivery must wait rather than drop, and cancel. | |||
| 239 | + | proofs := make(chan ports.SecurityBackendEvent) | |||
| 240 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 241 | + | done := make(chan error, 1) | |||
| 242 | + | go func() { | |||
| 243 | + | done <- Run(ctx, Options{Security: gate, SecurityChanges: changes, SecurityEvents: proofs, Instance: 4, Captured: replies, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, nil, nil, nil, incoming) | |||
| 244 | + | }() | |||
| 245 | + | file, err := os.CreateTemp(t.TempDir(), "queued") | |||
| 246 | + | if err != nil { | |||
| 247 | + | t.Fatal(err) | |||
| 248 | + | } | |||
| 249 | + | select { | |||
| 250 | + | case incoming <- ports.CaptureRequest{ID: 100, Dst: ports.SHMBuffer{File: file}}: | |||
| 251 | + | case <-time.After(time.Second): | |||
| 252 | + | t.Fatal("capture not admitted") | |||
| 253 | + | } | |||
| 254 | + | locked := ports.SecurityState{Generation: 3, Protected: true} | |||
| 255 | + | state.Store(locked) | |||
| 256 | + | changes <- locked | |||
| 257 | + | securityReceive(t, black) | |||
| 258 | + | cancel() | |||
| 259 | + | if err := securityReceive(t, done); err != nil { | |||
| 260 | + | t.Fatal(err) | |||
| 261 | + | } | |||
| 262 | + | result := securityReceive(t, replies) | |||
| 263 | + | if result.ID != 100 || result.Err == nil { | |||
| 264 | + | t.Fatal(result) | |||
| 265 | + | } | |||
| 266 | + | if _, err := file.Stat(); err == nil { | |||
| 267 | + | t.Fatal("queued FD leaked") | |||
| 268 | + | } | |||
| 269 | + | select { | |||
| 270 | + | case result := <-replies: | |||
| 271 | + | t.Fatalf("duplicate queued reply: %+v", result) | |||
| 272 | + | default: | |||
| 273 | + | } | |||
| 274 | + | } | |||
| 275 | + | ||||
| 276 | + | func TestSecurityOffOutputProofDoesNotRender(t *testing.T) { | |||
| 277 | + | var state atomic.Value | |||
| 278 | + | state.Store(ports.SecurityState{}) | |||
| 279 | + | gate := portsmocks.NewMockSessionSecurity(t) | |||
| 280 | + | gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return state.Load().(ports.SecurityState) }) | |||
| 281 | + | r := portsmocks.NewMockRenderer(t) | |||
| 282 | + | r.EXPECT().Close().Return().Once() | |||
| 283 | + | scenes := make(chan ports.Scene, 1) | |||
| 284 | + | scenes <- ports.Scene{Off: true} | |||
| 285 | + | presented := make(chan ports.OutputPresented, 1) | |||
| 286 | + | changes := make(chan ports.SecurityState, 1) | |||
| 287 | + | proofs := make(chan ports.SecurityBackendEvent, 1) | |||
| 288 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 289 | + | done := make(chan error, 1) | |||
| 290 | + | go func() { | |||
| 291 | + | done <- Run(ctx, Options{Security: gate, SecurityChanges: changes, SecurityEvents: proofs, Instance: 12, Presented: presented, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, nil) | |||
| 292 | + | }() | |||
| 293 | + | securityReceive(t, presented) // Off has actually been applied by the owner. | |||
| 294 | + | locked := ports.SecurityState{Generation: 4, Protected: true} | |||
| 295 | + | state.Store(locked) | |||
| 296 | + | changes <- locked | |||
| 297 | + | proof := securityReceive(t, proofs).(ports.SecurityOutputProof) | |||
| 298 | + | if proof.Proof.Kind != ports.ProtectionInactiveOutput || proof.Proof.Generation != 4 || proof.Proof.Instance != 12 { | |||
| 299 | + | t.Fatal(proof) | |||
| 300 | + | } | |||
| 301 | + | cancel() | |||
| 302 | + | if err := securityReceive(t, done); err != nil { | |||
| 303 | + | t.Fatal(err) | |||
| 304 | + | } | |||
| 305 | + | } | |||
| 306 | + | ||||
| 307 | + | func TestSecurityCompletionLeaseRecycledBeforeProtectionRenderFailure(t *testing.T) { | |||
| 308 | + | var state atomic.Value | |||
| 309 | + | state.Store(ports.SecurityState{}) | |||
| 310 | + | gate := portsmocks.NewMockSessionSecurity(t) | |||
| 311 | + | var submitted atomic.Bool | |||
| 312 | + | ownerWaiting := make(chan struct{}) | |||
| 313 | + | gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { | |||
| 314 | + | v := state.Load().(ports.SecurityState) | |||
| 315 | + | if submitted.CompareAndSwap(true, false) { | |||
| 316 | + | close(ownerWaiting) | |||
| 317 | + | } | |||
| 318 | + | return v | |||
| 319 | + | }) | |||
| 320 | + | r := portsmocks.NewMockRenderer(t) | |||
| 321 | + | frame := portsmocks.NewMockCaptureFrame(t) | |||
| 322 | + | frame.EXPECT().Done().Return(nil) | |||
| 323 | + | frame.EXPECT().Read(mock.Anything, mock.Anything, 8).RunAndReturn(func(image.Rectangle, []byte, int) error { | |||
| 324 | + | <-ownerWaiting // owner has checked the old gate and is selecting | |||
| 325 | + | state.Store(ports.SecurityState{Generation: 1, Protected: true}) | |||
| 326 | + | return nil | |||
| 327 | + | }).Once() | |||
| 328 | + | r.EXPECT().BeginCapture().Run(func() { submitted.Store(true) }).Return(frame, nil).Once() | |||
| 329 | + | var recycled atomic.Bool | |||
| 330 | + | r.EXPECT().EndCapture(frame).Run(func(ports.CaptureFrame) { recycled.Store(true) }).Return().Once() | |||
| 331 | + | failure := errors.New("protection render failed") | |||
| 332 | + | r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) (*os.File, error) { | |||
| 333 | + | if s.Seq == 0 { | |||
| 334 | + | if !recycled.Load() { | |||
| 335 | + | t.Error("pulled completion lease not recycled before transition") | |||
| 336 | + | } | |||
| 337 | + | return nil, failure | |||
| 338 | + | } | |||
| 339 | + | return nil, nil | |||
| 340 | + | }).Twice() | |||
| 341 | + | r.EXPECT().Close().Return().Once() | |||
| 342 | + | incoming := make(chan ports.CaptureRequest) | |||
| 343 | + | scenes := make(chan ports.Scene) | |||
| 344 | + | replies := make(chan ports.CaptureDone, 1) | |||
| 345 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 346 | + | defer cancel() | |||
| 347 | + | done := make(chan error, 1) | |||
| 348 | + | go func() { | |||
| 349 | + | done <- Run(ctx, Options{Security: gate, Width: 2, Height: 2, Captured: replies, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, incoming) | |||
| 350 | + | }() | |||
| 351 | + | file, err := os.CreateTemp(t.TempDir(), "capture") | |||
| 352 | + | if err != nil { | |||
| 353 | + | t.Fatal(err) | |||
| 354 | + | } | |||
| 355 | + | if err := file.Truncate(16); err != nil { | |||
| 356 | + | t.Fatal(err) | |||
| 357 | + | } | |||
| 358 | + | incoming <- ports.CaptureRequest{ID: 77, Region: image.Rect(0, 0, 2, 2), Width: 2, Height: 2, Stride: 8, Format: 1, Dst: ports.SHMBuffer{File: file}} | |||
| 359 | + | scenes <- ports.Scene{Seq: 1, Scale: 1} | |||
| 360 | + | securityReceive(t, replies) | |||
| 361 | + | if err := securityReceive(t, done); !errors.Is(err, failure) { | |||
| 362 | + | t.Fatal(err) | |||
| 363 | + | } | |||
| 364 | + | if !recycled.Load() { | |||
| 365 | + | t.Fatal("capture lease leaked") | |||
| 366 | + | } | |||
| 367 | + | // Read arguments contain an mmap slice that capture has now unmapped; | |||
| 368 | + | // don't let testify format that expired borrowed memory at cleanup. | |||
| 369 | + | frame.Calls = nil | |||
| 370 | + | } | |||
M internal/adapters/headlessinput/input.go +56 -9
| @@ -20,10 +20,53 @@ import ( | |||||
| 20 | // software cursor of the output under them (physical position on it), using | 20 | // software cursor of the output under them (physical position on it), using | ||
| 21 | // the latest layout from layouts. | 21 | // the latest layout from layouts. | ||
| 22 | func Run(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, script <-chan string, input chan<- ports.InputEvent, layouts <-chan ports.Layout, moveCursor func(output string, x, y float64), log zerowrap.Logger) error { | 22 | func Run(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, script <-chan string, input chan<- ports.InputEvent, layouts <-chan ports.Layout, moveCursor func(output string, x, y float64), log zerowrap.Logger) error { | ||
| 23 | + | return RunSecure(ctx, km, keymaps, script, input, layouts, moveCursor, log, nil) | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | // RunSecure snapshots the protection epoch before each event enters the input | |||
| 27 | + | // queue. Scripts are a headless test transport, never an unlock authority. | |||
| 28 | + | func RunSecure(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, script <-chan string, input chan<- ports.InputEvent, layouts <-chan ports.Layout, moveCursor func(output string, x, y float64), log zerowrap.Logger, security ports.SessionSecurity) error { | |||
| 29 | + | snapshot := func() ports.SecurityState { | |||
| 30 | + | if security != nil { | |||
| 31 | + | return security.Snapshot() | |||
| 32 | + | } | |||
| 33 | + | return ports.SecurityState{} | |||
| 34 | + | } | |||
| 35 | + | stamp := func(ev ports.InputEvent, state ports.SecurityState) ports.InputEvent { | |||
| 36 | + | if security == nil { | |||
| 37 | + | return ev | |||
| 38 | + | } | |||
| 39 | + | return ports.SecurityInput{State: state, Event: ev} | |||
| 40 | + | } | |||
| 41 | + | // Script payloads can contain credentials even when parsing fails. Keep a | |||
| 42 | + | // useful generic warning while protected, never input-derived fields. | |||
| 43 | + | warnInput := func(field, value, message string) { | |||
| 44 | + | entry := log.Warn() | |||
| 45 | + | if snapshot().Protected { | |||
| 46 | + | entry.Msg("invalid script input") | |||
| 47 | + | return | |||
| 48 | + | } | |||
| 49 | + | entry.Str(field, value).Msg(message) | |||
| 50 | + | } | |||
| 23 | var layout ports.Layout | 51 | var layout ports.Layout | ||
| 24 | defer func() { km.Close() }() | 52 | defer func() { km.Close() }() | ||
| 25 | emit := func(code uint32, down bool) error { | 53 | emit := func(code uint32, down bool) error { | ||
| 26 | - | ev := km.Key(code, down, msec(monotonic())) | 54 | + | state := snapshot() |
| 55 | + | var key ports.KeyEvent | |||
| 56 | + | if security != nil { | |||
| 57 | + | var deliver bool | |||
| 58 | + | var err error | |||
| 59 | + | key, deliver, err = km.KeySecure(code, down, msec(monotonic()), state) | |||
| 60 | + | if err != nil { | |||
| 61 | + | return err | |||
| 62 | + | } | |||
| 63 | + | if !deliver { | |||
| 64 | + | return nil | |||
| 65 | + | } | |||
| 66 | + | } else { | |||
| 67 | + | key = km.Key(code, down, msec(monotonic())) | |||
| 68 | + | } | |||
| 69 | + | ev := stamp(key, state) | |||
| 27 | select { | 70 | select { | ||
| 28 | case <-ctx.Done(): | 71 | case <-ctx.Done(): | ||
| 29 | return ctx.Err() | 72 | return ctx.Err() | ||
| @@ -41,7 +84,7 @@ func Run(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, script | |||||
| 41 | stroke := func(name string, mods []uint32) error { | 84 | stroke := func(name string, mods []uint32) error { | ||
| 42 | code, shift, ok := km.KeycodeFor(name) | 85 | code, shift, ok := km.KeycodeFor(name) | ||
| 43 | if !ok { | 86 | if !ok { | ||
| 44 | - | log.Warn().Str("key", name).Msg("unknown key") | 87 | + | warnInput("key", name, "unknown key") |
| 45 | return nil | 88 | return nil | ||
| 46 | } | 89 | } | ||
| 47 | if shift { | 90 | if shift { | ||
| @@ -79,6 +122,9 @@ func Run(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, script | |||||
| 79 | case <-ctx.Done(): | 122 | case <-ctx.Done(): | ||
| 80 | return ctx.Err() | 123 | return ctx.Err() | ||
| 81 | case next := <-keymaps: | 124 | case next := <-keymaps: | ||
| 125 | + | if security != nil { | |||
| 126 | + | next.QuarantineFrom(km) | |||
| 127 | + | } | |||
| 82 | km.Close() | 128 | km.Close() | ||
| 83 | km = next | 129 | km = next | ||
| 84 | log.Info().Msg("keymap replaced") | 130 | log.Info().Msg("keymap replaced") | ||
| @@ -89,6 +135,7 @@ func Run(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, script | |||||
| 89 | } | 135 | } | ||
| 90 | fields := strings.Fields(line) | 136 | fields := strings.Fields(line) | ||
| 91 | sendPointer := func(ev ports.InputEvent) error { | 137 | sendPointer := func(ev ports.InputEvent) error { | ||
| 138 | + | ev = stamp(ev, snapshot()) | |||
| 92 | select { | 139 | select { | ||
| 93 | case <-ctx.Done(): | 140 | case <-ctx.Done(): | ||
| 94 | return ctx.Err() | 141 | return ctx.Err() | ||
| @@ -122,7 +169,7 @@ func Run(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, script | |||||
| 122 | continue | 169 | continue | ||
| 123 | } | 170 | } | ||
| 124 | } | 171 | } | ||
| 125 | - | log.Warn().Str("line", line).Msg("invalid move") | 172 | + | warnInput("line", line, "invalid move") |
| 126 | continue | 173 | continue | ||
| 127 | } | 174 | } | ||
| 128 | if len(fields) > 0 && (fields[0] == "swipe" || fields[0] == "swipe4") { | 175 | if len(fields) > 0 && (fields[0] == "swipe" || fields[0] == "swipe4") { | ||
| @@ -132,7 +179,7 @@ func Run(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, script | |||||
| 132 | d, ok = dirs[fields[1]] | 179 | d, ok = dirs[fields[1]] | ||
| 133 | } | 180 | } | ||
| 134 | if !ok { | 181 | if !ok { | ||
| 135 | - | log.Warn().Str("line", line).Msg("invalid swipe") | 182 | + | warnInput("line", line, "invalid swipe") |
| 136 | continue | 183 | continue | ||
| 137 | } | 184 | } | ||
| 138 | fingers := 3 | 185 | fingers := 3 | ||
| @@ -153,7 +200,7 @@ func Run(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, script | |||||
| 153 | } | 200 | } | ||
| 154 | code := buttonCode(name) | 201 | code := buttonCode(name) | ||
| 155 | if len(fields) > 2 || (len(fields) != 2 && fields[0] != "click") || code == 0 { | 202 | if len(fields) > 2 || (len(fields) != 2 && fields[0] != "click") || code == 0 { | ||
| 156 | - | log.Warn().Str("line", line).Msg("invalid button") | 203 | + | warnInput("line", line, "invalid button") |
| 157 | continue | 204 | continue | ||
| 158 | } | 205 | } | ||
| 159 | if fields[0] != "up" { | 206 | if fields[0] != "up" { | ||
| @@ -176,7 +223,7 @@ func Run(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, script | |||||
| 176 | name = string(r) | 223 | name = string(r) | ||
| 177 | } | 224 | } | ||
| 178 | if name == "" { | 225 | if name == "" { | ||
| 179 | - | log.Warn().Str("rune", string(r)).Msg("unknown rune") | 226 | + | warnInput("rune", string(r), "unknown rune") |
| 180 | continue | 227 | continue | ||
| 181 | } | 228 | } | ||
| 182 | if err := stroke(name, nil); err != nil { | 229 | if err := stroke(name, nil); err != nil { | ||
| @@ -202,7 +249,7 @@ func Run(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, script | |||||
| 202 | } | 249 | } | ||
| 203 | } | 250 | } | ||
| 204 | if !valid { | 251 | if !valid { | ||
| 205 | - | log.Warn().Str("combo", line).Msg("unknown modifier") | 252 | + | warnInput("combo", line, "unknown modifier") |
| 206 | continue | 253 | continue | ||
| 207 | } | 254 | } | ||
| 208 | if err := stroke(parts[len(parts)-1], mods); err != nil { | 255 | if err := stroke(parts[len(parts)-1], mods); err != nil { | ||
| @@ -211,7 +258,7 @@ func Run(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, script | |||||
| 211 | case strings.HasPrefix(line, "sleep "): | 258 | case strings.HasPrefix(line, "sleep "): | ||
| 212 | d, err := time.ParseDuration(strings.TrimPrefix(line, "sleep ")) | 259 | d, err := time.ParseDuration(strings.TrimPrefix(line, "sleep ")) | ||
| 213 | if err != nil || d < 0 { | 260 | if err != nil || d < 0 { | ||
| 214 | - | log.Warn().Str("line", line).Msg("invalid sleep") | 261 | + | warnInput("line", line, "invalid sleep") |
| 215 | continue | 262 | continue | ||
| 216 | } | 263 | } | ||
| 217 | timer := time.NewTimer(d) | 264 | timer := time.NewTimer(d) | ||
| @@ -222,7 +269,7 @@ func Run(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, script | |||||
| 222 | case <-timer.C: | 269 | case <-timer.C: | ||
| 223 | } | 270 | } | ||
| 224 | default: | 271 | default: | ||
| 225 | - | log.Warn().Str("line", line).Msg("unknown script line") | 272 | + | warnInput("line", line, "unknown script line") |
| 226 | } | 273 | } | ||
| 227 | } | 274 | } | ||
| 228 | } | 275 | } | ||
A internal/adapters/headlessinput/security_test.go +270 -0
| @@ -0,0 +1,270 @@ | |||||
| 1 | + | package headlessinput | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "context" | |||
| 6 | + | "encoding/json" | |||
| 7 | + | "strings" | |||
| 8 | + | "sync" | |||
| 9 | + | "sync/atomic" | |||
| 10 | + | "testing" | |||
| 11 | + | "time" | |||
| 12 | + | ||||
| 13 | + | "github.com/bnema/neferwl/internal/adapters/xkb" | |||
| 14 | + | "github.com/bnema/neferwl/internal/logging" | |||
| 15 | + | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |||
| 16 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 17 | + | "github.com/bnema/zerowrap" | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | func TestSecureScriptSnapshotsEveryInputKind(t *testing.T) { | |||
| 21 | + | km, err := xkb.New(xkb.RMLVO{Layout: "us"}) | |||
| 22 | + | if err != nil { | |||
| 23 | + | t.Skip(err) | |||
| 24 | + | } | |||
| 25 | + | script := make(chan string, 4) | |||
| 26 | + | for _, line := range []string{"key A", "move 10 20", "click", "swipe left"} { | |||
| 27 | + | script <- line | |||
| 28 | + | } | |||
| 29 | + | close(script) | |||
| 30 | + | input := make(chan ports.InputEvent, 32) | |||
| 31 | + | security := portsmocks.NewMockSessionSecurity(t) | |||
| 32 | + | state := ports.SecurityState{Generation: 7, Protected: true} | |||
| 33 | + | security.EXPECT().Snapshot().Return(state) | |||
| 34 | + | if err := RunSecure(context.Background(), km, nil, script, input, nil, nil, logging.For(context.Background(), "input"), security); err != nil { | |||
| 35 | + | t.Fatal(err) | |||
| 36 | + | } | |||
| 37 | + | kinds := map[string]bool{} | |||
| 38 | + | for len(input) > 0 { | |||
| 39 | + | raw := <-input | |||
| 40 | + | ev, ok := raw.(ports.SecurityInput) | |||
| 41 | + | if !ok || ev.State != state { | |||
| 42 | + | t.Fatalf("unstamped event %T %+v", raw, raw) | |||
| 43 | + | } | |||
| 44 | + | switch ev.Event.(type) { | |||
| 45 | + | case ports.KeyEvent: | |||
| 46 | + | kinds["key"] = true | |||
| 47 | + | case ports.PointerMotion: | |||
| 48 | + | kinds["motion"] = true | |||
| 49 | + | case ports.PointerButton: | |||
| 50 | + | kinds["button"] = true | |||
| 51 | + | case ports.SwipeBegin: | |||
| 52 | + | kinds["begin"] = true | |||
| 53 | + | case ports.SwipeUpdate: | |||
| 54 | + | kinds["update"] = true | |||
| 55 | + | case ports.SwipeEnd: | |||
| 56 | + | kinds["end"] = true | |||
| 57 | + | default: | |||
| 58 | + | t.Fatalf("unexpected input %T", ev.Event) | |||
| 59 | + | } | |||
| 60 | + | } | |||
| 61 | + | if len(kinds) != 6 { | |||
| 62 | + | t.Fatalf("input kinds %v", kinds) | |||
| 63 | + | } | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | // securityLogBuffer is a real synchronized io.Writer, not a logging double. | |||
| 67 | + | type securityLogBuffer struct { | |||
| 68 | + | mu sync.Mutex | |||
| 69 | + | buf bytes.Buffer | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | func (b *securityLogBuffer) Write(p []byte) (int, error) { | |||
| 73 | + | b.mu.Lock() | |||
| 74 | + | defer b.mu.Unlock() | |||
| 75 | + | return b.buf.Write(p) | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | func (b *securityLogBuffer) String() string { | |||
| 79 | + | b.mu.Lock() | |||
| 80 | + | defer b.mu.Unlock() | |||
| 81 | + | return b.buf.String() | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | func TestSecureScriptDiagnosticsRedactProtectedPayloads(t *testing.T) { | |||
| 85 | + | lines := []string{ | |||
| 86 | + | "type 🔐", "key credential-secret", "key credential-modifier+A", | |||
| 87 | + | "move credential-coordinate 2", "swipe credential-direction", | |||
| 88 | + | "click credential-button", "sleep credential-duration", "credential-full-command", | |||
| 89 | + | } | |||
| 90 | + | for _, protected := range []bool{false, true} { | |||
| 91 | + | name := "unlocked" | |||
| 92 | + | if protected { | |||
| 93 | + | name = "protected" | |||
| 94 | + | } | |||
| 95 | + | t.Run(name, func(t *testing.T) { | |||
| 96 | + | km, err := xkb.New(xkb.RMLVO{Layout: "us"}) | |||
| 97 | + | if err != nil { | |||
| 98 | + | t.Skip(err) | |||
| 99 | + | } | |||
| 100 | + | security := portsmocks.NewMockSessionSecurity(t) | |||
| 101 | + | security.EXPECT().Snapshot().Return(ports.SecurityState{Generation: 1, Protected: protected}).Times(len(lines)) | |||
| 102 | + | var buf securityLogBuffer | |||
| 103 | + | log := zerowrap.Logger{Logger: zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: &buf}).With().Str("component", "input").Logger()} | |||
| 104 | + | script := make(chan string, len(lines)) | |||
| 105 | + | for _, line := range lines { | |||
| 106 | + | script <- line | |||
| 107 | + | } | |||
| 108 | + | close(script) | |||
| 109 | + | if err := RunSecure(context.Background(), km, nil, script, make(chan ports.InputEvent, 32), nil, nil, log, security); err != nil { | |||
| 110 | + | t.Fatal(err) | |||
| 111 | + | } | |||
| 112 | + | output := buf.String() | |||
| 113 | + | entries := strings.Split(strings.TrimSpace(output), "\n") | |||
| 114 | + | if len(entries) != len(lines) { | |||
| 115 | + | t.Fatalf("diagnostics missing: %s", output) | |||
| 116 | + | } | |||
| 117 | + | if protected { | |||
| 118 | + | for _, secret := range []string{"🔐", "credential", "type 🔐", "key credential-secret", "credential-full-command"} { | |||
| 119 | + | if strings.Contains(output, secret) { | |||
| 120 | + | t.Fatalf("protected credential %q logged: %s", secret, output) | |||
| 121 | + | } | |||
| 122 | + | } | |||
| 123 | + | for _, entry := range entries { | |||
| 124 | + | var fields map[string]any | |||
| 125 | + | if err := json.Unmarshal([]byte(entry), &fields); err != nil { | |||
| 126 | + | t.Fatal(err) | |||
| 127 | + | } | |||
| 128 | + | if fields["message"] != "invalid script input" { | |||
| 129 | + | t.Fatalf("input-derived diagnostic: %s", entry) | |||
| 130 | + | } | |||
| 131 | + | for _, field := range []string{"rune", "key", "combo", "line"} { | |||
| 132 | + | if _, exists := fields[field]; exists { | |||
| 133 | + | t.Fatalf("protected field %s: %s", field, entry) | |||
| 134 | + | } | |||
| 135 | + | } | |||
| 136 | + | } | |||
| 137 | + | } else { | |||
| 138 | + | for _, value := range []string{"🔐", "credential-secret", "credential-full-command", "unknown rune", "unknown key", "invalid move"} { | |||
| 139 | + | if !strings.Contains(output, value) { | |||
| 140 | + | t.Fatalf("unlocked diagnostic %q changed: %s", value, output) | |||
| 141 | + | } | |||
| 142 | + | } | |||
| 143 | + | } | |||
| 144 | + | }) | |||
| 145 | + | } | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | func TestSecureScriptSnapshotsBeforeKeyTranslation(t *testing.T) { | |||
| 149 | + | km, err := xkb.New(xkb.RMLVO{Layout: "us"}) | |||
| 150 | + | if err != nil { | |||
| 151 | + | t.Skip(err) | |||
| 152 | + | } | |||
| 153 | + | security := portsmocks.NewMockSessionSecurity(t) | |||
| 154 | + | state := ports.SecurityState{Generation: 7, Protected: true} | |||
| 155 | + | security.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { | |||
| 156 | + | // The first emitted event presses Shift. If snapshot happened after | |||
| 157 | + | // km.Key, the live keymap would already contain the Shift modifier. | |||
| 158 | + | if ev := km.Key(30, false, 0); ev.Mods&ports.ModShift != 0 { | |||
| 159 | + | t.Error("snapshot taken after key translation updated Shift") | |||
| 160 | + | } | |||
| 161 | + | return state | |||
| 162 | + | }).Once() | |||
| 163 | + | security.EXPECT().Snapshot().Return(state).Times(3) | |||
| 164 | + | script := make(chan string, 1) | |||
| 165 | + | script <- "key Shift+A" | |||
| 166 | + | close(script) | |||
| 167 | + | input := make(chan ports.InputEvent, 4) | |||
| 168 | + | if err := RunSecure(context.Background(), km, nil, script, input, nil, nil, logging.For(context.Background(), "input"), security); err != nil { | |||
| 169 | + | t.Fatal(err) | |||
| 170 | + | } | |||
| 171 | + | if len(input) != 4 { | |||
| 172 | + | t.Fatalf("events=%d", len(input)) | |||
| 173 | + | } | |||
| 174 | + | first := (<-input).(ports.SecurityInput) | |||
| 175 | + | if first.State != state || first.Event.(ports.KeyEvent).Keycode != 42 { | |||
| 176 | + | t.Fatalf("first event %#v", first) | |||
| 177 | + | } | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | func TestSecureScriptBlockedSendPreservesProductionSnapshot(t *testing.T) { | |||
| 181 | + | for _, line := range []string{"key a", "move 1 2"} { | |||
| 182 | + | t.Run(line, func(t *testing.T) { | |||
| 183 | + | km, err := xkb.New(xkb.RMLVO{Layout: "us"}) | |||
| 184 | + | if err != nil { | |||
| 185 | + | t.Skip(err) | |||
| 186 | + | } | |||
| 187 | + | original := ports.SecurityState{Generation: 1, Protected: true} | |||
| 188 | + | changed := ports.SecurityState{Generation: 2} | |||
| 189 | + | security := portsmocks.NewMockSessionSecurity(t) | |||
| 190 | + | var current atomic.Pointer[ports.SecurityState] | |||
| 191 | + | current.Store(&original) | |||
| 192 | + | produced := make(chan struct{}) | |||
| 193 | + | security.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { | |||
| 194 | + | state := *current.Load() | |||
| 195 | + | close(produced) | |||
| 196 | + | return state | |||
| 197 | + | }).Once() | |||
| 198 | + | if line == "key a" { | |||
| 199 | + | security.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return *current.Load() }).Once() | |||
| 200 | + | } | |||
| 201 | + | script := make(chan string, 1) | |||
| 202 | + | script <- line | |||
| 203 | + | close(script) | |||
| 204 | + | input := make(chan ports.InputEvent) // no receiver until gate changes | |||
| 205 | + | ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) | |||
| 206 | + | defer cancel() | |||
| 207 | + | done := make(chan error, 1) | |||
| 208 | + | go func() { done <- RunSecure(ctx, km, nil, script, input, nil, nil, logging.For(ctx, "input"), security) }() | |||
| 209 | + | select { | |||
| 210 | + | case <-produced: | |||
| 211 | + | case <-ctx.Done(): | |||
| 212 | + | t.Fatal("no production snapshot") | |||
| 213 | + | } | |||
| 214 | + | current.Store(&changed) | |||
| 215 | + | select { | |||
| 216 | + | case raw := <-input: | |||
| 217 | + | if ev, ok := raw.(ports.SecurityInput); !ok || ev.State != original { | |||
| 218 | + | t.Fatalf("blocked event restamped: %#v", raw) | |||
| 219 | + | } | |||
| 220 | + | case <-ctx.Done(): | |||
| 221 | + | t.Fatal("blocked event not delivered") | |||
| 222 | + | } | |||
| 223 | + | // The release of a key held across the transition is quarantined. | |||
| 224 | + | // It must not be delivered into the new epoch. | |||
| 225 | + | ||||
| 226 | + | select { | |||
| 227 | + | case err := <-done: | |||
| 228 | + | if err != nil { | |||
| 229 | + | t.Fatal(err) | |||
| 230 | + | } | |||
| 231 | + | case <-ctx.Done(): | |||
| 232 | + | t.Fatal("script did not stop") | |||
| 233 | + | } | |||
| 234 | + | }) | |||
| 235 | + | } | |||
| 236 | + | } | |||
| 237 | + | ||||
| 238 | + | func TestSecureScriptQuarantinesModifierAcrossTransition(t *testing.T) { | |||
| 239 | + | km, err := xkb.New(xkb.RMLVO{Layout: "us"}) | |||
| 240 | + | if err != nil { | |||
| 241 | + | t.Fatal(err) | |||
| 242 | + | } | |||
| 243 | + | initial := ports.SecurityState{} | |||
| 244 | + | locked := ports.SecurityState{Generation: 1, Protected: true} | |||
| 245 | + | security := portsmocks.NewMockSessionSecurity(t) | |||
| 246 | + | security.EXPECT().Snapshot().Return(initial).Once() // Super down | |||
| 247 | + | security.EXPECT().Snapshot().Return(locked).Times(5) // q down/up, held Super up, fresh q down/up | |||
| 248 | + | script := make(chan string, 2) | |||
| 249 | + | script <- "key Super+q" | |||
| 250 | + | script <- "key q" | |||
| 251 | + | close(script) | |||
| 252 | + | input := make(chan ports.InputEvent, 8) | |||
| 253 | + | if err := RunSecure(context.Background(), km, nil, script, input, nil, nil, logging.For(context.Background(), "input"), security); err != nil { | |||
| 254 | + | t.Fatal(err) | |||
| 255 | + | } | |||
| 256 | + | if len(input) != 5 { | |||
| 257 | + | t.Fatalf("quarantined release replayed: %d events", len(input)) | |||
| 258 | + | } | |||
| 259 | + | first := (<-input).(ports.SecurityInput) | |||
| 260 | + | if first.State != initial || first.Event.(ports.KeyEvent).Keycode != 125 { | |||
| 261 | + | t.Fatalf("first event %#v", first) | |||
| 262 | + | } | |||
| 263 | + | for len(input) > 0 { | |||
| 264 | + | ev := (<-input).(ports.SecurityInput) | |||
| 265 | + | key := ev.Event.(ports.KeyEvent) | |||
| 266 | + | if ev.State != locked || key.Keycode != 16 || key.Mods != 0 || key.State != (ports.ModState{}) { | |||
| 267 | + | t.Fatalf("script password inherited modifiers: %#v", ev) | |||
| 268 | + | } | |||
| 269 | + | } | |||
| 270 | + | } | |||
M internal/adapters/launcher/launcher.go +21 -2
| @@ -16,8 +16,10 @@ import ( | |||||
| 16 | ) | 16 | ) | ||
| 17 | 17 | ||||
| 18 | type Launcher struct { | 18 | type Launcher struct { | ||
| 19 | - | env []string | 19 | + | // Security is the optional defensive admission gate. Set before Run. |
| 20 | - | log zerowrap.Logger | 20 | + | Security ports.SessionSecurity |
| 21 | + | env []string | |||
| 22 | + | log zerowrap.Logger | |||
| 21 | } | 23 | } | ||
| 22 | 24 | ||||
| 23 | func New(env []string, log zerowrap.Logger) *Launcher { | 25 | func New(env []string, log zerowrap.Logger) *Launcher { | ||
| @@ -64,6 +66,9 @@ func (l *Launcher) Run(ctx context.Context, reqs <-chan ports.SpawnRequest) erro | |||||
| 64 | if !ok { | 66 | if !ok { | ||
| 65 | return nil | 67 | return nil | ||
| 66 | } | 68 | } | ||
| 69 | + | if !l.admitted(req) { | |||
| 70 | + | continue | |||
| 71 | + | } | |||
| 67 | if len(req.Argv) == 0 { | 72 | if len(req.Argv) == 0 { | ||
| 68 | l.log.Warn().Msg("empty spawn argv") | 73 | l.log.Warn().Msg("empty spawn argv") | ||
| 69 | continue | 74 | continue | ||
| @@ -79,6 +84,12 @@ func (l *Launcher) Run(ctx context.Context, reqs <-chan ports.SpawnRequest) erro | |||||
| 79 | cmd.Stdout = nil | 84 | cmd.Stdout = nil | ||
| 80 | cmd.Stderr = nil | 85 | cmd.Stderr = nil | ||
| 81 | cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} | 86 | cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} | ||
| 87 | + | // Path lookup and environment construction may race a transition. | |||
| 88 | + | // Recheck at the native process-start boundary using the original | |||
| 89 | + | // owner epoch, never relabel a delayed request. | |||
| 90 | + | if !l.admitted(req) { | |||
| 91 | + | continue | |||
| 92 | + | } | |||
| 82 | if err := cmd.Start(); err != nil { | 93 | if err := cmd.Start(); err != nil { | ||
| 83 | l.log.Warn().Err(err).Str("binary", req.Argv[0]).Msg("spawn failed") | 94 | l.log.Warn().Err(err).Str("binary", req.Argv[0]).Msg("spawn failed") | ||
| 84 | continue | 95 | continue | ||
| @@ -89,6 +100,14 @@ func (l *Launcher) Run(ctx context.Context, reqs <-chan ports.SpawnRequest) erro | |||||
| 89 | } | 100 | } | ||
| 90 | } | 101 | } | ||
| 91 | 102 | ||||
| 103 | + | func (l *Launcher) admitted(req ports.SpawnRequest) bool { | |||
| 104 | + | if l.Security == nil { | |||
| 105 | + | return true | |||
| 106 | + | } | |||
| 107 | + | state := l.Security.Snapshot() | |||
| 108 | + | return !state.Protected && req.Security == state | |||
| 109 | + | } | |||
| 110 | + | ||||
| 92 | func childLookPath(name string, env []string) (string, error) { | 111 | func childLookPath(name string, env []string) (string, error) { | ||
| 93 | if strings.Contains(name, "/") { | 112 | if strings.Contains(name, "/") { | ||
| 94 | return name, nil | 113 | return name, nil | ||
A internal/adapters/launcher/security_test.go +58 -0
| @@ -0,0 +1,58 @@ | |||||
| 1 | + | package launcher | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "os" | |||
| 6 | + | "path/filepath" | |||
| 7 | + | "testing" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/neferwl/internal/adapters/sessionsecurity" | |||
| 10 | + | "github.com/bnema/neferwl/internal/logging" | |||
| 11 | + | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |||
| 12 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | func TestSpawnAdmissionRequiresCurrentUnlockedEpoch(t *testing.T) { | |||
| 16 | + | var gate sessionsecurity.Gate | |||
| 17 | + | l := New(nil, logging.For(context.Background(), "launcher")) | |||
| 18 | + | l.Security = &gate | |||
| 19 | + | if !l.admitted(ports.SpawnRequest{}) { | |||
| 20 | + | t.Fatal("initial unlocked request refused") | |||
| 21 | + | } | |||
| 22 | + | protected, err := gate.Engage() | |||
| 23 | + | if err != nil { | |||
| 24 | + | t.Fatal(err) | |||
| 25 | + | } | |||
| 26 | + | if l.admitted(ports.SpawnRequest{}) || l.admitted(ports.SpawnRequest{Security: protected}) { | |||
| 27 | + | t.Fatal("protected spawn admitted") | |||
| 28 | + | } | |||
| 29 | + | unlocked, err := gate.Release(protected.Generation) | |||
| 30 | + | if err != nil { | |||
| 31 | + | t.Fatal(err) | |||
| 32 | + | } | |||
| 33 | + | if l.admitted(ports.SpawnRequest{}) || l.admitted(ports.SpawnRequest{Security: protected}) { | |||
| 34 | + | t.Fatal("old spawn admitted after release") | |||
| 35 | + | } | |||
| 36 | + | if !l.admitted(ports.SpawnRequest{Security: unlocked}) { | |||
| 37 | + | t.Fatal("current spawn refused") | |||
| 38 | + | } | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func TestSpawnRechecksBeforeProcessStart(t *testing.T) { | |||
| 42 | + | ctx := context.Background() | |||
| 43 | + | security := portsmocks.NewMockSessionSecurity(t) | |||
| 44 | + | security.EXPECT().Snapshot().Return(ports.SecurityState{}).Once() | |||
| 45 | + | security.EXPECT().Snapshot().Return(ports.SecurityState{Generation: 1, Protected: true}).Once() | |||
| 46 | + | l := New([]string{"PATH=/bin"}, logging.For(ctx, "launcher")) | |||
| 47 | + | l.Security = security | |||
| 48 | + | result := filepath.Join(t.TempDir(), "must-not-exist") | |||
| 49 | + | requests := make(chan ports.SpawnRequest, 1) | |||
| 50 | + | requests <- ports.SpawnRequest{Argv: []string{"touch", result}} | |||
| 51 | + | close(requests) | |||
| 52 | + | if err := l.Run(ctx, requests); err != nil { | |||
| 53 | + | t.Fatal(err) | |||
| 54 | + | } | |||
| 55 | + | if _, err := os.Stat(result); !os.IsNotExist(err) { | |||
| 56 | + | t.Fatalf("stale native spawn ran: %v", err) | |||
| 57 | + | } | |||
| 58 | + | } | |||
M internal/adapters/libinput/forward.go +19 -3
| @@ -42,16 +42,20 @@ func newForwarder(log zerowrap.Logger) *forwarder { | |||||
| 42 | // push queues ev without blocking. | 42 | // push queues ev without blocking. | ||
| 43 | func (f *forwarder) push(ev ports.InputEvent) { | 43 | func (f *forwarder) push(ev ports.InputEvent) { | ||
| 44 | f.mu.Lock() | 44 | f.mu.Lock() | ||
| 45 | - | if m, ok := ev.(ports.PointerMotion); ok { | 45 | + | if m, state, wrapped, ok := queuedMotion(ev); ok { |
| 46 | f.stats.Motions++ | 46 | f.stats.Motions++ | ||
| 47 | if n := len(f.queue); n > 0 { | 47 | if n := len(f.queue); n > 0 { | ||
| 48 | - | if prev, tail := f.queue[n-1].(ports.PointerMotion); tail { | 48 | + | if prev, prevState, prevWrapped, tail := queuedMotion(f.queue[n-1]); tail && wrapped == prevWrapped && state == prevState { |
| 49 | // Relative deltas add up: games read them, not the position. | 49 | // Relative deltas add up: games read them, not the position. | ||
| 50 | m.DX += prev.DX | 50 | m.DX += prev.DX | ||
| 51 | m.DY += prev.DY | 51 | m.DY += prev.DY | ||
| 52 | m.UnaccelDX += prev.UnaccelDX | 52 | m.UnaccelDX += prev.UnaccelDX | ||
| 53 | m.UnaccelDY += prev.UnaccelDY | 53 | m.UnaccelDY += prev.UnaccelDY | ||
| 54 | - | f.queue[n-1] = m | 54 | + | if wrapped { |
| 55 | + | f.queue[n-1] = ports.SecurityInput{State: state, Event: m} | |||
| 56 | + | } else { | |||
| 57 | + | f.queue[n-1] = m | |||
| 58 | + | } | |||
| 55 | f.stats.Coalesced++ | 59 | f.stats.Coalesced++ | ||
| 56 | f.mu.Unlock() | 60 | f.mu.Unlock() | ||
| 57 | return | 61 | return | ||
| @@ -70,6 +74,18 @@ func (f *forwarder) push(ev ports.InputEvent) { | |||||
| 70 | } | 74 | } | ||
| 71 | } | 75 | } | ||
| 72 | 76 | ||||
| 77 | + | // queuedMotion exposes only motion, retaining its exact production epoch. | |||
| 78 | + | // Raw and stamped events never coalesce with each other or across transitions. | |||
| 79 | + | func queuedMotion(ev ports.InputEvent) (ports.PointerMotion, ports.SecurityState, bool, bool) { | |||
| 80 | + | var state ports.SecurityState | |||
| 81 | + | wrapped := false | |||
| 82 | + | if secure, ok := ev.(ports.SecurityInput); ok { | |||
| 83 | + | state, ev, wrapped = secure.State, secure.Event, true | |||
| 84 | + | } | |||
| 85 | + | m, ok := ev.(ports.PointerMotion) | |||
| 86 | + | return m, state, wrapped, ok | |||
| 87 | + | } | |||
| 88 | + | ||||
| 73 | func (f *forwarder) pop() (ports.InputEvent, bool) { | 89 | func (f *forwarder) pop() (ports.InputEvent, bool) { | ||
| 74 | f.mu.Lock() | 90 | f.mu.Lock() | ||
| 75 | defer f.mu.Unlock() | 91 | defer f.mu.Unlock() | ||
M internal/adapters/libinput/forward_test.go +36 -0
| @@ -139,3 +139,39 @@ func (l *lockedBuffer) String() string { | |||||
| 139 | defer l.mu.Unlock() | 139 | defer l.mu.Unlock() | ||
| 140 | return l.b.String() | 140 | return l.b.String() | ||
| 141 | } | 141 | } | ||
| 142 | + | ||||
| 143 | + | func TestForwarderSecurityMotionEpochs(t *testing.T) { | |||
| 144 | + | states := []ports.SecurityState{ | |||
| 145 | + | {Generation: 1}, {Generation: 2, Protected: true}, {Generation: 3}, | |||
| 146 | + | {Generation: 3, Protected: true}, // exact state, not just generation | |||
| 147 | + | } | |||
| 148 | + | f := newForwarder(zerowrap.Default()) | |||
| 149 | + | for _, state := range states { | |||
| 150 | + | for i := range 1000 { | |||
| 151 | + | f.push(ports.SecurityInput{State: state, Event: ports.PointerMotion{X: float64(i), DX: 0.5, DY: 2, UnaccelDX: 3, UnaccelDY: 4}}) | |||
| 152 | + | } | |||
| 153 | + | } | |||
| 154 | + | // An unwrapped motion must not absorb or be absorbed by wrapped motion. | |||
| 155 | + | f.push(ports.PointerMotion{X: 5}) | |||
| 156 | + | f.push(ports.SecurityInput{State: states[len(states)-1], Event: ports.PointerMotion{X: 6}}) | |||
| 157 | + | if len(f.queue) != len(states)+2 { | |||
| 158 | + | t.Fatalf("wrong epoch coalescing: %d entries", len(f.queue)) | |||
| 159 | + | } | |||
| 160 | + | for _, state := range states { | |||
| 161 | + | ev, ok := f.pop() | |||
| 162 | + | want := ports.SecurityInput{State: state, Event: ports.PointerMotion{X: 999, DX: 500, DY: 2000, UnaccelDX: 3000, UnaccelDY: 4000}} | |||
| 163 | + | if !ok || ev != want { | |||
| 164 | + | t.Fatalf("lost epoch/deltas: got %#v, want %#v", ev, want) | |||
| 165 | + | } | |||
| 166 | + | } | |||
| 167 | + | if ev, _ := f.pop(); ev != (ports.PointerMotion{X: 5}) { | |||
| 168 | + | t.Fatalf("raw motion changed: %#v", ev) | |||
| 169 | + | } | |||
| 170 | + | if ev, _ := f.pop(); ev != (ports.SecurityInput{State: states[len(states)-1], Event: ports.PointerMotion{X: 6}}) { | |||
| 171 | + | t.Fatalf("wrapped tail changed: %#v", ev) | |||
| 172 | + | } | |||
| 173 | + | s := f.take() | |||
| 174 | + | if s.Motions != 4002 || s.Coalesced != 3996 { | |||
| 175 | + | t.Fatalf("stats %+v", s) | |||
| 176 | + | } | |||
| 177 | + | } | |||
M internal/adapters/libinput/libinput.go +70 -16
| @@ -184,6 +184,10 @@ func goString(p *byte) string { | |||||
| 184 | 184 | ||||
| 185 | // Options configures the input adapter. | 185 | // Options configures the input adapter. | ||
| 186 | type Options struct { | 186 | type Options struct { | ||
| 187 | + | // Security gates credential logging and emergency quit and stamps input | |||
| 188 | + | // before forwarding. Nil preserves raw unlocked behavior; core remains | |||
| 189 | + | // responsible for input admission. | |||
| 190 | + | Security ports.SessionSecurity | |||
| 187 | Seat ports.Seat | 191 | Seat ports.Seat | ||
| 188 | SeatName string | 192 | SeatName string | ||
| 189 | Keymap *xkb.Keymap | 193 | Keymap *xkb.Keymap | ||
| @@ -266,6 +270,9 @@ func Run(ctx context.Context, opts Options, input chan<- ports.InputEvent) error | |||||
| 266 | } | 270 | } | ||
| 267 | opts.Log.Info().Bool("active", on).Msg("input") | 271 | opts.Log.Info().Bool("active", on).Msg("input") | ||
| 268 | case km := <-opts.Keymaps: | 272 | case km := <-opts.Keymaps: | ||
| 273 | + | if opts.Security != nil { | |||
| 274 | + | km.QuarantineFrom(opts.Keymap) | |||
| 275 | + | } | |||
| 269 | opts.Keymap.Close() | 276 | opts.Keymap.Close() | ||
| 270 | opts.Keymap = km | 277 | opts.Keymap = km | ||
| 271 | opts.Log.Info().Msg("keymap replaced") | 278 | opts.Log.Info().Msg("keymap replaced") | ||
| @@ -418,7 +425,28 @@ func msec(v uint64) uint32 { return uint32(v / 1000) } | |||||
| 418 | // translate converts one libinput event. Timestamps are the device's | 425 | // translate converts one libinput event. Timestamps are the device's | ||
| 419 | // (CLOCK_MONOTONIC), so clients measure speeds (kinetic scrolling) on | 426 | // (CLOCK_MONOTONIC), so clients measure speeds (kinetic scrolling) on | ||
| 420 | // when events happened, not when they were read. | 427 | // when events happened, not when they were read. | ||
| 428 | + | // It snapshots admission before translating and queueing any input kind. | |||
| 421 | func translate(ev uintptr, opts Options, p *pointer, in *inputState) (ports.InputEvent, error) { | 429 | func translate(ev uintptr, opts Options, p *pointer, in *inputState) (ports.InputEvent, error) { | ||
| 430 | + | state := securitySnapshot(opts.Security) | |||
| 431 | + | out, err := translateEvent(ev, opts, p, in, state) | |||
| 432 | + | return secureInput(out, opts.Security, state), err | |||
| 433 | + | } | |||
| 434 | + | ||||
| 435 | + | func securitySnapshot(security ports.SessionSecurity) ports.SecurityState { | |||
| 436 | + | if security != nil { | |||
| 437 | + | return security.Snapshot() | |||
| 438 | + | } | |||
| 439 | + | return ports.SecurityState{} | |||
| 440 | + | } | |||
| 441 | + | ||||
| 442 | + | func secureInput(ev ports.InputEvent, security ports.SessionSecurity, state ports.SecurityState) ports.InputEvent { | |||
| 443 | + | if ev == nil || security == nil { | |||
| 444 | + | return ev | |||
| 445 | + | } | |||
| 446 | + | return ports.SecurityInput{State: state, Event: ev} | |||
| 447 | + | } | |||
| 448 | + | ||||
| 449 | + | func translateEvent(ev uintptr, opts Options, p *pointer, in *inputState, state ports.SecurityState) (ports.InputEvent, error) { | |||
| 422 | log := opts.Log | 450 | log := opts.Log | ||
| 423 | switch eventType(ev) { | 451 | switch eventType(ev) { | ||
| 424 | case evDeviceAdded: | 452 | case evDeviceAdded: | ||
| @@ -449,19 +477,7 @@ func translate(ev uintptr, opts Options, p *pointer, in *inputState) (ports.Inpu | |||||
| 449 | case evKeyboardKey: | 477 | case evKeyboardKey: | ||
| 450 | k := keyboardEvent(ev) | 478 | k := keyboardEvent(ev) | ||
| 451 | code, pressed := keyboardKey(k), keyboardState(k) == 1 | 479 | code, pressed := keyboardKey(k), keyboardState(k) == 1 | ||
| 452 | - | ke := opts.Keymap.Key(code, pressed, msec(keyboardUsec(k))) | 480 | + | return translateKeyboard(code, pressed, msec(keyboardUsec(k)), opts, state) |
| 453 | - | if opts.LogKeys { | |||
| 454 | - | log.Debug().Uint32("code", code).Str("keysym", ke.Keysym).Bool("pressed", pressed).Uint8("mods", uint8(ke.Mods)).Msg("key") | |||
| 455 | - | } | |||
| 456 | - | switch action, vt := hotkey(ke); action { | |||
| 457 | - | case hotkeyQuit: | |||
| 458 | - | log.Warn().Str("reason", "emergency-key").Msg("quit") | |||
| 459 | - | return nil, ErrEmergencyQuit | |||
| 460 | - | case hotkeyVT: | |||
| 461 | - | opts.Seat.SwitchVT(vt) | |||
| 462 | - | return nil, nil | |||
| 463 | - | } | |||
| 464 | - | return ke, nil | |||
| 465 | case evPointerMotion: | 481 | case evPointerMotion: | ||
| 466 | pe := pointerEvent(ev) | 482 | pe := pointerEvent(ev) | ||
| 467 | m := p.move(pointerDX(pe), pointerDY(pe)) | 483 | m := p.move(pointerDX(pe), pointerDY(pe)) | ||
| @@ -516,6 +532,41 @@ func translate(ev uintptr, opts Options, p *pointer, in *inputState) (ports.Inpu | |||||
| 516 | return nil, nil | 532 | return nil, nil | ||
| 517 | } | 533 | } | ||
| 518 | 534 | ||||
| 535 | + | // translateKeyboard applies the production epoch before native translation. | |||
| 536 | + | // Quarantined events never reach logging, reserved hotkeys or core. | |||
| 537 | + | func translateKeyboard(code uint32, pressed bool, timeMsec uint32, opts Options, state ports.SecurityState) (ports.InputEvent, error) { | |||
| 538 | + | var ke ports.KeyEvent | |||
| 539 | + | if opts.Security != nil { | |||
| 540 | + | var deliver bool | |||
| 541 | + | var err error | |||
| 542 | + | ke, deliver, err = opts.Keymap.KeySecure(code, pressed, timeMsec, state) | |||
| 543 | + | if err != nil || !deliver { | |||
| 544 | + | return nil, err | |||
| 545 | + | } | |||
| 546 | + | } else { | |||
| 547 | + | ke = opts.Keymap.Key(code, pressed, timeMsec) | |||
| 548 | + | } | |||
| 549 | + | return translateKey(ke, opts, state) | |||
| 550 | + | } | |||
| 551 | + | ||||
| 552 | + | // translateKey applies the keyboard policy without native calls. One coherent | |||
| 553 | + | // snapshot governs both logging and reserved hotkeys; protected keys still go | |||
| 554 | + | // to core for the locker. VT switching is allowed and does not release the lock. | |||
| 555 | + | func translateKey(ke ports.KeyEvent, opts Options, security ports.SecurityState) (ports.InputEvent, error) { | |||
| 556 | + | if opts.LogKeys && !security.Protected { | |||
| 557 | + | opts.Log.Debug().Str("component", "input").Uint32("code", ke.Keycode).Str("keysym", ke.Keysym).Bool("pressed", ke.Pressed).Uint8("mods", uint8(ke.Mods)).Msg("key") | |||
| 558 | + | } | |||
| 559 | + | switch action, vt := hotkey(ke, security.Protected); action { | |||
| 560 | + | case hotkeyQuit: | |||
| 561 | + | opts.Log.Warn().Str("component", "input").Str("reason", "emergency-key").Msg("quit") | |||
| 562 | + | return nil, ErrEmergencyQuit | |||
| 563 | + | case hotkeyVT: | |||
| 564 | + | opts.Seat.SwitchVT(vt) | |||
| 565 | + | return nil, nil | |||
| 566 | + | } | |||
| 567 | + | return ke, nil | |||
| 568 | + | } | |||
| 569 | + | ||||
| 519 | type hotkeyAction int | 570 | type hotkeyAction int | ||
| 520 | 571 | ||||
| 521 | const ( | 572 | const ( | ||
| @@ -524,8 +575,9 @@ const ( | |||||
| 524 | hotkeyVT | 575 | hotkeyVT | ||
| 525 | ) | 576 | ) | ||
| 526 | 577 | ||||
| 527 | - | // hotkey detects compositor-reserved keys on press: Ctrl+Alt+Backspace and Ctrl+Alt+F1..F12. | 578 | + | // hotkey detects compositor-reserved keys on press: Ctrl+Alt+Backspace and |
| 528 | - | func hotkey(ke ports.KeyEvent) (hotkeyAction, int) { | 579 | + | // Ctrl+Alt+F1..F12. Protection disables emergency quit, but not VT switching. |
| 580 | + | func hotkey(ke ports.KeyEvent, protected bool) (hotkeyAction, int) { | |||
| 529 | if !ke.Pressed { | 581 | if !ke.Pressed { | ||
| 530 | return hotkeyNone, 0 | 582 | return hotkeyNone, 0 | ||
| 531 | } | 583 | } | ||
| @@ -540,7 +592,9 @@ func hotkey(ke ports.KeyEvent) (hotkeyAction, int) { | |||||
| 540 | } | 592 | } | ||
| 541 | switch { | 593 | switch { | ||
| 542 | case ke.Keysym == "BackSpace" || ke.Keycode == 14: | 594 | case ke.Keysym == "BackSpace" || ke.Keycode == 14: | ||
| 543 | - | return hotkeyQuit, 0 | 595 | + | if !protected { |
| 596 | + | return hotkeyQuit, 0 | |||
| 597 | + | } | |||
| 544 | case ke.Keycode >= 59 && ke.Keycode <= 68: // F1..F10 | 598 | case ke.Keycode >= 59 && ke.Keycode <= 68: // F1..F10 | ||
| 545 | return hotkeyVT, int(ke.Keycode) - 58 | 599 | return hotkeyVT, int(ke.Keycode) - 58 | ||
| 546 | case ke.Keycode == 87 || ke.Keycode == 88: // F11, F12 | 600 | case ke.Keycode == 87 || ke.Keycode == 88: // F11, F12 | ||
M internal/adapters/libinput/libinput_test.go +165 -3
| @@ -1,10 +1,16 @@ | |||||
| 1 | package libinput | 1 | package libinput | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "bytes" | |||
| 5 | + | "errors" | |||
| 6 | + | "strings" | |||
| 4 | "testing" | 7 | "testing" | ||
| 5 | "time" | 8 | "time" | ||
| 6 | 9 | ||||
| 10 | + | "github.com/bnema/neferwl/internal/adapters/xkb" | |||
| 11 | + | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |||
| 7 | "github.com/bnema/neferwl/internal/ports" | 12 | "github.com/bnema/neferwl/internal/ports" | ||
| 13 | + | "github.com/bnema/zerowrap" | |||
| 8 | ) | 14 | ) | ||
| 9 | 15 | ||||
| 10 | func TestHotkey(t *testing.T) { | 16 | func TestHotkey(t *testing.T) { | ||
| @@ -23,9 +29,15 @@ func TestHotkey(t *testing.T) { | |||||
| 23 | {"ctrl alt f12 raw", ports.KeyEvent{Keysym: "F12", Keycode: 88, Mods: ca, Pressed: true}, hotkeyVT, 12}, | 29 | {"ctrl alt f12 raw", ports.KeyEvent{Keysym: "F12", Keycode: 88, Mods: ca, Pressed: true}, hotkeyVT, 12}, | ||
| 24 | {"plain f2", ports.KeyEvent{Keysym: "F2", Keycode: 60, Pressed: true}, hotkeyNone, 0}, | 30 | {"plain f2", ports.KeyEvent{Keysym: "F2", Keycode: 60, Pressed: true}, hotkeyNone, 0}, | ||
| 25 | } { | 31 | } { | ||
| 26 | - | a, vt := hotkey(tc.ev) | 32 | + | for _, protected := range []bool{false, true} { |
| 27 | - | if a != tc.action || vt != tc.vt { | 33 | + | want := tc.action |
| 28 | - | t.Errorf("%s: got %v %d", tc.name, a, vt) | 34 | + | if protected && want == hotkeyQuit { |
| 35 | + | want = hotkeyNone | |||
| 36 | + | } | |||
| 37 | + | a, vt := hotkey(tc.ev, protected) | |||
| 38 | + | if a != want || vt != tc.vt { | |||
| 39 | + | t.Errorf("%s protected=%v: got %v %d", tc.name, protected, a, vt) | |||
| 40 | + | } | |||
| 29 | } | 41 | } | ||
| 30 | } | 42 | } | ||
| 31 | } | 43 | } | ||
| @@ -175,3 +187,153 @@ func TestPointerConstraints(t *testing.T) { | |||||
| 175 | t.Fatal(p.x, p.y) | 187 | t.Fatal(p.x, p.y) | ||
| 176 | } | 188 | } | ||
| 177 | } | 189 | } | ||
| 190 | + | ||||
| 191 | + | func TestProtectedKeyboardPolicy(t *testing.T) { | |||
| 192 | + | ca := ports.ModCtrl | ports.ModAlt | |||
| 193 | + | for _, key := range []ports.KeyEvent{ | |||
| 194 | + | {Keysym: "credential-secret", Base: "raw-secret", Keycode: 424242, Pressed: true}, | |||
| 195 | + | {Keysym: "credential-secret", Keycode: 424242}, | |||
| 196 | + | {Keysym: "BackSpace", Mods: ca, Pressed: true}, | |||
| 197 | + | {Keysym: "raw-secret", Keycode: 14, Mods: ca, Pressed: true}, | |||
| 198 | + | } { | |||
| 199 | + | t.Run(key.Keysym, func(t *testing.T) { | |||
| 200 | + | var buf bytes.Buffer | |||
| 201 | + | security := portsmocks.NewMockSessionSecurity(t) | |||
| 202 | + | state := ports.SecurityState{Generation: 7, Protected: true} | |||
| 203 | + | security.EXPECT().Snapshot().Return(state).Once() | |||
| 204 | + | opts := Options{Security: security, LogKeys: true, Log: zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: &buf})} | |||
| 205 | + | produced := securitySnapshot(opts.Security) | |||
| 206 | + | ev, err := translateKey(key, opts, produced) | |||
| 207 | + | if err != nil || errors.Is(err, ErrEmergencyQuit) || ev != key { | |||
| 208 | + | t.Fatalf("protected key lost/quit: event=%#v err=%v", ev, err) | |||
| 209 | + | } | |||
| 210 | + | if got := secureInput(ev, opts.Security, produced); got != (ports.SecurityInput{State: state, Event: key}) { | |||
| 211 | + | t.Fatalf("wrong production epoch: %#v", got) | |||
| 212 | + | } | |||
| 213 | + | if buf.Len() != 0 { | |||
| 214 | + | t.Fatalf("protected key logged identifiers: %s", &buf) | |||
| 215 | + | } | |||
| 216 | + | }) | |||
| 217 | + | } | |||
| 218 | + | } | |||
| 219 | + | ||||
| 220 | + | func TestKeyboardPolicyUnlockedAndVT(t *testing.T) { | |||
| 221 | + | ca := ports.ModCtrl | ports.ModAlt | |||
| 222 | + | for _, protected := range []bool{false, true} { | |||
| 223 | + | t.Run(map[bool]string{false: "unlocked", true: "protected"}[protected], func(t *testing.T) { | |||
| 224 | + | var buf bytes.Buffer | |||
| 225 | + | security := portsmocks.NewMockSessionSecurity(t) | |||
| 226 | + | state := ports.SecurityState{Generation: 9, Protected: protected} | |||
| 227 | + | security.EXPECT().Snapshot().Return(state).Once() | |||
| 228 | + | seat := portsmocks.NewMockSeat(t) | |||
| 229 | + | seat.EXPECT().SwitchVT(2).Once() | |||
| 230 | + | opts := Options{Security: security, Seat: seat, LogKeys: true, Log: zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: &buf})} | |||
| 231 | + | ev, err := translateKey(ports.KeyEvent{Keysym: "XF86Switch_VT_2", Keycode: 60, Mods: ca, Pressed: true}, opts, securitySnapshot(opts.Security)) | |||
| 232 | + | if ev != nil || err != nil { | |||
| 233 | + | t.Fatalf("VT not consumed: %#v %v", ev, err) | |||
| 234 | + | } | |||
| 235 | + | if protected && buf.Len() != 0 { | |||
| 236 | + | t.Fatalf("protected VT key logged: %s", &buf) | |||
| 237 | + | } | |||
| 238 | + | if !protected && !strings.Contains(buf.String(), "XF86Switch_VT_2") { | |||
| 239 | + | t.Fatalf("unlocked debug logging changed: %s", &buf) | |||
| 240 | + | } | |||
| 241 | + | }) | |||
| 242 | + | } | |||
| 243 | + | var buf bytes.Buffer | |||
| 244 | + | opts := Options{LogKeys: true, Log: zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: &buf})} | |||
| 245 | + | key := ports.KeyEvent{Keysym: "q", Keycode: 16, Pressed: true} | |||
| 246 | + | state := securitySnapshot(nil) | |||
| 247 | + | ev, err := translateKey(key, opts, state) | |||
| 248 | + | if err != nil || secureInput(ev, nil, state) != key || !strings.Contains(buf.String(), `"keysym":"q"`) { | |||
| 249 | + | t.Fatalf("nil gate changed unlocked behavior: %#v %v %s", ev, err, &buf) | |||
| 250 | + | } | |||
| 251 | + | ev, err = translateKey(ports.KeyEvent{Keysym: "BackSpace", Mods: ca, Pressed: true}, opts, state) | |||
| 252 | + | if ev != nil || !errors.Is(err, ErrEmergencyQuit) || !strings.Contains(buf.String(), "emergency-key") { | |||
| 253 | + | t.Fatalf("unlocked emergency quit changed: %#v %v %s", ev, err, &buf) | |||
| 254 | + | } | |||
| 255 | + | } | |||
| 256 | + | ||||
| 257 | + | func TestSecurityInputAllKindsProductionEpoch(t *testing.T) { | |||
| 258 | + | security := portsmocks.NewMockSessionSecurity(t) | |||
| 259 | + | old := ports.SecurityState{Generation: 11, Protected: true} | |||
| 260 | + | newState := ports.SecurityState{Generation: 12} | |||
| 261 | + | events := []ports.InputEvent{ | |||
| 262 | + | ports.KeyEvent{Keysym: "secret", Pressed: true}, ports.PointerMotion{X: 1}, | |||
| 263 | + | ports.PointerButton{Button: 272}, ports.PointerAxis{}, | |||
| 264 | + | ports.SwipeBegin{}, ports.SwipeUpdate{}, ports.SwipeEnd{}, | |||
| 265 | + | } | |||
| 266 | + | security.EXPECT().Snapshot().Return(old).Times(len(events)) | |||
| 267 | + | security.EXPECT().Snapshot().Return(newState).Once() | |||
| 268 | + | f := newForwarder(zerowrap.Default()) | |||
| 269 | + | for _, ev := range events { | |||
| 270 | + | f.push(secureInput(ev, security, securitySnapshot(security))) | |||
| 271 | + | } | |||
| 272 | + | if securitySnapshot(security) != newState { | |||
| 273 | + | t.Fatal("did not transition") | |||
| 274 | + | } | |||
| 275 | + | for _, want := range events { | |||
| 276 | + | got, ok := f.pop() | |||
| 277 | + | if !ok || got != (ports.SecurityInput{State: old, Event: want}) { | |||
| 278 | + | t.Fatalf("queued event changed epoch: %#v", got) | |||
| 279 | + | } | |||
| 280 | + | } | |||
| 281 | + | if secureInput(nil, security, old) != nil { | |||
| 282 | + | t.Fatal("nil translated event was wrapped") | |||
| 283 | + | } | |||
| 284 | + | } | |||
| 285 | + | ||||
| 286 | + | func TestKeyboardProducerQuarantinesBeforeHotkeysAndLogging(t *testing.T) { | |||
| 287 | + | km, err := xkb.New(xkb.RMLVO{Layout: "us"}) | |||
| 288 | + | if err != nil { | |||
| 289 | + | t.Fatal(err) | |||
| 290 | + | } | |||
| 291 | + | defer km.Close() | |||
| 292 | + | initial := ports.SecurityState{} | |||
| 293 | + | locked := ports.SecurityState{Generation: 1, Protected: true} | |||
| 294 | + | unlocked := ports.SecurityState{Generation: 2} | |||
| 295 | + | security := portsmocks.NewMockSessionSecurity(t) | |||
| 296 | + | security.EXPECT().Snapshot().Return(initial).Times(3) | |||
| 297 | + | security.EXPECT().Snapshot().Return(locked).Times(4) | |||
| 298 | + | security.EXPECT().Snapshot().Return(unlocked).Times(5) | |||
| 299 | + | var buf bytes.Buffer | |||
| 300 | + | opts := Options{Keymap: km, Security: security, LogKeys: true, Log: zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: &buf})} | |||
| 301 | + | produce := func(code uint32, down bool) ports.InputEvent { | |||
| 302 | + | t.Helper() | |||
| 303 | + | state := securitySnapshot(opts.Security) | |||
| 304 | + | ev, err := translateKeyboard(code, down, 123, opts, state) | |||
| 305 | + | if err != nil { | |||
| 306 | + | t.Fatal(err) | |||
| 307 | + | } | |||
| 308 | + | return secureInput(ev, opts.Security, state) | |||
| 309 | + | } | |||
| 310 | + | for _, code := range []uint32{125, 29, 56} { | |||
| 311 | + | produce(code, true) | |||
| 312 | + | } | |||
| 313 | + | buf.Reset() | |||
| 314 | + | for _, code := range []uint32{125, 29, 56} { | |||
| 315 | + | if ev := produce(code, true); ev != nil { | |||
| 316 | + | t.Fatalf("inherited repeat delivered: %#v", ev) | |||
| 317 | + | } | |||
| 318 | + | } | |||
| 319 | + | // Ctrl+Alt held before lock must not activate emergency quit, even on a | |||
| 320 | + | // fresh Backspace. Super must not contaminate the credential either. | |||
| 321 | + | if ev := produce(14, true).(ports.SecurityInput).Event.(ports.KeyEvent); ev.Mods != 0 || ev.State != (ports.ModState{}) { | |||
| 322 | + | t.Fatalf("protected fresh key inherited native state: %+v", ev) | |||
| 323 | + | } | |||
| 324 | + | if buf.Len() != 0 { | |||
| 325 | + | t.Fatalf("protected keyboard logged: %s", &buf) | |||
| 326 | + | } | |||
| 327 | + | if ev := produce(16, true).(ports.SecurityInput); ev.State != unlocked || ev.Event.(ports.KeyEvent).Mods != 0 || ev.Event.(ports.KeyEvent).State != (ports.ModState{}) { | |||
| 328 | + | t.Fatalf("unlocked q inherited state: %#v", ev) | |||
| 329 | + | } | |||
| 330 | + | buf.Reset() | |||
| 331 | + | for _, code := range []uint32{14, 56, 29, 125} { | |||
| 332 | + | if ev := produce(code, false); ev != nil { | |||
| 333 | + | t.Fatalf("inherited release delivered: %#v", ev) | |||
| 334 | + | } | |||
| 335 | + | } | |||
| 336 | + | if buf.Len() != 0 { | |||
| 337 | + | t.Fatalf("quarantined release logged: %s", &buf) | |||
| 338 | + | } | |||
| 339 | + | } | |||
A internal/adapters/sessionsecurity/security.go +62 -0
| @@ -0,0 +1,62 @@ | |||||
| 1 | + | // Package sessionsecurity supplies a coherent defensive session gate to | |||
| 2 | + | // boundaries outside the Wayland display goroutine. It owns no window state. | |||
| 3 | + | package sessionsecurity | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "errors" | |||
| 7 | + | "math" | |||
| 8 | + | "sync/atomic" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | var ( | |||
| 14 | + | ErrGenerationExhausted = errors.New("session security generation exhausted") | |||
| 15 | + | ErrStaleRelease = errors.New("session security release does not own protection") | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | // Gate's zero value is an unlocked session. The packed atomic prevents readers | |||
| 19 | + | // from observing a generation from one transition and protection from another. | |||
| 20 | + | // Only the display owner calls Engage and Release. | |||
| 21 | + | type Gate struct{ state atomic.Uint64 } | |||
| 22 | + | ||||
| 23 | + | func unpack(state uint64) ports.SecurityState { | |||
| 24 | + | return ports.SecurityState{Generation: ports.LockGeneration(state >> 1), Protected: state&1 != 0} | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | func (g *Gate) Snapshot() ports.SecurityState { return unpack(g.state.Load()) } | |||
| 28 | + | ||||
| 29 | + | // Engage creates a new protected generation, including orphan-owner takeover. | |||
| 30 | + | func (g *Gate) Engage() (ports.SecurityState, error) { | |||
| 31 | + | for { | |||
| 32 | + | old := g.state.Load() | |||
| 33 | + | if old>>1 == math.MaxUint64>>1 { | |||
| 34 | + | return unpack(old), ErrGenerationExhausted | |||
| 35 | + | } | |||
| 36 | + | next := ((old>>1)+1)<<1 | 1 | |||
| 37 | + | if g.state.CompareAndSwap(old, next) { | |||
| 38 | + | return unpack(next), nil | |||
| 39 | + | } | |||
| 40 | + | } | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | func (g *Gate) Release(expected ports.LockGeneration) (ports.SecurityState, error) { | |||
| 44 | + | for { | |||
| 45 | + | old := g.state.Load() | |||
| 46 | + | state := unpack(old) | |||
| 47 | + | if !state.Protected || state.Generation != expected { | |||
| 48 | + | return state, ErrStaleRelease | |||
| 49 | + | } | |||
| 50 | + | // Reserve the final generation for protection. Publishing it unlocked | |||
| 51 | + | // would make every later Engage fail without being able to protect. | |||
| 52 | + | if old>>1 >= (math.MaxUint64>>1)-1 { | |||
| 53 | + | return state, ErrGenerationExhausted | |||
| 54 | + | } | |||
| 55 | + | next := ((old >> 1) + 1) << 1 | |||
| 56 | + | if g.state.CompareAndSwap(old, next) { | |||
| 57 | + | return unpack(next), nil | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | var _ ports.SessionSecurityController = (*Gate)(nil) | |||
A internal/adapters/sessionsecurity/security_test.go +107 -0
| @@ -0,0 +1,107 @@ | |||||
| 1 | + | package sessionsecurity | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "math" | |||
| 6 | + | "sync" | |||
| 7 | + | "testing" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func TestGateTransitionOwnership(t *testing.T) { | |||
| 13 | + | var g Gate | |||
| 14 | + | if got := g.Snapshot(); got != (ports.SecurityState{}) { | |||
| 15 | + | t.Fatalf("zero state %v", got) | |||
| 16 | + | } | |||
| 17 | + | first, err := g.Engage() | |||
| 18 | + | if err != nil || !first.Protected || first.Generation != 1 { | |||
| 19 | + | t.Fatalf("engage %v %v", first, err) | |||
| 20 | + | } | |||
| 21 | + | second, err := g.Engage() | |||
| 22 | + | if err != nil || !second.Protected || second.Generation != 2 { | |||
| 23 | + | t.Fatalf("takeover %v %v", second, err) | |||
| 24 | + | } | |||
| 25 | + | if _, err := g.Release(first.Generation); !errors.Is(err, ErrStaleRelease) { | |||
| 26 | + | t.Fatalf("old owner released: %v", err) | |||
| 27 | + | } | |||
| 28 | + | if got := g.Snapshot(); got != second { | |||
| 29 | + | t.Fatalf("stale release changed gate: %v", got) | |||
| 30 | + | } | |||
| 31 | + | released, err := g.Release(second.Generation) | |||
| 32 | + | if err != nil || released.Protected || released.Generation != 3 { | |||
| 33 | + | t.Fatalf("release %v %v", released, err) | |||
| 34 | + | } | |||
| 35 | + | if _, err := g.Release(second.Generation); !errors.Is(err, ErrStaleRelease) { | |||
| 36 | + | t.Fatalf("duplicate release: %v", err) | |||
| 37 | + | } | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | func TestGateExhaustionStaysProtected(t *testing.T) { | |||
| 41 | + | var g Gate | |||
| 42 | + | g.state.Store(math.MaxUint64) | |||
| 43 | + | before := g.Snapshot() | |||
| 44 | + | if _, err := g.Engage(); !errors.Is(err, ErrGenerationExhausted) { | |||
| 45 | + | t.Fatalf("engage: %v", err) | |||
| 46 | + | } | |||
| 47 | + | if _, err := g.Release(before.Generation); !errors.Is(err, ErrGenerationExhausted) { | |||
| 48 | + | t.Fatalf("release: %v", err) | |||
| 49 | + | } | |||
| 50 | + | if g.Snapshot() != before { | |||
| 51 | + | t.Fatal("exhaustion changed protection") | |||
| 52 | + | } | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | func TestGatePenultimateReleaseStaysProtected(t *testing.T) { | |||
| 56 | + | var g Gate | |||
| 57 | + | g.state.Store(math.MaxUint64 - 2) | |||
| 58 | + | before := g.Snapshot() | |||
| 59 | + | if _, err := g.Release(before.Generation); !errors.Is(err, ErrGenerationExhausted) { | |||
| 60 | + | t.Fatalf("penultimate release: %v", err) | |||
| 61 | + | } | |||
| 62 | + | if g.Snapshot() != before || !g.Snapshot().Protected { | |||
| 63 | + | t.Fatal("release exhausted generation while unprotected") | |||
| 64 | + | } | |||
| 65 | + | next, err := g.Engage() | |||
| 66 | + | if err != nil || !next.Protected || next.Generation != before.Generation+1 { | |||
| 67 | + | t.Fatalf("final engagement: %v %v", next, err) | |||
| 68 | + | } | |||
| 69 | + | if _, err := g.Release(next.Generation); !errors.Is(err, ErrGenerationExhausted) { | |||
| 70 | + | t.Fatalf("final release: %v", err) | |||
| 71 | + | } | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | func TestGateSnapshotCoherence(t *testing.T) { | |||
| 75 | + | var g Gate | |||
| 76 | + | var readers sync.WaitGroup | |||
| 77 | + | stop := make(chan struct{}) | |||
| 78 | + | for range 4 { | |||
| 79 | + | readers.Go(func() { | |||
| 80 | + | for { | |||
| 81 | + | select { | |||
| 82 | + | case <-stop: | |||
| 83 | + | return | |||
| 84 | + | default: | |||
| 85 | + | } | |||
| 86 | + | s := g.Snapshot() | |||
| 87 | + | // This test alternates engage/release without takeovers: | |||
| 88 | + | // odd generations protected, even generations unlocked. | |||
| 89 | + | if s.Protected != (s.Generation&1 != 0) { | |||
| 90 | + | t.Errorf("torn state %v", s) | |||
| 91 | + | return | |||
| 92 | + | } | |||
| 93 | + | } | |||
| 94 | + | }) | |||
| 95 | + | } | |||
| 96 | + | for range 1000 { | |||
| 97 | + | s, err := g.Engage() | |||
| 98 | + | if err != nil { | |||
| 99 | + | t.Fatal(err) | |||
| 100 | + | } | |||
| 101 | + | if _, err := g.Release(s.Generation); err != nil { | |||
| 102 | + | t.Fatal(err) | |||
| 103 | + | } | |||
| 104 | + | } | |||
| 105 | + | close(stop) | |||
| 106 | + | readers.Wait() | |||
| 107 | + | } | |||
M internal/adapters/vulkan/capture.go +6 -0
| @@ -377,6 +377,12 @@ func (r *Renderer) freeCapture() (*captureSlot, error) { | |||||
| 377 | return nil, nil | 377 | return nil, nil | ||
| 378 | } | 378 | } | ||
| 379 | 379 | ||||
| 380 | + | // CaptureSupported reports whether BeginCapture can work on this device: it | |||
| 381 | + | // needs sync-file export, which software devices such as lavapipe lack. | |||
| 382 | + | func (r *Renderer) CaptureSupported() bool { | |||
| 383 | + | return r.dd != nil && r.syncFD && r.dd.HasGetSemaphoreFdKHR() | |||
| 384 | + | } | |||
| 385 | + | ||||
| 380 | // BeginCapture submits a GPU copy of the last frame and leases its slot. | 386 | // BeginCapture submits a GPU copy of the last frame and leases its slot. | ||
| 381 | // The lease is valid until EndCapture and no longer: the same object | 387 | // The lease is valid until EndCapture and no longer: the same object | ||
| 382 | // serves the slot's next lease. | 388 | // serves the slot's next lease. | ||
A internal/adapters/wayland/acquire_failure_test.go +255 -0
| @@ -0,0 +1,255 @@ | |||||
| 1 | + | package wayland | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "math" | |||
| 6 | + | "os" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 11 | + | "github.com/bnema/purego-libwayland/protocol/wayland" | |||
| 12 | + | "github.com/stretchr/testify/mock" | |||
| 13 | + | "golang.org/x/sys/unix" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | func TestExplicitAcquireWatchFailureDiscardsCommit(t *testing.T) { | |||
| 17 | + | for _, failure := range []string{"signalled", "eventfd"} { | |||
| 18 | + | t.Run(failure, func(t *testing.T) { | |||
| 19 | + | h := newSyncHarness(t) | |||
| 20 | + | h.releases = map[uint32]*syncReleaseProxy{} | |||
| 21 | + | // Order setup after import and reset on the display owner so no | |||
| 22 | + | // concurrent device call can read the generated expectations. | |||
| 23 | + | if err := h.c.Roundtrip(); err != nil { | |||
| 24 | + | t.Fatal(err) | |||
| 25 | + | } | |||
| 26 | + | h.s.display.Do(func() { h.dev.ExpectedCalls = nil }) | |||
| 27 | + | h.dev.EXPECT().fdToHandle(mock.Anything).Return(7, nil).Maybe() | |||
| 28 | + | h.dev.EXPECT().destroy(mock.Anything).Return(nil).Maybe() | |||
| 29 | + | if failure == "signalled" { | |||
| 30 | + | h.dev.EXPECT().signalled(uint32(7), uint64(101)).Return(false, unix.EIO).Once() | |||
| 31 | + | } else { | |||
| 32 | + | h.dev.EXPECT().signalled(uint32(7), uint64(101)).Return(false, nil).Once() | |||
| 33 | + | h.dev.EXPECT().eventfd(uint32(7), uint64(101), mock.Anything).Return(unix.EIO).Once() | |||
| 34 | + | } | |||
| 35 | + | drainContents(h.contents) | |||
| 36 | + | buffer := h.dmabuf() | |||
| 37 | + | h.commit(buffer, 101, 102) | |||
| 38 | + | if got, ok := h.content(50 * time.Millisecond); ok { | |||
| 39 | + | t.Fatalf("untracked acquire published %+v", got) | |||
| 40 | + | } | |||
| 41 | + | h.s.display.Do(func() { | |||
| 42 | + | surf := h.s.windows[h.win].xdg.surface | |||
| 43 | + | if len(surf.queue) != 0 || surf.hold.acquire != nil { | |||
| 44 | + | t.Error("failed acquire retained queued or rendered fence") | |||
| 45 | + | } | |||
| 46 | + | for _, tl := range h.s.timelines { | |||
| 47 | + | if tl.uses != 0 { | |||
| 48 | + | t.Errorf("timeline uses=%d", tl.uses) | |||
| 49 | + | } | |||
| 50 | + | } | |||
| 51 | + | }) | |||
| 52 | + | if len(h.signalled()) != 0 { | |||
| 53 | + | t.Fatal("unsafe release point signalled") | |||
| 54 | + | } | |||
| 55 | + | select { | |||
| 56 | + | case <-h.releases[buffer].released: | |||
| 57 | + | t.Fatal("explicit buffer release event") | |||
| 58 | + | default: | |||
| 59 | + | } | |||
| 60 | + | }) | |||
| 61 | + | } | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | func TestImplicitAcquireWatchFailureDiscardsCommit(t *testing.T) { | |||
| 65 | + | for _, failure := range []string{"poll", "hangup"} { | |||
| 66 | + | t.Run(failure, func(t *testing.T) { | |||
| 67 | + | h := newSyncHarness(t) | |||
| 68 | + | h.releases = map[uint32]*syncReleaseProxy{} | |||
| 69 | + | drainContents(h.contents) | |||
| 70 | + | buffer := h.dmabuf() | |||
| 71 | + | var plane *os.File | |||
| 72 | + | var writer *os.File | |||
| 73 | + | if failure == "poll" { | |||
| 74 | + | plane = os.NewFile(uintptr(math.MaxInt32), "invalid") | |||
| 75 | + | } else { | |||
| 76 | + | var err error | |||
| 77 | + | plane, writer, err = os.Pipe() | |||
| 78 | + | if err != nil { | |||
| 79 | + | t.Fatal(err) | |||
| 80 | + | } | |||
| 81 | + | defer plane.Close() | |||
| 82 | + | } | |||
| 83 | + | // Real pollable descriptors exercise observation errors without replacing | |||
| 84 | + | // watch/poll functions or constructing a handwritten device double. | |||
| 85 | + | if err := h.c.Roundtrip(); err != nil { | |||
| 86 | + | t.Fatal(err) | |||
| 87 | + | } | |||
| 88 | + | h.s.display.Do(func() { | |||
| 89 | + | var buf *dmabufBuffer | |||
| 90 | + | var resource *wayland.Buffer | |||
| 91 | + | for r, b := range h.s.buffers { | |||
| 92 | + | if r.ID() == buffer { | |||
| 93 | + | buf = b.(*dmabufBuffer) | |||
| 94 | + | resource = wayland.WrapBuffer(r) | |||
| 95 | + | } | |||
| 96 | + | } | |||
| 97 | + | if buf == nil { | |||
| 98 | + | t.Error("buffer missing") | |||
| 99 | + | return | |||
| 100 | + | } | |||
| 101 | + | original := buf.buf.Planes[0].File | |||
| 102 | + | defer func() { buf.buf.Planes[0].File = original }() | |||
| 103 | + | buf.buf.Planes[0].File = plane | |||
| 104 | + | surf := h.s.windows[h.win].xdg.surface | |||
| 105 | + | surf.Attach(surf.wl, resource, 0, 0) | |||
| 106 | + | surf.Commit(surf.wl) | |||
| 107 | + | }) | |||
| 108 | + | if writer != nil { | |||
| 109 | + | writer.Close() | |||
| 110 | + | } | |||
| 111 | + | if got, ok := h.content(100 * time.Millisecond); ok { | |||
| 112 | + | t.Fatalf("failed implicit acquire published %+v", got) | |||
| 113 | + | } | |||
| 114 | + | h.s.display.Do(func() { | |||
| 115 | + | if len(h.s.windows[h.win].xdg.surface.queue) != 0 { | |||
| 116 | + | t.Error("failed implicit acquire left queue blocked") | |||
| 117 | + | } | |||
| 118 | + | }) | |||
| 119 | + | }) | |||
| 120 | + | } | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | func TestAcquirePollTerminalFailureRetiresActiveAndAdded(t *testing.T) { | |||
| 124 | + | poller := newMockacquirePoller(t) | |||
| 125 | + | wake := make(chan struct{}, 1) | |||
| 126 | + | sw, err := newSyncWaiter(func() { wake <- struct{}{} }) | |||
| 127 | + | if err != nil { | |||
| 128 | + | t.Fatal(err) | |||
| 129 | + | } | |||
| 130 | + | sw.poller = poller | |||
| 131 | + | defer sw.close() | |||
| 132 | + | fd, err := unix.Eventfd(0, unix.EFD_CLOEXEC) | |||
| 133 | + | if err != nil { | |||
| 134 | + | t.Fatal(err) | |||
| 135 | + | } | |||
| 136 | + | active := sw.add(fd) | |||
| 137 | + | var added *syncWait | |||
| 138 | + | poller.EXPECT().poll(mock.Anything, 100).RunAndReturn(func(fds []unix.PollFd, _ int) (int, error) { fds[0].Revents = unix.POLLIN; return 1, nil }).Once() | |||
| 139 | + | poller.EXPECT().poll(mock.Anything, 100).RunAndReturn(func([]unix.PollFd, int) (int, error) { | |||
| 140 | + | fd, err := unix.Eventfd(0, unix.EFD_CLOEXEC) | |||
| 141 | + | if err != nil { | |||
| 142 | + | t.Error(err) | |||
| 143 | + | return -1, err | |||
| 144 | + | } | |||
| 145 | + | added = sw.add(fd) | |||
| 146 | + | return -1, unix.EIO | |||
| 147 | + | }).Once() | |||
| 148 | + | sw.run(context.Background()) | |||
| 149 | + | select { | |||
| 150 | + | case <-wake: | |||
| 151 | + | default: | |||
| 152 | + | t.Fatal("terminal failure did not wake owner") | |||
| 153 | + | } | |||
| 154 | + | for _, w := range []*syncWait{active, added} { | |||
| 155 | + | if w == nil || !sw.fired(w) || !w.failed { | |||
| 156 | + | t.Fatal("wait stranded after terminal poll failure") | |||
| 157 | + | } | |||
| 158 | + | } | |||
| 159 | + | if !sw.stopped() { | |||
| 160 | + | t.Fatal("waiter not terminal") | |||
| 161 | + | } | |||
| 162 | + | dev := newMocksyncobjDevice(t) // no device access permitted after terminal failure | |||
| 163 | + | if w, err := sw.watch(syncPoint{tl: &timeline{dev: dev, handle: 7}, point: 1}); err == nil || !w.failed { | |||
| 164 | + | t.Fatal("new explicit watch accepted") | |||
| 165 | + | } | |||
| 166 | + | var waits [4]*syncWait | |||
| 167 | + | if err := sw.watchImplicit(&ports.DMABuf{}, &waits); err == nil { | |||
| 168 | + | t.Fatal("new implicit watch accepted") | |||
| 169 | + | } | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | func TestFailedRootRetiresEveryChildPredecessorGraph(t *testing.T) { | |||
| 173 | + | h := newSyncHarness(t) | |||
| 174 | + | sw := h.s.syncWait | |||
| 175 | + | root := &surface{server: h.s} | |||
| 176 | + | child := &surface{server: h.s} | |||
| 177 | + | a, b := &surface{server: h.s}, &surface{server: h.s} | |||
| 178 | + | tl := &timeline{dev: h.dev, handle: 7, alive: true, uses: 4} | |||
| 179 | + | var waits [2]*syncWait | |||
| 180 | + | var graphs [2]*update | |||
| 181 | + | h.s.display.Do(func() { | |||
| 182 | + | for i, surf := range []*surface{a, b} { | |||
| 183 | + | fd, err := unix.Eventfd(0, unix.EFD_CLOEXEC|unix.EFD_NONBLOCK) | |||
| 184 | + | if err != nil { | |||
| 185 | + | t.Error(err) | |||
| 186 | + | return | |||
| 187 | + | } | |||
| 188 | + | waits[i] = sw.add(fd) | |||
| 189 | + | surf.next.sync = &commitSync{acquire: syncPoint{tl: tl, point: uint64(21 + i*2)}, release: syncPoint{tl: tl, point: uint64(22 + i*2)}, wait: waits[i]} | |||
| 190 | + | surf.queueUpdate() | |||
| 191 | + | graphs[i] = surf.queue[len(surf.queue)-1] | |||
| 192 | + | graphs[i].refs++ // pin retired state for assertions | |||
| 193 | + | } | |||
| 194 | + | child.queueUpdate() | |||
| 195 | + | child.queueUpdate() | |||
| 196 | + | one, two := child.queue[0], child.queue[1] | |||
| 197 | + | one.deps = []*update{graphs[0]} | |||
| 198 | + | graphs[0].refs++ | |||
| 199 | + | two.deps = []*update{graphs[1]} | |||
| 200 | + | graphs[1].refs++ | |||
| 201 | + | root.queueUpdate() | |||
| 202 | + | u := root.queue[0] | |||
| 203 | + | u.deps = []*update{two} | |||
| 204 | + | two.refs++ | |||
| 205 | + | u.failed = true | |||
| 206 | + | child.queueUpdate() | |||
| 207 | + | later := child.queue[2] | |||
| 208 | + | u.applyGraph() | |||
| 209 | + | if len(child.queue) != 1 || child.queue[0] != later || len(a.queue) != 0 || len(b.queue) != 0 || tl.uses != 0 { | |||
| 210 | + | t.Errorf("retirement stranded prefix graphs: uses=%d child=%d a=%d b=%d", tl.uses, len(child.queue), len(a.queue), len(b.queue)) | |||
| 211 | + | } | |||
| 212 | + | for i, g := range graphs { | |||
| 213 | + | if !g.retired || g.refs != 1 || !waits[i].cancelled.Load() { | |||
| 214 | + | t.Error("grandchild not safely retired") | |||
| 215 | + | } | |||
| 216 | + | g.releaseRef() | |||
| 217 | + | } | |||
| 218 | + | child.dropQueue() | |||
| 219 | + | }) | |||
| 220 | + | if len(h.signalled()) != 0 { | |||
| 221 | + | t.Fatal("release signalled before acquire") | |||
| 222 | + | } | |||
| 223 | + | } | |||
| 224 | + | ||||
| 225 | + | func TestAcquireExportFailurePreservesCurrentContent(t *testing.T) { | |||
| 226 | + | h := newSyncHarness(t) | |||
| 227 | + | h.releases = map[uint32]*syncReleaseProxy{} | |||
| 228 | + | if err := h.c.Roundtrip(); err != nil { | |||
| 229 | + | t.Fatal(err) | |||
| 230 | + | } | |||
| 231 | + | h.s.display.Do(func() { h.dev.ExpectedCalls = nil }) | |||
| 232 | + | h.dev.EXPECT().destroy(mock.Anything).Return(nil).Maybe() | |||
| 233 | + | h.dev.EXPECT().signalled(uint32(7), uint64(111)).Return(true, nil).Once() | |||
| 234 | + | h.dev.EXPECT().exportSyncFile(uint32(7), uint64(111)).Return(nil, unix.EIO).Once() | |||
| 235 | + | h.dev.EXPECT().signal(uint32(7), uint64(112)).Return(nil).Once() // observed acquire: safe discarded release | |||
| 236 | + | drainContents(h.contents) | |||
| 237 | + | buffer := h.dmabuf() | |||
| 238 | + | var before ports.SurfaceContent | |||
| 239 | + | h.s.display.Do(func() { before = h.s.windows[h.win].xdg.surface.content }) | |||
| 240 | + | h.commit(buffer, 111, 112) | |||
| 241 | + | if got, ok := h.content(50 * time.Millisecond); ok { | |||
| 242 | + | t.Fatalf("export failure published %+v", got) | |||
| 243 | + | } | |||
| 244 | + | h.s.display.Do(func() { | |||
| 245 | + | surf := h.s.windows[h.win].xdg.surface | |||
| 246 | + | if surf.content.Seq != before.Seq || surf.content.DMABuf != before.DMABuf || surf.hold.acquire != nil || len(surf.queue) != 0 { | |||
| 247 | + | t.Error("export failure changed current content") | |||
| 248 | + | } | |||
| 249 | + | for _, tl := range h.s.timelines { | |||
| 250 | + | if tl.uses != 0 { | |||
| 251 | + | t.Error("export failure pinned timeline") | |||
| 252 | + | } | |||
| 253 | + | } | |||
| 254 | + | }) | |||
| 255 | + | } | |||
A internal/adapters/wayland/acquire_poller_mock_test.go +112 -0
| @@ -0,0 +1,112 @@ | |||||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |||
| 2 | + | // github.com/vektra/mockery | |||
| 3 | + | // template: testify | |||
| 4 | + | ||||
| 5 | + | package wayland | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | mock "github.com/stretchr/testify/mock" | |||
| 9 | + | "golang.org/x/sys/unix" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // newMockacquirePoller creates a new instance of mockacquirePoller. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 13 | + | // The first argument is typically a *testing.T value. | |||
| 14 | + | func newMockacquirePoller(t interface { | |||
| 15 | + | mock.TestingT | |||
| 16 | + | Cleanup(func()) | |||
| 17 | + | }) *mockacquirePoller { | |||
| 18 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 19 | + | helper.Helper() | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | mock := &mockacquirePoller{} | |||
| 23 | + | mock.Mock.Test(t) | |||
| 24 | + | ||||
| 25 | + | t.Cleanup(func() { | |||
| 26 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 27 | + | helper.Helper() | |||
| 28 | + | } | |||
| 29 | + | mock.AssertExpectations(t) | |||
| 30 | + | }) | |||
| 31 | + | ||||
| 32 | + | return mock | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | // mockacquirePoller is an autogenerated mock type for the acquirePoller type | |||
| 36 | + | type mockacquirePoller struct { | |||
| 37 | + | mock.Mock | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | type mockacquirePoller_Expecter struct { | |||
| 41 | + | mock *mock.Mock | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | func (_m *mockacquirePoller) EXPECT() *mockacquirePoller_Expecter { | |||
| 45 | + | return &mockacquirePoller_Expecter{mock: &_m.Mock} | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | // poll provides a mock function for the type mockacquirePoller | |||
| 49 | + | func (_mock *mockacquirePoller) poll(pollFds []unix.PollFd, n int) (int, error) { | |||
| 50 | + | ret := _mock.Called(pollFds, n) | |||
| 51 | + | ||||
| 52 | + | if len(ret) == 0 { | |||
| 53 | + | panic("no return value specified for poll") | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | var r0 int | |||
| 57 | + | var r1 error | |||
| 58 | + | if returnFunc, ok := ret.Get(0).(func([]unix.PollFd, int) (int, error)); ok { | |||
| 59 | + | return returnFunc(pollFds, n) | |||
| 60 | + | } | |||
| 61 | + | if returnFunc, ok := ret.Get(0).(func([]unix.PollFd, int) int); ok { | |||
| 62 | + | r0 = returnFunc(pollFds, n) | |||
| 63 | + | } else { | |||
| 64 | + | r0 = ret.Get(0).(int) | |||
| 65 | + | } | |||
| 66 | + | if returnFunc, ok := ret.Get(1).(func([]unix.PollFd, int) error); ok { | |||
| 67 | + | r1 = returnFunc(pollFds, n) | |||
| 68 | + | } else { | |||
| 69 | + | r1 = ret.Error(1) | |||
| 70 | + | } | |||
| 71 | + | return r0, r1 | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | // mockacquirePoller_poll_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'poll' | |||
| 75 | + | type mockacquirePoller_poll_Call struct { | |||
| 76 | + | *mock.Call | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | // poll is a helper method to define mock.On call | |||
| 80 | + | // - pollFds []unix.PollFd | |||
| 81 | + | // - n int | |||
| 82 | + | func (_e *mockacquirePoller_Expecter) poll(pollFds any, n any) *mockacquirePoller_poll_Call { | |||
| 83 | + | return &mockacquirePoller_poll_Call{Call: _e.mock.On("poll", pollFds, n)} | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | func (_c *mockacquirePoller_poll_Call) Run(run func(pollFds []unix.PollFd, n int)) *mockacquirePoller_poll_Call { | |||
| 87 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 88 | + | var arg0 []unix.PollFd | |||
| 89 | + | if args[0] != nil { | |||
| 90 | + | arg0 = args[0].([]unix.PollFd) | |||
| 91 | + | } | |||
| 92 | + | var arg1 int | |||
| 93 | + | if args[1] != nil { | |||
| 94 | + | arg1 = args[1].(int) | |||
| 95 | + | } | |||
| 96 | + | run( | |||
| 97 | + | arg0, | |||
| 98 | + | arg1, | |||
| 99 | + | ) | |||
| 100 | + | }) | |||
| 101 | + | return _c | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | func (_c *mockacquirePoller_poll_Call) Return(n1 int, err error) *mockacquirePoller_poll_Call { | |||
| 105 | + | _c.Call.Return(n1, err) | |||
| 106 | + | return _c | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | func (_c *mockacquirePoller_poll_Call) RunAndReturn(run func(pollFds []unix.PollFd, n int) (int, error)) *mockacquirePoller_poll_Call { | |||
| 110 | + | _c.Call.Return(run) | |||
| 111 | + | return _c | |||
| 112 | + | } | |||
M internal/adapters/wayland/activation.go +3 -1
| @@ -55,6 +55,8 @@ func (a activation) Activate(_ *xdgactivation.ActivationV1, token string, surf * | |||||
| 55 | state := s.surfaceOf(surf) | 55 | state := s.surfaceOf(surf) | ||
| 56 | reason := "" | 56 | reason := "" | ||
| 57 | switch { | 57 | switch { | ||
| 58 | + | case s.protected(): | |||
| 59 | + | reason = "session_protected" | |||
| 58 | case !ok: | 60 | case !ok: | ||
| 59 | reason = "unknown_token" | 61 | reason = "unknown_token" | ||
| 60 | case time.Since(t.at) > tokenLifetime: | 62 | case time.Since(t.at) > tokenLifetime: | ||
| @@ -105,7 +107,7 @@ func (t *tokenRequest) Commit(r *xdgactivation.ActivationTokenV1) { | |||||
| 105 | fresh := time.Since(s.seat.pressAt) <= tokenLifetime && !s.seat.pressAt.Before(s.seat.focusAt) | 107 | fresh := time.Since(s.seat.pressAt) <= tokenLifetime && !s.seat.pressAt.Before(s.seat.focusAt) | ||
| 106 | valid := s.focusClient() == c || (t.hasSerial && t.serial == s.seat.press && s.seat.pressClient == c && fresh) | 108 | valid := s.focusClient() == c || (t.hasSerial && t.serial == s.seat.press && s.seat.pressClient == c && fresh) | ||
| 107 | token := rand.Text() | 109 | token := rand.Text() | ||
| 108 | - | if valid { | 110 | + | if valid && !s.protected() { |
| 109 | s.pruneTokens() | 111 | s.pruneTokens() | ||
| 110 | s.tokens[token] = activationToken{client: c, at: time.Now()} | 112 | s.tokens[token] = activationToken{client: c, at: time.Now()} | ||
| 111 | } | 113 | } | ||
M internal/adapters/wayland/activation_test.go +1 -1
| @@ -24,7 +24,7 @@ func newToken(t *testing.T, c *wlturbo.Display, manager uint32, serialSeat ...ui | |||||
| 24 | id := c.AllocateID() | 24 | id := c.AllocateID() | ||
| 25 | p := &tokenProxy{tokens: make(chan string, 1)} | 25 | p := &tokenProxy{tokens: make(chan string, 1)} | ||
| 26 | p.SetID(id) | 26 | p.SetID(id) | ||
| 27 | - | c.Context().Register(p) | 27 | + | registerWireProxy(c, p) |
| 28 | requestProtocol(t, c, manager, xdgactivation.ActivationV1RequestGetActivationToken, id) | 28 | requestProtocol(t, c, manager, xdgactivation.ActivationV1RequestGetActivationToken, id) | ||
| 29 | if len(serialSeat) == 2 { | 29 | if len(serialSeat) == 2 { | ||
| 30 | requestProtocol(t, c, id, xdgactivation.ActivationTokenV1RequestSetSerial, serialSeat[0], serialSeat[1]) | 30 | requestProtocol(t, c, id, xdgactivation.ActivationTokenV1RequestSetSerial, serialSeat[0], serialSeat[1]) | ||
M internal/adapters/wayland/capture_admission_test.go +2 -2
| @@ -79,7 +79,7 @@ func (h *admissionHarness) client(t *testing.T) *admissionClient { | |||||
| 79 | if !ok { | 79 | if !ok { | ||
| 80 | t.Fatal("output missing") | 80 | t.Fatal("output missing") | ||
| 81 | } | 81 | } | ||
| 82 | - | out, err := c.Registry().BindID(g.Name, g.Interface, 4) | 82 | + | out, err := bindWireID(c, g.Name, g.Interface, 4) |
| 83 | if err != nil { | 83 | if err != nil { | ||
| 84 | t.Fatal(err) | 84 | t.Fatal(err) | ||
| 85 | } | 85 | } | ||
| @@ -93,7 +93,7 @@ func (a *admissionClient) newFrame() (uint32, *captureEvents) { | |||||
| 93 | frame := a.c.AllocateID() | 93 | frame := a.c.AllocateID() | ||
| 94 | ev := &captureEvents{events: make(chan uint16, 8)} | 94 | ev := &captureEvents{events: make(chan uint16, 8)} | ||
| 95 | ev.SetID(frame) | 95 | ev.SetID(frame) | ||
| 96 | - | a.c.Context().Register(ev) | 96 | + | registerWireProxy(a.c, ev) |
| 97 | return frame, ev | 97 | return frame, ev | ||
| 98 | } | 98 | } | ||
| 99 | 99 | ||||
M internal/adapters/wayland/capture_session.go +3 -3
| @@ -121,7 +121,7 @@ type captureManager struct { | |||||
| 121 | // update sends the workspaces that are new or changed, and the ones gone. | 121 | // update sends the workspaces that are new or changed, and the ones gone. | ||
| 122 | // Untrusted peers get no inventory. | 122 | // Untrusted peers get no inventory. | ||
| 123 | func (m *captureManager) update(snapshot ports.Workspaces) { | 123 | func (m *captureManager) update(snapshot ports.Workspaces) { | ||
| 124 | - | if !m.trusted || !m.res.Alive() { | 124 | + | if m.s.protected() || !m.trusted || !m.res.Alive() { |
| 125 | return | 125 | return | ||
| 126 | } | 126 | } | ||
| 127 | now := map[uint64]privateWorkspace{} | 127 | now := map[uint64]privateWorkspace{} | ||
| @@ -176,7 +176,7 @@ func (m *captureManager) BeginSession(r *capturesession.NeferwlCaptureManagerV1, | |||||
| 176 | stop := func(reason capturesession.NeferwlCaptureSessionV1StopReason) { res.SendStopped(uint32(reason)) } | 176 | stop := func(reason capturesession.NeferwlCaptureSessionV1StopReason) { res.SendStopped(uint32(reason)) } | ||
| 177 | o := s.outputOf(out) | 177 | o := s.outputOf(out) | ||
| 178 | switch { | 178 | switch { | ||
| 179 | - | case !m.trusted: | 179 | + | case s.protected() || !m.trusted: |
| 180 | s.log.Info().Str("reason", string(ports.CaptureReasonUnauthorized)).Msg("capture session refused") | 180 | s.log.Info().Str("reason", string(ports.CaptureReasonUnauthorized)).Msg("capture session refused") | ||
| 181 | stop(capturesession.NeferwlCaptureSessionV1StopReasonUnauthorized) | 181 | stop(capturesession.NeferwlCaptureSessionV1StopReasonUnauthorized) | ||
| 182 | return | 182 | return | ||
| @@ -221,7 +221,7 @@ func (m *captureManager) AttachSurface(r *capturesession.NeferwlCaptureManagerV1 | |||||
| 221 | // the same check): nothing else can attach. | 221 | // the same check): nothing else can attach. | ||
| 222 | ps := s.private.session | 222 | ps := s.private.session | ||
| 223 | switch { | 223 | switch { | ||
| 224 | - | case !m.trusted: // the UID of this connection, checked once at bind | 224 | + | case s.protected() || !m.trusted: // DISPLAY protection and bind-time UID |
| 225 | att.fail(capturesession.NeferwlCaptureLayerV1FailureUnauthorized) | 225 | att.fail(capturesession.NeferwlCaptureLayerV1FailureUnauthorized) | ||
| 226 | return | 226 | return | ||
| 227 | case ps == nil || subtle.ConstantTimeCompare([]byte(token), []byte(ps.token)) != 1: | 227 | case ps == nil || subtle.ConstantTimeCompare([]byte(token), []byte(ps.token)) != 1: | ||
M internal/adapters/wayland/capture_session_test.go +8 -8
| @@ -133,14 +133,14 @@ func (h *privateHarness) client(t *testing.T) *privateClient { | |||||
| 133 | if !ok { | 133 | if !ok { | ||
| 134 | t.Fatal("output missing") | 134 | t.Fatal("output missing") | ||
| 135 | } | 135 | } | ||
| 136 | - | out, err := c.Registry().BindID(g.Name, g.Interface, 4) | 136 | + | out, err := bindWireID(c, g.Name, g.Interface, 4) |
| 137 | if err != nil { | 137 | if err != nil { | ||
| 138 | t.Fatal(err) | 138 | t.Fatal(err) | ||
| 139 | } | 139 | } | ||
| 140 | registerProtocol(t, c, out) | 140 | registerProtocol(t, c, out) | ||
| 141 | pc := &privateClient{c: c, output: out, manager: bindProtocol(t, c, "neferwl_capture_manager_v1"), mgr: &managerEvents{msgs: make(chan sessionMsg, 32)}} | 141 | pc := &privateClient{c: c, output: out, manager: bindProtocol(t, c, "neferwl_capture_manager_v1"), mgr: &managerEvents{msgs: make(chan sessionMsg, 32)}} | ||
| 142 | pc.mgr.SetID(pc.manager) | 142 | pc.mgr.SetID(pc.manager) | ||
| 143 | - | c.Context().Register(pc.mgr) | 143 | + | registerWireProxy(c, pc.mgr) |
| 144 | return pc | 144 | return pc | ||
| 145 | } | 145 | } | ||
| 146 | 146 | ||||
| @@ -150,7 +150,7 @@ func (pc *privateClient) begin(t *testing.T, x, y, w, h int32, workspace uint64, | |||||
| 150 | id := pc.c.AllocateID() | 150 | id := pc.c.AllocateID() | ||
| 151 | ev := &sessionEvents{msgs: make(chan sessionMsg, 32)} | 151 | ev := &sessionEvents{msgs: make(chan sessionMsg, 32)} | ||
| 152 | ev.SetID(id) | 152 | ev.SetID(id) | ||
| 153 | - | pc.c.Context().Register(ev) | 153 | + | registerWireProxy(pc.c, ev) |
| 154 | requestProtocol(t, pc.c, pc.manager, capturesession.NeferwlCaptureManagerV1RequestBeginSession, id, pc.output, x, y, w, h, uint32(workspace>>32), uint32(workspace), record) | 154 | requestProtocol(t, pc.c, pc.manager, capturesession.NeferwlCaptureManagerV1RequestBeginSession, id, pc.output, x, y, w, h, uint32(workspace>>32), uint32(workspace), record) | ||
| 155 | if err := pc.c.Roundtrip(); err != nil { | 155 | if err := pc.c.Roundtrip(); err != nil { | ||
| 156 | t.Fatal(err) | 156 | t.Fatal(err) | ||
| @@ -348,7 +348,7 @@ func (hd *hud) attachSurface(t *testing.T, token string, surface uint32) chan se | |||||
| 348 | id := hd.c.AllocateID() | 348 | id := hd.c.AllocateID() | ||
| 349 | ev := &attachEvents{msgs: make(chan sessionMsg, 8)} | 349 | ev := &attachEvents{msgs: make(chan sessionMsg, 8)} | ||
| 350 | ev.SetID(id) | 350 | ev.SetID(id) | ||
| 351 | - | hd.c.Context().Register(ev) | 351 | + | registerWireProxy(hd.c, ev) |
| 352 | requestProtocol(t, hd.c, hd.manager, capturesession.NeferwlCaptureManagerV1RequestAttachSurface, id, token, surface) | 352 | requestProtocol(t, hd.c, hd.manager, capturesession.NeferwlCaptureManagerV1RequestAttachSurface, id, token, surface) | ||
| 353 | if err := hd.c.Roundtrip(); err != nil { | 353 | if err := hd.c.Roundtrip(); err != nil { | ||
| 354 | t.Fatal(err) | 354 | t.Fatal(err) | ||
| @@ -452,7 +452,7 @@ func mapLayer(t *testing.T, hd *hud) { | |||||
| 452 | requestProtocol(t, hd.c, hd.surface, wayland.SurfaceRequestCommit) | 452 | requestProtocol(t, hd.c, hd.surface, wayland.SurfaceRequestCommit) | ||
| 453 | cfg := &layerProxy{configured: make(chan [3]uint32, 4)} | 453 | cfg := &layerProxy{configured: make(chan [3]uint32, 4)} | ||
| 454 | cfg.SetID(hd.layer) | 454 | cfg.SetID(hd.layer) | ||
| 455 | - | hd.c.Context().Register(cfg) | 455 | + | registerWireProxy(hd.c, cfg) |
| 456 | if err := hd.c.Roundtrip(); err != nil { | 456 | if err := hd.c.Roundtrip(); err != nil { | ||
| 457 | t.Fatal(err) | 457 | t.Fatal(err) | ||
| 458 | } | 458 | } | ||
| @@ -480,7 +480,7 @@ func TestPrivateCaptureStopClosesLayers(t *testing.T) { | |||||
| 480 | closed := make(chan sessionMsg, 4) | 480 | closed := make(chan sessionMsg, 4) | ||
| 481 | lp := &layerClosedProxy{msgs: closed} | 481 | lp := &layerClosedProxy{msgs: closed} | ||
| 482 | lp.SetID(hd.layer) | 482 | lp.SetID(hd.layer) | ||
| 483 | - | hd.c.Context().Register(lp) | 483 | + | registerWireProxy(hd.c, lp) |
| 484 | confirmed := hd.attach(t, token) | 484 | confirmed := hd.attach(t, token) | ||
| 485 | layer := nextEvent[ports.CaptureSessionLayer](t, h.events) | 485 | layer := nextEvent[ports.CaptureSessionLayer](t, h.events) | ||
| 486 | h.send(ports.CaptureSessionState{ID: begin.ID, Output: "HEADLESS-1", Active: true, Revision: 2, Layers: []ports.WindowID{layer.Layer}}) | 486 | h.send(ports.CaptureSessionState{ID: begin.ID, Output: "HEADLESS-1", Active: true, Revision: 2, Layers: []ports.WindowID{layer.Layer}}) | ||
| @@ -611,7 +611,7 @@ func captureOnce(t *testing.T, h *privateHarness, pc *privateClient) (*captureEv | |||||
| 611 | frame := pc.c.AllocateID() | 611 | frame := pc.c.AllocateID() | ||
| 612 | ev := &captureEvents{events: make(chan uint16, 16)} | 612 | ev := &captureEvents{events: make(chan uint16, 16)} | ||
| 613 | ev.SetID(frame) | 613 | ev.SetID(frame) | ||
| 614 | - | pc.c.Context().Register(ev) | 614 | + | registerWireProxy(pc.c, ev) |
| 615 | requestProtocol(t, pc.c, mgr, wlr.ZwlrScreencopyManagerV1RequestCaptureOutput, frame, int32(0), pc.output) | 615 | requestProtocol(t, pc.c, mgr, wlr.ZwlrScreencopyManagerV1RequestCaptureOutput, frame, int32(0), pc.output) | ||
| 616 | requestProtocol(t, pc.c, frame, wlr.ZwlrScreencopyFrameV1RequestCopy, buf) | 616 | requestProtocol(t, pc.c, frame, wlr.ZwlrScreencopyFrameV1RequestCopy, buf) | ||
| 617 | if err := pc.c.Roundtrip(); err != nil { | 617 | if err := pc.c.Roundtrip(); err != nil { | ||
| @@ -761,7 +761,7 @@ func TestPrivateCaptureLayerMovedBelowTop(t *testing.T) { | |||||
| 761 | closed := make(chan sessionMsg, 4) | 761 | closed := make(chan sessionMsg, 4) | ||
| 762 | lp := &layerClosedProxy{msgs: closed} | 762 | lp := &layerClosedProxy{msgs: closed} | ||
| 763 | lp.SetID(hd.layer) | 763 | lp.SetID(hd.layer) | ||
| 764 | - | hd.c.Context().Register(lp) | 764 | + | registerWireProxy(hd.c, lp) |
| 765 | requestProtocol(t, hd.c, hd.layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetLayer, uint32(ports.LayerBottom)) | 765 | requestProtocol(t, hd.c, hd.layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetLayer, uint32(ports.LayerBottom)) | ||
| 766 | requestProtocol(t, hd.c, hd.surface, wayland.SurfaceRequestCommit) | 766 | requestProtocol(t, hd.c, hd.surface, wayland.SurfaceRequestCommit) | ||
| 767 | if err := hd.c.Roundtrip(); err != nil { | 767 | if err := hd.c.Roundtrip(); err != nil { | ||
M internal/adapters/wayland/clipboard.go +22 -3
| @@ -15,6 +15,11 @@ import ( | |||||
| 15 | // both without focus. Like wlroots, any client may bind it. Pasting hands the reader's pipe to the source client: | 15 | // both without focus. Like wlroots, any client may bind it. Pasting hands the reader's pipe to the source client: | ||
| 16 | // the data never goes through neferwl. Drag and drop is not implemented. | 16 | // the data never goes through neferwl. Drag and drop is not implemented. | ||
| 17 | 17 | ||||
| 18 | + | // protected is a defensive DISPLAY admission check, not lock ownership. | |||
| 19 | + | func (s *Server) protected() bool { | |||
| 20 | + | return s.security != nil && s.security.Snapshot().Protected | |||
| 21 | + | } | |||
| 22 | + | ||||
| 18 | const ( | 23 | const ( | ||
| 19 | selClipboard = iota | 24 | selClipboard = iota | ||
| 20 | selPrimary | 25 | selPrimary | ||
| @@ -89,6 +94,12 @@ func (s *Server) focusedClient() (server.Client, bool) { | |||||
| 89 | // setSelection makes src the selection of kind and announces it. A | 94 | // setSelection makes src the selection of kind and announces it. A | ||
| 90 | // replaced source is cancelled. | 95 | // replaced source is cancelled. | ||
| 91 | func (s *Server) setSelection(kind int, src *clipSource) { | 96 | func (s *Server) setSelection(kind int, src *clipSource) { | ||
| 97 | + | if s.protected() { | |||
| 98 | + | if src != nil && src != s.selections[kind] && src.alive() { | |||
| 99 | + | src.cancel() | |||
| 100 | + | } | |||
| 101 | + | return | |||
| 102 | + | } | |||
| 92 | old := s.selections[kind] | 103 | old := s.selections[kind] | ||
| 93 | if old == src { | 104 | if old == src { | ||
| 94 | return | 105 | return | ||
| @@ -109,6 +120,9 @@ func (s *Server) announceAll(kind int) { | |||||
| 109 | 120 | ||||
| 110 | // announce sends the selection of kind to the devices matching want. | 121 | // announce sends the selection of kind to the devices matching want. | ||
| 111 | func (s *Server) announce(kind int, want func(*clipDevice) bool) { | 122 | func (s *Server) announce(kind int, want func(*clipDevice) bool) { | ||
| 123 | + | if s.protected() { | |||
| 124 | + | return | |||
| 125 | + | } | |||
| 112 | src := s.selections[kind] | 126 | src := s.selections[kind] | ||
| 113 | if src != nil && !src.alive() { | 127 | if src != nil && !src.alive() { | ||
| 114 | src, s.selections[kind] = nil, nil | 128 | src, s.selections[kind] = nil, nil | ||
| @@ -142,6 +156,9 @@ func (s *Server) focusSelections() { | |||||
| 142 | // addClipDevice tracks a device and sends it the selections it may see. | 156 | // addClipDevice tracks a device and sends it the selections it may see. | ||
| 143 | func (s *Server) addClipDevice(d *clipDevice) { | 157 | func (s *Server) addClipDevice(d *clipDevice) { | ||
| 144 | s.clipDevices = append(s.clipDevices, d) | 158 | s.clipDevices = append(s.clipDevices, d) | ||
| 159 | + | if s.protected() { | |||
| 160 | + | return | |||
| 161 | + | } | |||
| 145 | c, ok := s.focusedClient() | 162 | c, ok := s.focusedClient() | ||
| 146 | if !d.control && (!ok || d.client != c) { | 163 | if !d.control && (!ok || d.client != c) { | ||
| 147 | return | 164 | return | ||
| @@ -170,7 +187,7 @@ func (s *Server) sourceGone(src *clipSource) { | |||||
| 170 | // focused one. Any client holding an offer may read it. | 187 | // focused one. Any client holding an offer may read it. | ||
| 171 | func (s *Server) maySet(c server.Client) bool { | 188 | func (s *Server) maySet(c server.Client) bool { | ||
| 172 | f, ok := s.focusedClient() | 189 | f, ok := s.focusedClient() | ||
| 173 | - | return ok && f == c | 190 | + | return !s.protected() && ok && f == c |
| 174 | } | 191 | } | ||
| 175 | 192 | ||||
| 176 | // setFocused sets a selection from a focus-bound device. A used source is | 193 | // setFocused sets a selection from a focus-bound device. A used source is | ||
| @@ -193,10 +210,12 @@ func (s *Server) setFocused(c server.Client, kind int, src *clipSource) { | |||||
| 193 | } | 210 | } | ||
| 194 | 211 | ||||
| 195 | // receive hands the reader's pipe to the source if it is still the | 212 | // receive hands the reader's pipe to the source if it is still the | ||
| 196 | - | // selection; the fd is always closed here (the event dups it). | 213 | + | // selection; the fd is always closed here (the event dups it). Protection |
| 214 | + | // rejects even previously announced offers. Transfers accepted before protection | |||
| 215 | + | // are client-to-client: their already handed-off pipes cannot be revoked here. | |||
| 197 | func (s *Server) receive(kind int, src *clipSource, mime string, fd int) { | 216 | func (s *Server) receive(kind int, src *clipSource, mime string, fd int) { | ||
| 198 | defer unix.Close(fd) | 217 | defer unix.Close(fd) | ||
| 199 | - | if s.selections[kind] != src || !src.alive() { | 218 | + | if s.protected() || s.selections[kind] != src || !src.alive() { |
| 200 | return | 219 | return | ||
| 201 | } | 220 | } | ||
| 202 | for _, m := range src.mimes { | 221 | for _, m := range src.mimes { | ||
M internal/adapters/wayland/clipboard_test.go +9 -9
| @@ -52,7 +52,7 @@ func (p *sourceEvents) Dispatch(e *wlturbo.Event) { | |||||
| 52 | func registerOffer(c *wlturbo.Display, id uint32) { | 52 | func registerOffer(c *wlturbo.Display, id uint32) { | ||
| 53 | p := &protocolProxy{} | 53 | p := &protocolProxy{} | ||
| 54 | p.SetID(id) | 54 | p.SetID(id) | ||
| 55 | - | c.Context().Register(p) | 55 | + | registerWireProxy(c, p) |
| 56 | } | 56 | } | ||
| 57 | 57 | ||||
| 58 | // clipClient is a client with a toplevel, a data device and a pointer to | 58 | // clipClient is a client with a toplevel, a data device and a pointer to | ||
| @@ -70,13 +70,13 @@ func newClipClient(t *testing.T, s *Server, dir string, events chan ports.Client | |||||
| 70 | seat := bindProtocol(t, c, "wl_seat") | 70 | seat := bindProtocol(t, c, "wl_seat") | ||
| 71 | registerProtocol(t, c, seat) | 71 | registerProtocol(t, c, seat) | ||
| 72 | g, _ := c.Registry().FindGlobal("wl_data_device_manager") | 72 | g, _ := c.Registry().FindGlobal("wl_data_device_manager") | ||
| 73 | - | manager, err := c.Registry().BindID(g.Name, g.Interface, 3) | 73 | + | manager, err := bindWireID(c, g.Name, g.Interface, 3) |
| 74 | if err != nil { | 74 | if err != nil { | ||
| 75 | t.Fatal(err) | 75 | t.Fatal(err) | ||
| 76 | } | 76 | } | ||
| 77 | device := &clipEvents{c: c, offers: make(chan uint32, 8)} | 77 | device := &clipEvents{c: c, offers: make(chan uint32, 8)} | ||
| 78 | device.SetID(c.AllocateID()) | 78 | device.SetID(c.AllocateID()) | ||
| 79 | - | c.Context().Register(device) | 79 | + | registerWireProxy(c, device) |
| 80 | requestProtocol(t, c, manager, wayland.DataDeviceManagerRequestGetDataDevice, device.ID(), seat) | 80 | requestProtocol(t, c, manager, wayland.DataDeviceManagerRequestGetDataDevice, device.ID(), seat) | ||
| 81 | w := toplevelMapper(t, c, events)() | 81 | w := toplevelMapper(t, c, events)() | ||
| 82 | return &clipClient{c: c, manager: manager, device: device, window: w.ID} | 82 | return &clipClient{c: c, manager: manager, device: device, window: w.ID} | ||
| @@ -100,7 +100,7 @@ func (cc *clipClient) copy(t *testing.T) *sourceEvents { | |||||
| 100 | t.Helper() | 100 | t.Helper() | ||
| 101 | src := &sourceEvents{sends: make(chan int, 4), cancelled: make(chan struct{}, 4)} | 101 | src := &sourceEvents{sends: make(chan int, 4), cancelled: make(chan struct{}, 4)} | ||
| 102 | src.SetID(cc.c.AllocateID()) | 102 | src.SetID(cc.c.AllocateID()) | ||
| 103 | - | cc.c.Context().Register(src) | 103 | + | registerWireProxy(cc.c, src) |
| 104 | requestProtocol(t, cc.c, cc.manager, wayland.DataDeviceManagerRequestCreateDataSource, src.ID()) | 104 | requestProtocol(t, cc.c, cc.manager, wayland.DataDeviceManagerRequestCreateDataSource, src.ID()) | ||
| 105 | requestProtocol(t, cc.c, src.ID(), wayland.DataSourceRequestOffer, "text/plain") | 105 | requestProtocol(t, cc.c, src.ID(), wayland.DataSourceRequestOffer, "text/plain") | ||
| 106 | requestProtocol(t, cc.c, cc.device.ID(), wayland.DataDeviceRequestSetSelection, src.ID(), uint32(0)) | 106 | requestProtocol(t, cc.c, cc.device.ID(), wayland.DataDeviceRequestSetSelection, src.ID(), uint32(0)) | ||
| @@ -161,7 +161,7 @@ func TestClipboardFollowsFocus(t *testing.T) { | |||||
| 161 | t.Fatal(err) | 161 | t.Fatal(err) | ||
| 162 | } | 162 | } | ||
| 163 | defer unix.Close(fds[0]) | 163 | defer unix.Close(fds[0]) | ||
| 164 | - | if err := b.c.SendRequestWithFDs(offer, uint16(wayland.DataOfferRequestReceive), []int{fds[1]}, "text/plain"); err != nil { | 164 | + | if err := wireRequest(b.c, offer, uint16(wayland.DataOfferRequestReceive), []int{fds[1]}, "text/plain"); err != nil { |
| 165 | t.Fatal(err) | 165 | t.Fatal(err) | ||
| 166 | } | 166 | } | ||
| 167 | _ = unix.Close(fds[1]) | 167 | _ = unix.Close(fds[1]) | ||
| @@ -208,7 +208,7 @@ func TestDataControlSeesSelection(t *testing.T) { | |||||
| 208 | offers := make(chan uint32, 8) | 208 | offers := make(chan uint32, 8) | ||
| 209 | dev := &controlEvents{c: m, offers: offers} | 209 | dev := &controlEvents{c: m, offers: offers} | ||
| 210 | dev.SetID(m.AllocateID()) | 210 | dev.SetID(m.AllocateID()) | ||
| 211 | - | m.Context().Register(dev) | 211 | + | registerWireProxy(m, dev) |
| 212 | requestProtocol(t, m, manager, extdatacontrol.ExtDataControlManagerV1RequestGetDataDevice, dev.ID(), seat) | 212 | requestProtocol(t, m, manager, extdatacontrol.ExtDataControlManagerV1RequestGetDataDevice, dev.ID(), seat) | ||
| 213 | next := func() uint32 { | 213 | next := func() uint32 { | ||
| 214 | t.Helper() | 214 | t.Helper() | ||
| @@ -294,7 +294,7 @@ func TestDataControlUsedSource(t *testing.T) { | |||||
| 294 | manager := bindProtocol(t, m, "ext_data_control_manager_v1") | 294 | manager := bindProtocol(t, m, "ext_data_control_manager_v1") | ||
| 295 | dev := &controlEvents{c: m, offers: make(chan uint32, 8)} | 295 | dev := &controlEvents{c: m, offers: make(chan uint32, 8)} | ||
| 296 | dev.SetID(m.AllocateID()) | 296 | dev.SetID(m.AllocateID()) | ||
| 297 | - | m.Context().Register(dev) | 297 | + | registerWireProxy(m, dev) |
| 298 | requestProtocol(t, m, manager, extdatacontrol.ExtDataControlManagerV1RequestGetDataDevice, dev.ID(), seat) | 298 | requestProtocol(t, m, manager, extdatacontrol.ExtDataControlManagerV1RequestGetDataDevice, dev.ID(), seat) | ||
| 299 | src := m.AllocateID() | 299 | src := m.AllocateID() | ||
| 300 | registerProtocol(t, m, src) | 300 | registerProtocol(t, m, src) | ||
| @@ -330,7 +330,7 @@ func TestPrimarySelectionFromManager(t *testing.T) { | |||||
| 330 | registerProtocol(t, a.c, seat) | 330 | registerProtocol(t, a.c, seat) | ||
| 331 | pdev := &primaryEvents{c: a.c, offers: make(chan uint32, 8)} | 331 | pdev := &primaryEvents{c: a.c, offers: make(chan uint32, 8)} | ||
| 332 | pdev.SetID(a.c.AllocateID()) | 332 | pdev.SetID(a.c.AllocateID()) | ||
| 333 | - | a.c.Context().Register(pdev) | 333 | + | registerWireProxy(a.c, pdev) |
| 334 | requestProtocol(t, a.c, pm, primaryselection.ZwpPrimarySelectionDeviceManagerV1RequestGetDevice, pdev.ID(), seat) | 334 | requestProtocol(t, a.c, pm, primaryselection.ZwpPrimarySelectionDeviceManagerV1RequestGetDevice, pdev.ID(), seat) | ||
| 335 | a.focus(t, commands) | 335 | a.focus(t, commands) | ||
| 336 | 336 | ||||
| @@ -340,7 +340,7 @@ func TestPrimarySelectionFromManager(t *testing.T) { | |||||
| 340 | manager := bindProtocol(t, m, "ext_data_control_manager_v1") | 340 | manager := bindProtocol(t, m, "ext_data_control_manager_v1") | ||
| 341 | dev := &controlEvents{c: m, offers: make(chan uint32, 8)} | 341 | dev := &controlEvents{c: m, offers: make(chan uint32, 8)} | ||
| 342 | dev.SetID(m.AllocateID()) | 342 | dev.SetID(m.AllocateID()) | ||
| 343 | - | m.Context().Register(dev) | 343 | + | registerWireProxy(m, dev) |
| 344 | requestProtocol(t, m, manager, extdatacontrol.ExtDataControlManagerV1RequestGetDataDevice, dev.ID(), mseat) | 344 | requestProtocol(t, m, manager, extdatacontrol.ExtDataControlManagerV1RequestGetDataDevice, dev.ID(), mseat) | ||
| 345 | src := m.AllocateID() | 345 | src := m.AllocateID() | ||
| 346 | registerProtocol(t, m, src) | 346 | registerProtocol(t, m, src) | ||
M internal/adapters/wayland/colormanagement_errors_test.go +1 -1
| @@ -72,7 +72,7 @@ func TestColorSurfaceErrors(t *testing.T) { | |||||
| 72 | global = name | 72 | global = name | ||
| 73 | } | 73 | } | ||
| 74 | } | 74 | } | ||
| 75 | - | out, err := c.Registry().BindID(global, "wl_output", 4) | 75 | + | out, err := bindWireID(c, global, "wl_output", 4) |
| 76 | if err != nil { | 76 | if err != nil { | ||
| 77 | t.Fatal(err) | 77 | t.Fatal(err) | ||
| 78 | } | 78 | } | ||
M internal/adapters/wayland/colormanagement_test.go +2 -2
| @@ -31,7 +31,7 @@ func (p *colorEvent) Dispatch(e *wlturbo.Event) { | |||||
| 31 | func watchColor(c *wlturbo.Display, id uint32) *colorEvent { | 31 | func watchColor(c *wlturbo.Display, id uint32) *colorEvent { | ||
| 32 | p := &colorEvent{} | 32 | p := &colorEvent{} | ||
| 33 | p.SetID(id) | 33 | p.SetID(id) | ||
| 34 | - | c.Context().Register(p) | 34 | + | registerWireProxy(c, p) |
| 35 | return p | 35 | return p | ||
| 36 | } | 36 | } | ||
| 37 | func colorServer(t *testing.T) (*Server, *wlturbo.Display, chan ports.OutputFormats) { | 37 | func colorServer(t *testing.T) (*Server, *wlturbo.Display, chan ports.OutputFormats) { | ||
| @@ -205,7 +205,7 @@ func TestColorOutputAndFeedback(t *testing.T) { | |||||
| 205 | name = id | 205 | name = id | ||
| 206 | } | 206 | } | ||
| 207 | } | 207 | } | ||
| 208 | - | out, err := c.Registry().BindID(name, "wl_output", 4) | 208 | + | out, err := bindWireID(c, name, "wl_output", 4) |
| 209 | if err != nil { | 209 | if err != nil { | ||
| 210 | t.Fatal(err) | 210 | t.Fatal(err) | ||
| 211 | } | 211 | } | ||
M internal/adapters/wayland/commit_hotpath_bench_test.go +8 -5
| @@ -31,7 +31,10 @@ func BenchmarkProtocolQueuedCommit(b *testing.B) { | |||||
| 31 | if err != nil { | 31 | if err != nil { | ||
| 32 | b.Fatal(err) | 32 | b.Fatal(err) | ||
| 33 | } | 33 | } | ||
| 34 | - | defer c.Close() | 34 | + | defer func() { |
| 35 | + | _ = c.Close() | |||
| 36 | + | forgetWireClient(c) | |||
| 37 | + | }() | |||
| 35 | if err := c.Roundtrip(); err != nil { | 38 | if err := c.Roundtrip(); err != nil { | ||
| 36 | b.Fatal(err) | 39 | b.Fatal(err) | ||
| 37 | } | 40 | } | ||
| @@ -40,14 +43,14 @@ func BenchmarkProtocolQueuedCommit(b *testing.B) { | |||||
| 40 | if !ok { | 43 | if !ok { | ||
| 41 | b.Fatalf("missing %s", name) | 44 | b.Fatalf("missing %s", name) | ||
| 42 | } | 45 | } | ||
| 43 | - | id, err := c.Registry().BindID(g.Name, g.Interface, 1) | 46 | + | id, err := bindWireID(c, g.Name, g.Interface, 1) |
| 44 | if err != nil { | 47 | if err != nil { | ||
| 45 | b.Fatal(err) | 48 | b.Fatal(err) | ||
| 46 | } | 49 | } | ||
| 47 | return id | 50 | return id | ||
| 48 | } | 51 | } | ||
| 49 | send := func(id, op uint32, args ...any) { | 52 | send := func(id, op uint32, args ...any) { | ||
| 50 | - | if err := c.SendRequest(id, uint16(op), args...); err != nil { | 53 | + | if err := wireRequest(c, id, uint16(op), nil, args...); err != nil { |
| 51 | b.Fatal(err) | 54 | b.Fatal(err) | ||
| 52 | } | 55 | } | ||
| 53 | } | 56 | } | ||
| @@ -56,7 +59,7 @@ func BenchmarkProtocolQueuedCommit(b *testing.B) { | |||||
| 56 | register := func(id uint32) { | 59 | register := func(id uint32) { | ||
| 57 | p := &protocolProxy{} | 60 | p := &protocolProxy{} | ||
| 58 | p.SetID(id) | 61 | p.SetID(id) | ||
| 59 | - | c.Context().Register(p) | 62 | + | registerWireProxy(c, p) |
| 60 | } | 63 | } | ||
| 61 | register(shm) | 64 | register(shm) | ||
| 62 | surf := c.AllocateID() | 65 | surf := c.AllocateID() | ||
| @@ -80,7 +83,7 @@ func BenchmarkProtocolQueuedCommit(b *testing.B) { | |||||
| 80 | pool, buf := c.AllocateID(), c.AllocateID() | 83 | pool, buf := c.AllocateID(), c.AllocateID() | ||
| 81 | register(pool) | 84 | register(pool) | ||
| 82 | register(buf) | 85 | register(buf) | ||
| 83 | - | if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(4)); err != nil { | 86 | + | if err := wireRequest(c, shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(4)); err != nil { |
| 84 | b.Fatal(err) | 87 | b.Fatal(err) | ||
| 85 | } | 88 | } | ||
| 86 | send(pool, wayland.ShmPoolRequestCreateBuffer, buf, int32(0), int32(1), int32(1), int32(4), uint32(0)) | 89 | send(pool, wayland.ShmPoolRequestCreateBuffer, buf, int32(0), int32(1), int32(1), int32(4), uint32(0)) | ||
M internal/adapters/wayland/constraints.go +11 -4
| @@ -52,7 +52,7 @@ type pointerWarp struct{ server *Server } | |||||
| 52 | func (pointerWarp) Destroy(*pointerwarp.WpPointerWarpV1) {} | 52 | func (pointerWarp) Destroy(*pointerwarp.WpPointerWarpV1) {} | ||
| 53 | func (h pointerWarp) WarpPointer(r *pointerwarp.WpPointerWarpV1, surf *wayland.Surface, p *wayland.Pointer, x, y server.Fixed, serial uint32) { | 53 | func (h pointerWarp) WarpPointer(r *pointerwarp.WpPointerWarpV1, surf *wayland.Surface, p *wayland.Pointer, x, y server.Fixed, serial uint32) { | ||
| 54 | s := h.server | 54 | s := h.server | ||
| 55 | - | if surf == nil || p == nil || p.Client() != r.Client() { | 55 | + | if s.protected() || surf == nil || p == nil || p.Client() != r.Client() { |
| 56 | return | 56 | return | ||
| 57 | } | 57 | } | ||
| 58 | if enter, ok := s.seat.enters[p.Resource]; !ok || enter != serial { | 58 | if enter, ok := s.seat.enters[p.Resource]; !ok || enter != serial { | ||
| @@ -75,7 +75,7 @@ func (relativeHandler) Destroy(*relativepointer.ZwpRelativePointerV1) {} | |||||
| 75 | // pointers. Absolute devices have no deltas and send nothing. | 75 | // pointers. Absolute devices have no deltas and send nothing. | ||
| 76 | // It reports whether any event was sent. | 76 | // It reports whether any event was sent. | ||
| 77 | func (s *Server) relativeMotion(w *window, c ports.PointerMotionTo) bool { | 77 | func (s *Server) relativeMotion(w *window, c ports.PointerMotionTo) bool { | ||
| 78 | - | if c.DX == 0 && c.DY == 0 && c.UnaccelDX == 0 && c.UnaccelDY == 0 { | 78 | + | if s.protected() || c.DX == 0 && c.DY == 0 && c.UnaccelDX == 0 && c.UnaccelDY == 0 { |
| 79 | return false | 79 | return false | ||
| 80 | } | 80 | } | ||
| 81 | if !w.mapped || !w.xdg.resource.Resource.Alive() { | 81 | if !w.mapped || !w.xdg.resource.Resource.Alive() { | ||
| @@ -142,7 +142,7 @@ func (m constraintManager) ConfinePointer(r *pointerconstraints.ZwpPointerConstr | |||||
| 142 | // create validates the request and registers the constraint on the surface. | 142 | // create validates the request and registers the constraint on the surface. | ||
| 143 | func (m constraintManager) create(r *pointerconstraints.ZwpPointerConstraintsV1, surf *wayland.Surface, reg *wayland.Region, lifetime uint32) *constraint { | 143 | func (m constraintManager) create(r *pointerconstraints.ZwpPointerConstraintsV1, surf *wayland.Surface, reg *wayland.Region, lifetime uint32) *constraint { | ||
| 144 | s := m.server | 144 | s := m.server | ||
| 145 | - | if surf == nil { | 145 | + | if s.protected() || surf == nil { |
| 146 | return nil | 146 | return nil | ||
| 147 | } | 147 | } | ||
| 148 | state := s.surfaces[surf.Resource] | 148 | state := s.surfaces[surf.Resource] | ||
| @@ -263,6 +263,10 @@ func (s *Server) deactivateConstraint() { | |||||
| 263 | // updateConstraint activates the constraint of the window holding both | 263 | // updateConstraint activates the constraint of the window holding both | ||
| 264 | // pointer and keyboard focus, and deactivates any other. | 264 | // pointer and keyboard focus, and deactivates any other. | ||
| 265 | func (s *Server) updateConstraint() { | 265 | func (s *Server) updateConstraint() { | ||
| 266 | + | if s.protected() { | |||
| 267 | + | s.deactivateConstraint() | |||
| 268 | + | return | |||
| 269 | + | } | |||
| 266 | var want *constraint | 270 | var want *constraint | ||
| 267 | if w := s.windows[s.seat.pointerFocus]; w != nil && w.mapped && s.seat.pointerFocus == s.seat.focused { | 271 | if w := s.windows[s.seat.pointerFocus]; w != nil && w.mapped && s.seat.pointerFocus == s.seat.focused { | ||
| 268 | if c := s.constraints[w.xdg.surface]; c != nil && !c.defunct && (c.active || c.contains(w)) { | 272 | if c := s.constraints[w.xdg.surface]; c != nil && !c.defunct && (c.active || c.contains(w)) { | ||
| @@ -288,6 +292,9 @@ func (s *Server) updateConstraint() { | |||||
| 288 | 292 | ||||
| 289 | // emitConstraint tells core about the active constraint, window-local. | 293 | // emitConstraint tells core about the active constraint, window-local. | ||
| 290 | func (s *Server) emitConstraint(c *constraint) { | 294 | func (s *Server) emitConstraint(c *constraint) { | ||
| 295 | + | if s.protected() { | |||
| 296 | + | return | |||
| 297 | + | } | |||
| 291 | x := c.surface.xdg | 298 | x := c.surface.xdg | ||
| 292 | if x == nil || x.window == nil { | 299 | if x == nil || x.window == nil { | ||
| 293 | return | 300 | return | ||
| @@ -353,5 +360,5 @@ func (c *constraint) contains(w *window) bool { | |||||
| 353 | 360 | ||||
| 354 | // locked reports whether the window's pointer is locked. | 361 | // locked reports whether the window's pointer is locked. | ||
| 355 | func (s *Server) locked(w *window) bool { | 362 | func (s *Server) locked(w *window) bool { | ||
| 356 | - | return s.constraint != nil && s.constraint.lock != nil && s.constraint.surface == w.xdg.surface | 363 | + | return !s.protected() && s.constraint != nil && s.constraint.lock != nil && s.constraint.surface == w.xdg.surface |
| 357 | } | 364 | } | ||
M internal/adapters/wayland/constraints_test.go +3 -3
| @@ -39,7 +39,7 @@ func logProxy(t *testing.T, c *wlturbo.Display, id uint32) chan []float64 { | |||||
| 39 | t.Helper() | 39 | t.Helper() | ||
| 40 | p := &eventLog{events: make(chan []float64, 64)} | 40 | p := &eventLog{events: make(chan []float64, 64)} | ||
| 41 | p.SetID(id) | 41 | p.SetID(id) | ||
| 42 | - | c.Context().Register(p) | 42 | + | registerWireProxy(c, p) |
| 43 | return p.events | 43 | return p.events | ||
| 44 | } | 44 | } | ||
| 45 | 45 | ||||
| @@ -80,7 +80,7 @@ func TestPointerWarp(t *testing.T) { | |||||
| 80 | pointer := c.AllocateID() | 80 | pointer := c.AllocateID() | ||
| 81 | p := &eventLog{events: make(chan []float64, 64), pointer: true} | 81 | p := &eventLog{events: make(chan []float64, 64), pointer: true} | ||
| 82 | p.SetID(pointer) | 82 | p.SetID(pointer) | ||
| 83 | - | c.Context().Register(p) | 83 | + | registerWireProxy(c, p) |
| 84 | requestProtocol(t, c, seat, wayland.SeatRequestGetPointer, pointer) | 84 | requestProtocol(t, c, seat, wayland.SeatRequestGetPointer, pointer) | ||
| 85 | w, surf, xdgID := surfaceMapper(t, c, events)() | 85 | w, surf, xdgID := surfaceMapper(t, c, events)() | ||
| 86 | requestProtocol(t, c, xdgID, xdgshell.SurfaceRequestSetWindowGeometry, int32(4), int32(6), int32(50), int32(50)) | 86 | requestProtocol(t, c, xdgID, xdgshell.SurfaceRequestSetWindowGeometry, int32(4), int32(6), int32(50), int32(50)) | ||
| @@ -100,7 +100,7 @@ func TestPointerWarp(t *testing.T) { | |||||
| 100 | pointer2 := c.AllocateID() | 100 | pointer2 := c.AllocateID() | ||
| 101 | p2 := &eventLog{events: make(chan []float64, 64), pointer: true} | 101 | p2 := &eventLog{events: make(chan []float64, 64), pointer: true} | ||
| 102 | p2.SetID(pointer2) | 102 | p2.SetID(pointer2) | ||
| 103 | - | c.Context().Register(p2) | 103 | + | registerWireProxy(c, p2) |
| 104 | requestProtocol(t, c, seat, wayland.SeatRequestGetPointer, pointer2) | 104 | requestProtocol(t, c, seat, wayland.SeatRequestGetPointer, pointer2) | ||
| 105 | requestProtocol(t, c, warp, pointerwarp.WpPointerWarpV1RequestWarpPointer, surf, pointer2, wlturbo.Fixed(10*256), wlturbo.Fixed(10*256), serial) | 105 | requestProtocol(t, c, warp, pointerwarp.WpPointerWarpV1RequestWarpPointer, surf, pointer2, wlturbo.Fixed(10*256), wlturbo.Fixed(10*256), serial) | ||
| 106 | // A stale serial is ignored; the enter serial moves the pointer. | 106 | // A stale serial is ignored; the enter serial moves the pointer. | ||
M internal/adapters/wayland/cursor.go +6 -3
| @@ -59,7 +59,7 @@ func (d cursorShapeDevice) SetShape(r *cursorshape.WpCursorShapeDeviceV1, _ uint | |||||
| 59 | return | 59 | return | ||
| 60 | } | 60 | } | ||
| 61 | s := d.server | 61 | s := d.server | ||
| 62 | - | if !s.hasPointerFocus(r.Client()) { | 62 | + | if s.protected() || !s.hasPointerFocus(r.Client()) { |
| 63 | return | 63 | return | ||
| 64 | } | 64 | } | ||
| 65 | s.cursorSurface = nil | 65 | s.cursorSurface = nil | ||
| @@ -70,6 +70,9 @@ type pointer struct{ server *Server } | |||||
| 70 | 70 | ||||
| 71 | func (p pointer) SetCursor(r *wayland.Pointer, _ uint32, surf *wayland.Surface, hotX, hotY int32) { | 71 | func (p pointer) SetCursor(r *wayland.Pointer, _ uint32, surf *wayland.Surface, hotX, hotY int32) { | ||
| 72 | s := p.server | 72 | s := p.server | ||
| 73 | + | if s.protected() { | |||
| 74 | + | return | |||
| 75 | + | } | |||
| 73 | var state *surface | 76 | var state *surface | ||
| 74 | if surf != nil { | 77 | if surf != nil { | ||
| 75 | state = s.surfaces[surf.Resource] | 78 | state = s.surfaces[surf.Resource] | ||
| @@ -137,7 +140,7 @@ func (s *surface) cursorImage() ports.CursorChange { | |||||
| 137 | 140 | ||||
| 138 | // setCursor queues a cursor change; only the latest is delivered. | 141 | // setCursor queues a cursor change; only the latest is delivered. | ||
| 139 | func (s *Server) setCursor(c ports.CursorChange) { | 142 | func (s *Server) setCursor(c ports.CursorChange) { | ||
| 140 | - | if s.channels.Cursors == nil { | 143 | + | if s.protected() || s.channels.Cursors == nil { |
| 141 | return | 144 | return | ||
| 142 | } | 145 | } | ||
| 143 | s.cursorMu.Lock() | 146 | s.cursorMu.Lock() | ||
| @@ -163,7 +166,7 @@ func (s *Server) forwardCursors(ctx context.Context) { | |||||
| 163 | c, ok := s.cursorLatest, s.cursorQueued | 166 | c, ok := s.cursorLatest, s.cursorQueued | ||
| 164 | s.cursorQueued = false | 167 | s.cursorQueued = false | ||
| 165 | s.cursorMu.Unlock() | 168 | s.cursorMu.Unlock() | ||
| 166 | - | if !ok { | 169 | + | if !ok || s.protected() { |
| 167 | continue | 170 | continue | ||
| 168 | } | 171 | } | ||
| 169 | select { | 172 | select { | ||
M internal/adapters/wayland/cursor_test.go +1 -1
| @@ -147,7 +147,7 @@ func TestSetCursorSurface(t *testing.T) { | |||||
| 147 | pool, buf := c.AllocateID(), c.AllocateID() | 147 | pool, buf := c.AllocateID(), c.AllocateID() | ||
| 148 | registerProtocol(t, c, pool) | 148 | registerProtocol(t, c, pool) | ||
| 149 | registerProtocol(t, c, buf) | 149 | registerProtocol(t, c, buf) | ||
| 150 | - | if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(12)); err != nil { | 150 | + | if err := wireRequest(c, shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(12)); err != nil { |
| 151 | t.Fatal(err) | 151 | t.Fatal(err) | ||
| 152 | } | 152 | } | ||
| 153 | requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buf, int32(0), int32(2), int32(1), int32(12), uint32(wayland.ShmFormatArgb8888)) | 153 | requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buf, int32(0), int32(2), int32(1), int32(12), uint32(wayland.ShmFormatArgb8888)) | ||
M internal/adapters/wayland/damage_test.go +2 -2
| @@ -26,7 +26,7 @@ func TestDamageHistory(t *testing.T) { | |||||
| 26 | serials := make(chan uint32, 8) | 26 | serials := make(chan uint32, 8) | ||
| 27 | xp := &configureProxy{serial: serials} | 27 | xp := &configureProxy{serial: serials} | ||
| 28 | xp.SetID(xdg) | 28 | xp.SetID(xdg) | ||
| 29 | - | c.Context().Register(xp) | 29 | + | registerWireProxy(c, xp) |
| 30 | registerProtocol(t, c, top) | 30 | registerProtocol(t, c, top) | ||
| 31 | requestProtocol(t, c, xdg, xdgshell.SurfaceRequestGetToplevel, top) | 31 | requestProtocol(t, c, xdg, xdgshell.SurfaceRequestGetToplevel, top) | ||
| 32 | requestProtocol(t, c, surf, wayland.SurfaceRequestCommit) | 32 | requestProtocol(t, c, surf, wayland.SurfaceRequestCommit) | ||
| @@ -44,7 +44,7 @@ func TestDamageHistory(t *testing.T) { | |||||
| 44 | } | 44 | } | ||
| 45 | pool := c.AllocateID() | 45 | pool := c.AllocateID() | ||
| 46 | registerProtocol(t, c, pool) | 46 | registerProtocol(t, c, pool) | ||
| 47 | - | if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(2*16*16*4)); err != nil { | 47 | + | if err := wireRequest(c, shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(2*16*16*4)); err != nil { |
| 48 | t.Fatal(err) | 48 | t.Fatal(err) | ||
| 49 | } | 49 | } | ||
| 50 | bufs := [2]uint32{c.AllocateID(), c.AllocateID()} | 50 | bufs := [2]uint32{c.AllocateID(), c.AllocateID()} | ||
M internal/adapters/wayland/decoration_test.go +1 -1
| @@ -31,7 +31,7 @@ func TestKDEDecorationAcknowledgesMode(t *testing.T) { | |||||
| 31 | deco := c.AllocateID() | 31 | deco := c.AllocateID() | ||
| 32 | proxy := &kdeModeProxy{modes: make(chan uint32, 8)} | 32 | proxy := &kdeModeProxy{modes: make(chan uint32, 8)} | ||
| 33 | proxy.SetID(deco) | 33 | proxy.SetID(deco) | ||
| 34 | - | c.Context().Register(proxy) | 34 | + | registerWireProxy(c, proxy) |
| 35 | requestProtocol(t, c, mgr, kdedecoration.OrgKdeKwinServerDecorationManagerRequestCreate, deco, surf) | 35 | requestProtocol(t, c, mgr, kdedecoration.OrgKdeKwinServerDecorationManagerRequestCreate, deco, surf) | ||
| 36 | requestProtocol(t, c, deco, kdedecoration.OrgKdeKwinServerDecorationRequestRequestMode, uint32(kdedecoration.OrgKdeKwinServerDecorationModeClient)) | 36 | requestProtocol(t, c, deco, kdedecoration.OrgKdeKwinServerDecorationRequestRequestMode, uint32(kdedecoration.OrgKdeKwinServerDecorationModeClient)) | ||
| 37 | // An undefined mode gets no answer. | 37 | // An undefined mode gets no answer. | ||
M internal/adapters/wayland/dmabuf_test.go +8 -8
| @@ -66,7 +66,7 @@ func TestDMABufParams(t *testing.T) { | |||||
| 66 | if !ok || g.Version != dmabufVersion { | 66 | if !ok || g.Version != dmabufVersion { | ||
| 67 | t.Fatalf("global %+v", g) | 67 | t.Fatalf("global %+v", g) | ||
| 68 | } | 68 | } | ||
| 69 | - | dm, err := c.Registry().BindID(g.Name, g.Interface, dmabufVersion) | 69 | + | dm, err := bindWireID(c, g.Name, g.Interface, dmabufVersion) |
| 70 | if err != nil { | 70 | if err != nil { | ||
| 71 | t.Fatal(err) | 71 | t.Fatal(err) | ||
| 72 | } | 72 | } | ||
| @@ -83,8 +83,8 @@ func TestDMABufParams(t *testing.T) { | |||||
| 83 | requestProtocol(t, c, dm, linuxdmabuf.ZwpLinuxDmabufV1RequestCreateParams, p) | 83 | requestProtocol(t, c, dm, linuxdmabuf.ZwpLinuxDmabufV1RequestCreateParams, p) | ||
| 84 | proxy := ¶msProxy{result: make(chan uint16, 2)} | 84 | proxy := ¶msProxy{result: make(chan uint16, 2)} | ||
| 85 | proxy.SetID(p) | 85 | proxy.SetID(p) | ||
| 86 | - | c.Context().Register(proxy) | 86 | + | registerWireProxy(c, proxy) |
| 87 | - | if err := c.SendRequestWithFDs(p, uint16(linuxdmabuf.ZwpLinuxBufferParamsV1RequestAdd), []int{fd}, uint32(0), uint32(0), uint32(256), uint32(0), uint32(0)); err != nil { | 87 | + | if err := wireRequest(c, p, uint16(linuxdmabuf.ZwpLinuxBufferParamsV1RequestAdd), []int{fd}, uint32(0), uint32(0), uint32(256), uint32(0), uint32(0)); err != nil { |
| 88 | t.Fatal(err) | 88 | t.Fatal(err) | ||
| 89 | } | 89 | } | ||
| 90 | requestProtocol(t, c, p, linuxdmabuf.ZwpLinuxBufferParamsV1RequestCreate, int32(64), height, format, uint32(0)) | 90 | requestProtocol(t, c, p, linuxdmabuf.ZwpLinuxBufferParamsV1RequestCreate, int32(64), height, format, uint32(0)) | ||
| @@ -106,7 +106,7 @@ func TestDMABufParams(t *testing.T) { | |||||
| 106 | // Taller than the file: out of bounds. | 106 | // Taller than the file: out of bounds. | ||
| 107 | big := c.AllocateID() | 107 | big := c.AllocateID() | ||
| 108 | requestProtocol(t, c, dm, linuxdmabuf.ZwpLinuxDmabufV1RequestCreateParams, big) | 108 | requestProtocol(t, c, dm, linuxdmabuf.ZwpLinuxDmabufV1RequestCreateParams, big) | ||
| 109 | - | if err := c.SendRequestWithFDs(big, uint16(linuxdmabuf.ZwpLinuxBufferParamsV1RequestAdd), []int{fd}, uint32(0), uint32(0), uint32(256), uint32(0), uint32(0)); err != nil { | 109 | + | if err := wireRequest(c, big, uint16(linuxdmabuf.ZwpLinuxBufferParamsV1RequestAdd), []int{fd}, uint32(0), uint32(0), uint32(256), uint32(0), uint32(0)); err != nil { |
| 110 | t.Fatal(err) | 110 | t.Fatal(err) | ||
| 111 | } | 111 | } | ||
| 112 | requestProtocol(t, c, big, linuxdmabuf.ZwpLinuxBufferParamsV1RequestCreate, int32(64), int32(17), linearARGB.Format, uint32(0)) | 112 | requestProtocol(t, c, big, linuxdmabuf.ZwpLinuxBufferParamsV1RequestCreate, int32(64), int32(17), linearARGB.Format, uint32(0)) | ||
| @@ -118,7 +118,7 @@ func TestDMABufParamsReuse(t *testing.T) { | |||||
| 118 | s, _, _, dir := dmabufServer(t, ports.DMABufSupport{Formats: []ports.DMABufFormat{linearARGB}}) | 118 | s, _, _, dir := dmabufServer(t, ports.DMABufSupport{Formats: []ports.DMABufFormat{linearARGB}}) | ||
| 119 | c := protocolClient(t, s, dir) | 119 | c := protocolClient(t, s, dir) | ||
| 120 | g, _ := c.Registry().FindGlobal("zwp_linux_dmabuf_v1") | 120 | g, _ := c.Registry().FindGlobal("zwp_linux_dmabuf_v1") | ||
| 121 | - | dm, err := c.Registry().BindID(g.Name, g.Interface, dmabufVersion) | 121 | + | dm, err := bindWireID(c, g.Name, g.Interface, dmabufVersion) |
| 122 | if err != nil { | 122 | if err != nil { | ||
| 123 | t.Fatal(err) | 123 | t.Fatal(err) | ||
| 124 | } | 124 | } | ||
| @@ -134,8 +134,8 @@ func TestDMABufParamsReuse(t *testing.T) { | |||||
| 134 | requestProtocol(t, c, dm, linuxdmabuf.ZwpLinuxDmabufV1RequestCreateParams, p) | 134 | requestProtocol(t, c, dm, linuxdmabuf.ZwpLinuxDmabufV1RequestCreateParams, p) | ||
| 135 | proxy := ¶msProxy{result: make(chan uint16, 2)} | 135 | proxy := ¶msProxy{result: make(chan uint16, 2)} | ||
| 136 | proxy.SetID(p) | 136 | proxy.SetID(p) | ||
| 137 | - | c.Context().Register(proxy) | 137 | + | registerWireProxy(c, proxy) |
| 138 | - | if err := c.SendRequestWithFDs(p, uint16(linuxdmabuf.ZwpLinuxBufferParamsV1RequestAdd), []int{fd}, uint32(0), uint32(0), uint32(256), uint32(0), uint32(0)); err != nil { | 138 | + | if err := wireRequest(c, p, uint16(linuxdmabuf.ZwpLinuxBufferParamsV1RequestAdd), []int{fd}, uint32(0), uint32(0), uint32(256), uint32(0), uint32(0)); err != nil { |
| 139 | t.Fatal(err) | 139 | t.Fatal(err) | ||
| 140 | } | 140 | } | ||
| 141 | requestProtocol(t, c, p, linuxdmabuf.ZwpLinuxBufferParamsV1RequestCreate, int32(64), int32(16), linearARGB.Format, uint32(0)) | 141 | requestProtocol(t, c, p, linuxdmabuf.ZwpLinuxBufferParamsV1RequestCreate, int32(64), int32(16), linearARGB.Format, uint32(0)) | ||
| @@ -232,7 +232,7 @@ func TestDMABufScanoutTranche(t *testing.T) { | |||||
| 232 | fb := c.AllocateID() | 232 | fb := c.AllocateID() | ||
| 233 | proxy := &feedbackProxy{rounds: make(chan []tranche, 8)} | 233 | proxy := &feedbackProxy{rounds: make(chan []tranche, 8)} | ||
| 234 | proxy.SetID(fb) | 234 | proxy.SetID(fb) | ||
| 235 | - | c.Context().Register(proxy) | 235 | + | registerWireProxy(c, proxy) |
| 236 | requestProtocol(t, c, dm, linuxdmabuf.ZwpLinuxDmabufV1RequestGetSurfaceFeedback, fb, surf) | 236 | requestProtocol(t, c, dm, linuxdmabuf.ZwpLinuxDmabufV1RequestGetSurfaceFeedback, fb, surf) | ||
| 237 | round := func(what string) []tranche { | 237 | round := func(what string) []tranche { | ||
| 238 | t.Helper() | 238 | t.Helper() | ||
M internal/adapters/wayland/dmabuf_yuv_test.go +2 -2
| @@ -32,7 +32,7 @@ func TestYUVParams(t *testing.T) { | |||||
| 32 | requestProtocol(t, c, dm, linuxdmabuf.ZwpLinuxDmabufV1RequestCreateParams, p) | 32 | requestProtocol(t, c, dm, linuxdmabuf.ZwpLinuxDmabufV1RequestCreateParams, p) | ||
| 33 | proxy := ¶msProxy{result: make(chan uint16, 2)} | 33 | proxy := ¶msProxy{result: make(chan uint16, 2)} | ||
| 34 | proxy.SetID(p) | 34 | proxy.SetID(p) | ||
| 35 | - | c.Context().Register(proxy) | 35 | + | registerWireProxy(c, proxy) |
| 36 | fd, err := unix.MemfdCreate("yuv-params", 0) | 36 | fd, err := unix.MemfdCreate("yuv-params", 0) | ||
| 37 | if err != nil { | 37 | if err != nil { | ||
| 38 | t.Fatal(err) | 38 | t.Fatal(err) | ||
| @@ -43,7 +43,7 @@ func TestYUVParams(t *testing.T) { | |||||
| 43 | } | 43 | } | ||
| 44 | add := func(idx, offset, stride, mod uint32) { | 44 | add := func(idx, offset, stride, mod uint32) { | ||
| 45 | t.Helper() | 45 | t.Helper() | ||
| 46 | - | if err := c.SendRequestWithFDs(p, uint16(linuxdmabuf.ZwpLinuxBufferParamsV1RequestAdd), []int{fd}, idx, offset, stride, uint32(0), mod); err != nil { | 46 | + | if err := wireRequest(c, p, uint16(linuxdmabuf.ZwpLinuxBufferParamsV1RequestAdd), []int{fd}, idx, offset, stride, uint32(0), mod); err != nil { |
| 47 | t.Fatal(err) | 47 | t.Fatal(err) | ||
| 48 | } | 48 | } | ||
| 49 | } | 49 | } | ||
M internal/adapters/wayland/drmlease.go +8 -1
| @@ -90,13 +90,16 @@ func (s *Server) applyLeaseEvent(event ports.LeaseMessage) { | |||||
| 90 | card := s.pendingLeaseCards[v.ID] | 90 | card := s.pendingLeaseCards[v.ID] | ||
| 91 | delete(s.pendingLeases, v.ID) | 91 | delete(s.pendingLeases, v.ID) | ||
| 92 | delete(s.pendingLeaseCards, v.ID) | 92 | delete(s.pendingLeaseCards, v.ID) | ||
| 93 | - | if obj == nil || !obj.res.Alive() { | 93 | + | if obj == nil || !obj.res.Alive() || s.security != nil && s.security.Snapshot().Protected { |
| 94 | if v.FD != nil { | 94 | if v.FD != nil { | ||
| 95 | v.FD.Close() | 95 | v.FD.Close() | ||
| 96 | } | 96 | } | ||
| 97 | if v.LeaseID != 0 && card != "" { | 97 | if v.LeaseID != 0 && card != "" { | ||
| 98 | s.sendLeaseRequest(ports.LeaseRevoke{Card: card, LeaseID: v.LeaseID}) | 98 | s.sendLeaseRequest(ports.LeaseRevoke{Card: card, LeaseID: v.LeaseID}) | ||
| 99 | } | 99 | } | ||
| 100 | + | if obj != nil { | |||
| 101 | + | obj.finish() | |||
| 102 | + | } | |||
| 100 | return | 103 | return | ||
| 101 | } | 104 | } | ||
| 102 | if v.Err != nil || v.FD == nil { | 105 | if v.Err != nil || v.FD == nil { | ||
| @@ -316,6 +319,10 @@ func (r *leaseRequestHandler) Submit(self *drmlease.WpDrmLeaseRequestV1, id uint | |||||
| 316 | } | 319 | } | ||
| 317 | obj.res = res | 320 | obj.res = res | ||
| 318 | res.OnDestroy = func() { obj.destroy() } | 321 | res.OnDestroy = func() { obj.destroy() } | ||
| 322 | + | if s.security != nil && s.security.Snapshot().Protected { | |||
| 323 | + | obj.finish() | |||
| 324 | + | return | |||
| 325 | + | } | |||
| 319 | for _, name := range r.names { | 326 | for _, name := range r.names { | ||
| 320 | if _, ok := r.bind.device.connectors[name]; !ok || r.withdrawn { | 327 | if _, ok := r.bind.device.connectors[name]; !ok || r.withdrawn { | ||
| 321 | obj.finish() | 328 | obj.finish() | ||
M internal/adapters/wayland/drmlease_test.go +10 -10
| @@ -31,7 +31,7 @@ func (p *leaseDeviceEvents) Dispatch(e *wlturbo.Event) { | |||||
| 31 | p.connectors = append(p.connectors, id) | 31 | p.connectors = append(p.connectors, id) | ||
| 32 | child := &leaseConnectorEvents{} | 32 | child := &leaseConnectorEvents{} | ||
| 33 | child.SetID(id) | 33 | child.SetID(id) | ||
| 34 | - | p.c.Context().Register(child) | 34 | + | registerWireProxy(p.c, child) |
| 35 | } | 35 | } | ||
| 36 | } | 36 | } | ||
| 37 | 37 | ||||
| @@ -117,7 +117,7 @@ func TestDRMLeaseFlow(t *testing.T) { | |||||
| 117 | dev := bindProtocol(t, c, "wp_drm_lease_device_v1") | 117 | dev := bindProtocol(t, c, "wp_drm_lease_device_v1") | ||
| 118 | p := &leaseDeviceEvents{c: c} | 118 | p := &leaseDeviceEvents{c: c} | ||
| 119 | p.SetID(dev) | 119 | p.SetID(dev) | ||
| 120 | - | c.Context().Register(p) | 120 | + | registerWireProxy(c, p) |
| 121 | if err := c.Roundtrip(); err != nil { | 121 | if err := c.Roundtrip(); err != nil { | ||
| 122 | t.Fatal(err) | 122 | t.Fatal(err) | ||
| 123 | } | 123 | } | ||
| @@ -133,7 +133,7 @@ func TestDRMLeaseFlow(t *testing.T) { | |||||
| 133 | lease := c.AllocateID() | 133 | lease := c.AllocateID() | ||
| 134 | l := &leaseResultEvents{} | 134 | l := &leaseResultEvents{} | ||
| 135 | l.SetID(lease) | 135 | l.SetID(lease) | ||
| 136 | - | c.Context().Register(l) | 136 | + | registerWireProxy(c, l) |
| 137 | requestProtocol(t, c, req, dl.WpDrmLeaseRequestV1RequestSubmit, lease) | 137 | requestProtocol(t, c, req, dl.WpDrmLeaseRequestV1RequestSubmit, lease) | ||
| 138 | if err := c.Roundtrip(); err != nil { | 138 | if err := c.Roundtrip(); err != nil { | ||
| 139 | t.Fatal(err) | 139 | t.Fatal(err) | ||
| @@ -194,7 +194,7 @@ func TestDRMLeaseErrors(t *testing.T) { | |||||
| 194 | dev := bindProtocol(t, c, "wp_drm_lease_device_v1") | 194 | dev := bindProtocol(t, c, "wp_drm_lease_device_v1") | ||
| 195 | p := &leaseDeviceEvents{c: c} | 195 | p := &leaseDeviceEvents{c: c} | ||
| 196 | p.SetID(dev) | 196 | p.SetID(dev) | ||
| 197 | - | c.Context().Register(p) | 197 | + | registerWireProxy(c, p) |
| 198 | if err := c.Roundtrip(); err != nil { | 198 | if err := c.Roundtrip(); err != nil { | ||
| 199 | t.Fatal(err) | 199 | t.Fatal(err) | ||
| 200 | } | 200 | } | ||
| @@ -222,7 +222,7 @@ func TestDRMLeaseFinished(t *testing.T) { | |||||
| 222 | dev := bindProtocol(t, c, "wp_drm_lease_device_v1") | 222 | dev := bindProtocol(t, c, "wp_drm_lease_device_v1") | ||
| 223 | p := &leaseDeviceEvents{c: c} | 223 | p := &leaseDeviceEvents{c: c} | ||
| 224 | p.SetID(dev) | 224 | p.SetID(dev) | ||
| 225 | - | c.Context().Register(p) | 225 | + | registerWireProxy(c, p) |
| 226 | if err := c.Roundtrip(); err != nil { | 226 | if err := c.Roundtrip(); err != nil { | ||
| 227 | t.Fatal(err) | 227 | t.Fatal(err) | ||
| 228 | } | 228 | } | ||
| @@ -234,7 +234,7 @@ func TestDRMLeaseFinished(t *testing.T) { | |||||
| 234 | lease := c.AllocateID() | 234 | lease := c.AllocateID() | ||
| 235 | l := &leaseResultEvents{} | 235 | l := &leaseResultEvents{} | ||
| 236 | l.SetID(lease) | 236 | l.SetID(lease) | ||
| 237 | - | c.Context().Register(l) | 237 | + | registerWireProxy(c, l) |
| 238 | requestProtocol(t, c, req, dl.WpDrmLeaseRequestV1RequestSubmit, lease) | 238 | requestProtocol(t, c, req, dl.WpDrmLeaseRequestV1RequestSubmit, lease) | ||
| 239 | if err := c.Roundtrip(); err != nil { | 239 | if err := c.Roundtrip(); err != nil { | ||
| 240 | t.Fatal(err) | 240 | t.Fatal(err) | ||
| @@ -347,7 +347,7 @@ func TestLeaseIDsAreScopedToCard(t *testing.T) { | |||||
| 347 | var devices []uint32 | 347 | var devices []uint32 | ||
| 348 | for _, g := range c.Registry().GetGlobals() { | 348 | for _, g := range c.Registry().GetGlobals() { | ||
| 349 | if g.Interface == "wp_drm_lease_device_v1" { | 349 | if g.Interface == "wp_drm_lease_device_v1" { | ||
| 350 | - | id, err := c.Registry().BindID(g.Name, g.Interface, 1) | 350 | + | id, err := bindWireID(c, g.Name, g.Interface, 1) |
| 351 | if err != nil { | 351 | if err != nil { | ||
| 352 | t.Fatal(err) | 352 | t.Fatal(err) | ||
| 353 | } | 353 | } | ||
| @@ -361,7 +361,7 @@ func TestLeaseIDsAreScopedToCard(t *testing.T) { | |||||
| 361 | for _, dev := range devices { | 361 | for _, dev := range devices { | ||
| 362 | p := &leaseDeviceEvents{c: c} | 362 | p := &leaseDeviceEvents{c: c} | ||
| 363 | p.SetID(dev) | 363 | p.SetID(dev) | ||
| 364 | - | c.Context().Register(p) | 364 | + | registerWireProxy(c, p) |
| 365 | bound = append(bound, p) | 365 | bound = append(bound, p) | ||
| 366 | } | 366 | } | ||
| 367 | if err := c.Roundtrip(); err != nil { | 367 | if err := c.Roundtrip(); err != nil { | ||
| @@ -380,7 +380,7 @@ func TestLeaseIDsAreScopedToCard(t *testing.T) { | |||||
| 380 | id := c.AllocateID() | 380 | id := c.AllocateID() | ||
| 381 | l := &leaseResultEvents{} | 381 | l := &leaseResultEvents{} | ||
| 382 | l.SetID(id) | 382 | l.SetID(id) | ||
| 383 | - | c.Context().Register(l) | 383 | + | registerWireProxy(c, l) |
| 384 | requestProtocol(t, c, req, dl.WpDrmLeaseRequestV1RequestSubmit, id) | 384 | requestProtocol(t, c, req, dl.WpDrmLeaseRequestV1RequestSubmit, id) | ||
| 385 | } | 385 | } | ||
| 386 | if err := c.Roundtrip(); err != nil { | 386 | if err := c.Roundtrip(); err != nil { | ||
| @@ -447,7 +447,7 @@ func TestLeaseDestroyPendingRevokesLateReply(t *testing.T) { | |||||
| 447 | dev := bindProtocol(t, c, "wp_drm_lease_device_v1") | 447 | dev := bindProtocol(t, c, "wp_drm_lease_device_v1") | ||
| 448 | p := &leaseDeviceEvents{c: c} | 448 | p := &leaseDeviceEvents{c: c} | ||
| 449 | p.SetID(dev) | 449 | p.SetID(dev) | ||
| 450 | - | c.Context().Register(p) | 450 | + | registerWireProxy(c, p) |
| 451 | if err := c.Roundtrip(); err != nil { | 451 | if err := c.Roundtrip(); err != nil { | ||
| 452 | t.Fatal(err) | 452 | t.Fatal(err) | ||
| 453 | } | 453 | } | ||
M internal/adapters/wayland/fifo.go +92 -13
| @@ -217,12 +217,14 @@ type update struct { | |||||
| 217 | region *ports.Rect | 217 | region *ports.Rect | ||
| 218 | layer *layerSurface | 218 | layer *layerSurface | ||
| 219 | layerNext layerState | 219 | layerNext layerState | ||
| 220 | + | lockAck lockConfigure | |||
| 220 | readyGeneration uint64 | 221 | readyGeneration uint64 | ||
| 221 | readyResult bool | 222 | readyResult bool | ||
| 222 | // refs counts incoming deps and prev links. Retired updates remain readable | 223 | // refs counts incoming deps and prev links. Retired updates remain readable | ||
| 223 | // until their last incoming link goes away, even after dropQueue. | 224 | // until their last incoming link goes away, even after dropQueue. | ||
| 224 | refs int | 225 | refs int | ||
| 225 | retired bool | 226 | retired bool | ||
| 227 | + | failed bool // acquire observation failed: discard, never publish | |||
| 226 | } | 228 | } | ||
| 227 | 229 | ||||
| 228 | // updatePool contains only retired updates with no incoming graph links. A | 230 | // updatePool contains only retired updates with no incoming graph links. A | ||
| @@ -308,6 +310,9 @@ func (s *surface) takePending() update { | |||||
| 308 | if s.layer != nil { | 310 | if s.layer != nil { | ||
| 309 | u.layer, u.layerNext = s.layer, s.layer.pending | 311 | u.layer, u.layerNext = s.layer, s.layer.pending | ||
| 310 | } | 312 | } | ||
| 313 | + | if s.lock != nil { | |||
| 314 | + | u.lockAck = s.lock.ack | |||
| 315 | + | } | |||
| 311 | return u | 316 | return u | ||
| 312 | } | 317 | } | ||
| 313 | 318 | ||||
| @@ -349,7 +354,8 @@ func (s *surface) queueUpdate() { | |||||
| 349 | if b, ok := s.server.buffers[u.buffer.Resource].(*dmabufBuffer); ok { | 354 | if b, ok := s.server.buffers[u.buffer.Resource].(*dmabufBuffer); ok { | ||
| 350 | err := s.server.syncWait.watchImplicit(b.buf, &u.implicit) | 355 | err := s.server.syncWait.watchImplicit(b.buf, &u.implicit) | ||
| 351 | if err != nil { | 356 | if err != nil { | ||
| 352 | - | // Partial registrations were cancelled; do not block this update. | 357 | + | // Partial registrations were cancelled; discard this update. |
| 358 | + | u.failed = true | |||
| 353 | s.server.log.Warn().Str("component", "wayland").Err(err).Msg("implicit buffer wait") | 359 | s.server.log.Warn().Str("component", "wayland").Err(err).Msg("implicit buffer wait") | ||
| 354 | } | 360 | } | ||
| 355 | } | 361 | } | ||
| @@ -396,7 +402,7 @@ func (s *surface) applyUpdate(u *update) { | |||||
| 396 | u.transform = s.transform | 402 | u.transform = s.transform | ||
| 397 | // The viewport change was made for that buffer: keep the crop that | 403 | // The viewport change was made for that buffer: keep the crop that | ||
| 398 | // matches the retained content instead of validating a mismatch. | 404 | // matches the retained content instead of validating a mismatch. | ||
| 399 | - | if u.vp != nil && u.vp.resource != nil && u.vp.resource.Resource.Alive() { | 405 | + | if u.vp != nil && (s.lock != nil || u.vp.resource != nil && u.vp.resource.Alive()) { |
| 400 | v := &s.committedViewport | 406 | v := &s.committedViewport | ||
| 401 | u.vpW, u.vpH, u.vpSet, u.vpSrc, u.vpCrop = v.destW, v.destH, v.dest, v.src, v.crop | 407 | u.vpW, u.vpH, u.vpSet, u.vpSrc, u.vpCrop = v.destW, v.destH, v.dest, v.src, v.crop | ||
| 402 | } | 408 | } | ||
| @@ -408,8 +414,19 @@ func (s *surface) applyUpdate(u *update) { | |||||
| 408 | // dropQueue discards the queued updates of a destroyed surface or role. | 414 | // dropQueue discards the queued updates of a destroyed surface or role. | ||
| 409 | // A live surface still gets its queued frame callbacks. | 415 | // A live surface still gets its queued frame callbacks. | ||
| 410 | func (s *surface) dropQueue() { | 416 | func (s *surface) dropQueue() { | ||
| 417 | + | if s.kind == roleSessionLock { | |||
| 418 | + | s.dropLockDeps() | |||
| 419 | + | } | |||
| 420 | + | s.dropQueuePrefix(len(s.queue)) | |||
| 421 | + | } | |||
| 422 | + | ||||
| 423 | + | // dropQueuePrefix retires only the first n updates. Leave the preserved suffix | |||
| 424 | + | // visible to release checks: a buffer shared with it is still client-owned work. | |||
| 425 | + | func (s *surface) dropQueuePrefix(n int) { | |||
| 426 | + | queue := s.queue[:n] | |||
| 427 | + | s.queue = s.queue[n:] | |||
| 411 | released := map[*server.Resource]bool{} | 428 | released := map[*server.Resource]bool{} | ||
| 412 | - | for _, u := range s.queue { | 429 | + | for _, u := range queue { |
| 413 | if s.destroyed { | 430 | if s.destroyed { | ||
| 414 | for _, cb := range u.callbacks { | 431 | for _, cb := range u.callbacks { | ||
| 415 | cb.Destroy() | 432 | cb.Destroy() | ||
| @@ -432,20 +449,22 @@ func (s *surface) dropQueue() { | |||||
| 432 | u.buffer.SendRelease() | 449 | u.buffer.SendRelease() | ||
| 433 | } | 450 | } | ||
| 434 | } | 451 | } | ||
| 435 | - | for i, u := range s.queue { | 452 | + | for i, u := range queue { |
| 436 | - | s.queue[i] = nil // do not retain retired objects in the queue backing array | 453 | + | queue[i] = nil // do not retain retired objects in the queue backing array |
| 437 | u.retireUpdate() | 454 | u.retireUpdate() | ||
| 438 | } | 455 | } | ||
| 439 | - | s.queue = s.queue[:0] | 456 | + | if len(s.queue) == 0 { |
| 440 | - | delete(s.server.fifoSurfaces, s) | 457 | + | s.queue = queue[:0] // preserve steady-state capacity when all were retired |
| 458 | + | delete(s.server.fifoSurfaces, s) | |||
| 459 | + | } | |||
| 441 | } | 460 | } | ||
| 442 | 461 | ||||
| 443 | func (s *surface) bufferQueuedElsewhere(b *wayland.Buffer) bool { | 462 | func (s *surface) bufferQueuedElsewhere(b *wayland.Buffer) bool { | ||
| 444 | for _, other := range s.server.surfaces { | 463 | for _, other := range s.server.surfaces { | ||
| 445 | - | if other == s { | 464 | + | if sameBuffer(other.next.buffer, b) { |
| 446 | - | continue | 465 | + | return true |
| 447 | } | 466 | } | ||
| 448 | - | if sameBuffer(other.current, b) { | 467 | + | if other != s && sameBuffer(other.current, b) { |
| 449 | return true | 468 | return true | ||
| 450 | } | 469 | } | ||
| 451 | for _, u := range other.queue { | 470 | for _, u := range other.queue { | ||
| @@ -611,16 +630,32 @@ func (u *update) graphReady(now time.Time) bool { | |||||
| 611 | u.implicit[i] = nil | 630 | u.implicit[i] = nil | ||
| 612 | } | 631 | } | ||
| 613 | } | 632 | } | ||
| 614 | - | } else { | 633 | + | } else if !u.failed { |
| 615 | if !s.syncReady(u.sync) { | 634 | if !s.syncReady(u.sync) { | ||
| 616 | return false | 635 | return false | ||
| 617 | } | 636 | } | ||
| 637 | + | if u.sync != nil && u.sync.wait != nil && u.sync.wait.failed { | |||
| 638 | + | u.failed = true | |||
| 639 | + | } | |||
| 640 | + | waiting := false | |||
| 618 | for _, w := range u.implicit { | 641 | for _, w := range u.implicit { | ||
| 619 | - | if w != nil && !s.server.syncWait.fired(w) { | 642 | + | if w == nil { |
| 620 | - | return false | 643 | + | continue |
| 621 | } | 644 | } | ||
| 645 | + | if !s.server.syncWait.fired(w) { | |||
| 646 | + | waiting = true | |||
| 647 | + | } else if w.failed { | |||
| 648 | + | u.failed = true | |||
| 649 | + | } | |||
| 650 | + | } | |||
| 651 | + | if waiting && !u.failed { | |||
| 652 | + | return false | |||
| 622 | } | 653 | } | ||
| 623 | } | 654 | } | ||
| 655 | + | if u.failed { | |||
| 656 | + | u.readyResult = true // ready only to retire, never to apply | |||
| 657 | + | return true | |||
| 658 | + | } | |||
| 624 | for _, dep := range u.deps { | 659 | for _, dep := range u.deps { | ||
| 625 | if !dep.owner.destroyed && !dep.graphReady(now) { | 660 | if !dep.owner.destroyed && !dep.graphReady(now) { | ||
| 626 | return false | 661 | return false | ||
| @@ -635,6 +670,24 @@ func (u *update) applyGraph() { | |||||
| 635 | return | 670 | return | ||
| 636 | } | 671 | } | ||
| 637 | s := u.owner | 672 | s := u.owner | ||
| 673 | + | if !u.failed && u.sync != nil && (u.buffer == nil || u.buffer.Alive()) && !s.prepareSync(u.sync) { | |||
| 674 | + | u.failed = true | |||
| 675 | + | } | |||
| 676 | + | if u.failed { | |||
| 677 | + | // Earlier commits still apply in order; only this update is discarded. | |||
| 678 | + | for len(s.queue) > 0 && s.queue[0] != u { | |||
| 679 | + | s.queue[0].applyGraph() | |||
| 680 | + | } | |||
| 681 | + | // Retire captured children too, cancelling their waits without replay. | |||
| 682 | + | u.dropFailedDeps() | |||
| 683 | + | for i, queued := range s.queue { | |||
| 684 | + | if queued == u { | |||
| 685 | + | s.dropQueuePrefix(i + 1) | |||
| 686 | + | break | |||
| 687 | + | } | |||
| 688 | + | } | |||
| 689 | + | return | |||
| 690 | + | } | |||
| 638 | for len(s.queue) > 0 && s.queue[0] != u { | 691 | for len(s.queue) > 0 && s.queue[0] != u { | ||
| 639 | s.queue[0].applyGraph() | 692 | s.queue[0].applyGraph() | ||
| 640 | } | 693 | } | ||
| @@ -661,6 +714,32 @@ func (u *update) applyGraph() { | |||||
| 661 | u.retireUpdate() | 714 | u.retireUpdate() | ||
| 662 | } | 715 | } | ||
| 663 | 716 | ||||
| 717 | + | // dropFailedDeps retires just the child prefixes owned by a rejected commit. | |||
| 718 | + | // Incoming graph refs retain their storage until the failed parent retires. | |||
| 719 | + | func (u *update) dropFailedDeps() { | |||
| 720 | + | for _, dep := range u.deps { | |||
| 721 | + | if dep.retired { | |||
| 722 | + | continue | |||
| 723 | + | } | |||
| 724 | + | // Every predecessor in the retired prefix can own a different | |||
| 725 | + | // dependency graph. Traverse it before severing any incoming refs. | |||
| 726 | + | for _, prefix := range dep.owner.queue { | |||
| 727 | + | if !prefix.retired { | |||
| 728 | + | prefix.dropFailedDeps() | |||
| 729 | + | } | |||
| 730 | + | if prefix == dep { | |||
| 731 | + | break | |||
| 732 | + | } | |||
| 733 | + | } | |||
| 734 | + | for i, queued := range dep.owner.queue { | |||
| 735 | + | if queued == dep { | |||
| 736 | + | dep.owner.dropQueuePrefix(i + 1) | |||
| 737 | + | break | |||
| 738 | + | } | |||
| 739 | + | } | |||
| 740 | + | } | |||
| 741 | + | } | |||
| 742 | + | ||||
| 664 | // sameBuffer reports whether two wrappers are the same wl_buffer. | 743 | // sameBuffer reports whether two wrappers are the same wl_buffer. | ||
| 665 | func sameBuffer(a, b *wayland.Buffer) bool { | 744 | func sameBuffer(a, b *wayland.Buffer) bool { | ||
| 666 | return a != nil && b != nil && a.Resource == b.Resource | 745 | return a != nil && b != nil && a.Resource == b.Resource | ||
M internal/adapters/wayland/fifo_test.go +1 -1
| @@ -44,7 +44,7 @@ func shmBuffer(t *testing.T, c *wlturbo.Display) uint32 { | |||||
| 44 | pool, buffer := c.AllocateID(), c.AllocateID() | 44 | pool, buffer := c.AllocateID(), c.AllocateID() | ||
| 45 | registerProtocol(t, c, pool) | 45 | registerProtocol(t, c, pool) | ||
| 46 | registerProtocol(t, c, buffer) | 46 | registerProtocol(t, c, buffer) | ||
| 47 | - | if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(4)); err != nil { | 47 | + | if err := wireRequest(c, shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(4)); err != nil { |
| 48 | t.Fatal(err) | 48 | t.Fatal(err) | ||
| 49 | } | 49 | } | ||
| 50 | requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buffer, int32(0), int32(1), int32(1), int32(4), uint32(0)) | 50 | requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buffer, int32(0), int32(1), int32(1), int32(4), uint32(0)) | ||
M internal/adapters/wayland/foreigntoplevel.go +33 -9
| @@ -62,10 +62,14 @@ func (m *toplevelManager) Stop(*wlr.ZwlrForeignToplevelManagerV1) { | |||||
| 62 | 62 | ||||
| 63 | // refresh announces w or sends what changed since the last done. | 63 | // refresh announces w or sends what changed since the last done. | ||
| 64 | func (m *toplevelManager) refresh(w *window) { | 64 | func (m *toplevelManager) refresh(w *window) { | ||
| 65 | - | if !m.res.Alive() { | 65 | + | if m.s.protected() || !m.res.Alive() { |
| 66 | return | 66 | return | ||
| 67 | } | 67 | } | ||
| 68 | h := m.handles[w.id] | 68 | h := m.handles[w.id] | ||
| 69 | + | if h != nil && h.req.closed { | |||
| 70 | + | m.closed(w.id) | |||
| 71 | + | h = nil | |||
| 72 | + | } | |||
| 69 | fresh := h == nil | 73 | fresh := h == nil | ||
| 70 | if fresh { | 74 | if fresh { | ||
| 71 | req := &toplevelRequests{s: m.s, id: w.id} | 75 | req := &toplevelRequests{s: m.s, id: w.id} | ||
| @@ -110,9 +114,14 @@ func (m *toplevelManager) closed(id ports.WindowID) { | |||||
| 110 | if h == nil { | 114 | if h == nil { | ||
| 111 | return | 115 | return | ||
| 112 | } | 116 | } | ||
| 113 | - | delete(m.handles, id) | 117 | + | // A remapped window keeps its ID but gets a new handle. Mark requests |
| 114 | - | // A remapped window keeps its ID but gets a new handle. | 118 | + | // inert immediately, but defer the wire event to avoid leaking lock-time |
| 119 | + | // unmap/disconnect timing. Keep the handle for the unlock refresh. | |||
| 115 | h.req.closed = true | 120 | h.req.closed = true | ||
| 121 | + | if m.s.protected() { | |||
| 122 | + | return | |||
| 123 | + | } | |||
| 124 | + | delete(m.handles, id) | |||
| 116 | if h.res.Alive() { | 125 | if h.res.Alive() { | ||
| 117 | h.res.SendClosed() | 126 | h.res.SendClosed() | ||
| 118 | } | 127 | } | ||
| @@ -192,15 +201,30 @@ func (s *Server) toplevelClosed(id ports.WindowID) { | |||||
| 192 | } | 201 | } | ||
| 193 | } | 202 | } | ||
| 194 | 203 | ||||
| 195 | - | // refreshToplevels resends the output events of every handle after a | 204 | + | // refreshToplevels reconciles inventory after output changes or unlock: |
| 196 | - | // wl_output was bound or an output removed. | 205 | + | // retire handles closed during protection, then announce current windows, |
| 206 | + | // including those mapped (or managers bound) while inventory was suppressed. | |||
| 197 | func (s *Server) refreshToplevels() { | 207 | func (s *Server) refreshToplevels() { | ||
| 208 | + | if s.protected() { | |||
| 209 | + | return | |||
| 210 | + | } | |||
| 211 | + | ids := make([]ports.WindowID, 0, len(s.windows)) | |||
| 212 | + | for id, w := range s.windows { | |||
| 213 | + | if w.toplevel != nil && w.mapped { | |||
| 214 | + | ids = append(ids, id) | |||
| 215 | + | } | |||
| 216 | + | } | |||
| 217 | + | slices.Sort(ids) | |||
| 198 | for _, m := range s.toplevelManagers { | 218 | for _, m := range s.toplevelManagers { | ||
| 199 | - | for id := range m.handles { | 219 | + | for id, h := range m.handles { |
| 200 | - | if w := s.windows[id]; w != nil { | 220 | + | w := s.windows[id] |
| 201 | - | m.refresh(w) | 221 | + | if h.req.closed || w == nil || w.toplevel == nil || !w.mapped { |
| 222 | + | m.closed(id) | |||
| 202 | } | 223 | } | ||
| 203 | } | 224 | } | ||
| 225 | + | for _, id := range ids { | |||
| 226 | + | m.refresh(s.windows[id]) | |||
| 227 | + | } | |||
| 204 | } | 228 | } | ||
| 205 | } | 229 | } | ||
| 206 | 230 | ||||
| @@ -213,7 +237,7 @@ type toplevelRequests struct { | |||||
| 213 | } | 237 | } | ||
| 214 | 238 | ||||
| 215 | func (t *toplevelRequests) window() *window { | 239 | func (t *toplevelRequests) window() *window { | ||
| 216 | - | if t.closed { | 240 | + | if t.s.protected() || t.closed { |
| 217 | return nil | 241 | return nil | ||
| 218 | } | 242 | } | ||
| 219 | if w := t.s.windows[t.id]; w != nil && w.toplevel != nil && w.mapped && w.toplevel.Resource.Alive() { | 243 | if w := t.s.windows[t.id]; w != nil && w.toplevel != nil && w.mapped && w.toplevel.Resource.Alive() { | ||
M internal/adapters/wayland/foreigntoplevel_test.go +5 -5
| @@ -29,7 +29,7 @@ func (p *toplevelManagerEvents) Dispatch(e *wlturbo.Event) { | |||||
| 29 | id := e.Uint32() | 29 | id := e.Uint32() | ||
| 30 | h := &toplevelHandleEvents{events: p.events} | 30 | h := &toplevelHandleEvents{events: p.events} | ||
| 31 | h.SetID(id) | 31 | h.SetID(id) | ||
| 32 | - | p.client.Context().Register(h) | 32 | + | registerWireProxy(p.client, h) |
| 33 | p.handles <- id | 33 | p.handles <- id | ||
| 34 | } | 34 | } | ||
| 35 | 35 | ||||
| @@ -105,7 +105,7 @@ func TestForeignToplevel(t *testing.T) { | |||||
| 105 | manager := bindVersion(t, c, "zwlr_foreign_toplevel_manager_v1", 3) | 105 | manager := bindVersion(t, c, "zwlr_foreign_toplevel_manager_v1", 3) | ||
| 106 | p := &toplevelManagerEvents{client: c, handles: make(chan uint32, 4), events: make(chan string, 64)} | 106 | p := &toplevelManagerEvents{client: c, handles: make(chan uint32, 4), events: make(chan string, 64)} | ||
| 107 | p.SetID(manager) | 107 | p.SetID(manager) | ||
| 108 | - | c.Context().Register(p) | 108 | + | registerWireProxy(c, p) |
| 109 | if got, want := takeEvents(t, c, p.events), []string{"title editor", "app_id app.editor", "state []", "done"}; !slices.Equal(got, want) { | 109 | if got, want := takeEvents(t, c, p.events), []string{"title editor", "app_id app.editor", "state []", "done"}; !slices.Equal(got, want) { | ||
| 110 | t.Fatalf("announce = %q, want %q", got, want) | 110 | t.Fatalf("announce = %q, want %q", got, want) | ||
| 111 | } | 111 | } | ||
| @@ -154,7 +154,7 @@ func TestForeignToplevel(t *testing.T) { | |||||
| 154 | serials := make(chan uint32, 8) | 154 | serials := make(chan uint32, 8) | ||
| 155 | xp := &configureProxy{serial: serials} | 155 | xp := &configureProxy{serial: serials} | ||
| 156 | xp.SetID(xdg) | 156 | xp.SetID(xdg) | ||
| 157 | - | c.Context().Register(xp) | 157 | + | registerWireProxy(c, xp) |
| 158 | requestProtocol(t, c, surf, wayland.SurfaceRequestCommit) | 158 | requestProtocol(t, c, surf, wayland.SurfaceRequestCommit) | ||
| 159 | ackAndAttach(t, c, surf, xdg, shmBuffer(t, c), serials) | 159 | ackAndAttach(t, c, surf, xdg, shmBuffer(t, c), serials) | ||
| 160 | if again := mapped(t, events, 2*time.Second); again.ID != w.ID { | 160 | if again := mapped(t, events, 2*time.Second); again.ID != w.ID { | ||
| @@ -200,7 +200,7 @@ func TestForeignToplevelV1HidesFullscreen(t *testing.T) { | |||||
| 200 | manager := bindVersion(t, c, "zwlr_foreign_toplevel_manager_v1", 1) | 200 | manager := bindVersion(t, c, "zwlr_foreign_toplevel_manager_v1", 1) | ||
| 201 | p := &toplevelManagerEvents{client: c, handles: make(chan uint32, 4), events: make(chan string, 64)} | 201 | p := &toplevelManagerEvents{client: c, handles: make(chan uint32, 4), events: make(chan string, 64)} | ||
| 202 | p.SetID(manager) | 202 | p.SetID(manager) | ||
| 203 | - | c.Context().Register(p) | 203 | + | registerWireProxy(c, p) |
| 204 | takeEvents(t, c, p.events) | 204 | takeEvents(t, c, p.events) | ||
| 205 | commands <- ports.ConfigureWindow{ID: w.ID, Width: 1920, Height: 1080, Fullscreen: true, Activated: true} | 205 | commands <- ports.ConfigureWindow{ID: w.ID, Width: 1920, Height: 1080, Fullscreen: true, Activated: true} | ||
| 206 | want := []string{fmt.Sprint("state ", []uint32{uint32(wlr.ZwlrForeignToplevelHandleV1StateActivated)}), "done"} | 206 | want := []string{fmt.Sprint("state ", []uint32{uint32(wlr.ZwlrForeignToplevelHandleV1StateActivated)}), "done"} | ||
| @@ -216,7 +216,7 @@ func TestForeignToplevelInvalidRectangle(t *testing.T) { | |||||
| 216 | manager := bindVersion(t, c, "zwlr_foreign_toplevel_manager_v1", 3) | 216 | manager := bindVersion(t, c, "zwlr_foreign_toplevel_manager_v1", 3) | ||
| 217 | p := &toplevelManagerEvents{client: c, handles: make(chan uint32, 4), events: make(chan string, 64)} | 217 | p := &toplevelManagerEvents{client: c, handles: make(chan uint32, 4), events: make(chan string, 64)} | ||
| 218 | p.SetID(manager) | 218 | p.SetID(manager) | ||
| 219 | - | c.Context().Register(p) | 219 | + | registerWireProxy(c, p) |
| 220 | takeEvents(t, c, p.events) | 220 | takeEvents(t, c, p.events) | ||
| 221 | handle := <-p.handles | 221 | handle := <-p.handles | ||
| 222 | requestProtocol(t, c, handle, wlr.ZwlrForeignToplevelHandleV1RequestSetRectangle, surf, int32(0), int32(0), int32(-1), int32(1)) | 222 | requestProtocol(t, c, handle, wlr.ZwlrForeignToplevelHandleV1RequestSetRectangle, surf, int32(0), int32(0), int32(-1), int32(1)) | ||
M internal/adapters/wayland/frames.go +2 -0
| @@ -149,6 +149,8 @@ func (s *Server) frameOutput(surf *surface) string { | |||||
| 149 | return surf.xdg.window.last.Output | 149 | return surf.xdg.window.last.Output | ||
| 150 | case surf.layer != nil && surf.layer.output != nil: | 150 | case surf.layer != nil && surf.layer.output != nil: | ||
| 151 | return surf.layer.output.name() | 151 | return surf.layer.output.name() | ||
| 152 | + | case surf.lock != nil && surf.lock.output != nil && !surf.lock.closed: | |||
| 153 | + | return surf.lock.output.name() | |||
| 152 | } | 154 | } | ||
| 153 | return "" | 155 | return "" | ||
| 154 | } | 156 | } | ||
M internal/adapters/wayland/globals.go +9 -0
| @@ -12,6 +12,15 @@ func registerGlobals(d *server.Display, o Options, s *Server) error { | |||||
| 12 | for _, register := range []func() error{ | 12 | for _, register := range []func() error{ | ||
| 13 | func() error { return registerXDG(d, s) }, | 13 | func() error { return registerXDG(d, s) }, | ||
| 14 | func() error { return registerLayer(d, s) }, | 14 | func() error { return registerLayer(d, s) }, | ||
| 15 | + | func() error { | |||
| 16 | + | // Advertise only with the production owner gate and authoritative | |||
| 17 | + | // backend proof/barrier channels. Standalone adapters cannot claim | |||
| 18 | + | // protection from composed-frame submission alone. | |||
| 19 | + | if s.securityController == nil || s.channels.SecurityChanges == nil || s.channels.SecurityEvents == nil { | |||
| 20 | + | return nil | |||
| 21 | + | } | |||
| 22 | + | return registerSessionLock(d, s) | |||
| 23 | + | }, | |||
| 15 | func() error { return registerXDGOutput(d, s) }, | 24 | func() error { return registerXDGOutput(d, s) }, | ||
| 16 | func() error { return registerOutputManagement(d, s) }, | 25 | func() error { return registerOutputManagement(d, s) }, | ||
| 17 | func() error { return registerWorkspaces(d, s) }, | 26 | func() error { return registerWorkspaces(d, s) }, | ||
M internal/adapters/wayland/idle_test.go +1 -1
| @@ -33,7 +33,7 @@ func newEventProxy(c *wlturbo.Display) (uint32, *eventProxy) { | |||||
| 33 | id := c.AllocateID() | 33 | id := c.AllocateID() | ||
| 34 | p := &eventProxy{events: make(chan [2]uint32, 16)} | 34 | p := &eventProxy{events: make(chan [2]uint32, 16)} | ||
| 35 | p.SetID(id) | 35 | p.SetID(id) | ||
| 36 | - | c.Context().Register(p) | 36 | + | registerWireProxy(c, p) |
| 37 | return id, p | 37 | return id, p | ||
| 38 | } | 38 | } | ||
| 39 | 39 | ||||
M internal/adapters/wayland/inhibit_test.go +1 -1
| @@ -47,7 +47,7 @@ func TestShortcutsInhibitor(t *testing.T) { | |||||
| 47 | in := c.AllocateID() | 47 | in := c.AllocateID() | ||
| 48 | p := &inhibitorProxy{states: make(chan bool, 4)} | 48 | p := &inhibitorProxy{states: make(chan bool, 4)} | ||
| 49 | p.SetID(in) | 49 | p.SetID(in) | ||
| 50 | - | c.Context().Register(p) | 50 | + | registerWireProxy(c, p) |
| 51 | requestProtocol(t, c, mgr, keyboardshortcutsinhibit.ZwpKeyboardShortcutsInhibitManagerV1RequestInhibitShortcuts, in, surf, seat) | 51 | requestProtocol(t, c, mgr, keyboardshortcutsinhibit.ZwpKeyboardShortcutsInhibitManagerV1RequestInhibitShortcuts, in, surf, seat) | ||
| 52 | if v := clientEvent[ports.ShortcutsInhibit](t, events); v != (ports.ShortcutsInhibit{Window: w.ID, Active: true}) { | 52 | if v := clientEvent[ports.ShortcutsInhibit](t, events); v != (ports.ShortcutsInhibit{Window: w.ID, Active: true}) { | ||
| 53 | t.Fatal(v) | 53 | t.Fatal(v) | ||
M internal/adapters/wayland/input_region_test.go +1 -1
| @@ -90,7 +90,7 @@ func TestSubsurfaceInputRegionOwnCommitAndOffset(t *testing.T) { | |||||
| 90 | pool, buffer := c.AllocateID(), c.AllocateID() | 90 | pool, buffer := c.AllocateID(), c.AllocateID() | ||
| 91 | registerProtocol(t, c, pool) | 91 | registerProtocol(t, c, pool) | ||
| 92 | registerProtocol(t, c, buffer) | 92 | registerProtocol(t, c, buffer) | ||
| 93 | - | if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(64)); err != nil { | 93 | + | if err := wireRequest(c, shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(64)); err != nil { |
| 94 | t.Fatal(err) | 94 | t.Fatal(err) | ||
| 95 | } | 95 | } | ||
| 96 | requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buffer, int32(0), int32(4), int32(4), int32(16), uint32(0)) | 96 | requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buffer, int32(0), int32(4), int32(4), int32(16), uint32(0)) | ||
M internal/adapters/wayland/keyboard_test.go +4 -4
| @@ -109,7 +109,7 @@ func TestKeyboardKeymapProtocol(t *testing.T) { | |||||
| 109 | id := c.AllocateID() | 109 | id := c.AllocateID() | ||
| 110 | proxy := &keymapProxy{result: make(chan error, 1)} | 110 | proxy := &keymapProxy{result: make(chan error, 1)} | ||
| 111 | proxy.SetID(id) | 111 | proxy.SetID(id) | ||
| 112 | - | c.Context().Register(proxy) | 112 | + | registerWireProxy(c, proxy) |
| 113 | requestProtocol(t, c, seat, wayland.SeatRequestGetKeyboard, id) | 113 | requestProtocol(t, c, seat, wayland.SeatRequestGetKeyboard, id) | ||
| 114 | if err := c.Roundtrip(); err != nil { | 114 | if err := c.Roundtrip(); err != nil { | ||
| 115 | t.Fatal(err) | 115 | t.Fatal(err) | ||
| @@ -162,7 +162,7 @@ func TestSetKeymap(t *testing.T) { | |||||
| 162 | if !ok { | 162 | if !ok { | ||
| 163 | t.Fatal("missing wl_seat") | 163 | t.Fatal("missing wl_seat") | ||
| 164 | } | 164 | } | ||
| 165 | - | seat, err := c.Registry().BindID(g.Name, g.Interface, 4) | 165 | + | seat, err := bindWireID(c, g.Name, g.Interface, 4) |
| 166 | if err != nil { | 166 | if err != nil { | ||
| 167 | t.Fatal(err) | 167 | t.Fatal(err) | ||
| 168 | } | 168 | } | ||
| @@ -170,7 +170,7 @@ func TestSetKeymap(t *testing.T) { | |||||
| 170 | id := c.AllocateID() | 170 | id := c.AllocateID() | ||
| 171 | proxy := &eventsProxy{} | 171 | proxy := &eventsProxy{} | ||
| 172 | proxy.SetID(id) | 172 | proxy.SetID(id) | ||
| 173 | - | c.Context().Register(proxy) | 173 | + | registerWireProxy(c, proxy) |
| 174 | requestProtocol(t, c, seat, wayland.SeatRequestGetKeyboard, id) | 174 | requestProtocol(t, c, seat, wayland.SeatRequestGetKeyboard, id) | ||
| 175 | if err := c.Roundtrip(); err != nil { | 175 | if err := c.Roundtrip(); err != nil { | ||
| 176 | t.Fatal(err) | 176 | t.Fatal(err) | ||
| @@ -212,7 +212,7 @@ func TestSetKeymap(t *testing.T) { | |||||
| 212 | id2 := c.AllocateID() | 212 | id2 := c.AllocateID() | ||
| 213 | late := &eventsProxy{} | 213 | late := &eventsProxy{} | ||
| 214 | late.SetID(id2) | 214 | late.SetID(id2) | ||
| 215 | - | c.Context().Register(late) | 215 | + | registerWireProxy(c, late) |
| 216 | requestProtocol(t, c, seat, wayland.SeatRequestGetKeyboard, id2) | 216 | requestProtocol(t, c, seat, wayland.SeatRequestGetKeyboard, id2) | ||
| 217 | if err := c.Roundtrip(); err != nil { | 217 | if err := c.Roundtrip(); err != nil { | ||
| 218 | t.Fatal(err) | 218 | t.Fatal(err) | ||
M internal/adapters/wayland/layer_scale_test.go +4 -4
| @@ -56,7 +56,7 @@ func TestLayerTargetScaleBeforeConfigure(t *testing.T) { | |||||
| 56 | name = first | 56 | name = first | ||
| 57 | } | 57 | } | ||
| 58 | var err error | 58 | var err error | ||
| 59 | - | out, err = c.Registry().BindID(name, "wl_output", 4) | 59 | + | out, err = bindWireID(c, name, "wl_output", 4) |
| 60 | if err != nil { | 60 | if err != nil { | ||
| 61 | t.Fatal(err) | 61 | t.Fatal(err) | ||
| 62 | } | 62 | } | ||
| @@ -73,13 +73,13 @@ func TestLayerTargetScaleBeforeConfigure(t *testing.T) { | |||||
| 73 | integers := make(chan int32, 4) | 73 | integers := make(chan int32, 4) | ||
| 74 | sp := &surfaceScaleProxy{scales: integers} | 74 | sp := &surfaceScaleProxy{scales: integers} | ||
| 75 | sp.SetID(surf) | 75 | sp.SetID(surf) | ||
| 76 | - | c.Context().Register(sp) | 76 | + | registerWireProxy(c, sp) |
| 77 | requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surf) | 77 | requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surf) | ||
| 78 | frac := c.AllocateID() | 78 | frac := c.AllocateID() | ||
| 79 | events := make(chan [3]uint32, 8) | 79 | events := make(chan [3]uint32, 8) | ||
| 80 | fp := &layerScaleOrderProxy{events: events} | 80 | fp := &layerScaleOrderProxy{events: events} | ||
| 81 | fp.SetID(frac) | 81 | fp.SetID(frac) | ||
| 82 | - | c.Context().Register(fp) | 82 | + | registerWireProxy(c, fp) |
| 83 | requestProtocol(t, c, fm, fractionalscale.WpFractionalScaleManagerV1RequestGetFractionalScale, frac, surf) | 83 | requestProtocol(t, c, fm, fractionalscale.WpFractionalScaleManagerV1RequestGetFractionalScale, frac, surf) | ||
| 84 | if err := c.Roundtrip(); err != nil { | 84 | if err := c.Roundtrip(); err != nil { | ||
| 85 | t.Fatal(err) | 85 | t.Fatal(err) | ||
| @@ -93,7 +93,7 @@ func TestLayerTargetScaleBeforeConfigure(t *testing.T) { | |||||
| 93 | layer := c.AllocateID() | 93 | layer := c.AllocateID() | ||
| 94 | lp := &layerScaleOrderProxy{layer: true, events: events} | 94 | lp := &layerScaleOrderProxy{layer: true, events: events} | ||
| 95 | lp.SetID(layer) | 95 | lp.SetID(layer) | ||
| 96 | - | c.Context().Register(lp) | 96 | + | registerWireProxy(c, lp) |
| 97 | requestProtocol(t, c, shell, wlrlayershell.ZwlrLayerShellV1RequestGetLayerSurface, layer, surf, out, uint32(ports.LayerOverlay), "scale-test") | 97 | requestProtocol(t, c, shell, wlrlayershell.ZwlrLayerShellV1RequestGetLayerSurface, layer, surf, out, uint32(ports.LayerOverlay), "scale-test") | ||
| 98 | requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetAnchor, uint32(15)) | 98 | requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetAnchor, uint32(15)) | ||
| 99 | requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetSize, uint32(0), uint32(0)) | 99 | requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetSize, uint32(0), uint32(0)) | ||
M internal/adapters/wayland/layer_test.go +10 -10
| @@ -52,7 +52,7 @@ func TestLayerLifecycle(t *testing.T) { | |||||
| 52 | surf, layer := layerProtocol(t, c) | 52 | surf, layer := layerProtocol(t, c) | ||
| 53 | proxy := &layerProxy{configured: make(chan [3]uint32, 2)} | 53 | proxy := &layerProxy{configured: make(chan [3]uint32, 2)} | ||
| 54 | proxy.SetID(layer) | 54 | proxy.SetID(layer) | ||
| 55 | - | c.Context().Register(proxy) | 55 | + | registerWireProxy(c, proxy) |
| 56 | requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetAnchor, uint32(13)) | 56 | requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetAnchor, uint32(13)) | ||
| 57 | requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetSize, uint32(0), uint32(30)) | 57 | requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetSize, uint32(0), uint32(30)) | ||
| 58 | requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetExclusiveZone, int32(30)) | 58 | requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetExclusiveZone, int32(30)) | ||
| @@ -83,7 +83,7 @@ func TestLayerLifecycle(t *testing.T) { | |||||
| 83 | pool, buf := c.AllocateID(), c.AllocateID() | 83 | pool, buf := c.AllocateID(), c.AllocateID() | ||
| 84 | registerProtocol(t, c, pool) | 84 | registerProtocol(t, c, pool) | ||
| 85 | registerProtocol(t, c, buf) | 85 | registerProtocol(t, c, buf) | ||
| 86 | - | if err = c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(1920*30*4)); err != nil { | 86 | + | if err = wireRequest(c, shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(1920*30*4)); err != nil { |
| 87 | t.Fatal(err) | 87 | t.Fatal(err) | ||
| 88 | } | 88 | } | ||
| 89 | requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buf, int32(0), int32(1920), int32(30), int32(1920*4), uint32(wayland.ShmFormatArgb8888)) | 89 | requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buf, int32(0), int32(1920), int32(30), int32(1920*4), uint32(wayland.ShmFormatArgb8888)) | ||
| @@ -199,18 +199,18 @@ func TestXDGOutputDone(t *testing.T) { | |||||
| 199 | if !ok { | 199 | if !ok { | ||
| 200 | t.Fatal("missing wl_output") | 200 | t.Fatal("missing wl_output") | ||
| 201 | } | 201 | } | ||
| 202 | - | output, err := c.Registry().BindID(global.Name, global.Interface, version) | 202 | + | output, err := bindWireID(c, global.Name, global.Interface, version) |
| 203 | if err != nil { | 203 | if err != nil { | ||
| 204 | t.Fatal(err) | 204 | t.Fatal(err) | ||
| 205 | } | 205 | } | ||
| 206 | op := &doneProxy{opcode: uint16(wayland.OutputEventDone)} | 206 | op := &doneProxy{opcode: uint16(wayland.OutputEventDone)} | ||
| 207 | op.SetID(output) | 207 | op.SetID(output) | ||
| 208 | - | c.Context().Register(op) | 208 | + | registerWireProxy(c, op) |
| 209 | global, ok = c.Registry().FindGlobal("zxdg_output_manager_v1") | 209 | global, ok = c.Registry().FindGlobal("zxdg_output_manager_v1") | ||
| 210 | if !ok { | 210 | if !ok { | ||
| 211 | t.Fatal("missing xdg output manager") | 211 | t.Fatal("missing xdg output manager") | ||
| 212 | } | 212 | } | ||
| 213 | - | manager, err := c.Registry().BindID(global.Name, global.Interface, 3) | 213 | + | manager, err := bindWireID(c, global.Name, global.Interface, 3) |
| 214 | if err != nil { | 214 | if err != nil { | ||
| 215 | t.Fatal(err) | 215 | t.Fatal(err) | ||
| 216 | } | 216 | } | ||
| @@ -222,7 +222,7 @@ func TestXDGOutputDone(t *testing.T) { | |||||
| 222 | xdg := c.AllocateID() | 222 | xdg := c.AllocateID() | ||
| 223 | xp := &doneProxy{opcode: 2} | 223 | xp := &doneProxy{opcode: 2} | ||
| 224 | xp.SetID(xdg) | 224 | xp.SetID(xdg) | ||
| 225 | - | c.Context().Register(xp) | 225 | + | registerWireProxy(c, xp) |
| 226 | requestProtocol(t, c, manager, 1, xdg, output) | 226 | requestProtocol(t, c, manager, 1, xdg, output) | ||
| 227 | if err := c.Roundtrip(); err != nil { | 227 | if err := c.Roundtrip(); err != nil { | ||
| 228 | t.Fatal(err) | 228 | t.Fatal(err) | ||
| @@ -260,7 +260,7 @@ func TestLayerPointerAndPopup(t *testing.T) { | |||||
| 260 | pointer := c.AllocateID() | 260 | pointer := c.AllocateID() | ||
| 261 | pp := &layerPointerProxy{enters: make(chan [2]float64, 1), buttons: make(chan uint32, 1)} | 261 | pp := &layerPointerProxy{enters: make(chan [2]float64, 1), buttons: make(chan uint32, 1)} | ||
| 262 | pp.SetID(pointer) | 262 | pp.SetID(pointer) | ||
| 263 | - | c.Context().Register(pp) | 263 | + | registerWireProxy(c, pp) |
| 264 | requestProtocol(t, c, seat, wayland.SeatRequestGetPointer, pointer) | 264 | requestProtocol(t, c, seat, wayland.SeatRequestGetPointer, pointer) | ||
| 265 | comp := bindProtocol(t, c, "wl_compositor") | 265 | comp := bindProtocol(t, c, "wl_compositor") | ||
| 266 | wm := bindProtocol(t, c, "xdg_wm_base") | 266 | wm := bindProtocol(t, c, "xdg_wm_base") | ||
| @@ -277,7 +277,7 @@ func TestLayerPointerAndPopup(t *testing.T) { | |||||
| 277 | pool, buf := c.AllocateID(), c.AllocateID() | 277 | pool, buf := c.AllocateID(), c.AllocateID() | ||
| 278 | registerProtocol(t, c, pool) | 278 | registerProtocol(t, c, pool) | ||
| 279 | registerProtocol(t, c, buf) | 279 | registerProtocol(t, c, buf) | ||
| 280 | - | if err = c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(4)); err != nil { | 280 | + | if err = wireRequest(c, shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(4)); err != nil { |
| 281 | t.Fatal(err) | 281 | t.Fatal(err) | ||
| 282 | } | 282 | } | ||
| 283 | requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buf, int32(0), int32(1), int32(1), int32(4), uint32(0)) | 283 | requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buf, int32(0), int32(1), int32(1), int32(4), uint32(0)) | ||
| @@ -285,7 +285,7 @@ func TestLayerPointerAndPopup(t *testing.T) { | |||||
| 285 | surf, layer := layerProtocol(t, c) | 285 | surf, layer := layerProtocol(t, c) | ||
| 286 | lp := &layerProxy{configured: make(chan [3]uint32, 2)} | 286 | lp := &layerProxy{configured: make(chan [3]uint32, 2)} | ||
| 287 | lp.SetID(layer) | 287 | lp.SetID(layer) | ||
| 288 | - | c.Context().Register(lp) | 288 | + | registerWireProxy(c, lp) |
| 289 | requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetSize, uint32(1), uint32(1)) | 289 | requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetSize, uint32(1), uint32(1)) | ||
| 290 | requestProtocol(t, c, surf, wayland.SurfaceRequestCommit) | 290 | requestProtocol(t, c, surf, wayland.SurfaceRequestCommit) | ||
| 291 | if err = c.Roundtrip(); err != nil { | 291 | if err = c.Roundtrip(); err != nil { | ||
| @@ -334,7 +334,7 @@ func TestLayerPointerAndPopup(t *testing.T) { | |||||
| 334 | popup := c.AllocateID() | 334 | popup := c.AllocateID() | ||
| 335 | proxy := &popupDoneProxy{done: done} | 335 | proxy := &popupDoneProxy{done: done} | ||
| 336 | proxy.SetID(popup) | 336 | proxy.SetID(popup) | ||
| 337 | - | c.Context().Register(proxy) | 337 | + | registerWireProxy(c, proxy) |
| 338 | requestProtocol(t, c, pxdg, xdgshell.SurfaceRequestGetPopup, popup, uint32(0), positioner) | 338 | requestProtocol(t, c, pxdg, xdgshell.SurfaceRequestGetPopup, popup, uint32(0), positioner) | ||
| 339 | requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestGetPopup, popup) | 339 | requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestGetPopup, popup) | ||
| 340 | requestProtocol(t, c, popup, xdgshell.PopupRequestGrab, seat, press) | 340 | requestProtocol(t, c, popup, xdgshell.PopupRequestGrab, seat, press) | ||
M internal/adapters/wayland/outputmanagement_test.go +18 -16
| @@ -27,7 +27,7 @@ func (p *managementEvents) Dispatch(e *wlturbo.Event) { | |||||
| 27 | if e.Opcode == uint16(wlr.ZwlrOutputManagerV1EventHead) { | 27 | if e.Opcode == uint16(wlr.ZwlrOutputManagerV1EventHead) { | ||
| 28 | h := &managementHeadEvents{client: p.client, modes: p.modes, events: p.headEvents} | 28 | h := &managementHeadEvents{client: p.client, modes: p.modes, events: p.headEvents} | ||
| 29 | h.SetID(msg[1]) | 29 | h.SetID(msg[1]) | ||
| 30 | - | p.client.Context().Register(h) | 30 | + | registerWireProxy(p.client, h) |
| 31 | } | 31 | } | ||
| 32 | p.events <- msg | 32 | p.events <- msg | ||
| 33 | } | 33 | } | ||
| @@ -51,7 +51,7 @@ func (p *managementHeadEvents) Dispatch(e *wlturbo.Event) { | |||||
| 51 | if e.Opcode == uint16(wlr.ZwlrOutputHeadV1EventMode) { | 51 | if e.Opcode == uint16(wlr.ZwlrOutputHeadV1EventMode) { | ||
| 52 | mode := &protocolProxy{} | 52 | mode := &protocolProxy{} | ||
| 53 | mode.SetID(e.Uint32()) | 53 | mode.SetID(e.Uint32()) | ||
| 54 | - | p.client.Context().Register(mode) | 54 | + | registerWireProxy(p.client, mode) |
| 55 | if p.modes != nil { | 55 | if p.modes != nil { | ||
| 56 | p.modes <- mode.ID() | 56 | p.modes <- mode.ID() | ||
| 57 | } | 57 | } | ||
| @@ -118,7 +118,7 @@ func TestOutputManagementStaleSerialAndLayout(t *testing.T) { | |||||
| 118 | id := bindVersion(t, c, "zwlr_output_manager_v1", 4) | 118 | id := bindVersion(t, c, "zwlr_output_manager_v1", 4) | ||
| 119 | manager := &managementEvents{events: make(chan [2]uint32, 32), client: c} | 119 | manager := &managementEvents{events: make(chan [2]uint32, 32), client: c} | ||
| 120 | manager.SetID(id) | 120 | manager.SetID(id) | ||
| 121 | - | c.Context().Register(manager) | 121 | + | registerWireProxy(c, manager) |
| 122 | if err := c.Roundtrip(); err != nil { | 122 | if err := c.Roundtrip(); err != nil { | ||
| 123 | t.Fatal(err) | 123 | t.Fatal(err) | ||
| 124 | } | 124 | } | ||
| @@ -144,7 +144,7 @@ func TestOutputManagementStaleSerialAndLayout(t *testing.T) { | |||||
| 144 | configID := c.AllocateID() | 144 | configID := c.AllocateID() | ||
| 145 | proxy := &managementEvents{events: make(chan [2]uint32, 8), client: c} | 145 | proxy := &managementEvents{events: make(chan [2]uint32, 8), client: c} | ||
| 146 | proxy.SetID(configID) | 146 | proxy.SetID(configID) | ||
| 147 | - | c.Context().Register(proxy) | 147 | + | registerWireProxy(c, proxy) |
| 148 | requestProtocol(t, c, id, wlr.ZwlrOutputManagerV1RequestCreateConfiguration, configID, first[1]) | 148 | requestProtocol(t, c, id, wlr.ZwlrOutputManagerV1RequestCreateConfiguration, configID, first[1]) | ||
| 149 | waitManagementEvent(t, c, proxy.events, uint32(wlr.ZwlrOutputConfigurationV1EventCancelled)) | 149 | waitManagementEvent(t, c, proxy.events, uint32(wlr.ZwlrOutputConfigurationV1EventCancelled)) | ||
| 150 | if !s.display.Do(func() { s.setOutputHeads(ports.OutputHeads{}) }) { | 150 | if !s.display.Do(func() { s.setOutputHeads(ports.OutputHeads{}) }) { | ||
| @@ -169,7 +169,7 @@ func TestOutputManagementHeadsAndApply(t *testing.T) { | |||||
| 169 | id := bindVersion(t, c, "zwlr_output_manager_v1", 4) | 169 | id := bindVersion(t, c, "zwlr_output_manager_v1", 4) | ||
| 170 | manager := &managementEvents{events: make(chan [2]uint32, 32), client: c} | 170 | manager := &managementEvents{events: make(chan [2]uint32, 32), client: c} | ||
| 171 | manager.SetID(id) | 171 | manager.SetID(id) | ||
| 172 | - | c.Context().Register(manager) | 172 | + | registerWireProxy(c, manager) |
| 173 | if err := c.Roundtrip(); err != nil { | 173 | if err := c.Roundtrip(); err != nil { | ||
| 174 | t.Fatal(err) | 174 | t.Fatal(err) | ||
| 175 | } | 175 | } | ||
| @@ -187,7 +187,7 @@ func TestOutputManagementHeadsAndApply(t *testing.T) { | |||||
| 187 | configID := c.AllocateID() | 187 | configID := c.AllocateID() | ||
| 188 | configProxy := &managementEvents{events: make(chan [2]uint32, 8), client: c} | 188 | configProxy := &managementEvents{events: make(chan [2]uint32, 8), client: c} | ||
| 189 | configProxy.SetID(configID) | 189 | configProxy.SetID(configID) | ||
| 190 | - | c.Context().Register(configProxy) | 190 | + | registerWireProxy(c, configProxy) |
| 191 | requestProtocol(t, c, id, wlr.ZwlrOutputManagerV1RequestCreateConfiguration, configID, serial[1]) | 191 | requestProtocol(t, c, id, wlr.ZwlrOutputManagerV1RequestCreateConfiguration, configID, serial[1]) | ||
| 192 | chID := c.AllocateID() | 192 | chID := c.AllocateID() | ||
| 193 | registerProtocol(t, c, chID) | 193 | registerProtocol(t, c, chID) | ||
| @@ -211,7 +211,7 @@ func TestOutputManagementHeadsAndApply(t *testing.T) { | |||||
| 211 | configID = c.AllocateID() | 211 | configID = c.AllocateID() | ||
| 212 | configProxy = &managementEvents{events: make(chan [2]uint32, 8), client: c} | 212 | configProxy = &managementEvents{events: make(chan [2]uint32, 8), client: c} | ||
| 213 | configProxy.SetID(configID) | 213 | configProxy.SetID(configID) | ||
| 214 | - | c.Context().Register(configProxy) | 214 | + | registerWireProxy(c, configProxy) |
| 215 | requestProtocol(t, c, id, wlr.ZwlrOutputManagerV1RequestCreateConfiguration, configID, serial[1]) | 215 | requestProtocol(t, c, id, wlr.ZwlrOutputManagerV1RequestCreateConfiguration, configID, serial[1]) | ||
| 216 | chID = c.AllocateID() | 216 | chID = c.AllocateID() | ||
| 217 | registerProtocol(t, c, chID) | 217 | registerProtocol(t, c, chID) | ||
| @@ -259,7 +259,7 @@ func TestOutputManagementUnsupportedSettings(t *testing.T) { | |||||
| 259 | managerID := bindVersion(t, c, "zwlr_output_manager_v1", 4) | 259 | managerID := bindVersion(t, c, "zwlr_output_manager_v1", 4) | ||
| 260 | manager := &managementEvents{events: make(chan [2]uint32, 32), client: c} | 260 | manager := &managementEvents{events: make(chan [2]uint32, 32), client: c} | ||
| 261 | manager.SetID(managerID) | 261 | manager.SetID(managerID) | ||
| 262 | - | c.Context().Register(manager) | 262 | + | registerWireProxy(c, manager) |
| 263 | if err := c.Roundtrip(); err != nil { | 263 | if err := c.Roundtrip(); err != nil { | ||
| 264 | t.Fatal(err) | 264 | t.Fatal(err) | ||
| 265 | } | 265 | } | ||
| @@ -271,7 +271,7 @@ func TestOutputManagementUnsupportedSettings(t *testing.T) { | |||||
| 271 | cfg := c.AllocateID() | 271 | cfg := c.AllocateID() | ||
| 272 | proxy := &managementEvents{events: make(chan [2]uint32, 8), client: c} | 272 | proxy := &managementEvents{events: make(chan [2]uint32, 8), client: c} | ||
| 273 | proxy.SetID(cfg) | 273 | proxy.SetID(cfg) | ||
| 274 | - | c.Context().Register(proxy) | 274 | + | registerWireProxy(c, proxy) |
| 275 | requestProtocol(t, c, managerID, wlr.ZwlrOutputManagerV1RequestCreateConfiguration, cfg, done[1]) | 275 | requestProtocol(t, c, managerID, wlr.ZwlrOutputManagerV1RequestCreateConfiguration, cfg, done[1]) | ||
| 276 | ch := c.AllocateID() | 276 | ch := c.AllocateID() | ||
| 277 | registerProtocol(t, c, ch) | 277 | registerProtocol(t, c, ch) | ||
| @@ -373,7 +373,7 @@ func TestOutputManagementProtocolErrors(t *testing.T) { | |||||
| 373 | managerID := bindVersion(t, c, "zwlr_output_manager_v1", 4) | 373 | managerID := bindVersion(t, c, "zwlr_output_manager_v1", 4) | ||
| 374 | p := &managementEvents{events: make(chan [2]uint32, 32), modes: make(chan uint32, 4), client: c} | 374 | p := &managementEvents{events: make(chan [2]uint32, 32), modes: make(chan uint32, 4), client: c} | ||
| 375 | p.SetID(managerID) | 375 | p.SetID(managerID) | ||
| 376 | - | c.Context().Register(p) | 376 | + | registerWireProxy(c, p) |
| 377 | if err := c.Roundtrip(); err != nil { | 377 | if err := c.Roundtrip(); err != nil { | ||
| 378 | t.Fatal(err) | 378 | t.Fatal(err) | ||
| 379 | } | 379 | } | ||
| @@ -390,7 +390,7 @@ func TestOutputManagementProtocolErrors(t *testing.T) { | |||||
| 390 | other := bindVersion(t, c, "zwlr_output_manager_v1", 4) | 390 | other := bindVersion(t, c, "zwlr_output_manager_v1", 4) | ||
| 391 | otherEvents := &managementEvents{events: make(chan [2]uint32, 32), modes: make(chan uint32, 4), client: c} | 391 | otherEvents := &managementEvents{events: make(chan [2]uint32, 32), modes: make(chan uint32, 4), client: c} | ||
| 392 | otherEvents.SetID(other) | 392 | otherEvents.SetID(other) | ||
| 393 | - | c.Context().Register(otherEvents) | 393 | + | registerWireProxy(c, otherEvents) |
| 394 | if err := c.Roundtrip(); err != nil { | 394 | if err := c.Roundtrip(); err != nil { | ||
| 395 | t.Fatal(err) | 395 | t.Fatal(err) | ||
| 396 | } | 396 | } | ||
| @@ -434,7 +434,7 @@ func TestOutputManagementInitialHeadState(t *testing.T) { | |||||
| 434 | id := bindVersion(t, c, "zwlr_output_manager_v1", 4) | 434 | id := bindVersion(t, c, "zwlr_output_manager_v1", 4) | ||
| 435 | p := &managementEvents{events: make(chan [2]uint32, 32), headEvents: make(chan [2]uint32, 32), modes: make(chan uint32, 4), client: c} | 435 | p := &managementEvents{events: make(chan [2]uint32, 32), headEvents: make(chan [2]uint32, 32), modes: make(chan uint32, 4), client: c} | ||
| 436 | p.SetID(id) | 436 | p.SetID(id) | ||
| 437 | - | c.Context().Register(p) | 437 | + | registerWireProxy(c, p) |
| 438 | if err := c.Roundtrip(); err != nil { | 438 | if err := c.Roundtrip(); err != nil { | ||
| 439 | t.Fatal(err) | 439 | t.Fatal(err) | ||
| 440 | } | 440 | } | ||
| @@ -465,7 +465,7 @@ func TestOutputManagementStopDestroysManager(t *testing.T) { | |||||
| 465 | id := bindVersion(t, c, "zwlr_output_manager_v1", 4) | 465 | id := bindVersion(t, c, "zwlr_output_manager_v1", 4) | ||
| 466 | p := &managementEvents{events: make(chan [2]uint32, 8), client: c} | 466 | p := &managementEvents{events: make(chan [2]uint32, 8), client: c} | ||
| 467 | p.SetID(id) | 467 | p.SetID(id) | ||
| 468 | - | c.Context().Register(p) | 468 | + | registerWireProxy(c, p) |
| 469 | requestProtocol(t, c, id, wlr.ZwlrOutputManagerV1RequestStop) | 469 | requestProtocol(t, c, id, wlr.ZwlrOutputManagerV1RequestStop) | ||
| 470 | waitManagementEvent(t, c, p.events, wlr.ZwlrOutputManagerV1EventDone) | 470 | waitManagementEvent(t, c, p.events, wlr.ZwlrOutputManagerV1EventDone) | ||
| 471 | waitManagementEvent(t, c, p.events, wlr.ZwlrOutputManagerV1EventFinished) | 471 | waitManagementEvent(t, c, p.events, wlr.ZwlrOutputManagerV1EventFinished) | ||
| @@ -487,7 +487,7 @@ func TestOutputManagementReleasedModeNotReused(t *testing.T) { | |||||
| 487 | id := bindVersion(t, c, "zwlr_output_manager_v1", 4) | 487 | id := bindVersion(t, c, "zwlr_output_manager_v1", 4) | ||
| 488 | p := &managementEvents{events: make(chan [2]uint32, 32), headEvents: make(chan [2]uint32, 32), modes: make(chan uint32, 4), client: c} | 488 | p := &managementEvents{events: make(chan [2]uint32, 32), headEvents: make(chan [2]uint32, 32), modes: make(chan uint32, 4), client: c} | ||
| 489 | p.SetID(id) | 489 | p.SetID(id) | ||
| 490 | - | c.Context().Register(p) | 490 | + | registerWireProxy(c, p) |
| 491 | if err := c.Roundtrip(); err != nil { | 491 | if err := c.Roundtrip(); err != nil { | ||
| 492 | t.Fatal(err) | 492 | t.Fatal(err) | ||
| 493 | } | 493 | } | ||
| @@ -531,7 +531,7 @@ func managementSetup(t *testing.T) (*Server, *wlturbo.Display, uint32, uint32, u | |||||
| 531 | id := bindVersion(t, c, "zwlr_output_manager_v1", 4) | 531 | id := bindVersion(t, c, "zwlr_output_manager_v1", 4) | ||
| 532 | p := &managementEvents{events: make(chan [2]uint32, 32), modes: make(chan uint32, 4), client: c} | 532 | p := &managementEvents{events: make(chan [2]uint32, 32), modes: make(chan uint32, 4), client: c} | ||
| 533 | p.SetID(id) | 533 | p.SetID(id) | ||
| 534 | - | c.Context().Register(p) | 534 | + | registerWireProxy(c, p) |
| 535 | if err := c.Roundtrip(); err != nil { | 535 | if err := c.Roundtrip(); err != nil { | ||
| 536 | t.Fatal(err) | 536 | t.Fatal(err) | ||
| 537 | } | 537 | } | ||
| @@ -585,6 +585,7 @@ func TestOutputManagementDestroyConfigurationChildren(t *testing.T) { | |||||
| 585 | t.Fatal("no child resource") | 585 | t.Fatal("no child resource") | ||
| 586 | } | 586 | } | ||
| 587 | requestProtocol(t, c, cfg, wlr.ZwlrOutputConfigurationV1RequestDestroy) | 587 | requestProtocol(t, c, cfg, wlr.ZwlrOutputConfigurationV1RequestDestroy) | ||
| 588 | + | retireWireObject(c, ch) // the parent destructor also destroys its configuration heads | |||
| 588 | if err := c.Roundtrip(); err != nil { | 589 | if err := c.Roundtrip(); err != nil { | ||
| 589 | t.Fatal(err) | 590 | t.Fatal(err) | ||
| 590 | } | 591 | } | ||
| @@ -602,7 +603,7 @@ func TestOutputManagementStaleConfigurationSingleTerminal(t *testing.T) { | |||||
| 602 | cfg := c.AllocateID() | 603 | cfg := c.AllocateID() | ||
| 603 | p := &managementEvents{events: make(chan [2]uint32, 8), client: c} | 604 | p := &managementEvents{events: make(chan [2]uint32, 8), client: c} | ||
| 604 | p.SetID(cfg) | 605 | p.SetID(cfg) | ||
| 605 | - | c.Context().Register(p) | 606 | + | registerWireProxy(c, p) |
| 606 | requestProtocol(t, c, id, wlr.ZwlrOutputManagerV1RequestCreateConfiguration, cfg, serial-1) | 607 | requestProtocol(t, c, id, wlr.ZwlrOutputManagerV1RequestCreateConfiguration, cfg, serial-1) | ||
| 607 | waitManagementEvent(t, c, p.events, wlr.ZwlrOutputConfigurationV1EventCancelled) | 608 | waitManagementEvent(t, c, p.events, wlr.ZwlrOutputConfigurationV1EventCancelled) | ||
| 608 | requestProtocol(t, c, cfg, wlr.ZwlrOutputConfigurationV1RequestApply) | 609 | requestProtocol(t, c, cfg, wlr.ZwlrOutputConfigurationV1RequestApply) | ||
| @@ -634,6 +635,7 @@ func TestOutputManagementDestroyDropsPendingReply(t *testing.T) { | |||||
| 634 | t.Fatal("display stopped") | 635 | t.Fatal("display stopped") | ||
| 635 | } | 636 | } | ||
| 636 | requestProtocol(t, c, cfg, wlr.ZwlrOutputConfigurationV1RequestDestroy) | 637 | requestProtocol(t, c, cfg, wlr.ZwlrOutputConfigurationV1RequestDestroy) | ||
| 638 | + | retireWireObject(c, ch) // the parent destructor also destroys its configuration heads | |||
| 637 | if err := c.Roundtrip(); err != nil { | 639 | if err := c.Roundtrip(); err != nil { | ||
| 638 | t.Fatal(err) | 640 | t.Fatal(err) | ||
| 639 | } | 641 | } | ||
M internal/adapters/wayland/outputs.go +14 -0
| @@ -193,6 +193,11 @@ func (s *Server) setOutputs(c ports.SetOutputs) { | |||||
| 193 | l.close() | 193 | l.close() | ||
| 194 | } | 194 | } | ||
| 195 | } | 195 | } | ||
| 196 | + | for _, l := range s.lockSurfaces { | |||
| 197 | + | if l.output == o { | |||
| 198 | + | l.unmap() | |||
| 199 | + | } | |||
| 200 | + | } | |||
| 196 | for _, surf := range s.surfaces { | 201 | for _, surf := range s.surfaces { | ||
| 197 | surf.leave(o) | 202 | surf.leave(o) | ||
| 198 | } | 203 | } | ||
| @@ -211,7 +216,16 @@ func (s *Server) setOutputs(c ports.SetOutputs) { | |||||
| 211 | } | 216 | } | ||
| 212 | scaleChanged := o.place.Scale != p.Scale | 217 | scaleChanged := o.place.Scale != p.Scale | ||
| 213 | sizeChanged := o.place.Info.Width != p.Info.Width || o.place.Info.Height != p.Info.Height | 218 | sizeChanged := o.place.Info.Width != p.Info.Width || o.place.Info.Height != p.Info.Height | ||
| 219 | + | logicalChanged := o.place.Width != p.Width || o.place.Height != p.Height | |||
| 214 | o.place = p | 220 | o.place = p | ||
| 221 | + | if logicalChanged { | |||
| 222 | + | for _, l := range s.lockSurfaces { | |||
| 223 | + | if l.output == o && !l.closed { | |||
| 224 | + | l.sendConfigure() | |||
| 225 | + | } | |||
| 226 | + | } | |||
| 227 | + | s.lockSurfaceChanged() | |||
| 228 | + | } | |||
| 215 | if sizeChanged { | 229 | if sizeChanged { | ||
| 216 | for session := range s.captureSessions { | 230 | for session := range s.captureSessions { | ||
| 217 | if session.o == o && !session.stopped { | 231 | if session.o == o && !session.stopped { | ||
M internal/adapters/wayland/outputs_test.go +4 -2
| @@ -92,6 +92,8 @@ func TestFixesAckAndDestroyRegistry(t *testing.T) { | |||||
| 92 | waitOutputs(t, c, 1) | 92 | waitOutputs(t, c, 1) | ||
| 93 | requestProtocol(t, c, fixes, wayland.FixesRequestAckGlobalRemove, c.Registry().ID(), name) | 93 | requestProtocol(t, c, fixes, wayland.FixesRequestAckGlobalRemove, c.Registry().ID(), name) | ||
| 94 | requestProtocol(t, c, fixes, wayland.FixesRequestDestroyRegistry, c.Registry().ID()) | 94 | requestProtocol(t, c, fixes, wayland.FixesRequestDestroyRegistry, c.Registry().ID()) | ||
| 95 | + | // wl_fixes destroys another object, not the request's own proxy. | |||
| 96 | + | c.Context().UnregisterID(c.Registry().ID()) | |||
| 95 | if err := c.Roundtrip(); err != nil { | 97 | if err := c.Roundtrip(); err != nil { | ||
| 96 | t.Fatalf("valid ack and destroy: %v", err) | 98 | t.Fatalf("valid ack and destroy: %v", err) | ||
| 97 | } | 99 | } | ||
| @@ -121,7 +123,7 @@ func TestOutputHotplugAndLayerOutput(t *testing.T) { | |||||
| 121 | name = n | 123 | name = n | ||
| 122 | } | 124 | } | ||
| 123 | } | 125 | } | ||
| 124 | - | out, err := c.Registry().BindID(name, "wl_output", 4) | 126 | + | out, err := bindWireID(c, name, "wl_output", 4) |
| 125 | if err != nil { | 127 | if err != nil { | ||
| 126 | t.Fatal(err) | 128 | t.Fatal(err) | ||
| 127 | } | 129 | } | ||
| @@ -134,7 +136,7 @@ func TestOutputHotplugAndLayerOutput(t *testing.T) { | |||||
| 134 | layer := c.AllocateID() | 136 | layer := c.AllocateID() | ||
| 135 | proxy := &closedProxy{configured: make(chan [3]uint32, 2), closed: make(chan struct{})} | 137 | proxy := &closedProxy{configured: make(chan [3]uint32, 2), closed: make(chan struct{})} | ||
| 136 | proxy.SetID(layer) | 138 | proxy.SetID(layer) | ||
| 137 | - | c.Context().Register(proxy) | 139 | + | registerWireProxy(c, proxy) |
| 138 | requestProtocol(t, c, shell, wlrlayershell.ZwlrLayerShellV1RequestGetLayerSurface, layer, surf, out, uint32(ports.LayerTop), "bar") | 140 | requestProtocol(t, c, shell, wlrlayershell.ZwlrLayerShellV1RequestGetLayerSurface, layer, surf, out, uint32(ports.LayerTop), "bar") | ||
| 139 | requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetAnchor, uint32(13)) | 141 | requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetAnchor, uint32(13)) | ||
| 140 | requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetSize, uint32(0), uint32(30)) | 142 | requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetSize, uint32(0), uint32(30)) | ||
M internal/adapters/wayland/presentation_test.go +4 -4
| @@ -109,7 +109,7 @@ func TestPresentationFeedback(t *testing.T) { | |||||
| 109 | fb := c.AllocateID() | 109 | fb := c.AllocateID() | ||
| 110 | p := &feedbackEvents{out: make(chan presentedEvent, 4)} | 110 | p := &feedbackEvents{out: make(chan presentedEvent, 4)} | ||
| 111 | p.SetID(fb) | 111 | p.SetID(fb) | ||
| 112 | - | c.Context().Register(p) | 112 | + | registerWireProxy(c, p) |
| 113 | requestProtocol(t, c, pres, presentationtime.WpPresentationRequestFeedback, surf, fb) | 113 | requestProtocol(t, c, pres, presentationtime.WpPresentationRequestFeedback, surf, fb) | ||
| 114 | return p | 114 | return p | ||
| 115 | } | 115 | } | ||
| @@ -142,7 +142,7 @@ func TestPresentationFeedback(t *testing.T) { | |||||
| 142 | } | 142 | } | ||
| 143 | pool := c.AllocateID() | 143 | pool := c.AllocateID() | ||
| 144 | registerProtocol(t, c, pool) | 144 | registerProtocol(t, c, pool) | ||
| 145 | - | if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(4)); err != nil { | 145 | + | if err := wireRequest(c, shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(4)); err != nil { |
| 146 | t.Fatal(err) | 146 | t.Fatal(err) | ||
| 147 | } | 147 | } | ||
| 148 | commit := func() { | 148 | commit := func() { | ||
| @@ -218,7 +218,7 @@ func TestPresentationFeedback(t *testing.T) { | |||||
| 218 | cfb := c.AllocateID() | 218 | cfb := c.AllocateID() | ||
| 219 | cp := &feedbackEvents{out: make(chan presentedEvent, 1)} | 219 | cp := &feedbackEvents{out: make(chan presentedEvent, 1)} | ||
| 220 | cp.SetID(cfb) | 220 | cp.SetID(cfb) | ||
| 221 | - | c.Context().Register(cp) | 221 | + | registerWireProxy(c, cp) |
| 222 | requestProtocol(t, c, pres, presentationtime.WpPresentationRequestFeedback, child, cfb) | 222 | requestProtocol(t, c, pres, presentationtime.WpPresentationRequestFeedback, child, cfb) | ||
| 223 | requestProtocol(t, c, child, wayland.SurfaceRequestCommit) | 223 | requestProtocol(t, c, child, wayland.SurfaceRequestCommit) | ||
| 224 | commit() // the parent applies the subsurface | 224 | commit() // the parent applies the subsurface | ||
| @@ -248,7 +248,7 @@ func TestPresentationHiddenDiscarded(t *testing.T) { | |||||
| 248 | fb := c.AllocateID() | 248 | fb := c.AllocateID() | ||
| 249 | p := &feedbackEvents{out: make(chan presentedEvent, 1)} | 249 | p := &feedbackEvents{out: make(chan presentedEvent, 1)} | ||
| 250 | p.SetID(fb) | 250 | p.SetID(fb) | ||
| 251 | - | c.Context().Register(p) | 251 | + | registerWireProxy(c, p) |
| 252 | requestProtocol(t, c, pres, presentationtime.WpPresentationRequestFeedback, surf, fb) | 252 | requestProtocol(t, c, pres, presentationtime.WpPresentationRequestFeedback, surf, fb) | ||
| 253 | requestProtocol(t, c, surf, wayland.SurfaceRequestCommit) | 253 | requestProtocol(t, c, surf, wayland.SurfaceRequestCommit) | ||
| 254 | if err := c.Roundtrip(); err != nil { | 254 | if err := c.Roundtrip(); err != nil { | ||
M internal/adapters/wayland/protocol_test.go +20 -20
| @@ -23,7 +23,7 @@ func protocolClient(t *testing.T, s *Server, dir string) *wlturbo.Display { | |||||
| 23 | if err != nil { | 23 | if err != nil { | ||
| 24 | t.Fatal(err) | 24 | t.Fatal(err) | ||
| 25 | } | 25 | } | ||
| 26 | - | t.Cleanup(func() { _ = c.Close() }) | 26 | + | t.Cleanup(func() { _ = c.Close(); forgetWireClient(c) }) |
| 27 | if err := c.Roundtrip(); err != nil { | 27 | if err := c.Roundtrip(); err != nil { | ||
| 28 | t.Fatal(err) | 28 | t.Fatal(err) | ||
| 29 | } | 29 | } | ||
| @@ -41,7 +41,7 @@ func bindVersion(t *testing.T, c *wlturbo.Display, iface string, version uint32) | |||||
| 41 | if !ok { | 41 | if !ok { | ||
| 42 | t.Fatalf("missing %s", iface) | 42 | t.Fatalf("missing %s", iface) | ||
| 43 | } | 43 | } | ||
| 44 | - | id, err := c.Registry().BindID(g.Name, g.Interface, version) | 44 | + | id, err := bindWireID(c, g.Name, g.Interface, version) |
| 45 | if err != nil { | 45 | if err != nil { | ||
| 46 | t.Fatal(err) | 46 | t.Fatal(err) | ||
| 47 | } | 47 | } | ||
| @@ -50,7 +50,7 @@ func bindVersion(t *testing.T, c *wlturbo.Display, iface string, version uint32) | |||||
| 50 | 50 | ||||
| 51 | func requestProtocol(t *testing.T, c *wlturbo.Display, id uint32, op uint32, args ...any) { | 51 | func requestProtocol(t *testing.T, c *wlturbo.Display, id uint32, op uint32, args ...any) { | ||
| 52 | t.Helper() | 52 | t.Helper() | ||
| 53 | - | if err := c.SendRequest(id, uint16(op), args...); err != nil { | 53 | + | if err := wireRequest(c, id, uint16(op), nil, args...); err != nil { |
| 54 | t.Fatal(err) | 54 | t.Fatal(err) | ||
| 55 | } | 55 | } | ||
| 56 | } | 56 | } | ||
| @@ -89,7 +89,7 @@ func registerProtocol(t *testing.T, c *wlturbo.Display, id uint32) { | |||||
| 89 | t.Helper() | 89 | t.Helper() | ||
| 90 | p := &protocolProxy{} | 90 | p := &protocolProxy{} | ||
| 91 | p.SetID(id) | 91 | p.SetID(id) | ||
| 92 | - | c.Context().Register(p) | 92 | + | registerWireProxy(c, p) |
| 93 | } | 93 | } | ||
| 94 | 94 | ||||
| 95 | func TestXDGProtocolErrors(t *testing.T) { | 95 | func TestXDGProtocolErrors(t *testing.T) { | ||
| @@ -134,7 +134,7 @@ func TestXDGProtocolErrors(t *testing.T) { | |||||
| 134 | } | 134 | } | ||
| 135 | pool := c.AllocateID() | 135 | pool := c.AllocateID() | ||
| 136 | registerProtocol(t, c, shm) | 136 | registerProtocol(t, c, shm) | ||
| 137 | - | if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(4)); err != nil { | 137 | + | if err := wireRequest(c, shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(4)); err != nil { |
| 138 | t.Fatal(err) | 138 | t.Fatal(err) | ||
| 139 | } | 139 | } | ||
| 140 | buffer := c.AllocateID() | 140 | buffer := c.AllocateID() | ||
| @@ -199,7 +199,7 @@ func TestRecreatedToplevelRequiresNewBuffer(t *testing.T) { | |||||
| 199 | serials := make(chan uint32, 4) | 199 | serials := make(chan uint32, 4) | ||
| 200 | proxy := &configureProxy{serial: serials} | 200 | proxy := &configureProxy{serial: serials} | ||
| 201 | proxy.SetID(xdg) | 201 | proxy.SetID(xdg) | ||
| 202 | - | c.Context().Register(proxy) | 202 | + | registerWireProxy(c, proxy) |
| 203 | fd, err := unix.MemfdCreate("recreated-buffer", 0) | 203 | fd, err := unix.MemfdCreate("recreated-buffer", 0) | ||
| 204 | if err != nil { | 204 | if err != nil { | ||
| 205 | t.Fatal(err) | 205 | t.Fatal(err) | ||
| @@ -212,7 +212,7 @@ func TestRecreatedToplevelRequiresNewBuffer(t *testing.T) { | |||||
| 212 | pool, buffer := c.AllocateID(), c.AllocateID() | 212 | pool, buffer := c.AllocateID(), c.AllocateID() | ||
| 213 | registerProtocol(t, c, pool) | 213 | registerProtocol(t, c, pool) | ||
| 214 | registerProtocol(t, c, buffer) | 214 | registerProtocol(t, c, buffer) | ||
| 215 | - | if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(4)); err != nil { | 215 | + | if err := wireRequest(c, shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(4)); err != nil { |
| 216 | t.Fatal(err) | 216 | t.Fatal(err) | ||
| 217 | } | 217 | } | ||
| 218 | requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buffer, int32(0), int32(1), int32(1), int32(4), uint32(0)) | 218 | requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buffer, int32(0), int32(1), int32(1), int32(4), uint32(0)) | ||
| @@ -292,7 +292,7 @@ func TestSHMInvalidStride(t *testing.T) { | |||||
| 292 | pool := c.AllocateID() | 292 | pool := c.AllocateID() | ||
| 293 | registerProtocol(t, c, shm) | 293 | registerProtocol(t, c, shm) | ||
| 294 | registerProtocol(t, c, pool) | 294 | registerProtocol(t, c, pool) | ||
| 295 | - | if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(16)); err != nil { | 295 | + | if err := wireRequest(c, shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(16)); err != nil { |
| 296 | t.Fatal(err) | 296 | t.Fatal(err) | ||
| 297 | } | 297 | } | ||
| 298 | requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, c.AllocateID(), int32(0), int32(2), int32(1), int32(4), uint32(wayland.ShmFormatArgb8888)) | 298 | requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, c.AllocateID(), int32(0), int32(2), int32(1), int32(4), uint32(wayland.ShmFormatArgb8888)) | ||
| @@ -320,7 +320,7 @@ func TestSHMUnpaddedLastRow(t *testing.T) { | |||||
| 320 | pool := c.AllocateID() | 320 | pool := c.AllocateID() | ||
| 321 | registerProtocol(t, c, shm) | 321 | registerProtocol(t, c, shm) | ||
| 322 | registerProtocol(t, c, pool) | 322 | registerProtocol(t, c, pool) | ||
| 323 | - | if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, tc.size); err != nil { | 323 | + | if err := wireRequest(c, shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, tc.size); err != nil { |
| 324 | t.Fatal(err) | 324 | t.Fatal(err) | ||
| 325 | } | 325 | } | ||
| 326 | buffer := c.AllocateID() | 326 | buffer := c.AllocateID() | ||
| @@ -390,7 +390,7 @@ func TestSHMBufferReleasedOnReplaceAndDestroy(t *testing.T) { | |||||
| 390 | pool := c.AllocateID() | 390 | pool := c.AllocateID() | ||
| 391 | registerProtocol(t, c, shm) | 391 | registerProtocol(t, c, shm) | ||
| 392 | registerProtocol(t, c, pool) | 392 | registerProtocol(t, c, pool) | ||
| 393 | - | if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(64)); err != nil { | 393 | + | if err := wireRequest(c, shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(64)); err != nil { |
| 394 | t.Fatal(err) | 394 | t.Fatal(err) | ||
| 395 | } | 395 | } | ||
| 396 | released := make(chan uint32, 4) | 396 | released := make(chan uint32, 4) | ||
| @@ -399,7 +399,7 @@ func TestSHMBufferReleasedOnReplaceAndDestroy(t *testing.T) { | |||||
| 399 | bufs[i] = c.AllocateID() | 399 | bufs[i] = c.AllocateID() | ||
| 400 | p := &releaseProxy{released: released} | 400 | p := &releaseProxy{released: released} | ||
| 401 | p.SetID(bufs[i]) | 401 | p.SetID(bufs[i]) | ||
| 402 | - | c.Context().Register(p) | 402 | + | registerWireProxy(c, p) |
| 403 | requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, bufs[i], int32(i*32), int32(2), int32(2), int32(8), uint32(wayland.ShmFormatXrgb8888)) | 403 | requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, bufs[i], int32(i*32), int32(2), int32(2), int32(8), uint32(wayland.ShmFormatXrgb8888)) | ||
| 404 | } | 404 | } | ||
| 405 | surf := c.AllocateID() | 405 | surf := c.AllocateID() | ||
| @@ -466,7 +466,7 @@ func TestPointerProtocol(t *testing.T) { | |||||
| 466 | pointer := c.AllocateID() | 466 | pointer := c.AllocateID() | ||
| 467 | p := &pointerEvents{enters: make(chan [2]float64, 1), buttons: make(chan uint32, 8), axes: make(chan string, 16)} | 467 | p := &pointerEvents{enters: make(chan [2]float64, 1), buttons: make(chan uint32, 8), axes: make(chan string, 16)} | ||
| 468 | p.SetID(pointer) | 468 | p.SetID(pointer) | ||
| 469 | - | c.Context().Register(p) | 469 | + | registerWireProxy(c, p) |
| 470 | requestProtocol(t, c, seat, wayland.SeatRequestGetPointer, pointer) | 470 | requestProtocol(t, c, seat, wayland.SeatRequestGetPointer, pointer) | ||
| 471 | comp := bindProtocol(t, c, "wl_compositor") | 471 | comp := bindProtocol(t, c, "wl_compositor") | ||
| 472 | wm := bindProtocol(t, c, "xdg_wm_base") | 472 | wm := bindProtocol(t, c, "xdg_wm_base") | ||
| @@ -479,7 +479,7 @@ func TestPointerProtocol(t *testing.T) { | |||||
| 479 | serials := make(chan uint32, 1) | 479 | serials := make(chan uint32, 1) | ||
| 480 | xp := &configureProxy{serial: serials} | 480 | xp := &configureProxy{serial: serials} | ||
| 481 | xp.SetID(xdg) | 481 | xp.SetID(xdg) | ||
| 482 | - | c.Context().Register(xp) | 482 | + | registerWireProxy(c, xp) |
| 483 | top := c.AllocateID() | 483 | top := c.AllocateID() | ||
| 484 | registerProtocol(t, c, top) | 484 | registerProtocol(t, c, top) | ||
| 485 | requestProtocol(t, c, xdg, xdgshell.SurfaceRequestGetToplevel, top) | 485 | requestProtocol(t, c, xdg, xdgshell.SurfaceRequestGetToplevel, top) | ||
| @@ -499,7 +499,7 @@ func TestPointerProtocol(t *testing.T) { | |||||
| 499 | pool, buffer := c.AllocateID(), c.AllocateID() | 499 | pool, buffer := c.AllocateID(), c.AllocateID() | ||
| 500 | registerProtocol(t, c, pool) | 500 | registerProtocol(t, c, pool) | ||
| 501 | registerProtocol(t, c, buffer) | 501 | registerProtocol(t, c, buffer) | ||
| 502 | - | if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(4)); err != nil { | 502 | + | if err := wireRequest(c, shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(4)); err != nil { |
| 503 | t.Fatal(err) | 503 | t.Fatal(err) | ||
| 504 | } | 504 | } | ||
| 505 | requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buffer, int32(0), int32(1), int32(1), int32(4), uint32(0)) | 505 | requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buffer, int32(0), int32(1), int32(1), int32(4), uint32(0)) | ||
| @@ -577,7 +577,7 @@ func TestHeldKeyOnKeyboardFocusTransfer(t *testing.T) { | |||||
| 577 | keyboard := c.AllocateID() | 577 | keyboard := c.AllocateID() | ||
| 578 | kp := &heldKeyProxy{enters: make(chan []byte, 4)} | 578 | kp := &heldKeyProxy{enters: make(chan []byte, 4)} | ||
| 579 | kp.SetID(keyboard) | 579 | kp.SetID(keyboard) | ||
| 580 | - | c.Context().Register(kp) | 580 | + | registerWireProxy(c, kp) |
| 581 | requestProtocol(t, c, seat, wayland.SeatRequestGetKeyboard, keyboard) | 581 | requestProtocol(t, c, seat, wayland.SeatRequestGetKeyboard, keyboard) | ||
| 582 | mapWindow := toplevelMapper(t, c, events) | 582 | mapWindow := toplevelMapper(t, c, events) | ||
| 583 | a := mapWindow().ID | 583 | a := mapWindow().ID | ||
| @@ -642,7 +642,7 @@ func surfaceMapper(t *testing.T, c *wlturbo.Display, events <-chan ports.ClientE | |||||
| 642 | pool, buffer := c.AllocateID(), c.AllocateID() | 642 | pool, buffer := c.AllocateID(), c.AllocateID() | ||
| 643 | registerProtocol(t, c, pool) | 643 | registerProtocol(t, c, pool) | ||
| 644 | registerProtocol(t, c, buffer) | 644 | registerProtocol(t, c, buffer) | ||
| 645 | - | if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(4)); err != nil { | 645 | + | if err := wireRequest(c, shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(4)); err != nil { |
| 646 | t.Fatal(err) | 646 | t.Fatal(err) | ||
| 647 | } | 647 | } | ||
| 648 | requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buffer, int32(0), int32(1), int32(1), int32(4), uint32(0)) | 648 | requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buffer, int32(0), int32(1), int32(1), int32(4), uint32(0)) | ||
| @@ -654,7 +654,7 @@ func surfaceMapper(t *testing.T, c *wlturbo.Display, events <-chan ports.ClientE | |||||
| 654 | serials := make(chan uint32, 1) | 654 | serials := make(chan uint32, 1) | ||
| 655 | xp := &configureProxy{serial: serials} | 655 | xp := &configureProxy{serial: serials} | ||
| 656 | xp.SetID(xdg) | 656 | xp.SetID(xdg) | ||
| 657 | - | c.Context().Register(xp) | 657 | + | registerWireProxy(c, xp) |
| 658 | registerProtocol(t, c, top) | 658 | registerProtocol(t, c, top) | ||
| 659 | requestProtocol(t, c, xdg, xdgshell.SurfaceRequestGetToplevel, top) | 659 | requestProtocol(t, c, xdg, xdgshell.SurfaceRequestGetToplevel, top) | ||
| 660 | requestProtocol(t, c, surf, wayland.SurfaceRequestCommit) | 660 | requestProtocol(t, c, surf, wayland.SurfaceRequestCommit) | ||
| @@ -697,7 +697,7 @@ func xdgWindow(t *testing.T, c *wlturbo.Display, comp, wm, buffer uint32, role f | |||||
| 697 | serials = make(chan uint32, 4) | 697 | serials = make(chan uint32, 4) | ||
| 698 | proxy := &configureProxy{serial: serials} | 698 | proxy := &configureProxy{serial: serials} | ||
| 699 | proxy.SetID(xdg) | 699 | proxy.SetID(xdg) | ||
| 700 | - | c.Context().Register(proxy) | 700 | + | registerWireProxy(c, proxy) |
| 701 | role(xdg) | 701 | role(xdg) | ||
| 702 | requestProtocol(t, c, surf, wayland.SurfaceRequestCommit) | 702 | requestProtocol(t, c, surf, wayland.SurfaceRequestCommit) | ||
| 703 | if err := c.Roundtrip(); err != nil { | 703 | if err := c.Roundtrip(); err != nil { | ||
| @@ -746,7 +746,7 @@ func TestPopupAndFloatingLifecycle(t *testing.T) { | |||||
| 746 | pool, buffer := c.AllocateID(), c.AllocateID() | 746 | pool, buffer := c.AllocateID(), c.AllocateID() | ||
| 747 | registerProtocol(t, c, pool) | 747 | registerProtocol(t, c, pool) | ||
| 748 | registerProtocol(t, c, buffer) | 748 | registerProtocol(t, c, buffer) | ||
| 749 | - | if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(4)); err != nil { | 749 | + | if err := wireRequest(c, shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(4)); err != nil { |
| 750 | t.Fatal(err) | 750 | t.Fatal(err) | ||
| 751 | } | 751 | } | ||
| 752 | requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buffer, int32(0), int32(1), int32(1), int32(4), uint32(0)) | 752 | requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buffer, int32(0), int32(1), int32(1), int32(4), uint32(0)) | ||
| @@ -777,7 +777,7 @@ func TestPopupAndFloatingLifecycle(t *testing.T) { | |||||
| 777 | popup := c.AllocateID() | 777 | popup := c.AllocateID() | ||
| 778 | proxy := &popupDoneProxy{done: done} | 778 | proxy := &popupDoneProxy{done: done} | ||
| 779 | proxy.SetID(popup) | 779 | proxy.SetID(popup) | ||
| 780 | - | c.Context().Register(proxy) | 780 | + | registerWireProxy(c, proxy) |
| 781 | requestProtocol(t, c, pxdg, xdgshell.SurfaceRequestGetPopup, popup, xdg, positioner) | 781 | requestProtocol(t, c, pxdg, xdgshell.SurfaceRequestGetPopup, popup, xdg, positioner) | ||
| 782 | }) | 782 | }) | ||
| 783 | req, ok := waitEvent(t, events, 2*time.Second).(ports.PopupRequest) | 783 | req, ok := waitEvent(t, events, 2*time.Second).(ports.PopupRequest) | ||
M internal/adapters/wayland/release_test.go +2 -2
| @@ -35,7 +35,7 @@ func TestCachedSubsurfaceBufferReleaseOnRoleDestroy(t *testing.T) { | |||||
| 35 | b := shmBuffer(t, c) | 35 | b := shmBuffer(t, c) | ||
| 36 | p := &syncReleaseProxy{released: make(chan struct{}, 4)} | 36 | p := &syncReleaseProxy{released: make(chan struct{}, 4)} | ||
| 37 | p.SetID(b) | 37 | p.SetID(b) | ||
| 38 | - | c.Context().Register(p) | 38 | + | registerWireProxy(c, p) |
| 39 | requestProtocol(t, c, child, wayland.SurfaceRequestAttach, b, int32(0), int32(0)) | 39 | requestProtocol(t, c, child, wayland.SurfaceRequestAttach, b, int32(0), int32(0)) | ||
| 40 | requestProtocol(t, c, child, wayland.SurfaceRequestCommit) | 40 | requestProtocol(t, c, child, wayland.SurfaceRequestCommit) | ||
| 41 | if err := c.Roundtrip(); err != nil { | 41 | if err := c.Roundtrip(); err != nil { | ||
| @@ -245,7 +245,7 @@ func TestHeldBufferRequeuedBeforeOutputAck(t *testing.T) { | |||||
| 245 | b, other := shmBuffer(t, c), shmBuffer(t, c) | 245 | b, other := shmBuffer(t, c), shmBuffer(t, c) | ||
| 246 | p := &syncReleaseProxy{released: make(chan struct{}, 4)} | 246 | p := &syncReleaseProxy{released: make(chan struct{}, 4)} | ||
| 247 | p.SetID(b) | 247 | p.SetID(b) | ||
| 248 | - | c.Context().Register(p) | 248 | + | registerWireProxy(c, p) |
| 249 | attach := func(buf uint32) { | 249 | attach := func(buf uint32) { | ||
| 250 | requestProtocol(t, c, root, wayland.SurfaceRequestAttach, buf, int32(0), int32(0)) | 250 | requestProtocol(t, c, root, wayland.SurfaceRequestAttach, buf, int32(0), int32(0)) | ||
| 251 | requestProtocol(t, c, root, wayland.SurfaceRequestCommit) | 251 | requestProtocol(t, c, root, wayland.SurfaceRequestCommit) | ||
M internal/adapters/wayland/scale.go +5 -0
| @@ -28,6 +28,11 @@ func (s *Server) outputOfSurface(surf *surface) *output { | |||||
| 28 | name = surf.xdg.window.last.Output | 28 | name = surf.xdg.window.last.Output | ||
| 29 | case surf.layer != nil && surf.layer.output != nil: | 29 | case surf.layer != nil && surf.layer.output != nil: | ||
| 30 | return surf.layer.output | 30 | return surf.layer.output | ||
| 31 | + | case surf.lock != nil: | |||
| 32 | + | if !surf.lock.closed { | |||
| 33 | + | return surf.lock.output | |||
| 34 | + | } | |||
| 35 | + | return nil | |||
| 31 | } | 36 | } | ||
| 32 | return s.outputByName(name) | 37 | return s.outputByName(name) | ||
| 33 | } | 38 | } | ||
M internal/adapters/wayland/scale_test.go +7 -7
| @@ -60,7 +60,7 @@ func TestFractionalScaleAndViewport(t *testing.T) { | |||||
| 60 | c := protocolClient(t, s, dir) | 60 | c := protocolClient(t, s, dir) | ||
| 61 | // wl_surface.preferred_buffer_scale needs wl_compositor v6. | 61 | // wl_surface.preferred_buffer_scale needs wl_compositor v6. | ||
| 62 | g, _ := c.Registry().FindGlobal("wl_compositor") | 62 | g, _ := c.Registry().FindGlobal("wl_compositor") | ||
| 63 | - | comp, err := c.Registry().BindID(g.Name, g.Interface, 6) | 63 | + | comp, err := bindWireID(c, g.Name, g.Interface, 6) |
| 64 | if err != nil { | 64 | if err != nil { | ||
| 65 | t.Fatal(err) | 65 | t.Fatal(err) | ||
| 66 | } | 66 | } | ||
| @@ -74,13 +74,13 @@ func TestFractionalScaleAndViewport(t *testing.T) { | |||||
| 74 | bufferScales := make(chan int32, 4) | 74 | bufferScales := make(chan int32, 4) | ||
| 75 | sp := &surfaceScaleProxy{scales: bufferScales} | 75 | sp := &surfaceScaleProxy{scales: bufferScales} | ||
| 76 | sp.SetID(surf) | 76 | sp.SetID(surf) | ||
| 77 | - | c.Context().Register(sp) | 77 | + | registerWireProxy(c, sp) |
| 78 | requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surf) | 78 | requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surf) | ||
| 79 | frac := c.AllocateID() | 79 | frac := c.AllocateID() | ||
| 80 | values := make(chan uint32, 4) | 80 | values := make(chan uint32, 4) | ||
| 81 | proxy := &scaleProxy{values: values} | 81 | proxy := &scaleProxy{values: values} | ||
| 82 | proxy.SetID(frac) | 82 | proxy.SetID(frac) | ||
| 83 | - | c.Context().Register(proxy) | 83 | + | registerWireProxy(c, proxy) |
| 84 | requestProtocol(t, c, fm, fractionalscale.WpFractionalScaleManagerV1RequestGetFractionalScale, frac, surf) | 84 | requestProtocol(t, c, fm, fractionalscale.WpFractionalScaleManagerV1RequestGetFractionalScale, frac, surf) | ||
| 85 | vp := c.AllocateID() | 85 | vp := c.AllocateID() | ||
| 86 | requestProtocol(t, c, vpm, viewporter.WpViewporterRequestGetViewport, vp, surf) | 86 | requestProtocol(t, c, vpm, viewporter.WpViewporterRequestGetViewport, vp, surf) | ||
| @@ -121,7 +121,7 @@ func TestFractionalScaleAndViewport(t *testing.T) { | |||||
| 121 | serials := make(chan uint32, 4) | 121 | serials := make(chan uint32, 4) | ||
| 122 | cp := &configureProxy{serial: serials} | 122 | cp := &configureProxy{serial: serials} | ||
| 123 | cp.SetID(xdg) | 123 | cp.SetID(xdg) | ||
| 124 | - | c.Context().Register(cp) | 124 | + | registerWireProxy(c, cp) |
| 125 | top := c.AllocateID() | 125 | top := c.AllocateID() | ||
| 126 | registerProtocol(t, c, top) | 126 | registerProtocol(t, c, top) | ||
| 127 | requestProtocol(t, c, xdg, xdgshell.SurfaceRequestGetToplevel, top) | 127 | requestProtocol(t, c, xdg, xdgshell.SurfaceRequestGetToplevel, top) | ||
| @@ -142,7 +142,7 @@ func TestFractionalScaleAndViewport(t *testing.T) { | |||||
| 142 | pool, buffer := c.AllocateID(), c.AllocateID() | 142 | pool, buffer := c.AllocateID(), c.AllocateID() | ||
| 143 | registerProtocol(t, c, pool) | 143 | registerProtocol(t, c, pool) | ||
| 144 | registerProtocol(t, c, buffer) | 144 | registerProtocol(t, c, buffer) | ||
| 145 | - | if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, size); err != nil { | 145 | + | if err := wireRequest(c, shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, size); err != nil { |
| 146 | t.Fatal(err) | 146 | t.Fatal(err) | ||
| 147 | } | 147 | } | ||
| 148 | requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buffer, int32(0), int32(30), int32(15), int32(120), uint32(0)) | 148 | requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buffer, int32(0), int32(30), int32(15), int32(120), uint32(0)) | ||
| @@ -270,7 +270,7 @@ func TestViewportApplyErrors(t *testing.T) { | |||||
| 270 | pool, buf := c.AllocateID(), c.AllocateID() | 270 | pool, buf := c.AllocateID(), c.AllocateID() | ||
| 271 | registerProtocol(t, c, pool) | 271 | registerProtocol(t, c, pool) | ||
| 272 | registerProtocol(t, c, buf) | 272 | registerProtocol(t, c, buf) | ||
| 273 | - | if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(4)); err != nil { | 273 | + | if err := wireRequest(c, shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(4)); err != nil { |
| 274 | t.Fatal(err) | 274 | t.Fatal(err) | ||
| 275 | } | 275 | } | ||
| 276 | requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buf, int32(0), int32(1), int32(1), int32(4), uint32(0)) | 276 | requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buf, int32(0), int32(1), int32(1), int32(4), uint32(0)) | ||
| @@ -321,7 +321,7 @@ func TestViewportSourceEdgeRounding(t *testing.T) { | |||||
| 321 | pool, buf := c.AllocateID(), c.AllocateID() | 321 | pool, buf := c.AllocateID(), c.AllocateID() | ||
| 322 | registerProtocol(t, c, pool) | 322 | registerProtocol(t, c, pool) | ||
| 323 | registerProtocol(t, c, buf) | 323 | registerProtocol(t, c, buf) | ||
| 324 | - | if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(w*h*4)); err != nil { | 324 | + | if err := wireRequest(c, shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(w*h*4)); err != nil { |
| 325 | t.Fatal(err) | 325 | t.Fatal(err) | ||
| 326 | } | 326 | } | ||
| 327 | requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buf, int32(0), int32(w), int32(h), int32(w*4), uint32(0)) | 327 | requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buf, int32(0), int32(w), int32(h), int32(w*4), uint32(0)) | ||
M internal/adapters/wayland/screencopy.go +14 -2
| @@ -154,6 +154,10 @@ func (s *Server) validCaptureBuffer(b *wayland.Buffer, rect image.Rectangle, for | |||||
| 154 | // requestCapture duplicates the pool descriptor, so destroying the wl_buffer | 154 | // requestCapture duplicates the pool descriptor, so destroying the wl_buffer | ||
| 155 | // cannot invalidate a capture in flight. | 155 | // cannot invalidate a capture in flight. | ||
| 156 | func (s *Server) requestCapture(o *output, rect image.Rectangle, cursor bool, b *wayland.Buffer, format uint32, reply func(ports.CaptureDone), life *server.Resource) (uint64, bool) { | 156 | func (s *Server) requestCapture(o *output, rect image.Rectangle, cursor bool, b *wayland.Buffer, format uint32, reply func(ports.CaptureDone), life *server.Resource) (uint64, bool) { | ||
| 157 | + | if s.protected() { | |||
| 158 | + | s.log.Debug().Str("component", "wayland").Msg("capture refused: session protected") | |||
| 159 | + | return 0, false | |||
| 160 | + | } | |||
| 157 | if o == nil || b == nil || s.channels.Captures == nil || s.channels.Captured == nil || s.outputByNameExact(o.name()) != o { | 161 | if o == nil || b == nil || s.channels.Captures == nil || s.channels.Captured == nil || s.outputByNameExact(o.name()) != o { | ||
| 158 | return 0, false | 162 | return 0, false | ||
| 159 | } | 163 | } | ||
| @@ -220,6 +224,7 @@ func (s *Server) forwardCaptured(ctxDone <-chan struct{}) { | |||||
| 220 | delete(s.captureReplies, done.ID) | 224 | delete(s.captureReplies, done.ID) | ||
| 221 | fn(done) | 225 | fn(done) | ||
| 222 | } | 226 | } | ||
| 227 | + | s.maybeLocked() | |||
| 223 | }) { | 228 | }) { | ||
| 224 | return | 229 | return | ||
| 225 | } | 230 | } | ||
| @@ -264,7 +269,7 @@ func (m copyCaptureManager) CreateSession(r *ext.ExtImageCopyCaptureManagerV1, i | |||||
| 264 | m.s.captureSessions[state] = struct{}{} | 269 | m.s.captureSessions[state] = struct{}{} | ||
| 265 | // Frames outlive their session (the spec): each frame drops its own reply. | 270 | // Frames outlive their session (the spec): each frame drops its own reply. | ||
| 266 | res.OnDestroy = func() { delete(m.s.captureSessions, state) } | 271 | res.OnDestroy = func() { delete(m.s.captureSessions, state) } | ||
| 267 | - | if o == nil { | 272 | + | if m.s.protected() || o == nil { |
| 268 | res.SendStopped() | 273 | res.SendStopped() | ||
| 269 | state.stopped = true | 274 | state.stopped = true | ||
| 270 | return | 275 | return | ||
| @@ -274,6 +279,13 @@ func (m copyCaptureManager) CreateSession(r *ext.ExtImageCopyCaptureManagerV1, i | |||||
| 274 | 279 | ||||
| 275 | // sendConstraints sends one complete constraint batch: formats, size, done. | 280 | // sendConstraints sends one complete constraint batch: formats, size, done. | ||
| 276 | func (c *captureSession) sendConstraints(w, h int) { | 281 | func (c *captureSession) sendConstraints(w, h int) { | ||
| 282 | + | if c.s.protected() { | |||
| 283 | + | if !c.stopped { | |||
| 284 | + | c.stopped = true | |||
| 285 | + | c.res.SendStopped() | |||
| 286 | + | } | |||
| 287 | + | return | |||
| 288 | + | } | |||
| 277 | c.res.SendBufferSize(uint32(w), uint32(h)) | 289 | c.res.SendBufferSize(uint32(w), uint32(h)) | ||
| 278 | c.res.SendShmFormat(uint32(wayland.ShmFormatXrgb8888)) | 290 | c.res.SendShmFormat(uint32(wayland.ShmFormatXrgb8888)) | ||
| 279 | c.res.SendShmFormat(uint32(wayland.ShmFormatArgb8888)) | 291 | c.res.SendShmFormat(uint32(wayland.ShmFormatArgb8888)) | ||
| @@ -369,7 +381,7 @@ func (f *captureExtFrame) Capture(*ext.ExtImageCopyCaptureFrameV1) { | |||||
| 369 | } | 381 | } | ||
| 370 | f.used = true | 382 | f.used = true | ||
| 371 | c := f.session | 383 | c := f.session | ||
| 372 | - | if c.stopped || c.o == nil || c.s.outputByNameExact(c.o.name()) != c.o { | 384 | + | if c.stopped || c.o == nil || c.s.protected() || c.s.outputByNameExact(c.o.name()) != c.o { |
| 373 | f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonStopped)) | 385 | f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonStopped)) | ||
| 374 | return | 386 | return | ||
| 375 | } | 387 | } | ||
M internal/adapters/wayland/screencopy_test.go +18 -18
| @@ -66,7 +66,7 @@ func TestScreencopyProtocol(t *testing.T) { | |||||
| 66 | if out == 0 { | 66 | if out == 0 { | ||
| 67 | t.Fatal("output global missing") | 67 | t.Fatal("output global missing") | ||
| 68 | } | 68 | } | ||
| 69 | - | outputID, err := c.Registry().BindID(out, "wl_output", 4) | 69 | + | outputID, err := bindWireID(c, out, "wl_output", 4) |
| 70 | if err != nil { | 70 | if err != nil { | ||
| 71 | t.Fatal(err) | 71 | t.Fatal(err) | ||
| 72 | } | 72 | } | ||
| @@ -75,7 +75,7 @@ func TestScreencopyProtocol(t *testing.T) { | |||||
| 75 | frame := c.AllocateID() | 75 | frame := c.AllocateID() | ||
| 76 | events := &captureEvents{events: make(chan uint16, 8)} | 76 | events := &captureEvents{events: make(chan uint16, 8)} | ||
| 77 | events.SetID(frame) | 77 | events.SetID(frame) | ||
| 78 | - | c.Context().Register(events) | 78 | + | registerWireProxy(c, events) |
| 79 | requestProtocol(t, c, manager, wlr.ZwlrScreencopyManagerV1RequestCaptureOutputRegion, frame, int32(0), outputID, int32(1), int32(1), int32(2), int32(2)) | 79 | requestProtocol(t, c, manager, wlr.ZwlrScreencopyManagerV1RequestCaptureOutputRegion, frame, int32(0), outputID, int32(1), int32(1), int32(2), int32(2)) | ||
| 80 | if err := c.Roundtrip(); err != nil { | 80 | if err := c.Roundtrip(); err != nil { | ||
| 81 | t.Fatal(err) | 81 | t.Fatal(err) | ||
| @@ -99,7 +99,7 @@ func TestScreencopyProtocol(t *testing.T) { | |||||
| 99 | pool, buf := c.AllocateID(), c.AllocateID() | 99 | pool, buf := c.AllocateID(), c.AllocateID() | ||
| 100 | registerProtocol(t, c, pool) | 100 | registerProtocol(t, c, pool) | ||
| 101 | registerProtocol(t, c, buf) | 101 | registerProtocol(t, c, buf) | ||
| 102 | - | if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(16)); err != nil { | 102 | + | if err := wireRequest(c, shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(16)); err != nil { |
| 103 | t.Fatal(err) | 103 | t.Fatal(err) | ||
| 104 | } | 104 | } | ||
| 105 | requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buf, int32(0), int32(2), int32(2), int32(8), uint32(wayland.ShmFormatXrgb8888)) | 105 | requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buf, int32(0), int32(2), int32(2), int32(8), uint32(wayland.ShmFormatXrgb8888)) | ||
| @@ -153,7 +153,7 @@ func captureTestClient(t *testing.T) (*Server, *wlturbo.Display, uint32) { | |||||
| 153 | if !ok { | 153 | if !ok { | ||
| 154 | t.Fatal("wl_output missing") | 154 | t.Fatal("wl_output missing") | ||
| 155 | } | 155 | } | ||
| 156 | - | out, err := c.Registry().BindID(g.Name, g.Interface, 4) | 156 | + | out, err := bindWireID(c, g.Name, g.Interface, 4) |
| 157 | if err != nil { | 157 | if err != nil { | ||
| 158 | t.Fatal(err) | 158 | t.Fatal(err) | ||
| 159 | } | 159 | } | ||
| @@ -173,7 +173,7 @@ func captureTestExt(t *testing.T, c *wlturbo.Display, out uint32) (uint32, uint3 | |||||
| 173 | requestProtocol(t, c, sourceManager, source.ExtOutputImageCaptureSourceManagerV1RequestCreateSource, src, out) | 173 | requestProtocol(t, c, sourceManager, source.ExtOutputImageCaptureSourceManagerV1RequestCreateSource, src, out) | ||
| 174 | events := &captureEvents{events: make(chan uint16, 16)} | 174 | events := &captureEvents{events: make(chan uint16, 16)} | ||
| 175 | events.SetID(session) | 175 | events.SetID(session) | ||
| 176 | - | c.Context().Register(events) | 176 | + | registerWireProxy(c, events) |
| 177 | requestProtocol(t, c, manager, ext.ExtImageCopyCaptureManagerV1RequestCreateSession, session, src, uint32(0)) | 177 | requestProtocol(t, c, manager, ext.ExtImageCopyCaptureManagerV1RequestCreateSession, session, src, uint32(0)) | ||
| 178 | if err := c.Roundtrip(); err != nil { | 178 | if err := c.Roundtrip(); err != nil { | ||
| 179 | t.Fatal(err) | 179 | t.Fatal(err) | ||
| @@ -228,7 +228,7 @@ func TestCaptureProtocolErrors(t *testing.T) { | |||||
| 228 | registerProtocol(t, c, pool) | 228 | registerProtocol(t, c, pool) | ||
| 229 | registerProtocol(t, c, buf) | 229 | registerProtocol(t, c, buf) | ||
| 230 | registerProtocol(t, c, shm) | 230 | registerProtocol(t, c, shm) | ||
| 231 | - | if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(testOutputs[0].Info.Width*testOutputs[0].Info.Height*4)); err != nil { | 231 | + | if err := wireRequest(c, shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(testOutputs[0].Info.Width*testOutputs[0].Info.Height*4)); err != nil { |
| 232 | t.Fatal(err) | 232 | t.Fatal(err) | ||
| 233 | } | 233 | } | ||
| 234 | w := int32(1) | 234 | w := int32(1) | ||
| @@ -282,7 +282,7 @@ func TestCaptureProtocolErrors(t *testing.T) { | |||||
| 282 | frame := c.AllocateID() | 282 | frame := c.AllocateID() | ||
| 283 | frameEvents := &captureDetails{events: make(chan []uint32, 4)} | 283 | frameEvents := &captureDetails{events: make(chan []uint32, 4)} | ||
| 284 | frameEvents.SetID(frame) | 284 | frameEvents.SetID(frame) | ||
| 285 | - | c.Context().Register(frameEvents) | 285 | + | registerWireProxy(c, frameEvents) |
| 286 | requestProtocol(t, c, session, ext.ExtImageCopyCaptureSessionV1RequestCreateFrame, frame) | 286 | requestProtocol(t, c, session, ext.ExtImageCopyCaptureSessionV1RequestCreateFrame, frame) | ||
| 287 | switch tc.object { | 287 | switch tc.object { | ||
| 288 | case "frame": | 288 | case "frame": | ||
| @@ -301,7 +301,7 @@ func TestCaptureProtocolErrors(t *testing.T) { | |||||
| 301 | pool, buf := c.AllocateID(), c.AllocateID() | 301 | pool, buf := c.AllocateID(), c.AllocateID() | ||
| 302 | registerProtocol(t, c, pool) | 302 | registerProtocol(t, c, pool) | ||
| 303 | registerProtocol(t, c, buf) | 303 | registerProtocol(t, c, buf) | ||
| 304 | - | if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(4)); err != nil { | 304 | + | if err := wireRequest(c, shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(4)); err != nil { |
| 305 | t.Fatal(err) | 305 | t.Fatal(err) | ||
| 306 | } | 306 | } | ||
| 307 | requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buf, int32(0), int32(1), int32(1), int32(4), uint32(wayland.ShmFormatXrgb8888)) | 307 | requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buf, int32(0), int32(1), int32(1), int32(4), uint32(wayland.ShmFormatXrgb8888)) | ||
| @@ -436,7 +436,7 @@ func TestExtCaptureLifecycle(t *testing.T) { | |||||
| 436 | if !ok { | 436 | if !ok { | ||
| 437 | t.Fatal("output missing") | 437 | t.Fatal("output missing") | ||
| 438 | } | 438 | } | ||
| 439 | - | out, err := c.Registry().BindID(g.Name, g.Interface, 4) | 439 | + | out, err := bindWireID(c, g.Name, g.Interface, 4) |
| 440 | if err != nil { | 440 | if err != nil { | ||
| 441 | t.Fatal(err) | 441 | t.Fatal(err) | ||
| 442 | } | 442 | } | ||
| @@ -447,7 +447,7 @@ func TestExtCaptureLifecycle(t *testing.T) { | |||||
| 447 | registerProtocol(t, c, src) | 447 | registerProtocol(t, c, src) | ||
| 448 | p := &captureDetails{events: make(chan []uint32, 16)} | 448 | p := &captureDetails{events: make(chan []uint32, 16)} | ||
| 449 | p.SetID(session) | 449 | p.SetID(session) | ||
| 450 | - | c.Context().Register(p) | 450 | + | registerWireProxy(c, p) |
| 451 | requestProtocol(t, c, srcMgr, source.ExtOutputImageCaptureSourceManagerV1RequestCreateSource, src, out) | 451 | requestProtocol(t, c, srcMgr, source.ExtOutputImageCaptureSourceManagerV1RequestCreateSource, src, out) | ||
| 452 | requestProtocol(t, c, mgr, ext.ExtImageCopyCaptureManagerV1RequestCreateSession, session, src, uint32(0)) | 452 | requestProtocol(t, c, mgr, ext.ExtImageCopyCaptureManagerV1RequestCreateSession, session, src, uint32(0)) | ||
| 453 | if err := c.Roundtrip(); err != nil { | 453 | if err := c.Roundtrip(); err != nil { | ||
| @@ -473,14 +473,14 @@ func TestExtCaptureLifecycle(t *testing.T) { | |||||
| 473 | pool, buf := c.AllocateID(), c.AllocateID() | 473 | pool, buf := c.AllocateID(), c.AllocateID() | ||
| 474 | registerProtocol(t, c, pool) | 474 | registerProtocol(t, c, pool) | ||
| 475 | registerProtocol(t, c, buf) | 475 | registerProtocol(t, c, buf) | ||
| 476 | - | if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(80)); err != nil { | 476 | + | if err := wireRequest(c, shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(80)); err != nil { |
| 477 | t.Fatal(err) | 477 | t.Fatal(err) | ||
| 478 | } | 478 | } | ||
| 479 | requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buf, int32(0), int32(4), int32(4), int32(16), uint32(wayland.ShmFormatXrgb8888)) | 479 | requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buf, int32(0), int32(4), int32(4), int32(16), uint32(wayland.ShmFormatXrgb8888)) | ||
| 480 | frame := c.AllocateID() | 480 | frame := c.AllocateID() | ||
| 481 | fp := &captureDetails{events: make(chan []uint32, 16)} | 481 | fp := &captureDetails{events: make(chan []uint32, 16)} | ||
| 482 | fp.SetID(frame) | 482 | fp.SetID(frame) | ||
| 483 | - | c.Context().Register(fp) | 483 | + | registerWireProxy(c, fp) |
| 484 | requestProtocol(t, c, session, ext.ExtImageCopyCaptureSessionV1RequestCreateFrame, frame) | 484 | requestProtocol(t, c, session, ext.ExtImageCopyCaptureSessionV1RequestCreateFrame, frame) | ||
| 485 | requestProtocol(t, c, frame, ext.ExtImageCopyCaptureFrameV1RequestAttachBuffer, buf) | 485 | requestProtocol(t, c, frame, ext.ExtImageCopyCaptureFrameV1RequestAttachBuffer, buf) | ||
| 486 | requestProtocol(t, c, frame, ext.ExtImageCopyCaptureFrameV1RequestCapture) | 486 | requestProtocol(t, c, frame, ext.ExtImageCopyCaptureFrameV1RequestCapture) | ||
| @@ -534,7 +534,7 @@ func TestExtCaptureLifecycle(t *testing.T) { | |||||
| 534 | requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buf2, int32(0), int32(5), int32(4), int32(20), uint32(wayland.ShmFormatXrgb8888)) | 534 | requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buf2, int32(0), int32(5), int32(4), int32(20), uint32(wayland.ShmFormatXrgb8888)) | ||
| 535 | fp2 := &captureDetails{events: make(chan []uint32, 16)} | 535 | fp2 := &captureDetails{events: make(chan []uint32, 16)} | ||
| 536 | fp2.SetID(frame2) | 536 | fp2.SetID(frame2) | ||
| 537 | - | c.Context().Register(fp2) | 537 | + | registerWireProxy(c, fp2) |
| 538 | requestProtocol(t, c, session, ext.ExtImageCopyCaptureSessionV1RequestCreateFrame, frame2) | 538 | requestProtocol(t, c, session, ext.ExtImageCopyCaptureSessionV1RequestCreateFrame, frame2) | ||
| 539 | requestProtocol(t, c, frame2, ext.ExtImageCopyCaptureFrameV1RequestAttachBuffer, buf2) | 539 | requestProtocol(t, c, frame2, ext.ExtImageCopyCaptureFrameV1RequestAttachBuffer, buf2) | ||
| 540 | requestProtocol(t, c, frame2, ext.ExtImageCopyCaptureFrameV1RequestCapture) | 540 | requestProtocol(t, c, frame2, ext.ExtImageCopyCaptureFrameV1RequestCapture) | ||
| @@ -592,7 +592,7 @@ func TestExtCaptureStoppedOnOutputRemoval(t *testing.T) { | |||||
| 592 | if !ok { | 592 | if !ok { | ||
| 593 | t.Fatal("output missing") | 593 | t.Fatal("output missing") | ||
| 594 | } | 594 | } | ||
| 595 | - | out, err := c.Registry().BindID(g.Name, g.Interface, 4) | 595 | + | out, err := bindWireID(c, g.Name, g.Interface, 4) |
| 596 | if err != nil { | 596 | if err != nil { | ||
| 597 | t.Fatal(err) | 597 | t.Fatal(err) | ||
| 598 | } | 598 | } | ||
| @@ -603,7 +603,7 @@ func TestExtCaptureStoppedOnOutputRemoval(t *testing.T) { | |||||
| 603 | registerProtocol(t, c, src) | 603 | registerProtocol(t, c, src) | ||
| 604 | p := &captureDetails{events: make(chan []uint32, 16)} | 604 | p := &captureDetails{events: make(chan []uint32, 16)} | ||
| 605 | p.SetID(session) | 605 | p.SetID(session) | ||
| 606 | - | c.Context().Register(p) | 606 | + | registerWireProxy(c, p) |
| 607 | requestProtocol(t, c, srcMgr, source.ExtOutputImageCaptureSourceManagerV1RequestCreateSource, src, out) | 607 | requestProtocol(t, c, srcMgr, source.ExtOutputImageCaptureSourceManagerV1RequestCreateSource, src, out) | ||
| 608 | requestProtocol(t, c, mgr, ext.ExtImageCopyCaptureManagerV1RequestCreateSession, session, src, uint32(0)) | 608 | requestProtocol(t, c, mgr, ext.ExtImageCopyCaptureManagerV1RequestCreateSession, session, src, uint32(0)) | ||
| 609 | if err := c.Roundtrip(); err != nil { | 609 | if err := c.Roundtrip(); err != nil { | ||
| @@ -647,7 +647,7 @@ func TestWlrOutputGoneAndDestroyedBeforeReply(t *testing.T) { | |||||
| 647 | if !ok { | 647 | if !ok { | ||
| 648 | t.Fatal("output missing") | 648 | t.Fatal("output missing") | ||
| 649 | } | 649 | } | ||
| 650 | - | out, err := c.Registry().BindID(g.Name, g.Interface, 4) | 650 | + | out, err := bindWireID(c, g.Name, g.Interface, 4) |
| 651 | if err != nil { | 651 | if err != nil { | ||
| 652 | t.Fatal(err) | 652 | t.Fatal(err) | ||
| 653 | } | 653 | } | ||
| @@ -656,7 +656,7 @@ func TestWlrOutputGoneAndDestroyedBeforeReply(t *testing.T) { | |||||
| 656 | frame := c.AllocateID() | 656 | frame := c.AllocateID() | ||
| 657 | events := &captureEvents{events: make(chan uint16, 8)} | 657 | events := &captureEvents{events: make(chan uint16, 8)} | ||
| 658 | events.SetID(frame) | 658 | events.SetID(frame) | ||
| 659 | - | c.Context().Register(events) | 659 | + | registerWireProxy(c, events) |
| 660 | requestProtocol(t, c, manager, wlr.ZwlrScreencopyManagerV1RequestCaptureOutput, frame, int32(0), out) | 660 | requestProtocol(t, c, manager, wlr.ZwlrScreencopyManagerV1RequestCaptureOutput, frame, int32(0), out) | ||
| 661 | if err := c.Roundtrip(); err != nil { | 661 | if err := c.Roundtrip(); err != nil { | ||
| 662 | t.Fatal(err) | 662 | t.Fatal(err) | ||
| @@ -732,7 +732,7 @@ func captureSmallBuffer(t *testing.T, c *wlturbo.Display) (uint32, uint32, int) | |||||
| 732 | pool, buf := c.AllocateID(), c.AllocateID() | 732 | pool, buf := c.AllocateID(), c.AllocateID() | ||
| 733 | registerProtocol(t, c, pool) | 733 | registerProtocol(t, c, pool) | ||
| 734 | registerProtocol(t, c, buf) | 734 | registerProtocol(t, c, buf) | ||
| 735 | - | if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(16)); err != nil { | 735 | + | if err := wireRequest(c, shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(16)); err != nil { |
| 736 | t.Fatal(err) | 736 | t.Fatal(err) | ||
| 737 | } | 737 | } | ||
| 738 | requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buf, int32(0), int32(2), int32(2), int32(8), uint32(wayland.ShmFormatXrgb8888)) | 738 | requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buf, int32(0), int32(2), int32(2), int32(8), uint32(wayland.ShmFormatXrgb8888)) | ||
M internal/adapters/wayland/seat.go +48 -1
| @@ -47,10 +47,40 @@ type seatState struct { | |||||
| 47 | 47 | ||||
| 48 | // applyInput delivers a core input command to the focused client. | 48 | // applyInput delivers a core input command to the focused client. | ||
| 49 | func (s *Server) applyInput(cmd ports.ClientCommand) { | 49 | func (s *Server) applyInput(cmd ports.ClientCommand) { | ||
| 50 | + | if s.security != nil && s.security.Snapshot().Protected { | |||
| 51 | + | var target ports.WindowID | |||
| 52 | + | switch c := cmd.(type) { | |||
| 53 | + | case ports.SetKeymap: | |||
| 54 | + | // Configuration updates do not authorize normal-client input. | |||
| 55 | + | case ports.PointerFocus: | |||
| 56 | + | target = c.ID | |||
| 57 | + | case ports.PointerMotionTo: | |||
| 58 | + | target = c.ID | |||
| 59 | + | case ports.PointerButtonTo: | |||
| 60 | + | target = c.ID | |||
| 61 | + | case ports.PointerAxisTo: | |||
| 62 | + | target = c.ID | |||
| 63 | + | case ports.FocusWindow: | |||
| 64 | + | target = c.ID | |||
| 65 | + | case ports.ForwardKey: | |||
| 66 | + | target = c.ID | |||
| 67 | + | } | |||
| 68 | + | if target != 0 && !s.lockInputTarget(target) { | |||
| 69 | + | return | |||
| 70 | + | } | |||
| 71 | + | } | |||
| 50 | switch c := cmd.(type) { | 72 | switch c := cmd.(type) { | ||
| 51 | case ports.PointerFocus: | 73 | case ports.PointerFocus: | ||
| 52 | s.changePointerFocus(c.ID, c.X, c.Y) | 74 | s.changePointerFocus(c.ID, c.X, c.Y) | ||
| 53 | case ports.PointerMotionTo: | 75 | case ports.PointerMotionTo: | ||
| 76 | + | if l := s.lockSurfaces[c.ID]; l != nil && s.lockInputTarget(c.ID) && c.ID == s.seat.pointerFocus { | |||
| 77 | + | s.seat.pointerX, s.seat.pointerY = c.X, c.Y | |||
| 78 | + | for _, p := range s.clientPointers(l.resource.Client()) { | |||
| 79 | + | p.SendMotion(c.TimeMsec, server.FixedFromFloat(c.X), server.FixedFromFloat(c.Y)) | |||
| 80 | + | pointerFrame(p) | |||
| 81 | + | } | |||
| 82 | + | return | |||
| 83 | + | } | |||
| 54 | if l := s.layers[c.ID]; l != nil && c.ID == s.seat.pointerFocus { | 84 | if l := s.layers[c.ID]; l != nil && c.ID == s.seat.pointerFocus { | ||
| 55 | s.seat.pointerX, s.seat.pointerY = c.X, c.Y | 85 | s.seat.pointerX, s.seat.pointerY = c.X, c.Y | ||
| 56 | for _, p := range s.layerPointers(l) { | 86 | for _, p := range s.layerPointers(l) { | ||
| @@ -111,7 +141,7 @@ func (s *Server) applyInput(cmd ports.ClientCommand) { | |||||
| 111 | s.changeFocus(c.ID) | 141 | s.changeFocus(c.ID) | ||
| 112 | } | 142 | } | ||
| 113 | case ports.ForwardKey: | 143 | case ports.ForwardKey: | ||
| 114 | - | if s.grabKey(c) { | 144 | + | if !(s.security != nil && s.security.Snapshot().Protected) && s.grabKey(c) { |
| 115 | return | 145 | return | ||
| 116 | } | 146 | } | ||
| 117 | _, keyboards := s.focusTarget(c.ID) | 147 | _, keyboards := s.focusTarget(c.ID) | ||
| @@ -170,6 +200,11 @@ func (s *Server) clientKeyboards(c server.Client) []*wayland.Keyboard { | |||||
| 170 | 200 | ||||
| 171 | // focusTarget resolves a window or layer ID to its wl_surface and keyboards. | 201 | // focusTarget resolves a window or layer ID to its wl_surface and keyboards. | ||
| 172 | func (s *Server) focusTarget(id ports.WindowID) (*wayland.Surface, []*wayland.Keyboard) { | 202 | func (s *Server) focusTarget(id ports.WindowID) (*wayland.Surface, []*wayland.Keyboard) { | ||
| 203 | + | if l := s.lockSurfaces[id]; l != nil && !l.closed && l.surface.wl.Alive() { | |||
| 204 | + | // Outgoing leaves also resolve a role whose OnDestroy resource is | |||
| 205 | + | // already dead. Admission is enforced before entering or delivering. | |||
| 206 | + | return l.surface.wl, s.clientKeyboards(l.surface.wl.Client()) | |||
| 207 | + | } | |||
| 173 | if w := s.windows[id]; w != nil && w.mapped { | 208 | if w := s.windows[id]; w != nil && w.mapped { | ||
| 174 | if surf := w.xdg.surfaceResource(); surf != nil { | 209 | if surf := w.xdg.surfaceResource(); surf != nil { | ||
| 175 | return surf, s.windowKeyboards(w) | 210 | return surf, s.windowKeyboards(w) | ||
| @@ -186,6 +221,9 @@ func (s *Server) focusTarget(id ports.WindowID) (*wayland.Surface, []*wayland.Ke | |||||
| 186 | // pointerTarget resolves a mapped window or layer ID to its client and | 221 | // pointerTarget resolves a mapped window or layer ID to its client and | ||
| 187 | // live pointers. | 222 | // live pointers. | ||
| 188 | func (s *Server) pointerTarget(id ports.WindowID) (server.Client, []*wayland.Pointer, bool) { | 223 | func (s *Server) pointerTarget(id ports.WindowID) (server.Client, []*wayland.Pointer, bool) { | ||
| 224 | + | if l := s.lockSurfaces[id]; l != nil && s.lockInputTarget(id) { | |||
| 225 | + | return l.resource.Client(), s.clientPointers(l.resource.Client()), true | |||
| 226 | + | } | |||
| 189 | if w := s.windows[id]; w != nil && w.mapped && w.xdg.resource.Resource.Alive() { | 227 | if w := s.windows[id]; w != nil && w.mapped && w.xdg.resource.Resource.Alive() { | ||
| 190 | return w.xdg.resource.Client(), s.windowPointers(w), true | 228 | return w.xdg.resource.Client(), s.windowPointers(w), true | ||
| 191 | } | 229 | } | ||
| @@ -198,6 +236,9 @@ func (s *Server) pointerTarget(id ports.WindowID) (server.Client, []*wayland.Poi | |||||
| 198 | // pointerSurface is the wl_surface of a mapped window or layer and the | 236 | // pointerSurface is the wl_surface of a mapped window or layer and the | ||
| 199 | // point in its surface coordinates. | 237 | // point in its surface coordinates. | ||
| 200 | func (s *Server) pointerSurface(id ports.WindowID, x, y float64) (*wayland.Surface, []*wayland.Pointer, float64, float64) { | 238 | func (s *Server) pointerSurface(id ports.WindowID, x, y float64) (*wayland.Surface, []*wayland.Pointer, float64, float64) { | ||
| 239 | + | if l := s.lockSurfaces[id]; l != nil && !l.closed && l.surface.wl.Alive() { | |||
| 240 | + | return l.surface.wl, s.clientPointers(l.surface.wl.Client()), x, y | |||
| 241 | + | } | |||
| 201 | if w := s.windows[id]; w != nil { | 242 | if w := s.windows[id]; w != nil { | ||
| 202 | if surf := w.xdg.surfaceResource(); surf != nil && surf.Resource.Alive() { | 243 | if surf := w.xdg.surfaceResource(); surf != nil && surf.Resource.Alive() { | ||
| 203 | x, y := w.surfacePoint(x, y) | 244 | x, y := w.surfacePoint(x, y) | ||
| @@ -285,6 +326,9 @@ func (s *Server) sendModifiers(k *wayland.Keyboard) { | |||||
| 285 | } | 326 | } | ||
| 286 | 327 | ||||
| 287 | func (s *Server) changeFocus(id ports.WindowID) { | 328 | func (s *Server) changeFocus(id ports.WindowID) { | ||
| 329 | + | if s.security != nil && s.security.Snapshot().Protected && id != 0 && !s.lockInputTarget(id) { | |||
| 330 | + | return | |||
| 331 | + | } | |||
| 288 | // Enter carries seat held keys and modifiers: they need the seat keymap. | 332 | // Enter carries seat held keys and modifiers: they need the seat keymap. | ||
| 289 | s.useKeymap(nil) | 333 | s.useKeymap(nil) | ||
| 290 | old := s.focusClient() | 334 | old := s.focusClient() | ||
| @@ -421,6 +465,9 @@ func (s *Server) clientPointers(c server.Client) []*wayland.Pointer { | |||||
| 421 | return result | 465 | return result | ||
| 422 | } | 466 | } | ||
| 423 | func (s *Server) changePointerFocus(id ports.WindowID, x, y float64) { | 467 | func (s *Server) changePointerFocus(id ports.WindowID, x, y float64) { | ||
| 468 | + | if s.security != nil && s.security.Snapshot().Protected && id != 0 && !s.lockInputTarget(id) { | |||
| 469 | + | return | |||
| 470 | + | } | |||
| 424 | if id == s.seat.pointerFocus { | 471 | if id == s.seat.pointerFocus { | ||
| 425 | return | 472 | return | ||
| 426 | } | 473 | } | ||
A internal/adapters/wayland/sensitive_test.go +473 -0
| @@ -0,0 +1,473 @@ | |||||
| 1 | + | package wayland | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "sync/atomic" | |||
| 5 | + | "testing" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/neferwl/internal/adapters/wayland/capturesession" | |||
| 9 | + | "github.com/bnema/purego-libwayland/protocol/cursorshape" | |||
| 10 | + | wlr "github.com/bnema/purego-libwayland/protocol/wlrforeigntoplevel" | |||
| 11 | + | screencopy "github.com/bnema/purego-libwayland/protocol/wlrscreencopy" | |||
| 12 | + | "github.com/bnema/purego-libwayland/protocol/xdgactivation" | |||
| 13 | + | "github.com/bnema/purego-libwayland/protocol/xdgshell" | |||
| 14 | + | ||||
| 15 | + | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |||
| 16 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 17 | + | source "github.com/bnema/purego-libwayland/protocol/extimagecapturesource" | |||
| 18 | + | ext "github.com/bnema/purego-libwayland/protocol/extimagecopycapture" | |||
| 19 | + | "github.com/bnema/purego-libwayland/protocol/inputmethod" | |||
| 20 | + | "github.com/bnema/purego-libwayland/protocol/textinput" | |||
| 21 | + | "github.com/bnema/purego-libwayland/protocol/virtualkeyboard" | |||
| 22 | + | "github.com/bnema/purego-libwayland/protocol/wayland" | |||
| 23 | + | "golang.org/x/sys/unix" | |||
| 24 | + | ) | |||
| 25 | + | ||||
| 26 | + | // installSecurity uses the canonical generated mock, with coherent atomic | |||
| 27 | + | // snapshots; transitions are serialized through the real DISPLAY loop. | |||
| 28 | + | func installSecurity(t *testing.T, s *Server) *atomic.Pointer[ports.SecurityState] { | |||
| 29 | + | t.Helper() | |||
| 30 | + | state := &atomic.Pointer[ports.SecurityState]{} | |||
| 31 | + | state.Store(&ports.SecurityState{}) | |||
| 32 | + | security := portsmocks.NewMockSessionSecurity(t) | |||
| 33 | + | security.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return *state.Load() }).Maybe() | |||
| 34 | + | if !s.display.Do(func() { s.security = security }) { | |||
| 35 | + | t.Fatal("display stopped") | |||
| 36 | + | } | |||
| 37 | + | return state | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | func protect(t *testing.T, s *Server, state *atomic.Pointer[ports.SecurityState], generation ports.LockGeneration, protected bool) { | |||
| 41 | + | t.Helper() | |||
| 42 | + | if !s.display.Do(func() { state.Store(&ports.SecurityState{Generation: generation, Protected: protected}) }) { | |||
| 43 | + | t.Fatal("display stopped") | |||
| 44 | + | } | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func TestProtectedClipboardPreexistingOffer(t *testing.T) { | |||
| 48 | + | s, events, commands, dir := keyboardServer(t) | |||
| 49 | + | state := installSecurity(t, s) | |||
| 50 | + | a := newClipClient(t, s, dir, events) | |||
| 51 | + | a.focus(t, commands) | |||
| 52 | + | src := a.copy(t) | |||
| 53 | + | offer, ok := a.selection(t) | |||
| 54 | + | if !ok || offer == 0 { | |||
| 55 | + | t.Fatal("no unlocked offer") | |||
| 56 | + | } | |||
| 57 | + | protect(t, s, state, 1, true) | |||
| 58 | + | var fds [2]int | |||
| 59 | + | if err := unix.Pipe2(fds[:], unix.O_CLOEXEC|unix.O_NONBLOCK); err != nil { | |||
| 60 | + | t.Fatal(err) | |||
| 61 | + | } | |||
| 62 | + | defer unix.Close(fds[0]) | |||
| 63 | + | if err := wireRequest(a.c, offer, uint16(wayland.DataOfferRequestReceive), []int{fds[1]}, "text/plain"); err != nil { | |||
| 64 | + | t.Fatal(err) | |||
| 65 | + | } | |||
| 66 | + | unix.Close(fds[1]) | |||
| 67 | + | roundtrip(t, a.c) | |||
| 68 | + | select { | |||
| 69 | + | case <-src.sends: | |||
| 70 | + | t.Fatal("protected receive handed pipe to source") | |||
| 71 | + | default: | |||
| 72 | + | } | |||
| 73 | + | var b [1]byte | |||
| 74 | + | n, err := unix.Read(fds[0], b[:]) | |||
| 75 | + | if err != nil || n != 0 { | |||
| 76 | + | t.Fatalf("receive pipe not closed: n=%d err=%v", n, err) | |||
| 77 | + | } | |||
| 78 | + | rejected := a.copy(t) | |||
| 79 | + | select { | |||
| 80 | + | case <-rejected.cancelled: | |||
| 81 | + | default: | |||
| 82 | + | t.Fatal("protected selection not rejected") | |||
| 83 | + | } | |||
| 84 | + | if id, ok := a.selection(t); ok { | |||
| 85 | + | t.Fatalf("protected offer announced %d", id) | |||
| 86 | + | } | |||
| 87 | + | protect(t, s, state, 2, false) | |||
| 88 | + | // Existing clipboard data remains usable when unlocked. | |||
| 89 | + | a.copy(t) | |||
| 90 | + | if id, ok := a.selection(t); !ok || id == 0 { | |||
| 91 | + | t.Fatal("unlocked selection not restored") | |||
| 92 | + | } | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | func TestProtectedCaptureAdmission(t *testing.T) { | |||
| 96 | + | h := newAdmissionHarness(t, 4) | |||
| 97 | + | state := installSecurity(t, h.s) | |||
| 98 | + | a := h.client(t) | |||
| 99 | + | // Create the ext session before protection, then test its late frame. | |||
| 100 | + | p := a.captureExt(t) | |||
| 101 | + | req, ok := h.accepted(t, p) | |||
| 102 | + | if !ok { | |||
| 103 | + | t.Fatal("unlocked ext capture rejected") | |||
| 104 | + | } | |||
| 105 | + | a.destroy(t, p) | |||
| 106 | + | h.complete(t, req, 0) | |||
| 107 | + | protect(t, h.s, state, 1, true) | |||
| 108 | + | if _, ok := h.accepted(t, a.captureWlr(t)); ok { | |||
| 109 | + | t.Fatal("protected wlr capture admitted") | |||
| 110 | + | } | |||
| 111 | + | if _, ok := h.accepted(t, a.captureExt(t)); ok { | |||
| 112 | + | t.Fatal("protected ext capture admitted") | |||
| 113 | + | } | |||
| 114 | + | if h.inflight(t) != 0 { | |||
| 115 | + | t.Fatal("protected captures reserved credits") | |||
| 116 | + | } | |||
| 117 | + | var next uint64 | |||
| 118 | + | if !h.s.display.Do(func() { next = h.s.nextCapture }) { | |||
| 119 | + | t.Fatal("display stopped") | |||
| 120 | + | } | |||
| 121 | + | if next != req.ID { | |||
| 122 | + | t.Fatalf("protected capture allocated ID: %d", next) | |||
| 123 | + | } | |||
| 124 | + | // A newly created ext session is stopped and never admits capture. | |||
| 125 | + | b := h.client(t) | |||
| 126 | + | sourceManager := bindProtocol(t, b.c, "ext_output_image_capture_source_manager_v1") | |||
| 127 | + | manager := bindProtocol(t, b.c, "ext_image_copy_capture_manager_v1") | |||
| 128 | + | src, session := b.c.AllocateID(), b.c.AllocateID() | |||
| 129 | + | registerProtocol(t, b.c, src) | |||
| 130 | + | requestProtocol(t, b.c, sourceManager, source.ExtOutputImageCaptureSourceManagerV1RequestCreateSource, src, b.output) | |||
| 131 | + | ev := &captureEvents{events: make(chan uint16, 16)} | |||
| 132 | + | ev.SetID(session) | |||
| 133 | + | registerWireProxy(b.c, ev) | |||
| 134 | + | requestProtocol(t, b.c, manager, ext.ExtImageCopyCaptureManagerV1RequestCreateSession, session, src, uint32(0)) | |||
| 135 | + | roundtrip(t, b.c) | |||
| 136 | + | select { | |||
| 137 | + | case op := <-ev.events: | |||
| 138 | + | if op != uint16(ext.ExtImageCopyCaptureSessionV1EventStopped) { | |||
| 139 | + | t.Fatalf("new session event %d", op) | |||
| 140 | + | } | |||
| 141 | + | default: | |||
| 142 | + | t.Fatal("new protected ext session not stopped") | |||
| 143 | + | } | |||
| 144 | + | if len(ev.events) != 0 { | |||
| 145 | + | t.Fatal("protected session disclosed constraints") | |||
| 146 | + | } | |||
| 147 | + | b.session = session | |||
| 148 | + | if _, ok := h.accepted(t, b.captureExt(t)); ok { | |||
| 149 | + | t.Fatal("new protected session admitted") | |||
| 150 | + | } | |||
| 151 | + | protect(t, h.s, state, 2, false) | |||
| 152 | + | req, ok = h.accepted(t, a.captureWlr(t)) | |||
| 153 | + | if !ok { | |||
| 154 | + | t.Fatal("unlocked wlr capture rejected") | |||
| 155 | + | } | |||
| 156 | + | h.complete(t, req, 0) | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | func TestProtectedVirtualKeyboard(t *testing.T) { | |||
| 160 | + | for _, destroyProtected := range []bool{true, false} { | |||
| 161 | + | t.Run(map[bool]string{true: "protected-destroy", false: "post-unlock-destroy"}[destroyProtected], func(t *testing.T) { | |||
| 162 | + | s, events, commands, dir := keyboardServer(t) | |||
| 163 | + | state := installSecurity(t, s) | |||
| 164 | + | target, log := focusedTarget(t, s, events, commands, dir) | |||
| 165 | + | typer, kb := virtualTyper(t, s, dir) | |||
| 166 | + | sendVirtualKeymap(t, typer, kb, keymapText(t)) | |||
| 167 | + | requestProtocol(t, typer, kb, virtualkeyboard.ZwpVirtualKeyboardV1RequestKey, uint32(1), uint32(30), uint32(1)) | |||
| 168 | + | roundtrip(t, typer, target) | |||
| 169 | + | if len(log.opcodes) == 0 { | |||
| 170 | + | t.Fatal("unlocked virtual key missing") | |||
| 171 | + | } | |||
| 172 | + | log.opcodes = nil | |||
| 173 | + | protect(t, s, state, 1, true) | |||
| 174 | + | requestProtocol(t, typer, kb, virtualkeyboard.ZwpVirtualKeyboardV1RequestKey, uint32(2), uint32(31), uint32(1)) | |||
| 175 | + | requestProtocol(t, typer, kb, virtualkeyboard.ZwpVirtualKeyboardV1RequestModifiers, uint32(1), uint32(0), uint32(0), uint32(0)) | |||
| 176 | + | roundtrip(t, typer, target) | |||
| 177 | + | if len(log.opcodes) != 0 { | |||
| 178 | + | t.Fatalf("protected virtual events: %v", log.opcodes) | |||
| 179 | + | } | |||
| 180 | + | if !destroyProtected { | |||
| 181 | + | protect(t, s, state, 2, false) | |||
| 182 | + | } | |||
| 183 | + | requestProtocol(t, typer, kb, virtualkeyboard.ZwpVirtualKeyboardV1RequestDestroy) | |||
| 184 | + | roundtrip(t, typer, target) | |||
| 185 | + | if len(log.opcodes) != 0 { | |||
| 186 | + | t.Fatalf("old virtual held state injected on destroy: %v", log.opcodes) | |||
| 187 | + | } | |||
| 188 | + | }) | |||
| 189 | + | } | |||
| 190 | + | } | |||
| 191 | + | ||||
| 192 | + | func TestProtectedInputMethod(t *testing.T) { | |||
| 193 | + | s, events, commands, dir := keyboardServer(t) | |||
| 194 | + | state := installSecurity(t, s) | |||
| 195 | + | ime := newIMEApp(t, s, dir) | |||
| 196 | + | app := newTextApp(t, s, events, commands, dir) | |||
| 197 | + | app.enable(t) | |||
| 198 | + | _, grab := ime.grab(t) | |||
| 199 | + | settle(t, app.c, ime.c) | |||
| 200 | + | app.log.take() | |||
| 201 | + | ime.log.take() | |||
| 202 | + | grab.take() | |||
| 203 | + | protect(t, s, state, 1, true) | |||
| 204 | + | app.request(t, textinput.ZwpTextInputV3RequestSetSurroundingText, "locker-secret", int32(13), int32(13)) | |||
| 205 | + | app.request(t, textinput.ZwpTextInputV3RequestCommit) | |||
| 206 | + | ime.request(t, inputmethod.ZwpInputMethodV2RequestCommitString, "injected") | |||
| 207 | + | ime.request(t, inputmethod.ZwpInputMethodV2RequestCommit, uint32(1)) | |||
| 208 | + | // Exercise the live grab's delivery entrypoint; normal seat routing/reset is | |||
| 209 | + | // deliberately outside this adapter assignment. | |||
| 210 | + | if !s.display.Do(func() { | |||
| 211 | + | s.grabKey(ports.ForwardKey{ID: app.window, Key: ports.KeyEvent{Keycode: 30, Pressed: true, State: ports.ModState{Depressed: 1}}}) | |||
| 212 | + | s.sendGrabKeymap(true) | |||
| 213 | + | }) { | |||
| 214 | + | t.Fatal("display stopped") | |||
| 215 | + | } | |||
| 216 | + | _, late := ime.grab(t) | |||
| 217 | + | settle(t, app.c, ime.c) | |||
| 218 | + | expectEvents(t, "protected IM state", ime.log.take()) | |||
| 219 | + | expectEvents(t, "protected IM injection", app.log.take()) | |||
| 220 | + | expectEvents(t, "existing protected grab", grab.take()) | |||
| 221 | + | expectEvents(t, "new protected grab", late.take()) | |||
| 222 | + | protect(t, s, state, 2, false) | |||
| 223 | + | app.enable(t) | |||
| 224 | + | settle(t, app.c, ime.c) | |||
| 225 | + | if len(ime.log.take()) == 0 { | |||
| 226 | + | t.Fatal("unlocked IM state not delivered") | |||
| 227 | + | } | |||
| 228 | + | } | |||
| 229 | + | ||||
| 230 | + | func TestProtectedPrivateCapture(t *testing.T) { | |||
| 231 | + | h := newPrivateHarness(t) | |||
| 232 | + | state := installSecurity(t, h.s) | |||
| 233 | + | owner := h.client(t) | |||
| 234 | + | _, msgs := owner.begin(t, 0, 0, 0, 0, 0, 0) | |||
| 235 | + | begin := nextEvent[ports.CaptureSessionBegin](t, h.events) | |||
| 236 | + | h.send(ports.CaptureSessionState{ID: begin.ID, Output: "HEADLESS-1", Active: true, Revision: 1}) | |||
| 237 | + | token := waitMsg(t, owner.c, msgs).token | |||
| 238 | + | // Establish that this active owner's capture is admitted and clean before | |||
| 239 | + | // protection, then refuse the same connection without ending its session. | |||
| 240 | + | captureOnce(t, h, owner) | |||
| 241 | + | accepted := receive1(t, h.captures) | |||
| 242 | + | if !accepted.Clean || accepted.Session != begin.ID { | |||
| 243 | + | t.Fatalf("owner capture not clean: %+v", accepted) | |||
| 244 | + | } | |||
| 245 | + | accepted.Dst.File.Close() | |||
| 246 | + | h.captured <- ports.CaptureDone{ID: accepted.ID, Output: accepted.Output, Time: time.Now()} | |||
| 247 | + | hd := h.hud(t, ports.LayerTop, 0) | |||
| 248 | + | protect(t, h.s, state, 1, true) | |||
| 249 | + | failed, _ := captureOnce(t, h, owner) | |||
| 250 | + | if !eventsInclude(failed, uint16(screencopy.ZwlrScreencopyFrameV1EventFailed)) { | |||
| 251 | + | t.Fatal("protected active owner's clean capture not failed") | |||
| 252 | + | } | |||
| 253 | + | select { | |||
| 254 | + | case req := <-h.captures: | |||
| 255 | + | req.Dst.File.Close() | |||
| 256 | + | t.Fatal("protected active owner's capture admitted") | |||
| 257 | + | default: | |||
| 258 | + | } | |||
| 259 | + | att := hd.attach(t, token) | |||
| 260 | + | expectAttachment(t, hd.c, att, capturesession.NeferwlCaptureLayerV1EventFailed, int64(capturesession.NeferwlCaptureLayerV1FailureUnauthorized)) | |||
| 261 | + | other := h.client(t) | |||
| 262 | + | _, refused := other.begin(t, 0, 0, 0, 0, 0, 0) | |||
| 263 | + | expectAttachment(t, other.c, refused, capturesession.NeferwlCaptureSessionV1EventStopped, int64(capturesession.NeferwlCaptureSessionV1StopReasonUnauthorized)) | |||
| 264 | + | snapshot := ports.Workspaces{Outputs: []ports.WorkspaceOutput{{Name: "HEADLESS-1", Workspaces: []ports.WorkspaceInfo{{ID: 1, Name: "sensitive"}}}}} | |||
| 265 | + | if !h.s.display.Do(func() { h.s.workspaceSnapshot = snapshot; h.s.updateCaptureWorkspaces(snapshot) }) { | |||
| 266 | + | t.Fatal("display stopped") | |||
| 267 | + | } | |||
| 268 | + | noMessage(t, owner.c, owner.mgr.msgs) | |||
| 269 | + | noMessage(t, other.c, other.mgr.msgs) | |||
| 270 | + | late := h.client(t) | |||
| 271 | + | noMessage(t, late.c, late.mgr.msgs) | |||
| 272 | + | for len(h.events) > 0 { | |||
| 273 | + | switch ev := (<-h.events).(type) { | |||
| 274 | + | case ports.CaptureSessionBegin, ports.CaptureSessionLayer: | |||
| 275 | + | t.Fatalf("protected private request reached core: %+v", ev) | |||
| 276 | + | } | |||
| 277 | + | } | |||
| 278 | + | } | |||
| 279 | + | ||||
| 280 | + | func TestProtectedActivationAndForeignInventory(t *testing.T) { | |||
| 281 | + | s, events, commands, dir := lifecycleServer(t) | |||
| 282 | + | state := installSecurity(t, s) | |||
| 283 | + | c := protocolClient(t, s, dir) | |||
| 284 | + | w, surf, xdg := surfaceMapper(t, c, events)() | |||
| 285 | + | commands <- ports.FocusWindow{ID: w.ID} | |||
| 286 | + | waitFocus(t, s, w.ID) | |||
| 287 | + | manager := bindProtocol(t, c, "xdg_activation_v1") | |||
| 288 | + | token := newToken(t, c, manager) | |||
| 289 | + | foreign := bindVersion(t, c, "zwlr_foreign_toplevel_manager_v1", 3) | |||
| 290 | + | log := &toplevelManagerEvents{client: c, handles: make(chan uint32, 4), events: make(chan string, 64)} | |||
| 291 | + | log.SetID(foreign) | |||
| 292 | + | registerWireProxy(c, log) | |||
| 293 | + | takeEvents(t, c, log.events) | |||
| 294 | + | handle := <-log.handles | |||
| 295 | + | seat := bindProtocol(t, c, "wl_seat") | |||
| 296 | + | registerProtocol(t, c, seat) | |||
| 297 | + | protect(t, s, state, 1, true) | |||
| 298 | + | requestProtocol(t, c, manager, xdgactivation.ActivationV1RequestActivate, token, surf) | |||
| 299 | + | requestProtocol(t, c, handle, wlr.ZwlrForeignToplevelHandleV1RequestActivate, seat) | |||
| 300 | + | requestProtocol(t, c, handle, wlr.ZwlrForeignToplevelHandleV1RequestSetFullscreen, uint32(0)) | |||
| 301 | + | requestProtocol(t, c, xdg+1, xdgshell.ToplevelRequestSetTitle, "sensitive-title") | |||
| 302 | + | noActivation(t, c, events) | |||
| 303 | + | if len(log.events) != 0 { | |||
| 304 | + | t.Fatal("foreign inventory updated while protected") | |||
| 305 | + | } | |||
| 306 | + | lateID := bindVersion(t, c, "zwlr_foreign_toplevel_manager_v1", 3) | |||
| 307 | + | late := &toplevelManagerEvents{client: c, handles: make(chan uint32, 4), events: make(chan string, 64)} | |||
| 308 | + | late.SetID(lateID) | |||
| 309 | + | registerWireProxy(c, late) | |||
| 310 | + | roundtrip(t, c) | |||
| 311 | + | if len(late.handles) != 0 || len(late.events) != 0 { | |||
| 312 | + | t.Fatal("new foreign manager disclosed protected inventory") | |||
| 313 | + | } | |||
| 314 | + | protect(t, s, state, 2, false) | |||
| 315 | + | requestProtocol(t, c, manager, xdgactivation.ActivationV1RequestActivate, token, surf) | |||
| 316 | + | noActivation(t, c, events) // the protected attempt consumed the token | |||
| 317 | + | } | |||
| 318 | + | ||||
| 319 | + | func TestProtectedCursorChanges(t *testing.T) { | |||
| 320 | + | s, events, commands, cursors, dir := cursorServer(t) | |||
| 321 | + | state := installSecurity(t, s) | |||
| 322 | + | c := protocolClient(t, s, dir) | |||
| 323 | + | seat := bindProtocol(t, c, "wl_seat") | |||
| 324 | + | registerProtocol(t, c, seat) | |||
| 325 | + | pointer := c.AllocateID() | |||
| 326 | + | registerProtocol(t, c, pointer) | |||
| 327 | + | requestProtocol(t, c, seat, wayland.SeatRequestGetPointer, pointer) | |||
| 328 | + | manager := bindProtocol(t, c, "wp_cursor_shape_manager_v1") | |||
| 329 | + | device := c.AllocateID() | |||
| 330 | + | registerProtocol(t, c, device) | |||
| 331 | + | requestProtocol(t, c, manager, cursorshape.WpCursorShapeManagerV1RequestGetPointer, device, pointer) | |||
| 332 | + | w := toplevelMapper(t, c, events)() | |||
| 333 | + | commands <- ports.PointerFocus{ID: w.ID} | |||
| 334 | + | nextCursor(t, cursors) | |||
| 335 | + | protect(t, s, state, 1, true) | |||
| 336 | + | requestProtocol(t, c, device, cursorshape.WpCursorShapeDeviceV1RequestSetShape, uint32(1), uint32(cursorshape.WpCursorShapeDeviceV1ShapeText)) | |||
| 337 | + | requestProtocol(t, c, pointer, wayland.PointerRequestSetCursor, uint32(1), uint32(0), int32(0), int32(0)) | |||
| 338 | + | roundtrip(t, c) | |||
| 339 | + | select { | |||
| 340 | + | case change := <-cursors: | |||
| 341 | + | t.Fatalf("normal cursor overrode protection: %+v", change) | |||
| 342 | + | case <-time.After(50 * time.Millisecond): | |||
| 343 | + | } | |||
| 344 | + | } | |||
| 345 | + | ||||
| 346 | + | func TestProtectedVirtualKeyboardQuietEpoch(t *testing.T) { | |||
| 347 | + | s, events, commands, dir := keyboardServer(t) | |||
| 348 | + | state := installSecurity(t, s) | |||
| 349 | + | target, log := focusedTarget(t, s, events, commands, dir) | |||
| 350 | + | typer, kb := virtualTyper(t, s, dir) | |||
| 351 | + | sendVirtualKeymap(t, typer, kb, keymapText(t)) | |||
| 352 | + | requestProtocol(t, typer, kb, virtualkeyboard.ZwpVirtualKeyboardV1RequestKey, uint32(1), uint32(30), uint32(1)) | |||
| 353 | + | roundtrip(t, typer, target) | |||
| 354 | + | log.opcodes = nil | |||
| 355 | + | protect(t, s, state, 1, true) | |||
| 356 | + | protect(t, s, state, 2, false) | |||
| 357 | + | // No requests during protection: generation must still invalidate held keys. | |||
| 358 | + | requestProtocol(t, typer, kb, virtualkeyboard.ZwpVirtualKeyboardV1RequestKey, uint32(2), uint32(30), uint32(0)) | |||
| 359 | + | requestProtocol(t, typer, kb, virtualkeyboard.ZwpVirtualKeyboardV1RequestDestroy) | |||
| 360 | + | roundtrip(t, typer, target) | |||
| 361 | + | if len(log.opcodes) != 0 { | |||
| 362 | + | t.Fatalf("old virtual state crossed quiet lock epoch: %v", log.opcodes) | |||
| 363 | + | } | |||
| 364 | + | } | |||
| 365 | + | ||||
| 366 | + | func TestProtectedVirtualKeymapRequiresResend(t *testing.T) { | |||
| 367 | + | for _, resend := range []bool{false, true} { | |||
| 368 | + | t.Run(map[bool]string{false: "old-keymap-rejected", true: "fresh-keymap-accepted"}[resend], func(t *testing.T) { | |||
| 369 | + | s, events, commands, dir := keyboardServer(t) | |||
| 370 | + | state := installSecurity(t, s) | |||
| 371 | + | target, log := focusedTarget(t, s, events, commands, dir) | |||
| 372 | + | typer, kb := virtualTyper(t, s, dir) | |||
| 373 | + | sendVirtualKeymap(t, typer, kb, keymapText(t)) | |||
| 374 | + | requestProtocol(t, typer, kb, virtualkeyboard.ZwpVirtualKeyboardV1RequestKey, uint32(1), uint32(30), uint32(1)) | |||
| 375 | + | roundtrip(t, typer, target) | |||
| 376 | + | log.opcodes = nil | |||
| 377 | + | var owner *virtualKeyboard | |||
| 378 | + | var oldFD int | |||
| 379 | + | if !s.display.Do(func() { owner = s.seat.keymapOwner; oldFD = owner.fd }) { | |||
| 380 | + | t.Fatal("display stopped") | |||
| 381 | + | } | |||
| 382 | + | protect(t, s, state, 1, true) | |||
| 383 | + | sendVirtualKeymap(t, typer, kb, keymapText(t)) | |||
| 384 | + | roundtrip(t, typer, target) | |||
| 385 | + | var cleared bool | |||
| 386 | + | if !s.display.Do(func() { cleared = owner.fd == -1 && owner.text == "" && owner.size == 0 }) { | |||
| 387 | + | t.Fatal("display stopped") | |||
| 388 | + | } | |||
| 389 | + | if !cleared { | |||
| 390 | + | t.Fatal("protected keymap retained old layout") | |||
| 391 | + | } | |||
| 392 | + | if _, err := unix.FcntlInt(uintptr(oldFD), unix.F_GETFD, 0); err != unix.EBADF { | |||
| 393 | + | t.Fatalf("old keymap FD not closed: %v", err) | |||
| 394 | + | } | |||
| 395 | + | if len(log.opcodes) != 0 { | |||
| 396 | + | t.Fatalf("protected keymap events: %v", log.opcodes) | |||
| 397 | + | } | |||
| 398 | + | protect(t, s, state, 2, false) | |||
| 399 | + | if resend { | |||
| 400 | + | sendVirtualKeymap(t, typer, kb, keymapText(t)) | |||
| 401 | + | } | |||
| 402 | + | requestProtocol(t, typer, kb, virtualkeyboard.ZwpVirtualKeyboardV1RequestKey, uint32(2), uint32(31), uint32(1)) | |||
| 403 | + | if !resend { | |||
| 404 | + | expectProtocolError(t, typer, kb, uint32(virtualkeyboard.ZwpVirtualKeyboardV1ErrorNoKeymap)) | |||
| 405 | + | roundtrip(t, target) | |||
| 406 | + | if len(log.opcodes) != 0 { | |||
| 407 | + | t.Fatalf("old keymap injected after unlock: %v", log.opcodes) | |||
| 408 | + | } | |||
| 409 | + | } else { | |||
| 410 | + | roundtrip(t, typer, target) | |||
| 411 | + | found := false | |||
| 412 | + | for _, op := range log.opcodes { | |||
| 413 | + | if op == uint16(wayland.KeyboardEventKey) { | |||
| 414 | + | found = true | |||
| 415 | + | } | |||
| 416 | + | } | |||
| 417 | + | if !found { | |||
| 418 | + | t.Fatal("fresh post-unlock keymap cannot type") | |||
| 419 | + | } | |||
| 420 | + | } | |||
| 421 | + | }) | |||
| 422 | + | } | |||
| 423 | + | } | |||
| 424 | + | ||||
| 425 | + | func TestProtectedForeignCloseDeferredUntilRefresh(t *testing.T) { | |||
| 426 | + | s, events, _, dir := lifecycleServer(t) | |||
| 427 | + | state := installSecurity(t, s) | |||
| 428 | + | c := protocolClient(t, s, dir) | |||
| 429 | + | w, surf, _ := surfaceMapper(t, c, events)() | |||
| 430 | + | foreign := bindVersion(t, c, "zwlr_foreign_toplevel_manager_v1", 3) | |||
| 431 | + | log := &toplevelManagerEvents{client: c, handles: make(chan uint32, 4), events: make(chan string, 64)} | |||
| 432 | + | log.SetID(foreign) | |||
| 433 | + | registerWireProxy(c, log) | |||
| 434 | + | takeEvents(t, c, log.events) | |||
| 435 | + | <-log.handles | |||
| 436 | + | protect(t, s, state, 1, true) | |||
| 437 | + | requestProtocol(t, c, surf, wayland.SurfaceRequestAttach, uint32(0), int32(0), int32(0)) | |||
| 438 | + | requestProtocol(t, c, surf, wayland.SurfaceRequestCommit) | |||
| 439 | + | roundtrip(t, c) | |||
| 440 | + | unmapped(t, events, w.ID) | |||
| 441 | + | if !s.display.Do(func() { s.refreshToplevels() }) { | |||
| 442 | + | t.Fatal("display stopped") | |||
| 443 | + | } | |||
| 444 | + | roundtrip(t, c) | |||
| 445 | + | if len(log.events) != 0 { | |||
| 446 | + | t.Fatal("protected foreign close leaked timing") | |||
| 447 | + | } | |||
| 448 | + | var retained bool | |||
| 449 | + | if !s.display.Do(func() { retained = s.toplevelManagers[0].handles[w.ID] != nil }) { | |||
| 450 | + | t.Fatal("display stopped") | |||
| 451 | + | } | |||
| 452 | + | if !retained { | |||
| 453 | + | t.Fatal("deferred foreign handle lost before unlock") | |||
| 454 | + | } | |||
| 455 | + | protect(t, s, state, 2, false) | |||
| 456 | + | if !s.display.Do(func() { s.refreshToplevels() }) { | |||
| 457 | + | t.Fatal("display stopped") | |||
| 458 | + | } | |||
| 459 | + | got := takeEvents(t, c, log.events) | |||
| 460 | + | if len(got) != 1 || got[0] != "closed" { | |||
| 461 | + | t.Fatalf("unlock deferred events: %v", got) | |||
| 462 | + | } | |||
| 463 | + | if !s.display.Do(func() { retained = s.toplevelManagers[0].handles[w.ID] != nil; s.refreshToplevels() }) { | |||
| 464 | + | t.Fatal("display stopped") | |||
| 465 | + | } | |||
| 466 | + | if retained { | |||
| 467 | + | t.Fatal("stale foreign inventory retained on unlock") | |||
| 468 | + | } | |||
| 469 | + | roundtrip(t, c) | |||
| 470 | + | if len(log.events) != 0 { | |||
| 471 | + | t.Fatal("deferred close sent twice") | |||
| 472 | + | } | |||
| 473 | + | } | |||
M internal/adapters/wayland/server.go +49 -18
| @@ -11,6 +11,7 @@ import ( | |||||
| 11 | 11 | ||||
| 12 | "github.com/bnema/neferwl/internal/adapters/clock" | 12 | "github.com/bnema/neferwl/internal/adapters/clock" | ||
| 13 | "github.com/bnema/neferwl/internal/ports" | 13 | "github.com/bnema/neferwl/internal/ports" | ||
| 14 | + | "github.com/bnema/neferwl/internal/sessionlock" | |||
| 14 | "github.com/bnema/purego-libwayland/protocol/fractionalscale" | 15 | "github.com/bnema/purego-libwayland/protocol/fractionalscale" | ||
| 15 | "github.com/bnema/purego-libwayland/protocol/wayland" | 16 | "github.com/bnema/purego-libwayland/protocol/wayland" | ||
| 16 | "github.com/bnema/purego-libwayland/server" | 17 | "github.com/bnema/purego-libwayland/server" | ||
| @@ -33,18 +34,25 @@ type Options struct { | |||||
| 33 | RepeatRate, RepeatDelay int | 34 | RepeatRate, RepeatDelay int | ||
| 34 | // Clock runs idle notification timers; nil is the system clock. | 35 | // Clock runs idle notification timers; nil is the system clock. | ||
| 35 | Clock ports.Clock | 36 | Clock ports.Clock | ||
| 37 | + | // Security is display-owned protection control. Nil leaves session-lock | |||
| 38 | + | // unadvertised; independent protocol tests remain unlocked. | |||
| 39 | + | Security ports.SessionSecurityController | |||
| 36 | // syncDev replaces the render node's syncobj interface (tests). | 40 | // syncDev replaces the render node's syncobj interface (tests). | ||
| 37 | syncDev syncobjDevice | 41 | syncDev syncobjDevice | ||
| 38 | } | 42 | } | ||
| 39 | 43 | ||||
| 40 | // Channels carries client notifications and commands. Events may be unbuffered. | 44 | // Channels carries client notifications and commands. Events may be unbuffered. | ||
| 41 | type Channels struct { | 45 | type Channels struct { | ||
| 42 | - | LeaseRequests chan<- ports.LeaseMessage | 46 | + | // SecurityChanges requests ordered backend protection/release. The |
| 43 | - | LeaseEvents <-chan ports.LeaseMessage | 47 | + | // backend echoes actual inventory/proofs/barriers on SecurityEvents. |
| 44 | - | Events chan<- ports.ClientEvent | 48 | + | SecurityChanges chan<- ports.SecurityState |
| 45 | - | Contents chan<- ports.SurfaceContent | 49 | + | SecurityEvents <-chan ports.SecurityBackendEvent |
| 46 | - | Commands <-chan ports.ClientCommand | 50 | + | LeaseRequests chan<- ports.LeaseMessage |
| 47 | - | Workspaces <-chan ports.Workspaces | 51 | + | LeaseEvents <-chan ports.LeaseMessage |
| 52 | + | Events chan<- ports.ClientEvent | |||
| 53 | + | Contents chan<- ports.SurfaceContent | |||
| 54 | + | Commands <-chan ports.ClientCommand | |||
| 55 | + | Workspaces <-chan ports.Workspaces | |||
| 48 | // Cursors receives the cursor the client under the pointer asks for. | 56 | // Cursors receives the cursor the client under the pointer asks for. | ||
| 49 | Cursors chan<- ports.CursorChange | 57 | Cursors chan<- ports.CursorChange | ||
| 50 | // Presented paces frame callbacks on the outputs' page flips; outputs | 58 | // Presented paces frame callbacks on the outputs' page flips; outputs | ||
| @@ -65,17 +73,25 @@ type Channels struct { | |||||
| 65 | OutputApply chan<- ports.OutputApply | 73 | OutputApply chan<- ports.OutputApply | ||
| 66 | OutputApplied <-chan ports.OutputApplied | 74 | OutputApplied <-chan ports.OutputApplied | ||
| 67 | } | 75 | } | ||
| 76 | + | ||||
| 68 | type Server struct { | 77 | type Server struct { | ||
| 69 | - | leaseDevices map[string]*leaseDevice | 78 | + | security ports.SessionSecurity |
| 70 | - | pendingLeases map[uint64]*leaseObject | 79 | + | securityController ports.SessionSecurityController |
| 71 | - | pendingLeaseCards map[uint64]string | 80 | + | securityPending []ports.SecurityState // display-owned ordered backend outbox |
| 72 | - | activeLeases map[leaseKey]*leaseObject | 81 | + | securityWake chan struct{} |
| 73 | - | leaseMu sync.Mutex // only the outbound lease queue; never window state | 82 | + | sessionLock *sessionLock |
| 74 | - | leaseOut []ports.LeaseMessage | 83 | + | lockReadiness sessionlock.Readiness |
| 75 | - | leaseReady chan struct{} | 84 | + | lockSurfaces map[ports.WindowID]*lockSurface |
| 76 | - | nextLeaseID uint64 | 85 | + | leaseDevices map[string]*leaseDevice |
| 77 | - | nextCapture uint64 | 86 | + | pendingLeases map[uint64]*leaseObject |
| 78 | - | captureReplies map[uint64]func(ports.CaptureDone) | 87 | + | pendingLeaseCards map[uint64]string |
| 88 | + | activeLeases map[leaseKey]*leaseObject | |||
| 89 | + | leaseMu sync.Mutex // only the outbound lease queue; never window state | |||
| 90 | + | leaseOut []ports.LeaseMessage | |||
| 91 | + | leaseReady chan struct{} | |||
| 92 | + | nextLeaseID uint64 | |||
| 93 | + | nextCapture uint64 | |||
| 94 | + | captureReplies map[uint64]func(ports.CaptureDone) | |||
| 79 | // captureInflight counts accepted captures until the backend completes | 95 | // captureInflight counts accepted captures until the backend completes | ||
| 80 | // them, independent of client resources; only the display loop touches it. | 96 | // them, independent of client resources; only the display loop touches it. | ||
| 81 | captureInflight map[uint64]struct{} | 97 | captureInflight map[uint64]struct{} | ||
| @@ -237,7 +253,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) { | |||||
| 237 | d.Close() | 253 | d.Close() | ||
| 238 | return nil, err | 254 | return nil, err | ||
| 239 | } | 255 | } | ||
| 240 | - | s := &Server{display: d, awaiting: map[string][]*wayland.Callback{}, frameDue: map[string]time.Time{}, frameReady: make(chan struct{}, 1), reports: map[string]ports.OutputPresented{}, env: linuxProcEnv{}, name: name, cleanup: cleanup, log: log, channels: ch, surfaces: make(map[*server.Resource]*surface), buffers: make(map[*server.Resource]clientBuffer), windows: make(map[ports.WindowID]*window), layers: make(map[ports.WindowID]*layerSurface), nextWindow: 1, eventReady: make(chan struct{}, 1), contents: make(map[ports.WindowID]ports.SurfaceContent), contentSeq: make(map[ports.WindowID]uint64), damage: map[ports.WindowID][]ports.SeqDamage{}, contentReady: make(chan struct{}, 1), cursorReady: make(chan struct{}, 1), dataSources: map[*server.Resource]*clipSource{}, primarySources: map[*server.Resource]*clipSource{}, controlSources: map[*server.Resource]*clipSource{}, contentNotify: make(chan struct{}), regions: map[*server.Resource]*region{}, relatives: map[server.Client][]*relativepointer.ZwpRelativePointerV1{}, constraints: map[*surface]*constraint{}, positioners: map[*server.Resource]*positioner{}, seat: seatState{keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), pointers: make(map[server.Client][]*wayland.Pointer), enters: make(map[*server.Resource]uint32), repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay}} | 256 | + | s := &Server{securityWake: make(chan struct{}, 1), security: opts.Security, securityController: opts.Security, display: d, awaiting: map[string][]*wayland.Callback{}, frameDue: map[string]time.Time{}, frameReady: make(chan struct{}, 1), reports: map[string]ports.OutputPresented{}, env: linuxProcEnv{}, name: name, cleanup: cleanup, log: log, channels: ch, surfaces: make(map[*server.Resource]*surface), buffers: make(map[*server.Resource]clientBuffer), windows: make(map[ports.WindowID]*window), layers: make(map[ports.WindowID]*layerSurface), nextWindow: 1, eventReady: make(chan struct{}, 1), contents: make(map[ports.WindowID]ports.SurfaceContent), contentSeq: make(map[ports.WindowID]uint64), damage: map[ports.WindowID][]ports.SeqDamage{}, contentReady: make(chan struct{}, 1), cursorReady: make(chan struct{}, 1), dataSources: map[*server.Resource]*clipSource{}, primarySources: map[*server.Resource]*clipSource{}, controlSources: map[*server.Resource]*clipSource{}, contentNotify: make(chan struct{}), regions: map[*server.Resource]*region{}, relatives: map[server.Client][]*relativepointer.ZwpRelativePointerV1{}, constraints: map[*surface]*constraint{}, positioners: map[*server.Resource]*positioner{}, seat: seatState{keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), pointers: make(map[server.Client][]*wayland.Pointer), enters: make(map[*server.Resource]uint32), repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay}} |
| 241 | s.leaseDevices = map[string]*leaseDevice{} | 257 | s.leaseDevices = map[string]*leaseDevice{} | ||
| 242 | s.pendingLeases = map[uint64]*leaseObject{} | 258 | s.pendingLeases = map[uint64]*leaseObject{} | ||
| 243 | s.pendingLeaseCards = map[uint64]string{} | 259 | s.pendingLeaseCards = map[uint64]string{} | ||
| @@ -336,7 +352,9 @@ func (s *Server) Run(ctx context.Context) error { | |||||
| 336 | s.started = time.Now() | 352 | s.started = time.Now() | ||
| 337 | s.ctx = ctx | 353 | s.ctx = ctx | ||
| 338 | var wg sync.WaitGroup | 354 | var wg sync.WaitGroup | ||
| 339 | - | wg.Add(12) | 355 | + | wg.Add(14) |
| 356 | + | go func() { defer wg.Done(); s.forwardBackendSecurity(ctx) }() | |||
| 357 | + | go func() { defer wg.Done(); s.forwardSecurityEvents(ctx) }() | |||
| 340 | go func() { defer wg.Done(); s.forwardLeases(ctx) }() | 358 | go func() { defer wg.Done(); s.forwardLeases(ctx) }() | ||
| 341 | go func() { defer wg.Done(); s.forwardLeaseRequests(ctx) }() | 359 | go func() { defer wg.Done(); s.forwardLeaseRequests(ctx) }() | ||
| 342 | go func() { defer wg.Done(); s.forwardWorkspaces(ctx) }() | 360 | go func() { defer wg.Done(); s.forwardWorkspaces(ctx) }() | ||
| @@ -609,6 +627,19 @@ func drainCommands(first ports.ClientCommand, cmds <-chan ports.ClientCommand) ( | |||||
| 609 | } | 627 | } | ||
| 610 | 628 | ||||
| 611 | func (s *Server) apply(cmd ports.ClientCommand) { | 629 | func (s *Server) apply(cmd ports.ClientCommand) { | ||
| 630 | + | if envelope, ok := cmd.(ports.SecurityCommand); ok { | |||
| 631 | + | if s.security == nil || envelope.State != s.security.Snapshot() { | |||
| 632 | + | return | |||
| 633 | + | } | |||
| 634 | + | cmd = envelope.Command | |||
| 635 | + | } else if s.security != nil && s.security.Snapshot().Generation != 0 { | |||
| 636 | + | // Only input/focus commands require an owner envelope. Config relay | |||
| 637 | + | // also produces SetKeymap and other epoch-independent commands. | |||
| 638 | + | switch cmd.(type) { | |||
| 639 | + | case ports.PointerFocus, ports.PointerMotionTo, ports.PointerButtonTo, ports.PointerAxisTo, ports.FocusWindow, ports.ForwardKey: | |||
| 640 | + | return | |||
| 641 | + | } | |||
| 642 | + | } | |||
| 612 | switch c := cmd.(type) { | 643 | switch c := cmd.(type) { | ||
| 613 | case ports.PointerFocus, ports.PointerMotionTo, ports.PointerButtonTo, ports.PointerAxisTo, ports.SetKeymap, ports.FocusWindow, ports.ForwardKey: | 644 | case ports.PointerFocus, ports.PointerMotionTo, ports.PointerButtonTo, ports.PointerAxisTo, ports.SetKeymap, ports.FocusWindow, ports.ForwardKey: | ||
| 614 | s.applyInput(c) | 645 | s.applyInput(c) | ||
A internal/adapters/wayland/session_lock.go +278 -0
| @@ -0,0 +1,278 @@ | |||||
| 1 | + | package wayland | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 7 | + | "github.com/bnema/purego-libwayland/protocol/extsessionlock" | |||
| 8 | + | "github.com/bnema/purego-libwayland/protocol/wayland" | |||
| 9 | + | "github.com/bnema/purego-libwayland/server" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // sessionLock is owned by the display goroutine. Resource destruction removes | |||
| 13 | + | // its owner, never the protection gate. An ordinary new owner may take over. | |||
| 14 | + | type sessionLock struct { | |||
| 15 | + | s *Server | |||
| 16 | + | res *extsessionlock.ExtSessionLockV1 | |||
| 17 | + | state ports.SecurityState | |||
| 18 | + | locked bool | |||
| 19 | + | surfaces map[*output]*lockSurface | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | type sessionLockManager struct{ s *Server } | |||
| 23 | + | ||||
| 24 | + | // registerSessionLock is enabled only when the display and backend share | |||
| 25 | + | // protection control and authoritative, non-dropping proof channels. | |||
| 26 | + | func registerSessionLock(d *server.Display, s *Server) error { | |||
| 27 | + | return extsessionlock.NewExtSessionLockManagerV1Global(d, 1, func(c server.Client, version, id uint32) { | |||
| 28 | + | _, _ = extsessionlock.NewExtSessionLockManagerV1(c, int32(version), id, sessionLockManager{s}) | |||
| 29 | + | }) | |||
| 30 | + | } | |||
| 31 | + | func (sessionLockManager) Destroy(*extsessionlock.ExtSessionLockManagerV1) {} | |||
| 32 | + | ||||
| 33 | + | func (m sessionLockManager) Lock(r *extsessionlock.ExtSessionLockManagerV1, id uint32) { | |||
| 34 | + | s := m.s | |||
| 35 | + | l := &sessionLock{s: s, surfaces: make(map[*output]*lockSurface)} | |||
| 36 | + | res, err := extsessionlock.NewExtSessionLockV1(r.Client(), r.Version(), id, l) | |||
| 37 | + | if err != nil { | |||
| 38 | + | return | |||
| 39 | + | } | |||
| 40 | + | l.res = res | |||
| 41 | + | if s.securityController == nil || s.channels.SecurityChanges == nil || s.channels.SecurityEvents == nil || s.sessionLock != nil || len(s.securityPending) >= maxSecurityPending { | |||
| 42 | + | res.SendFinished() | |||
| 43 | + | return | |||
| 44 | + | } | |||
| 45 | + | // Engage before queueing any core event. No display-owned protocol | |||
| 46 | + | // admission can observe the old policy after this point. | |||
| 47 | + | state, err := s.securityController.Engage() | |||
| 48 | + | if err != nil { | |||
| 49 | + | res.SendFinished() | |||
| 50 | + | return | |||
| 51 | + | } | |||
| 52 | + | l.state = state | |||
| 53 | + | s.sessionLock = l | |||
| 54 | + | s.lockReadiness.Begin(state.Generation) | |||
| 55 | + | res.OnDestroy = l.orphan | |||
| 56 | + | s.resetProtectionInput() | |||
| 57 | + | if ps := s.private.session; ps != nil { | |||
| 58 | + | ps.end(true) | |||
| 59 | + | } | |||
| 60 | + | for _, lease := range s.activeLeases { | |||
| 61 | + | lease.destroy() | |||
| 62 | + | } | |||
| 63 | + | // Preserve card tombstones until late replies are consumed/revoked, but | |||
| 64 | + | // revoke their protocol authority across both acquire and release. | |||
| 65 | + | for id, lease := range s.pendingLeases { | |||
| 66 | + | lease.finish() | |||
| 67 | + | delete(s.pendingLeases, id) | |||
| 68 | + | } | |||
| 69 | + | s.updateWorkspaceManagers(s.workspaceSnapshot) | |||
| 70 | + | s.emit(ports.SessionLockChanged{State: state}) | |||
| 71 | + | s.requestBackendSecurity(state) | |||
| 72 | + | s.maybeLocked() | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | func (l *sessionLock) Destroy(r *extsessionlock.ExtSessionLockV1) { | |||
| 76 | + | if l.locked { | |||
| 77 | + | r.PostError(uint32(extsessionlock.ExtSessionLockV1ErrorInvalidDestroy), "destroy while locked") | |||
| 78 | + | } | |||
| 79 | + | } | |||
| 80 | + | func (l *sessionLock) UnlockAndDestroy(r *extsessionlock.ExtSessionLockV1) { | |||
| 81 | + | s := l.s | |||
| 82 | + | if !l.locked || s.sessionLock != l { | |||
| 83 | + | r.PostError(uint32(extsessionlock.ExtSessionLockV1ErrorInvalidUnlock), "unlock before protection confirmation") | |||
| 84 | + | return | |||
| 85 | + | } | |||
| 86 | + | state, err := s.securityController.Release(l.state.Generation) | |||
| 87 | + | if err != nil { | |||
| 88 | + | r.PostError(uint32(extsessionlock.ExtSessionLockV1ErrorInvalidUnlock), "protection release rejected") | |||
| 89 | + | return | |||
| 90 | + | } | |||
| 91 | + | // Clear input before any desktop focus enter; no password held keys. | |||
| 92 | + | s.resetProtectionInput() | |||
| 93 | + | s.lockReadiness.End(l.state.Generation) | |||
| 94 | + | s.sessionLock = nil | |||
| 95 | + | s.refreshToplevels() | |||
| 96 | + | s.updateWorkspaceManagers(s.workspaceSnapshot) | |||
| 97 | + | s.updateCaptureWorkspaces(s.workspaceSnapshot) | |||
| 98 | + | for _, surface := range l.surfaces { | |||
| 99 | + | surface.unmap() | |||
| 100 | + | } | |||
| 101 | + | s.emit(ports.SessionLockChanged{State: state}) | |||
| 102 | + | s.requestBackendSecurity(state) | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | func (l *sessionLock) GetLockSurface(r *extsessionlock.ExtSessionLockV1, id uint32, wl *wayland.Surface, out *wayland.Output) { | |||
| 106 | + | s := l.s | |||
| 107 | + | if s.sessionLock != l { | |||
| 108 | + | _, _ = extsessionlock.NewExtSessionLockSurfaceV1(r.Client(), r.Version(), id, refusedLockSurface{}) | |||
| 109 | + | return | |||
| 110 | + | } | |||
| 111 | + | surface, output := s.surfaceOf(wl), s.outputOf(out) | |||
| 112 | + | if surface == nil || output == nil || wl == nil || out == nil || wl.Client() != r.Client() || out.Client() != r.Client() { | |||
| 113 | + | r.PostError(uint32(extsessionlock.ExtSessionLockV1ErrorRole), "lock surface and output must belong to lock owner") | |||
| 114 | + | return | |||
| 115 | + | } | |||
| 116 | + | if _, exists := l.surfaces[output]; output != nil && exists { | |||
| 117 | + | r.PostError(uint32(extsessionlock.ExtSessionLockV1ErrorDuplicateOutput), "duplicate lock output") | |||
| 118 | + | return | |||
| 119 | + | } | |||
| 120 | + | ls, err := s.newLockSurface(r, id, surface, output, func(*lockSurface) { s.lockSurfaceChanged() }) | |||
| 121 | + | if err != nil || ls == nil { | |||
| 122 | + | return | |||
| 123 | + | } | |||
| 124 | + | l.surfaces[output] = ls | |||
| 125 | + | s.lockSurfaceChanged() | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | func (l *sessionLock) orphan() { | |||
| 129 | + | s := l.s | |||
| 130 | + | if s.sessionLock != l { | |||
| 131 | + | return | |||
| 132 | + | } | |||
| 133 | + | // Never release or send finished merely because a client died. | |||
| 134 | + | s.resetProtectionInput() | |||
| 135 | + | s.sessionLock = nil | |||
| 136 | + | for _, surface := range l.surfaces { | |||
| 137 | + | surface.unmap() | |||
| 138 | + | } | |||
| 139 | + | s.emit(ports.SessionLockChanged{State: l.state}) | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | func (s *Server) resetProtectionInput() { | |||
| 143 | + | s.changeFocus(0) | |||
| 144 | + | s.changePointerFocus(0, 0, 0) | |||
| 145 | + | s.deactivateText() | |||
| 146 | + | s.deactivateConstraint() | |||
| 147 | + | clear(s.seat.heldKeys) | |||
| 148 | + | clear(s.seat.grabKeys) | |||
| 149 | + | s.seat.modState = ports.ModState{} | |||
| 150 | + | s.seat.press, s.seat.pressClient = 0, server.Client{} | |||
| 151 | + | s.seat.wheelRest, s.seat.wheelHeld = [2]int32{}, [2]float64{} | |||
| 152 | + | s.cursorSurface = nil | |||
| 153 | + | } | |||
| 154 | + | ||||
| 155 | + | // maxSecurityPending bounds takeover churn while the backend is stalled. | |||
| 156 | + | // Acquire checks this bound before engaging; release retains a reserved slot. | |||
| 157 | + | const maxSecurityPending = 16 | |||
| 158 | + | ||||
| 159 | + | func (s *Server) requestBackendSecurity(state ports.SecurityState) { | |||
| 160 | + | s.securityPending = append(s.securityPending, state) | |||
| 161 | + | select { | |||
| 162 | + | case s.securityWake <- struct{}{}: | |||
| 163 | + | default: | |||
| 164 | + | } | |||
| 165 | + | } | |||
| 166 | + | ||||
| 167 | + | // forwardBackendSecurity transfers immutable states without blocking display | |||
| 168 | + | // dispatch. Queue mutation is always on the display owner, not this goroutine. | |||
| 169 | + | func (s *Server) forwardBackendSecurity(ctx context.Context) { | |||
| 170 | + | for { | |||
| 171 | + | var state ports.SecurityState | |||
| 172 | + | var pending bool | |||
| 173 | + | if !s.display.Do(func() { | |||
| 174 | + | if len(s.securityPending) != 0 { | |||
| 175 | + | state, pending = s.securityPending[0], true | |||
| 176 | + | } | |||
| 177 | + | }) { | |||
| 178 | + | return | |||
| 179 | + | } | |||
| 180 | + | if !pending { | |||
| 181 | + | select { | |||
| 182 | + | case <-ctx.Done(): | |||
| 183 | + | return | |||
| 184 | + | case <-s.display.Stopped(): | |||
| 185 | + | return | |||
| 186 | + | case <-s.securityWake: | |||
| 187 | + | continue | |||
| 188 | + | } | |||
| 189 | + | } | |||
| 190 | + | select { | |||
| 191 | + | case <-ctx.Done(): | |||
| 192 | + | return | |||
| 193 | + | case <-s.display.Stopped(): | |||
| 194 | + | return | |||
| 195 | + | case s.channels.SecurityChanges <- state: | |||
| 196 | + | } | |||
| 197 | + | if !s.display.Do(func() { | |||
| 198 | + | // Only this sender pops; appending on display preserves the head. | |||
| 199 | + | if len(s.securityPending) != 0 && s.securityPending[0] == state { | |||
| 200 | + | s.securityPending[0] = ports.SecurityState{} | |||
| 201 | + | s.securityPending = s.securityPending[1:] | |||
| 202 | + | } | |||
| 203 | + | }) { | |||
| 204 | + | return | |||
| 205 | + | } | |||
| 206 | + | } | |||
| 207 | + | } | |||
| 208 | + | ||||
| 209 | + | func (s *Server) forwardSecurityEvents(ctx context.Context) { | |||
| 210 | + | for { | |||
| 211 | + | select { | |||
| 212 | + | case <-ctx.Done(): | |||
| 213 | + | return | |||
| 214 | + | case <-s.display.Stopped(): | |||
| 215 | + | return | |||
| 216 | + | case event, ok := <-s.channels.SecurityEvents: | |||
| 217 | + | if !ok { | |||
| 218 | + | return | |||
| 219 | + | } | |||
| 220 | + | if !s.display.Do(func() { s.applySecurityEvent(event) }) { | |||
| 221 | + | return | |||
| 222 | + | } | |||
| 223 | + | } | |||
| 224 | + | } | |||
| 225 | + | } | |||
| 226 | + | ||||
| 227 | + | func (s *Server) applySecurityEvent(event ports.SecurityBackendEvent) { | |||
| 228 | + | switch v := event.(type) { | |||
| 229 | + | case ports.SecurityOutputAdded: | |||
| 230 | + | s.lockReadiness.Add(v.Instance) | |||
| 231 | + | case ports.SecurityOutputRemoved: | |||
| 232 | + | s.lockReadiness.Remove(v.Instance) | |||
| 233 | + | case ports.SecurityOutputProof: | |||
| 234 | + | s.lockReadiness.Record(v.Proof) | |||
| 235 | + | case ports.SecurityOutputInvalidated: | |||
| 236 | + | s.lockReadiness.Invalidate(v.Generation, v.Instance) | |||
| 237 | + | case ports.SecurityBackendBarrier: | |||
| 238 | + | if v.Err == nil { | |||
| 239 | + | s.lockReadiness.BackendBarrier(v.Generation) | |||
| 240 | + | } else { | |||
| 241 | + | s.log.Error().Err(v.Err).Msg("session security backend barrier failed") | |||
| 242 | + | } | |||
| 243 | + | } | |||
| 244 | + | s.maybeLocked() | |||
| 245 | + | } | |||
| 246 | + | ||||
| 247 | + | func (s *Server) lockInputTarget(id ports.WindowID) bool { | |||
| 248 | + | l := s.sessionLock | |||
| 249 | + | if l == nil || !l.locked { | |||
| 250 | + | return false | |||
| 251 | + | } | |||
| 252 | + | surface := s.lockSurfaces[id] | |||
| 253 | + | if surface == nil || !surface.mapped || surface.closed || !surface.resource.Alive() { | |||
| 254 | + | return false | |||
| 255 | + | } | |||
| 256 | + | for _, owned := range l.surfaces { | |||
| 257 | + | if surface == owned { | |||
| 258 | + | return true | |||
| 259 | + | } | |||
| 260 | + | } | |||
| 261 | + | return false | |||
| 262 | + | } | |||
| 263 | + | ||||
| 264 | + | func (s *Server) maybeLocked() { | |||
| 265 | + | l := s.sessionLock | |||
| 266 | + | if l == nil || l.locked || !l.res.Alive() { | |||
| 267 | + | return | |||
| 268 | + | } | |||
| 269 | + | if len(s.captureInflight) == 0 { | |||
| 270 | + | s.lockReadiness.CaptureBarrier(l.state.Generation) | |||
| 271 | + | } | |||
| 272 | + | if !s.lockReadiness.Ready() { | |||
| 273 | + | return | |||
| 274 | + | } | |||
| 275 | + | l.locked = true | |||
| 276 | + | l.res.SendLocked() | |||
| 277 | + | s.lockSurfaceChanged() | |||
| 278 | + | } | |||
A internal/adapters/wayland/session_lock_barrier_test.go +277 -0
| @@ -0,0 +1,277 @@ | |||||
| 1 | + | package wayland | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "image" | |||
| 7 | + | "os" | |||
| 8 | + | "testing" | |||
| 9 | + | "time" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/neferwl/internal/adapters/capture" | |||
| 12 | + | "github.com/bnema/neferwl/internal/adapters/sessionsecurity" | |||
| 13 | + | "github.com/bnema/neferwl/internal/logging" | |||
| 14 | + | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |||
| 15 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 16 | + | "github.com/stretchr/testify/mock" | |||
| 17 | + | "golang.org/x/sys/unix" | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | // Real server fixture: channels are wired before Run, so the completion loop | |||
| 21 | + | // cannot latch a nil channel. No backend or protocol handler is replaced. | |||
| 22 | + | type barrierServer struct { | |||
| 23 | + | s *Server | |||
| 24 | + | gate *sessionsecurity.Gate | |||
| 25 | + | changes chan ports.SecurityState | |||
| 26 | + | captures chan ports.CaptureRequest | |||
| 27 | + | captured chan ports.CaptureDone | |||
| 28 | + | cancel context.CancelFunc | |||
| 29 | + | stopped chan struct{} | |||
| 30 | + | dir string | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | func newBarrierServer(t *testing.T) *barrierServer { | |||
| 34 | + | t.Helper() | |||
| 35 | + | h := &barrierServer{gate: &sessionsecurity.Gate{}, changes: make(chan ports.SecurityState, 1), captures: make(chan ports.CaptureRequest, 4), captured: make(chan ports.CaptureDone, 4), stopped: make(chan struct{}), dir: t.TempDir()} | |||
| 36 | + | layout := ports.Layout{{Info: ports.OutputInfo{Name: "HEADLESS-1", Width: 2, Height: 2}, Width: 2, Height: 2, Scale: 1}} | |||
| 37 | + | var err error | |||
| 38 | + | h.s, err = New(Options{RuntimeDir: h.dir, Outputs: layout, Security: h.gate}, Channels{SecurityChanges: h.changes, SecurityEvents: make(chan ports.SecurityBackendEvent, 16), Captures: h.captures, Captured: h.captured}, logging.For(context.Background(), "wayland")) | |||
| 39 | + | if err != nil { | |||
| 40 | + | t.Fatal(err) | |||
| 41 | + | } | |||
| 42 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 43 | + | h.cancel = cancel | |||
| 44 | + | var runErr error | |||
| 45 | + | go func() { runErr = h.s.Run(ctx); close(h.stopped) }() | |||
| 46 | + | t.Cleanup(func() { | |||
| 47 | + | cancel() | |||
| 48 | + | select { | |||
| 49 | + | case <-h.stopped: | |||
| 50 | + | if runErr != nil { | |||
| 51 | + | t.Error(runErr) | |||
| 52 | + | } | |||
| 53 | + | case <-time.After(3 * time.Second): | |||
| 54 | + | t.Error("barrier server shutdown blocked") | |||
| 55 | + | } | |||
| 56 | + | }) | |||
| 57 | + | return h | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | func TestSessionLockBarrierWaitsForAdmittedCopyAfterFrameDeath(t *testing.T) { | |||
| 61 | + | h := newBarrierServer(t) | |||
| 62 | + | // Reuse the real raw-wire admission client, retaining its SHM memfd so a | |||
| 63 | + | // write performed after lock admission can be observed independently. | |||
| 64 | + | client := protocolClient(t, h.s, h.dir) | |||
| 65 | + | output := bindVersion(t, client, "wl_output", 4) | |||
| 66 | + | registerProtocol(t, client, output) | |||
| 67 | + | _, buffer, fd := captureSmallBuffer(t, client) | |||
| 68 | + | defer unix.Close(fd) | |||
| 69 | + | a := &admissionClient{c: client, wlr: bindVersion(t, client, "zwlr_screencopy_manager_v1", 3), output: output, buf: buffer} | |||
| 70 | + | frame := a.captureWlr(t) | |||
| 71 | + | var request ports.CaptureRequest | |||
| 72 | + | select { | |||
| 73 | + | case request = <-h.captures: | |||
| 74 | + | case <-time.After(2 * time.Second): | |||
| 75 | + | t.Fatal("actual capture not admitted") | |||
| 76 | + | } | |||
| 77 | + | // Pipeline owns the dup'd destination from this point, including closure. | |||
| 78 | + | renderer := portsmocks.NewMockRenderer(t) | |||
| 79 | + | pixels := portsmocks.NewMockCaptureFrame(t) | |||
| 80 | + | entered, release := make(chan struct{}), make(chan struct{}) | |||
| 81 | + | pixels.EXPECT().Done().Return(nil) | |||
| 82 | + | pixels.EXPECT().Read(image.Rect(0, 0, 2, 2), mock.Anything, 8).RunAndReturn(func(_ image.Rectangle, dst []byte, _ int) error { | |||
| 83 | + | close(entered) | |||
| 84 | + | <-release | |||
| 85 | + | copy(dst, []byte{0x42, 0x31, 0x20, 0xff}) | |||
| 86 | + | return nil | |||
| 87 | + | }).Once() | |||
| 88 | + | renderer.EXPECT().BeginCapture().Return(pixels, nil).Once() | |||
| 89 | + | renderer.EXPECT().EndCapture(pixels).Return().Once() | |||
| 90 | + | pipelineReplies := make(chan ports.CaptureDone, 1) | |||
| 91 | + | pipeline := capture.NewPipeline(context.Background(), pipelineReplies) | |||
| 92 | + | pipeline.Security = h.gate | |||
| 93 | + | released := false | |||
| 94 | + | t.Cleanup(func() { | |||
| 95 | + | if !released { | |||
| 96 | + | close(release) | |||
| 97 | + | } | |||
| 98 | + | pipeline.Close(renderer) | |||
| 99 | + | // testify records the mmap-backed Read argument. Forget its call | |||
| 100 | + | // history after joining the worker: expectation counters still verify | |||
| 101 | + | // Once, but cleanup must not format bytes that are now unmapped. | |||
| 102 | + | pixels.Calls = nil // worker joined; follows capture/pipeline_test.go | |||
| 103 | + | }) | |||
| 104 | + | pipeline.SubmitScoped(h.gate.Snapshot(), renderer, []ports.CaptureRequest{request}) | |||
| 105 | + | select { | |||
| 106 | + | case <-entered: | |||
| 107 | + | case <-time.After(2 * time.Second): | |||
| 108 | + | t.Fatal("capture worker did not enter Read") | |||
| 109 | + | } | |||
| 110 | + | a.destroy(t, frame) | |||
| 111 | + | locker := protocolClient(t, h.s, h.dir) | |||
| 112 | + | _, locked := lifecycleAcquire(t, locker) | |||
| 113 | + | state := lifecycleTransition(t, h.changes) | |||
| 114 | + | h.s.display.Do(func() { | |||
| 115 | + | h.s.applySecurityEvent(ports.SecurityOutputAdded{Instance: 1, Output: "HEADLESS-1"}) | |||
| 116 | + | h.s.applySecurityEvent(ports.SecurityBackendBarrier{Generation: state.Generation}) | |||
| 117 | + | lifecycleProof(h.s, state, 1) | |||
| 118 | + | if len(h.s.captureInflight) != 1 || h.s.sessionLock.locked { | |||
| 119 | + | t.Error("destroyed resource bypassed held copy barrier") | |||
| 120 | + | } | |||
| 121 | + | }) | |||
| 122 | + | roundtrip(t, locker) | |||
| 123 | + | if *locked != 0 { | |||
| 124 | + | t.Fatal("locked while Read can still write normal pixels") | |||
| 125 | + | } | |||
| 126 | + | close(release) | |||
| 127 | + | released = true | |||
| 128 | + | var completion ports.CaptureDone | |||
| 129 | + | select { | |||
| 130 | + | case completion = <-pipelineReplies: | |||
| 131 | + | case <-time.After(2 * time.Second): | |||
| 132 | + | t.Fatal("capture pipeline did not complete") | |||
| 133 | + | } | |||
| 134 | + | if completion.Err != nil || completion.ID != request.ID { | |||
| 135 | + | t.Fatalf("capture completion %+v", completion) | |||
| 136 | + | } | |||
| 137 | + | if _, err := request.Dst.File.Stat(); !errors.Is(err, os.ErrClosed) { | |||
| 138 | + | t.Fatalf("completion precedes destination close: %v", err) | |||
| 139 | + | } | |||
| 140 | + | got := make([]byte, 4) | |||
| 141 | + | if _, err := unix.Pread(fd, got, 0); err != nil { | |||
| 142 | + | t.Fatal(err) | |||
| 143 | + | } | |||
| 144 | + | if got[0] != 0x42 || got[3] != 0xff { | |||
| 145 | + | t.Fatalf("actual copy not visible: %x", got) | |||
| 146 | + | } | |||
| 147 | + | h.s.display.Do(func() { | |||
| 148 | + | if len(h.s.captureInflight) != 1 || h.s.sessionLock.locked { | |||
| 149 | + | t.Error("worker completion not forwarded yet must retain credit") | |||
| 150 | + | } | |||
| 151 | + | }) | |||
| 152 | + | // Reply channel is the sole production completion funnel. Closing a frame | |||
| 153 | + | // never synthesizes this reply or cancels its outstanding memory write. | |||
| 154 | + | processed := make(chan struct{}) | |||
| 155 | + | h.s.display.Do(func() { | |||
| 156 | + | original := h.s.captureReplies[request.ID] | |||
| 157 | + | h.s.captureReplies[request.ID] = func(done ports.CaptureDone) { | |||
| 158 | + | if original != nil { | |||
| 159 | + | original(done) | |||
| 160 | + | } // frame destruction removes the wire reply, not credit | |||
| 161 | + | close(processed) | |||
| 162 | + | } | |||
| 163 | + | }) | |||
| 164 | + | h.captured <- completion | |||
| 165 | + | select { | |||
| 166 | + | case <-processed: | |||
| 167 | + | case <-time.After(2 * time.Second): | |||
| 168 | + | t.Fatal("server did not process actual capture completion") | |||
| 169 | + | } | |||
| 170 | + | h.s.display.Do(func() { | |||
| 171 | + | if len(h.s.captureInflight) != 0 || !h.s.sessionLock.locked { | |||
| 172 | + | t.Error("completed copy did not clear lock barrier") | |||
| 173 | + | } | |||
| 174 | + | }) | |||
| 175 | + | // The opaque completed token returns the real capture lease on its owner. | |||
| 176 | + | select { | |||
| 177 | + | case token := <-pipeline.Completed(): | |||
| 178 | + | pipeline.Recycle(token, renderer) | |||
| 179 | + | case <-time.After(2 * time.Second): | |||
| 180 | + | t.Fatal("capture lease not returned") | |||
| 181 | + | } | |||
| 182 | + | roundtrip(t, locker) | |||
| 183 | + | if *locked != 1 { | |||
| 184 | + | t.Fatalf("locked events=%d", *locked) | |||
| 185 | + | } | |||
| 186 | + | } | |||
| 187 | + | ||||
| 188 | + | func TestSessionLockBarrierTakeoverSaturationRefusesWithoutGenerationChange(t *testing.T) { | |||
| 189 | + | h := newBarrierServer(t) | |||
| 190 | + | sentinel := ports.SecurityState{Generation: 999} | |||
| 191 | + | h.changes <- sentinel // authoritative backend stalled for the whole churn | |||
| 192 | + | for i := 0; i < maxSecurityPending; i++ { | |||
| 193 | + | client := protocolClient(t, h.s, h.dir) | |||
| 194 | + | _, locked := lifecycleAcquire(t, client) | |||
| 195 | + | if *locked != 0 { | |||
| 196 | + | t.Fatal("stalled backend confirmed lock") | |||
| 197 | + | } | |||
| 198 | + | orphaned := make(chan struct{}) | |||
| 199 | + | h.s.display.Do(func() { r := h.s.sessionLock.res; old := r.OnDestroy; r.OnDestroy = func() { old(); close(orphaned) } }) | |||
| 200 | + | if err := client.Close(); err != nil { | |||
| 201 | + | t.Fatal(err) | |||
| 202 | + | } | |||
| 203 | + | select { | |||
| 204 | + | case <-orphaned: | |||
| 205 | + | case <-time.After(2 * time.Second): | |||
| 206 | + | t.Fatal("owner death not processed") | |||
| 207 | + | } | |||
| 208 | + | } | |||
| 209 | + | before := h.gate.Snapshot() | |||
| 210 | + | client := protocolClient(t, h.s, h.dir) | |||
| 211 | + | refused, err := bindLockManager(t, client).Lock() | |||
| 212 | + | if err != nil { | |||
| 213 | + | t.Fatal(err) | |||
| 214 | + | } | |||
| 215 | + | finished := 0 | |||
| 216 | + | refused.OnFinished(func() { finished++ }) | |||
| 217 | + | roundtrip(t, client) | |||
| 218 | + | h.s.display.Do(func() { | |||
| 219 | + | if len(h.s.securityPending) != maxSecurityPending || h.s.sessionLock != nil { | |||
| 220 | + | t.Error("stalled outbox exceeded bound or admitted owner") | |||
| 221 | + | } | |||
| 222 | + | }) | |||
| 223 | + | if finished != 1 || h.gate.Snapshot() != before || !before.Protected { | |||
| 224 | + | t.Fatalf("refusal finished=%d before=%+v after=%+v", finished, before, h.gate.Snapshot()) | |||
| 225 | + | } | |||
| 226 | + | } | |||
| 227 | + | ||||
| 228 | + | func TestSessionLockBarrierReservedReleaseAndBlockedSenderShutdown(t *testing.T) { | |||
| 229 | + | h := newBarrierServer(t) | |||
| 230 | + | sentinel := ports.SecurityState{Generation: 999} | |||
| 231 | + | h.changes <- sentinel | |||
| 232 | + | client := protocolClient(t, h.s, h.dir) | |||
| 233 | + | lock, locked := lifecycleAcquire(t, client) | |||
| 234 | + | state := h.gate.Snapshot() | |||
| 235 | + | var queued []ports.SecurityState | |||
| 236 | + | h.s.display.Do(func() { | |||
| 237 | + | // Immutable channel messages exercise the reserved release slot without | |||
| 238 | + | // fabricating another accepted owner while this one is live. | |||
| 239 | + | for len(h.s.securityPending) < maxSecurityPending { | |||
| 240 | + | h.s.requestBackendSecurity(ports.SecurityState{Generation: ports.LockGeneration(100 + len(h.s.securityPending)), Protected: true}) | |||
| 241 | + | } | |||
| 242 | + | queued = append(queued, h.s.securityPending...) | |||
| 243 | + | h.s.applySecurityEvent(ports.SecurityBackendBarrier{Generation: state.Generation}) | |||
| 244 | + | }) | |||
| 245 | + | roundtrip(t, client) | |||
| 246 | + | if *locked != 1 { | |||
| 247 | + | t.Fatal("owner not confirmed") | |||
| 248 | + | } | |||
| 249 | + | if err := lock.UnlockAndDestroy(); err != nil { | |||
| 250 | + | t.Fatal(err) | |||
| 251 | + | } | |||
| 252 | + | roundtrip(t, client) | |||
| 253 | + | released := h.gate.Snapshot() | |||
| 254 | + | h.s.display.Do(func() { | |||
| 255 | + | if len(h.s.securityPending) != maxSecurityPending+1 || h.s.securityPending[maxSecurityPending] != released { | |||
| 256 | + | t.Error("reserved release slot lost") | |||
| 257 | + | } | |||
| 258 | + | }) | |||
| 259 | + | if got := lifecycleTransition(t, h.changes); got != sentinel { | |||
| 260 | + | t.Fatal("backend sentinel changed") | |||
| 261 | + | } | |||
| 262 | + | for _, want := range append(queued, released) { | |||
| 263 | + | if got := lifecycleTransition(t, h.changes); got != want { | |||
| 264 | + | t.Fatalf("outbox got %+v want %+v", got, want) | |||
| 265 | + | } | |||
| 266 | + | } | |||
| 267 | + | // Block another transition and cancel the real server. Run joins the sender; | |||
| 268 | + | // successful return proves it did not leak in a blocked channel send. | |||
| 269 | + | h.changes <- sentinel | |||
| 270 | + | h.s.display.Do(func() { h.s.requestBackendSecurity(ports.SecurityState{Generation: 777, Protected: true}) }) | |||
| 271 | + | h.cancel() | |||
| 272 | + | select { | |||
| 273 | + | case <-h.stopped: | |||
| 274 | + | case <-time.After(3 * time.Second): | |||
| 275 | + | t.Fatal("blocked backend sender prevented Run shutdown") | |||
| 276 | + | } | |||
| 277 | + | } | |||
A internal/adapters/wayland/session_lock_input_epoch_test.go +272 -0
| @@ -0,0 +1,272 @@ | |||||
| 1 | + | package wayland | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "slices" | |||
| 6 | + | "strings" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/neferwl/internal/adapters/sessionsecurity" | |||
| 11 | + | "github.com/bnema/neferwl/internal/logging" | |||
| 12 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 13 | + | "github.com/bnema/purego-libwayland/protocol/wayland" | |||
| 14 | + | "github.com/bnema/wlturbo" | |||
| 15 | + | "github.com/bnema/wlturbo/protocol/core" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | // This fixture wires real channel ports before starting the display owner. | |||
| 19 | + | func inputEpochLockServer(t *testing.T) (*Server, *sessionsecurity.Gate, chan ports.SecurityState, chan ports.ClientEvent, string) { | |||
| 20 | + | t.Helper() | |||
| 21 | + | gate := &sessionsecurity.Gate{} | |||
| 22 | + | changes := make(chan ports.SecurityState, 16) | |||
| 23 | + | events := make(chan ports.ClientEvent, 128) | |||
| 24 | + | dir := t.TempDir() | |||
| 25 | + | s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs, Security: gate, Keymap: keymapText(t)}, Channels{Events: events, SecurityChanges: changes, SecurityEvents: make(chan ports.SecurityBackendEvent, 16)}, logging.For(context.Background(), "wayland")) | |||
| 26 | + | if err != nil { | |||
| 27 | + | t.Fatal(err) | |||
| 28 | + | } | |||
| 29 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 30 | + | done := make(chan error, 1) | |||
| 31 | + | go func() { done <- s.Run(ctx) }() | |||
| 32 | + | t.Cleanup(func() { | |||
| 33 | + | cancel() | |||
| 34 | + | select { | |||
| 35 | + | case err := <-done: | |||
| 36 | + | if err != nil { | |||
| 37 | + | t.Error(err) | |||
| 38 | + | } | |||
| 39 | + | case <-time.After(3 * time.Second): | |||
| 40 | + | t.Error("input epoch server did not stop") | |||
| 41 | + | } | |||
| 42 | + | }) | |||
| 43 | + | return s, gate, changes, events, dir | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func inputEpochKeyboard(t *testing.T, c *wlturbo.Display) *core.Keyboard { | |||
| 47 | + | t.Helper() | |||
| 48 | + | seat := core.NewSeat(c.Context()) | |||
| 49 | + | g, ok := c.Registry().FindGlobal("wl_seat") | |||
| 50 | + | if !ok { | |||
| 51 | + | t.Fatal("seat missing") | |||
| 52 | + | } | |||
| 53 | + | if err := c.Registry().Bind(g.Name, g.Interface, min(g.Version, 4), seat); err != nil { | |||
| 54 | + | t.Fatal(err) | |||
| 55 | + | } | |||
| 56 | + | keyboard, err := seat.GetKeyboard() | |||
| 57 | + | if err != nil { | |||
| 58 | + | t.Fatal(err) | |||
| 59 | + | } | |||
| 60 | + | return keyboard | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | func inputEpochCommitLocker(t *testing.T, s *Server, c *wlturbo.Display, lockSurface *core.Surface, ack func(uint32) error, onConfigure func(func(uint32, uint32, uint32))) (ports.WindowID, lockConfigure) { | |||
| 64 | + | t.Helper() | |||
| 65 | + | var cfg lockConfigure | |||
| 66 | + | onConfigure(func(serial, w, h uint32) { cfg = lockConfigure{serial: serial, width: int(w), height: int(h)} }) | |||
| 67 | + | roundtrip(t, c) | |||
| 68 | + | if cfg.serial == 0 { | |||
| 69 | + | t.Fatal("missing lock configure") | |||
| 70 | + | } | |||
| 71 | + | if err := ack(cfg.serial); err != nil { | |||
| 72 | + | t.Fatal(err) | |||
| 73 | + | } | |||
| 74 | + | lockViewport(t, c, lockSurface.ID(), cfg.width, cfg.height) | |||
| 75 | + | requestProtocol(t, c, lockSurface.ID(), wayland.SurfaceRequestAttach, shmBuffer(t, c), int32(0), int32(0)) | |||
| 76 | + | requestProtocol(t, c, lockSurface.ID(), wayland.SurfaceRequestCommit) | |||
| 77 | + | roundtrip(t, c) | |||
| 78 | + | var id ports.WindowID | |||
| 79 | + | s.display.Do(func() { | |||
| 80 | + | surf := s.surfaceOf(findTestSurface(s, lockSurface.ID())) | |||
| 81 | + | if surf == nil || surf.lock == nil || !surf.lock.mapped { | |||
| 82 | + | t.Error("acknowledged buffered lock role not mapped") | |||
| 83 | + | return | |||
| 84 | + | } | |||
| 85 | + | id = surf.lock.id | |||
| 86 | + | }) | |||
| 87 | + | return id, cfg | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | // Marker follows display-owned event emission; no sleeps or polling are | |||
| 91 | + | // needed to establish that all earlier SessionLockChanged events were read. | |||
| 92 | + | func inputEpochLockEvents(t *testing.T, s *Server, events <-chan ports.ClientEvent) []ports.SessionLockChanged { | |||
| 93 | + | t.Helper() | |||
| 94 | + | const marker ports.WindowID = ^ports.WindowID(0) | |||
| 95 | + | if !s.display.Do(func() { s.emit(ports.WindowAppID{ID: marker, AppID: "input-epoch-marker"}) }) { | |||
| 96 | + | t.Fatal("display stopped") | |||
| 97 | + | } | |||
| 98 | + | deadline := time.After(2 * time.Second) | |||
| 99 | + | var locks []ports.SessionLockChanged | |||
| 100 | + | for { | |||
| 101 | + | select { | |||
| 102 | + | case ev := <-events: | |||
| 103 | + | if v, ok := ev.(ports.WindowAppID); ok && v.ID == marker { | |||
| 104 | + | return locks | |||
| 105 | + | } | |||
| 106 | + | if v, ok := ev.(ports.SessionLockChanged); ok { | |||
| 107 | + | locks = append(locks, v) | |||
| 108 | + | } | |||
| 109 | + | case <-deadline: | |||
| 110 | + | t.Fatal("event marker missing") | |||
| 111 | + | return nil | |||
| 112 | + | } | |||
| 113 | + | } | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | func TestSessionLockInputEpochHeldPasswordClearedBeforeDesktopEnter(t *testing.T) { | |||
| 117 | + | s, gate, changes, events, dir := inputEpochLockServer(t) | |||
| 118 | + | desktop := protocolClient(t, s, dir) | |||
| 119 | + | normal, normalSurface, _ := surfaceMapper(t, desktop, events)() | |||
| 120 | + | keys := inputEpochKeyboard(t, desktop) | |||
| 121 | + | var enters [][]byte | |||
| 122 | + | var enterSurfaces []uint32 | |||
| 123 | + | var mods []ports.ModState | |||
| 124 | + | var delivered []uint32 | |||
| 125 | + | keys.OnEnter(func(_ uint32, surface uint32, held []byte) { | |||
| 126 | + | enters = append(enters, slices.Clone(held)) | |||
| 127 | + | enterSurfaces = append(enterSurfaces, surface) | |||
| 128 | + | }) | |||
| 129 | + | keys.OnModifiers(func(_ uint32, d, l, k, g uint32) { | |||
| 130 | + | mods = append(mods, ports.ModState{Depressed: d, Latched: l, Locked: k, Group: g}) | |||
| 131 | + | }) | |||
| 132 | + | keys.OnKey(func(_, _, key, _ uint32) { delivered = append(delivered, key) }) | |||
| 133 | + | roundtrip(t, desktop) | |||
| 134 | + | s.display.Do(func() { | |||
| 135 | + | s.apply(ports.SecurityCommand{State: gate.Snapshot(), Command: ports.FocusWindow{ID: normal.ID}}) | |||
| 136 | + | }) | |||
| 137 | + | roundtrip(t, desktop) | |||
| 138 | + | enters, enterSurfaces, mods, delivered = nil, nil, nil, nil | |||
| 139 | + | ime := newIMEApp(t, s, dir) | |||
| 140 | + | _, grab := ime.grab(t) | |||
| 141 | + | roundtrip(t, ime.c) | |||
| 142 | + | grab.take() | |||
| 143 | + | locker := protocolClient(t, s, dir) | |||
| 144 | + | lock, locked := lifecycleAcquire(t, locker) | |||
| 145 | + | state := lifecycleTransition(t, changes) | |||
| 146 | + | surf, out := lifecycleSurfaceOutput(t, locker) | |||
| 147 | + | role, err := lock.GetLockSurface(surf, out) | |||
| 148 | + | if err != nil { | |||
| 149 | + | t.Fatal(err) | |||
| 150 | + | } | |||
| 151 | + | id, _ := inputEpochCommitLocker(t, s, locker, surf, role.AckConfigure, role.OnConfigure) | |||
| 152 | + | lockerKeys := inputEpochKeyboard(t, locker) | |||
| 153 | + | var passwordKeys []uint32 | |||
| 154 | + | lockerKeys.OnKey(func(_, _, key, _ uint32) { passwordKeys = append(passwordKeys, key) }) | |||
| 155 | + | roundtrip(t, locker) | |||
| 156 | + | s.display.Do(func() { s.applySecurityEvent(ports.SecurityBackendBarrier{Generation: state.Generation}) }) | |||
| 157 | + | roundtrip(t, locker) | |||
| 158 | + | if *locked != 1 { | |||
| 159 | + | t.Fatal("lock not confirmed") | |||
| 160 | + | } | |||
| 161 | + | password := ports.KeyEvent{Keycode: 30, Pressed: true, TimeMsec: 1, State: ports.ModState{Depressed: 65, Latched: 2, Locked: 16, Group: 1}} | |||
| 162 | + | s.display.Do(func() { | |||
| 163 | + | s.apply(ports.SecurityCommand{State: state, Command: ports.FocusWindow{ID: id}}) | |||
| 164 | + | s.apply(ports.SecurityCommand{State: state, Command: ports.ForwardKey{ID: id, Key: password}}) | |||
| 165 | + | if !s.seat.heldKeys[30] || s.seat.modState != password.State { | |||
| 166 | + | t.Error("test did not seed held password/native seat modifier state") | |||
| 167 | + | } | |||
| 168 | + | }) | |||
| 169 | + | settle(t, locker, ime.c, desktop) | |||
| 170 | + | if !slices.Equal(passwordKeys, []uint32{30}) { | |||
| 171 | + | t.Fatalf("locker key events %v", passwordKeys) | |||
| 172 | + | } | |||
| 173 | + | if len(delivered) != 0 { | |||
| 174 | + | t.Fatalf("locker password reached desktop before release: %v", delivered) | |||
| 175 | + | } | |||
| 176 | + | for _, event := range grab.take() { | |||
| 177 | + | if strings.HasPrefix(event, "key ") { | |||
| 178 | + | t.Fatalf("password reached IME: %s", event) | |||
| 179 | + | } | |||
| 180 | + | } | |||
| 181 | + | if err := lock.UnlockAndDestroy(); err != nil { | |||
| 182 | + | t.Fatal(err) | |||
| 183 | + | } | |||
| 184 | + | roundtrip(t, locker) | |||
| 185 | + | released := lifecycleTransition(t, changes) | |||
| 186 | + | if released.Protected || released == state { | |||
| 187 | + | t.Fatal(released) | |||
| 188 | + | } | |||
| 189 | + | s.display.Do(func() { | |||
| 190 | + | if len(s.seat.heldKeys) != 0 || len(s.seat.grabKeys) != 0 || s.seat.modState != (ports.ModState{}) { | |||
| 191 | + | t.Error("release retained password input state") | |||
| 192 | + | } | |||
| 193 | + | s.apply(ports.SecurityCommand{State: state, Command: ports.FocusWindow{ID: normal.ID}}) | |||
| 194 | + | s.apply(ports.FocusWindow{ID: normal.ID}) | |||
| 195 | + | if s.seat.focused != 0 { | |||
| 196 | + | t.Error("stale or raw focus crossed release") | |||
| 197 | + | } | |||
| 198 | + | s.apply(ports.SecurityCommand{State: released, Command: ports.FocusWindow{ID: normal.ID}}) | |||
| 199 | + | // Old envelopes aimed at the newly focused desktop are still rejected. | |||
| 200 | + | s.apply(ports.SecurityCommand{State: state, Command: ports.ForwardKey{ID: normal.ID, Key: password}}) | |||
| 201 | + | password.Pressed = false | |||
| 202 | + | s.apply(ports.SecurityCommand{State: state, Command: ports.ForwardKey{ID: normal.ID, Key: password}}) | |||
| 203 | + | s.apply(ports.ForwardKey{ID: normal.ID, Key: password}) | |||
| 204 | + | }) | |||
| 205 | + | settle(t, desktop, locker, ime.c) | |||
| 206 | + | if len(delivered) != 0 { | |||
| 207 | + | t.Fatalf("password/stale key reached desktop: %v", delivered) | |||
| 208 | + | } | |||
| 209 | + | if len(enters) != 1 || enterSurfaces[0] != normalSurface || len(enters[0]) != 0 { | |||
| 210 | + | t.Fatalf("desktop enter surfaces=%v held=%v", enterSurfaces, enters) | |||
| 211 | + | } | |||
| 212 | + | if len(mods) == 0 || mods[len(mods)-1] != (ports.ModState{}) { | |||
| 213 | + | t.Fatalf("desktop modifiers not reset: %+v", mods) | |||
| 214 | + | } | |||
| 215 | + | for _, event := range grab.take() { | |||
| 216 | + | if strings.HasPrefix(event, "key ") { | |||
| 217 | + | t.Fatalf("stale password reached IME after release: %s", event) | |||
| 218 | + | } | |||
| 219 | + | } | |||
| 220 | + | } | |||
| 221 | + | ||||
| 222 | + | func TestSessionLockInputEpochMappedRolePublishedOnlyAfterEveryProof(t *testing.T) { | |||
| 223 | + | s, _, changes, events, dir := inputEpochLockServer(t) | |||
| 224 | + | c := protocolClient(t, s, dir) | |||
| 225 | + | s.display.Do(func() { | |||
| 226 | + | s.applySecurityEvent(ports.SecurityOutputAdded{Instance: 11, Output: "HEADLESS-1"}) | |||
| 227 | + | s.applySecurityEvent(ports.SecurityOutputAdded{Instance: 12, Output: "HEADLESS-2"}) | |||
| 228 | + | }) | |||
| 229 | + | lock, locked := lifecycleAcquire(t, c) | |||
| 230 | + | state := lifecycleTransition(t, changes) | |||
| 231 | + | surf, out := lifecycleSurfaceOutput(t, c) | |||
| 232 | + | role, err := lock.GetLockSurface(surf, out) | |||
| 233 | + | if err != nil { | |||
| 234 | + | t.Fatal(err) | |||
| 235 | + | } | |||
| 236 | + | id, cfg := inputEpochCommitLocker(t, s, c, surf, role.AckConfigure, role.OnConfigure) | |||
| 237 | + | before := inputEpochLockEvents(t, s, events) | |||
| 238 | + | if len(before) == 0 { | |||
| 239 | + | t.Fatal("no core protection event") | |||
| 240 | + | } | |||
| 241 | + | for _, ev := range before { | |||
| 242 | + | if ev.State != state || len(ev.Surfaces) != 0 { | |||
| 243 | + | t.Fatalf("preconfirm placement leaked %+v", ev) | |||
| 244 | + | } | |||
| 245 | + | } | |||
| 246 | + | s.display.Do(func() { | |||
| 247 | + | s.applySecurityEvent(ports.SecurityBackendBarrier{Generation: state.Generation}) | |||
| 248 | + | lifecycleProof(s, state, 11) | |||
| 249 | + | }) | |||
| 250 | + | roundtrip(t, c) | |||
| 251 | + | if *locked != 0 { | |||
| 252 | + | t.Fatal("locked without second output proof") | |||
| 253 | + | } | |||
| 254 | + | for _, ev := range inputEpochLockEvents(t, s, events) { | |||
| 255 | + | if len(ev.Surfaces) != 0 { | |||
| 256 | + | t.Fatalf("one-proof placement leaked %+v", ev) | |||
| 257 | + | } | |||
| 258 | + | } | |||
| 259 | + | s.display.Do(func() { lifecycleProof(s, state, 12) }) | |||
| 260 | + | roundtrip(t, c) | |||
| 261 | + | if *locked != 1 { | |||
| 262 | + | t.Fatalf("locked callbacks %d", *locked) | |||
| 263 | + | } | |||
| 264 | + | after := inputEpochLockEvents(t, s, events) | |||
| 265 | + | if cfg.width != testOutputs[0].Width || cfg.height != testOutputs[0].Height { | |||
| 266 | + | t.Fatalf("lock configure %+v does not match output %+v", cfg, testOutputs[0]) | |||
| 267 | + | } | |||
| 268 | + | want := ports.LockSurfacePlacement{ID: id, Output: "HEADLESS-1", Width: cfg.width, Height: cfg.height} | |||
| 269 | + | if len(after) != 1 || after[0].State != state || !slices.Equal(after[0].Surfaces, []ports.LockSurfacePlacement{want}) { | |||
| 270 | + | t.Fatalf("validated placement events %+v want %+v", after, want) | |||
| 271 | + | } | |||
| 272 | + | } | |||
A internal/adapters/wayland/session_lock_lifecycle_test.go +543 -0
| @@ -0,0 +1,543 @@ | |||||
| 1 | + | package wayland | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "slices" | |||
| 6 | + | "strings" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/neferwl/internal/adapters/sessionsecurity" | |||
| 11 | + | "github.com/bnema/neferwl/internal/logging" | |||
| 12 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 13 | + | "github.com/bnema/purego-libwayland/protocol/drmlease" | |||
| 14 | + | serverlock "github.com/bnema/purego-libwayland/protocol/extsessionlock" | |||
| 15 | + | "github.com/bnema/purego-libwayland/protocol/wayland" | |||
| 16 | + | "github.com/bnema/wlturbo" | |||
| 17 | + | "github.com/bnema/wlturbo/protocol/core" | |||
| 18 | + | lockclient "github.com/bnema/wlturbo/protocol/extsessionlock" | |||
| 19 | + | "golang.org/x/sys/unix" | |||
| 20 | + | ) | |||
| 21 | + | ||||
| 22 | + | // A real display with capture completion wiring established before Run starts. | |||
| 23 | + | func lifecycleLockServer(t *testing.T, leaseRequests ...chan ports.LeaseMessage) (*Server, *sessionsecurity.Gate, chan ports.SecurityState, chan ports.CaptureDone, string) { | |||
| 24 | + | t.Helper() | |||
| 25 | + | gate := &sessionsecurity.Gate{} | |||
| 26 | + | changes := make(chan ports.SecurityState, 16) | |||
| 27 | + | captured := make(chan ports.CaptureDone, 16) | |||
| 28 | + | dir := t.TempDir() | |||
| 29 | + | channels := Channels{SecurityChanges: changes, SecurityEvents: make(chan ports.SecurityBackendEvent, 16), Captured: captured} | |||
| 30 | + | if len(leaseRequests) != 0 { | |||
| 31 | + | channels.LeaseRequests = leaseRequests[0] | |||
| 32 | + | } | |||
| 33 | + | s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs, Security: gate, Keymap: keymapText(t)}, channels, logging.For(context.Background(), "wayland")) | |||
| 34 | + | if err != nil { | |||
| 35 | + | t.Fatal(err) | |||
| 36 | + | } | |||
| 37 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 38 | + | done := make(chan error, 1) | |||
| 39 | + | go func() { done <- s.Run(ctx) }() | |||
| 40 | + | t.Cleanup(func() { | |||
| 41 | + | cancel() | |||
| 42 | + | select { | |||
| 43 | + | case err := <-done: | |||
| 44 | + | if err != nil { | |||
| 45 | + | t.Error(err) | |||
| 46 | + | } | |||
| 47 | + | case <-time.After(3 * time.Second): | |||
| 48 | + | t.Error("lock lifecycle server did not stop") | |||
| 49 | + | } | |||
| 50 | + | }) | |||
| 51 | + | return s, gate, changes, captured, dir | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func lifecycleTransition(t *testing.T, changes <-chan ports.SecurityState) ports.SecurityState { | |||
| 55 | + | t.Helper() | |||
| 56 | + | select { | |||
| 57 | + | case state := <-changes: | |||
| 58 | + | return state | |||
| 59 | + | case <-time.After(2 * time.Second): | |||
| 60 | + | t.Fatal("missing security transition") | |||
| 61 | + | return ports.SecurityState{} | |||
| 62 | + | } | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | func lifecycleAcquire(t *testing.T, c *wlturbo.Display) (*lockclient.ExtSessionLock, *int) { | |||
| 66 | + | t.Helper() | |||
| 67 | + | lock, err := bindLockManager(t, c).Lock() | |||
| 68 | + | if err != nil { | |||
| 69 | + | t.Fatal(err) | |||
| 70 | + | } | |||
| 71 | + | count := new(int) | |||
| 72 | + | lock.OnLocked(func() { *count++ }) | |||
| 73 | + | roundtrip(t, c) | |||
| 74 | + | return lock, count | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | func lifecycleProof(s *Server, state ports.SecurityState, instance ports.OutputInstance) { | |||
| 78 | + | s.applySecurityEvent(ports.SecurityOutputProof{Proof: ports.OutputProtection{Generation: state.Generation, Instance: instance, Kind: ports.ProtectionProtectedFrame}}) | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | func TestSessionLockLifecycleCaptureCompletionAndProofEpoch(t *testing.T) { | |||
| 82 | + | s, gate, changes, captured, dir := lifecycleLockServer(t) | |||
| 83 | + | c := protocolClient(t, s, dir) | |||
| 84 | + | s.display.Do(func() { | |||
| 85 | + | s.captureInflight[77] = struct{}{} | |||
| 86 | + | s.applySecurityEvent(ports.SecurityOutputAdded{Instance: 1, Output: "HEADLESS-1"}) | |||
| 87 | + | }) | |||
| 88 | + | _, locked := lifecycleAcquire(t, c) | |||
| 89 | + | state := lifecycleTransition(t, changes) | |||
| 90 | + | s.display.Do(func() { | |||
| 91 | + | s.applySecurityEvent(ports.SecurityBackendBarrier{Generation: state.Generation}) | |||
| 92 | + | lifecycleProof(s, ports.SecurityState{Generation: state.Generation + 1}, 1) | |||
| 93 | + | lifecycleProof(s, state, 99) // unregistered output must not satisfy inventory | |||
| 94 | + | lifecycleProof(s, state, 1) | |||
| 95 | + | if s.sessionLock.locked { | |||
| 96 | + | t.Error("capture credit bypassed") | |||
| 97 | + | } | |||
| 98 | + | }) | |||
| 99 | + | roundtrip(t, c) | |||
| 100 | + | if *locked != 0 { | |||
| 101 | + | t.Fatal("locked before capture completion") | |||
| 102 | + | } | |||
| 103 | + | // Unknown completion cannot retire an outstanding credit. | |||
| 104 | + | unknown := make(chan struct{}) | |||
| 105 | + | s.display.Do(func() { s.captureReplies[88] = func(ports.CaptureDone) { close(unknown) } }) | |||
| 106 | + | captured <- ports.CaptureDone{ID: 88} | |||
| 107 | + | select { | |||
| 108 | + | case <-unknown: | |||
| 109 | + | case <-time.After(2 * time.Second): | |||
| 110 | + | t.Fatal("unknown completion not consumed") | |||
| 111 | + | } | |||
| 112 | + | s.display.Do(func() { | |||
| 113 | + | if _, exists := s.captureInflight[77]; !exists || s.sessionLock.locked { | |||
| 114 | + | t.Error("unknown completion retired real credit") | |||
| 115 | + | } | |||
| 116 | + | }) | |||
| 117 | + | processed := make(chan struct{}) | |||
| 118 | + | s.display.Do(func() { s.captureReplies[77] = func(ports.CaptureDone) { close(processed) } }) | |||
| 119 | + | captured <- ports.CaptureDone{ID: 77} | |||
| 120 | + | select { | |||
| 121 | + | case <-processed: | |||
| 122 | + | case <-time.After(2 * time.Second): | |||
| 123 | + | t.Fatal("capture completion not consumed") | |||
| 124 | + | } | |||
| 125 | + | roundtrip(t, c) | |||
| 126 | + | if *locked != 1 || !gate.Snapshot().Protected { | |||
| 127 | + | t.Fatalf("completion locked=%d gate=%+v", *locked, gate.Snapshot()) | |||
| 128 | + | } | |||
| 129 | + | s.display.Do(func() { lifecycleProof(s, state, 1); s.maybeLocked() }) | |||
| 130 | + | roundtrip(t, c) | |||
| 131 | + | if *locked != 1 { | |||
| 132 | + | t.Fatal("locked event repeated") | |||
| 133 | + | } | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | func TestSessionLockLifecycleUnlockProtocolErrors(t *testing.T) { | |||
| 137 | + | for _, scenario := range []string{"unlock before locked", "destroy after locked"} { | |||
| 138 | + | t.Run(scenario, func(t *testing.T) { | |||
| 139 | + | s, gate, changes, _, dir := lockManagerServer(t) | |||
| 140 | + | c := protocolClient(t, s, dir) | |||
| 141 | + | lock, locked := lifecycleAcquire(t, c) | |||
| 142 | + | state := lifecycleTransition(t, changes) | |||
| 143 | + | code := serverlock.ExtSessionLockV1ErrorInvalidUnlock | |||
| 144 | + | id := lock.ID() | |||
| 145 | + | if scenario == "destroy after locked" { | |||
| 146 | + | s.display.Do(func() { s.applySecurityEvent(ports.SecurityBackendBarrier{Generation: state.Generation}) }) | |||
| 147 | + | roundtrip(t, c) | |||
| 148 | + | if *locked != 1 { | |||
| 149 | + | t.Fatal("zero-output backend barrier did not confirm") | |||
| 150 | + | } | |||
| 151 | + | code = serverlock.ExtSessionLockV1ErrorInvalidDestroy | |||
| 152 | + | if err := lock.Destroy(); err != nil { | |||
| 153 | + | t.Fatal(err) | |||
| 154 | + | } | |||
| 155 | + | } else { | |||
| 156 | + | if err := lock.UnlockAndDestroy(); err != nil { | |||
| 157 | + | t.Fatal(err) | |||
| 158 | + | } | |||
| 159 | + | } | |||
| 160 | + | expectProtocolError(t, c, id, uint32(code)) | |||
| 161 | + | if !gate.Snapshot().Protected { | |||
| 162 | + | t.Fatal("invalid destructor unlocked") | |||
| 163 | + | } | |||
| 164 | + | }) | |||
| 165 | + | } | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | func TestSessionLockLifecycleOrphanTakeoverAndValidUnlock(t *testing.T) { | |||
| 169 | + | s, gate, changes, _, dir := lockManagerServer(t) | |||
| 170 | + | c := protocolClient(t, s, dir) | |||
| 171 | + | _, locked := lifecycleAcquire(t, c) | |||
| 172 | + | firstState := lifecycleTransition(t, changes) | |||
| 173 | + | s.display.Do(func() { | |||
| 174 | + | s.applySecurityEvent(ports.SecurityOutputAdded{Instance: 1, Output: "HEADLESS-1"}) | |||
| 175 | + | s.applySecurityEvent(ports.SecurityBackendBarrier{Generation: firstState.Generation}) | |||
| 176 | + | lifecycleProof(s, firstState, 1) | |||
| 177 | + | }) | |||
| 178 | + | roundtrip(t, c) | |||
| 179 | + | if *locked != 1 { | |||
| 180 | + | t.Fatal("first lock not confirmed") | |||
| 181 | + | } | |||
| 182 | + | orphaned := make(chan struct{}) | |||
| 183 | + | s.display.Do(func() { r := s.sessionLock.res; old := r.OnDestroy; r.OnDestroy = func() { old(); close(orphaned) } }) | |||
| 184 | + | if err := c.Close(); err != nil { | |||
| 185 | + | t.Fatal(err) | |||
| 186 | + | } | |||
| 187 | + | select { | |||
| 188 | + | case <-orphaned: | |||
| 189 | + | case <-time.After(2 * time.Second): | |||
| 190 | + | t.Fatal("owner disconnect not processed") | |||
| 191 | + | } | |||
| 192 | + | s.display.Do(func() { | |||
| 193 | + | if s.sessionLock != nil || !gate.Snapshot().Protected { | |||
| 194 | + | t.Error("orphan released protection") | |||
| 195 | + | } | |||
| 196 | + | }) | |||
| 197 | + | next := protocolClient(t, s, dir) | |||
| 198 | + | second, secondLocked := lifecycleAcquire(t, next) | |||
| 199 | + | state := lifecycleTransition(t, changes) | |||
| 200 | + | if state.Generation <= firstState.Generation || !state.Protected { | |||
| 201 | + | t.Fatalf("takeover state %+v", state) | |||
| 202 | + | } | |||
| 203 | + | s.display.Do(func() { | |||
| 204 | + | s.applySecurityEvent(ports.SecurityBackendBarrier{Generation: state.Generation}) | |||
| 205 | + | lifecycleProof(s, firstState, 1) | |||
| 206 | + | }) | |||
| 207 | + | roundtrip(t, next) | |||
| 208 | + | if *secondLocked != 0 { | |||
| 209 | + | t.Fatal("stale proof legalized takeover") | |||
| 210 | + | } | |||
| 211 | + | s.display.Do(func() { lifecycleProof(s, state, 1) }) | |||
| 212 | + | roundtrip(t, next) | |||
| 213 | + | if *secondLocked != 1 { | |||
| 214 | + | t.Fatal("takeover not confirmed") | |||
| 215 | + | } | |||
| 216 | + | if err := second.UnlockAndDestroy(); err != nil { | |||
| 217 | + | t.Fatal(err) | |||
| 218 | + | } | |||
| 219 | + | roundtrip(t, next) // explicit server processing before client exit | |||
| 220 | + | released := lifecycleTransition(t, changes) | |||
| 221 | + | s.display.Do(func() { | |||
| 222 | + | if s.sessionLock != nil || gate.Snapshot().Protected || len(s.seat.heldKeys) != 0 || len(s.seat.grabKeys) != 0 { | |||
| 223 | + | t.Error("valid unlock failed to reset ownership/input") | |||
| 224 | + | } | |||
| 225 | + | }) | |||
| 226 | + | if released.Protected || released.Generation <= state.Generation { | |||
| 227 | + | t.Fatalf("unlock transition %+v", released) | |||
| 228 | + | } | |||
| 229 | + | } | |||
| 230 | + | ||||
| 231 | + | func lifecycleSurfaceOutput(t *testing.T, c *wlturbo.Display) (*core.Surface, *core.Output) { | |||
| 232 | + | t.Helper() | |||
| 233 | + | comp := core.NewCompositor(c.Context()) | |||
| 234 | + | g, ok := c.Registry().FindGlobal("wl_compositor") | |||
| 235 | + | if !ok { | |||
| 236 | + | t.Fatal("compositor missing") | |||
| 237 | + | } | |||
| 238 | + | if err := c.Registry().Bind(g.Name, g.Interface, 6, comp); err != nil { | |||
| 239 | + | t.Fatal(err) | |||
| 240 | + | } | |||
| 241 | + | surf, err := comp.CreateSurface() | |||
| 242 | + | if err != nil { | |||
| 243 | + | t.Fatal(err) | |||
| 244 | + | } | |||
| 245 | + | out := core.NewOutput(c.Context()) | |||
| 246 | + | g, ok = c.Registry().FindGlobal("wl_output") | |||
| 247 | + | if !ok { | |||
| 248 | + | t.Fatal("output missing") | |||
| 249 | + | } | |||
| 250 | + | if err := c.Registry().Bind(g.Name, g.Interface, 4, out); err != nil { | |||
| 251 | + | t.Fatal(err) | |||
| 252 | + | } | |||
| 253 | + | return surf, out | |||
| 254 | + | } | |||
| 255 | + | ||||
| 256 | + | func TestSessionLockLifecycleRefusedPipelinedSurfaceInert(t *testing.T) { | |||
| 257 | + | s, gate, changes, _, dir := lockManagerServer(t) | |||
| 258 | + | c := protocolClient(t, s, dir) | |||
| 259 | + | manager := bindLockManager(t, c) | |||
| 260 | + | accepted, err := manager.Lock() | |||
| 261 | + | if err != nil { | |||
| 262 | + | t.Fatal(err) | |||
| 263 | + | } | |||
| 264 | + | refused, err := manager.Lock() | |||
| 265 | + | if err != nil { | |||
| 266 | + | t.Fatal(err) | |||
| 267 | + | } | |||
| 268 | + | finished, configured := 0, 0 | |||
| 269 | + | refused.OnFinished(func() { finished++ }) | |||
| 270 | + | surf, out := lifecycleSurfaceOutput(t, c) | |||
| 271 | + | role, err := refused.GetLockSurface(surf, out) | |||
| 272 | + | if err != nil { | |||
| 273 | + | t.Fatal(err) | |||
| 274 | + | } | |||
| 275 | + | role.OnConfigure(func(uint32, uint32, uint32) { configured++ }) | |||
| 276 | + | requestProtocol(t, c, surf.ID(), wayland.SurfaceRequestAttach, shmBuffer(t, c), int32(0), int32(0)) | |||
| 277 | + | requestProtocol(t, c, surf.ID(), wayland.SurfaceRequestCommit) | |||
| 278 | + | roundtrip(t, c) | |||
| 279 | + | state := lifecycleTransition(t, changes) | |||
| 280 | + | s.display.Do(func() { | |||
| 281 | + | stateSurf := s.surfaceOf(findTestSurface(s, surf.ID())) | |||
| 282 | + | if stateSurf.kind != roleNone || stateSurf.lock != nil || len(s.lockSurfaces) != 0 || s.sessionLock.res.ID() != accepted.ID() || s.lockInputTarget(stateSurf.windowID()) { | |||
| 283 | + | t.Error("refused surface gained active role/input") | |||
| 284 | + | } | |||
| 285 | + | }) | |||
| 286 | + | if finished != 1 || configured != 0 || gate.Snapshot() != state { | |||
| 287 | + | t.Fatalf("refused finished=%d configure=%d", finished, configured) | |||
| 288 | + | } | |||
| 289 | + | if err := role.Destroy(); err != nil { | |||
| 290 | + | t.Fatal(err) | |||
| 291 | + | } | |||
| 292 | + | if err := refused.Destroy(); err != nil { | |||
| 293 | + | t.Fatal(err) | |||
| 294 | + | } | |||
| 295 | + | roundtrip(t, c) | |||
| 296 | + | } | |||
| 297 | + | ||||
| 298 | + | func TestSessionLockLifecycleIMEGrabCannotReceiveLockerKey(t *testing.T) { | |||
| 299 | + | s, _, changes, _, dir := lifecycleLockServer(t) | |||
| 300 | + | ime := newIMEApp(t, s, dir) | |||
| 301 | + | _, grab := ime.grab(t) | |||
| 302 | + | roundtrip(t, ime.c) | |||
| 303 | + | grab.take() | |||
| 304 | + | locker := protocolClient(t, s, dir) | |||
| 305 | + | lock, locked := lifecycleAcquire(t, locker) | |||
| 306 | + | state := lifecycleTransition(t, changes) | |||
| 307 | + | surf, out := lifecycleSurfaceOutput(t, locker) | |||
| 308 | + | role, err := lock.GetLockSurface(surf, out) | |||
| 309 | + | if err != nil { | |||
| 310 | + | t.Fatal(err) | |||
| 311 | + | } | |||
| 312 | + | var cfg lockConfigure | |||
| 313 | + | role.OnConfigure(func(serial, w, h uint32) { cfg = lockConfigure{serial: serial, width: int(w), height: int(h)} }) | |||
| 314 | + | roundtrip(t, locker) | |||
| 315 | + | if cfg.serial == 0 { | |||
| 316 | + | t.Fatal("configure missing") | |||
| 317 | + | } | |||
| 318 | + | if err := role.AckConfigure(cfg.serial); err != nil { | |||
| 319 | + | t.Fatal(err) | |||
| 320 | + | } | |||
| 321 | + | lockViewport(t, locker, surf.ID(), cfg.width, cfg.height) | |||
| 322 | + | requestProtocol(t, locker, surf.ID(), wayland.SurfaceRequestAttach, shmBuffer(t, locker), int32(0), int32(0)) | |||
| 323 | + | requestProtocol(t, locker, surf.ID(), wayland.SurfaceRequestCommit) | |||
| 324 | + | seat := bindProtocol(t, locker, "wl_seat") | |||
| 325 | + | keys := &eventsProxy{} | |||
| 326 | + | keys.SetID(locker.AllocateID()) | |||
| 327 | + | registerWireProxy(locker, keys) | |||
| 328 | + | requestProtocol(t, locker, seat, wayland.SeatRequestGetKeyboard, keys.ID()) | |||
| 329 | + | roundtrip(t, locker) | |||
| 330 | + | s.display.Do(func() { s.applySecurityEvent(ports.SecurityBackendBarrier{Generation: state.Generation}) }) | |||
| 331 | + | roundtrip(t, locker) | |||
| 332 | + | if *locked != 1 { | |||
| 333 | + | t.Fatal("lock not confirmed") | |||
| 334 | + | } | |||
| 335 | + | s.display.Do(func() { | |||
| 336 | + | l := s.surfaceOf(findTestSurface(s, surf.ID())).lock | |||
| 337 | + | s.apply(ports.SecurityCommand{State: state, Command: ports.FocusWindow{ID: l.id}}) | |||
| 338 | + | s.apply(ports.SecurityCommand{State: state, Command: ports.ForwardKey{ID: l.id, Key: ports.KeyEvent{Keycode: 30, Pressed: true, TimeMsec: 1}}}) | |||
| 339 | + | }) | |||
| 340 | + | settle(t, locker, ime.c) | |||
| 341 | + | for _, event := range grab.take() { | |||
| 342 | + | if strings.HasPrefix(event, "key ") { | |||
| 343 | + | t.Fatalf("locker key leaked to IME: %s", event) | |||
| 344 | + | } | |||
| 345 | + | } | |||
| 346 | + | if !slices.Contains(keys.opcodes, uint16(wayland.KeyboardEventKey)) { | |||
| 347 | + | t.Fatal("locker did not receive key") | |||
| 348 | + | } | |||
| 349 | + | } | |||
| 350 | + | ||||
| 351 | + | func TestSessionLockLifecycleLatePrelockLeaseRevokedAfterUnlock(t *testing.T) { | |||
| 352 | + | requests := make(chan ports.LeaseMessage, 16) | |||
| 353 | + | s, gate, changes, _, dir := lifecycleLockServer(t, requests) | |||
| 354 | + | c := protocolClient(t, s, dir) | |||
| 355 | + | const card = "/dev/dri/card-lifecycle" | |||
| 356 | + | s.display.Do(func() { | |||
| 357 | + | s.applyLeaseEvent(ports.LeaseConnectors{Card: card, Device: leaseFD(t), Connectors: []ports.LeaseConnector{{Card: card, Name: "DP-1", ConnectorID: 42}}}) | |||
| 358 | + | }) | |||
| 359 | + | roundtrip(t, c) | |||
| 360 | + | dev := bindProtocol(t, c, "wp_drm_lease_device_v1") | |||
| 361 | + | device := &leaseDeviceEvents{c: c} | |||
| 362 | + | device.SetID(dev) | |||
| 363 | + | registerWireProxy(c, device) | |||
| 364 | + | roundtrip(t, c) | |||
| 365 | + | for _, fd := range device.fds { | |||
| 366 | + | defer unix.Close(fd) | |||
| 367 | + | } | |||
| 368 | + | if len(device.connectors) != 1 { | |||
| 369 | + | t.Fatal("lease connector missing") | |||
| 370 | + | } | |||
| 371 | + | req := c.AllocateID() | |||
| 372 | + | registerProtocol(t, c, req) | |||
| 373 | + | requestProtocol(t, c, dev, drmlease.WpDrmLeaseDeviceV1RequestCreateLeaseRequest, req) | |||
| 374 | + | requestProtocol(t, c, req, drmlease.WpDrmLeaseRequestV1RequestRequestConnector, device.connectors[0]) | |||
| 375 | + | result := &leaseResultEvents{} | |||
| 376 | + | result.SetID(c.AllocateID()) | |||
| 377 | + | registerWireProxy(c, result) | |||
| 378 | + | requestProtocol(t, c, req, drmlease.WpDrmLeaseRequestV1RequestSubmit, result.ID()) | |||
| 379 | + | roundtrip(t, c) | |||
| 380 | + | var pending ports.LeaseRequest | |||
| 381 | + | select { | |||
| 382 | + | case message := <-requests: | |||
| 383 | + | var ok bool | |||
| 384 | + | pending, ok = message.(ports.LeaseRequest) | |||
| 385 | + | if !ok { | |||
| 386 | + | t.Fatalf("request %T", message) | |||
| 387 | + | } | |||
| 388 | + | case <-time.After(2 * time.Second): | |||
| 389 | + | t.Fatal("lease request not sent") | |||
| 390 | + | } | |||
| 391 | + | lock, locked := lifecycleAcquire(t, c) | |||
| 392 | + | state := lifecycleTransition(t, changes) | |||
| 393 | + | s.display.Do(func() { s.applySecurityEvent(ports.SecurityBackendBarrier{Generation: state.Generation}) }) | |||
| 394 | + | roundtrip(t, c) | |||
| 395 | + | if *locked != 1 || result.finished != 1 { | |||
| 396 | + | t.Fatalf("acquire locked=%d lease finished=%d", *locked, result.finished) | |||
| 397 | + | } | |||
| 398 | + | if err := lock.UnlockAndDestroy(); err != nil { | |||
| 399 | + | t.Fatal(err) | |||
| 400 | + | } | |||
| 401 | + | roundtrip(t, c) | |||
| 402 | + | lifecycleTransition(t, changes) | |||
| 403 | + | if gate.Snapshot().Protected { | |||
| 404 | + | t.Fatal("valid unlock failed") | |||
| 405 | + | } | |||
| 406 | + | fd := leaseFD(t) | |||
| 407 | + | s.display.Do(func() { s.applyLeaseEvent(ports.LeaseReply{ID: pending.ID, LeaseID: 123, FD: fd}) }) | |||
| 408 | + | roundtrip(t, c) | |||
| 409 | + | if _, err := fd.Stat(); err == nil { | |||
| 410 | + | t.Fatal("late lease FD not closed") | |||
| 411 | + | } | |||
| 412 | + | select { | |||
| 413 | + | case message := <-requests: | |||
| 414 | + | revoke, ok := message.(ports.LeaseRevoke) | |||
| 415 | + | if !ok || revoke.Card != card || revoke.LeaseID != 123 { | |||
| 416 | + | t.Fatalf("late reply request %+v", message) | |||
| 417 | + | } | |||
| 418 | + | case <-time.After(2 * time.Second): | |||
| 419 | + | t.Fatal("late lease was not revoked") | |||
| 420 | + | } | |||
| 421 | + | s.display.Do(func() { | |||
| 422 | + | if len(s.pendingLeases) != 0 || len(s.pendingLeaseCards) != 0 || len(s.activeLeases) != 0 { | |||
| 423 | + | t.Error("late reply retained authority") | |||
| 424 | + | } | |||
| 425 | + | }) | |||
| 426 | + | if len(result.fds) != 0 { | |||
| 427 | + | for _, fd := range result.fds { | |||
| 428 | + | unix.Close(fd) | |||
| 429 | + | } | |||
| 430 | + | t.Fatal("late lease FD delivered after unlock") | |||
| 431 | + | } | |||
| 432 | + | } | |||
| 433 | + | ||||
| 434 | + | func TestSessionLockLifecycleRoleUnmapSendsSeatLeaves(t *testing.T) { | |||
| 435 | + | for _, kind := range []string{"role destroy", "output removal"} { | |||
| 436 | + | t.Run(kind, func(t *testing.T) { | |||
| 437 | + | s, gate, changes, _, dir := lifecycleLockServer(t) | |||
| 438 | + | c := protocolClient(t, s, dir) | |||
| 439 | + | lock, locked := lifecycleAcquire(t, c) | |||
| 440 | + | state := lifecycleTransition(t, changes) | |||
| 441 | + | surf, out := lifecycleSurfaceOutput(t, c) | |||
| 442 | + | role, err := lock.GetLockSurface(surf, out) | |||
| 443 | + | if err != nil { | |||
| 444 | + | t.Fatal(err) | |||
| 445 | + | } | |||
| 446 | + | var cfg lockConfigure | |||
| 447 | + | role.OnConfigure(func(serial, w, h uint32) { cfg = lockConfigure{serial: serial, width: int(w), height: int(h)} }) | |||
| 448 | + | roundtrip(t, c) | |||
| 449 | + | if err := role.AckConfigure(cfg.serial); err != nil { | |||
| 450 | + | t.Fatal(err) | |||
| 451 | + | } | |||
| 452 | + | lockViewport(t, c, surf.ID(), cfg.width, cfg.height) | |||
| 453 | + | requestProtocol(t, c, surf.ID(), wayland.SurfaceRequestAttach, shmBuffer(t, c), int32(0), int32(0)) | |||
| 454 | + | requestProtocol(t, c, surf.ID(), wayland.SurfaceRequestCommit) | |||
| 455 | + | seat := bindProtocol(t, c, "wl_seat") | |||
| 456 | + | keys := &eventsProxy{} | |||
| 457 | + | keys.SetID(c.AllocateID()) | |||
| 458 | + | registerWireProxy(c, keys) | |||
| 459 | + | requestProtocol(t, c, seat, wayland.SeatRequestGetKeyboard, keys.ID()) | |||
| 460 | + | pointer := newIMELog(c, c.AllocateID(), "enter:uoff", "leave:uo", "motion:uff", "button:uuuu", "axis:uuf") | |||
| 461 | + | requestProtocol(t, c, seat, wayland.SeatRequestGetPointer, pointer.ID()) | |||
| 462 | + | roundtrip(t, c) | |||
| 463 | + | s.display.Do(func() { s.applySecurityEvent(ports.SecurityBackendBarrier{Generation: state.Generation}) }) | |||
| 464 | + | roundtrip(t, c) | |||
| 465 | + | if *locked != 1 { | |||
| 466 | + | t.Fatal("lock not confirmed") | |||
| 467 | + | } | |||
| 468 | + | s.display.Do(func() { | |||
| 469 | + | l := s.surfaceOf(findTestSurface(s, surf.ID())).lock | |||
| 470 | + | s.changeFocus(l.id) | |||
| 471 | + | s.changePointerFocus(l.id, 1, 2) | |||
| 472 | + | }) | |||
| 473 | + | roundtrip(t, c) | |||
| 474 | + | if !slices.Contains(keys.opcodes, uint16(wayland.KeyboardEventEnter)) { | |||
| 475 | + | t.Fatal("keyboard enter missing") | |||
| 476 | + | } | |||
| 477 | + | keys.opcodes = nil | |||
| 478 | + | pointer.take() | |||
| 479 | + | if kind == "role destroy" { | |||
| 480 | + | if err := role.Destroy(); err != nil { | |||
| 481 | + | t.Fatal(err) | |||
| 482 | + | } | |||
| 483 | + | } else { | |||
| 484 | + | s.display.Do(func() { s.setOutputs(ports.SetOutputs{}) }) | |||
| 485 | + | } | |||
| 486 | + | roundtrip(t, c) | |||
| 487 | + | if !slices.Contains(keys.opcodes, uint16(wayland.KeyboardEventLeave)) { | |||
| 488 | + | t.Fatal("live lock keyboard did not receive leave") | |||
| 489 | + | } | |||
| 490 | + | leave := false | |||
| 491 | + | for _, event := range pointer.take() { | |||
| 492 | + | if strings.HasPrefix(event, "leave ") { | |||
| 493 | + | leave = true | |||
| 494 | + | } | |||
| 495 | + | } | |||
| 496 | + | if !leave { | |||
| 497 | + | t.Fatal("live lock pointer did not receive leave") | |||
| 498 | + | } | |||
| 499 | + | s.display.Do(func() { | |||
| 500 | + | if s.seat.focused != 0 || s.seat.pointerFocus != 0 || !gate.Snapshot().Protected || !findTestSurface(s, surf.ID()).Alive() { | |||
| 501 | + | t.Error("unmap lost protection/live wl_surface or retained seat focus") | |||
| 502 | + | } | |||
| 503 | + | }) | |||
| 504 | + | }) | |||
| 505 | + | } | |||
| 506 | + | } | |||
| 507 | + | ||||
| 508 | + | func TestSessionLockLifecycleBackendOutboxPreservesBlockedTransitions(t *testing.T) { | |||
| 509 | + | s, gate, changes, _, dir := lockManagerServer(t) | |||
| 510 | + | // Fill the backend channel before any transition; display remains responsive. | |||
| 511 | + | sentinel := ports.SecurityState{Generation: 999} | |||
| 512 | + | changes <- sentinel | |||
| 513 | + | c := protocolClient(t, s, dir) | |||
| 514 | + | lock, locked := lifecycleAcquire(t, c) | |||
| 515 | + | state := gate.Snapshot() | |||
| 516 | + | s.display.Do(func() { s.applySecurityEvent(ports.SecurityBackendBarrier{Generation: state.Generation}) }) | |||
| 517 | + | roundtrip(t, c) | |||
| 518 | + | if *locked != 1 { | |||
| 519 | + | t.Fatal("test protection proof not consumed") | |||
| 520 | + | } | |||
| 521 | + | if err := lock.UnlockAndDestroy(); err != nil { | |||
| 522 | + | t.Fatal(err) | |||
| 523 | + | } | |||
| 524 | + | roundtrip(t, c) | |||
| 525 | + | released := gate.Snapshot() | |||
| 526 | + | s.display.Do(func() { | |||
| 527 | + | if len(s.securityPending) < 2 || s.securityPending[0] != state || s.securityPending[1] != released { | |||
| 528 | + | t.Errorf("blocked outbox lost ordered transitions: %+v", s.securityPending) | |||
| 529 | + | } | |||
| 530 | + | }) | |||
| 531 | + | if got := lifecycleTransition(t, changes); got != sentinel { | |||
| 532 | + | t.Fatalf("sentinel %+v", got) | |||
| 533 | + | } | |||
| 534 | + | if got := lifecycleTransition(t, changes); got != state { | |||
| 535 | + | t.Fatalf("acquire %+v", got) | |||
| 536 | + | } | |||
| 537 | + | if got := lifecycleTransition(t, changes); got != released { | |||
| 538 | + | t.Fatalf("release %+v", got) | |||
| 539 | + | } | |||
| 540 | + | if released.Protected || released.Generation <= state.Generation { | |||
| 541 | + | t.Fatal("unlock generation did not advance") | |||
| 542 | + | } | |||
| 543 | + | } | |||
A internal/adapters/wayland/session_lock_refused.go +12 -0
| @@ -0,0 +1,12 @@ | |||||
| 1 | + | package wayland | |||
| 2 | + | ||||
| 3 | + | import "github.com/bnema/purego-libwayland/protocol/extsessionlock" | |||
| 4 | + | ||||
| 5 | + | // A constructor pipelined before finished must still create its new object, | |||
| 6 | + | // but a refused lock never assigns a wl_surface role or changes active focus. | |||
| 7 | + | type refusedLockSurface struct{} | |||
| 8 | + | ||||
| 9 | + | func (refusedLockSurface) Destroy(*extsessionlock.ExtSessionLockSurfaceV1) {} | |||
| 10 | + | func (refusedLockSurface) AckConfigure(r *extsessionlock.ExtSessionLockSurfaceV1, _ uint32) { | |||
| 11 | + | r.PostError(uint32(extsessionlock.ExtSessionLockSurfaceV1ErrorInvalidSerial), "refused lock has no configure") | |||
| 12 | + | } | |||
A internal/adapters/wayland/session_lock_surface.go +267 -0
| @@ -0,0 +1,267 @@ | |||||
| 1 | + | package wayland | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "cmp" | |||
| 5 | + | "slices" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 8 | + | "github.com/bnema/purego-libwayland/protocol/extsessionlock" | |||
| 9 | + | "github.com/bnema/purego-libwayland/protocol/wayland" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // lockConfigure is an issued configure, or the last acknowledged configure. | |||
| 13 | + | // It is copied into each commit: a later ACK cannot change a queued response. | |||
| 14 | + | type lockConfigure struct { | |||
| 15 | + | serial uint32 | |||
| 16 | + | width, height int | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | type lockSurface struct { | |||
| 20 | + | resource *extsessionlock.ExtSessionLockSurfaceV1 | |||
| 21 | + | server *Server | |||
| 22 | + | surface *surface | |||
| 23 | + | output *output | |||
| 24 | + | id ports.WindowID | |||
| 25 | + | serials []lockConfigure | |||
| 26 | + | ack lockConfigure | |||
| 27 | + | mapped, closed bool | |||
| 28 | + | // onUnmap is a display-owner lifecycle notification, like Resource.OnDestroy. | |||
| 29 | + | // The lock owner must remain protected; this never requests an unlock. | |||
| 30 | + | onUnmap func(*lockSurface) | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | // newLockSurface only assigns the surface role. The caller validates ownership, | |||
| 34 | + | // admission, output lifetime and duplicate outputs before calling it. No global | |||
| 35 | + | // is registered and no locked/finished event is sent here. | |||
| 36 | + | func (s *Server) newLockSurface(r *extsessionlock.ExtSessionLockV1, id uint32, surf *surface, out *output, onUnmap func(*lockSurface)) (*lockSurface, error) { | |||
| 37 | + | if surf.kind != roleNone { | |||
| 38 | + | r.PostError(uint32(extsessionlock.ExtSessionLockV1ErrorRole), "surface already has a role") | |||
| 39 | + | return nil, nil | |||
| 40 | + | } | |||
| 41 | + | if surf.current != nil || surf.next.buffer != nil || surf.bufferCommitted { | |||
| 42 | + | r.PostError(uint32(extsessionlock.ExtSessionLockV1ErrorAlreadyConstructed), "surface has an attached or previously committed buffer") | |||
| 43 | + | return nil, nil | |||
| 44 | + | } | |||
| 45 | + | l := &lockSurface{server: s, surface: surf, output: out, id: s.nextWindow, onUnmap: onUnmap} | |||
| 46 | + | res, err := extsessionlock.NewExtSessionLockSurfaceV1(r.Client(), r.Version(), id, l) | |||
| 47 | + | if err != nil { | |||
| 48 | + | return nil, err | |||
| 49 | + | } | |||
| 50 | + | l.resource = res | |||
| 51 | + | s.nextWindow++ | |||
| 52 | + | surf.kind, surf.lock, surf.role = roleSessionLock, l, l.commit | |||
| 53 | + | if s.lockSurfaces == nil { | |||
| 54 | + | s.lockSurfaces = make(map[ports.WindowID]*lockSurface) | |||
| 55 | + | } | |||
| 56 | + | s.lockSurfaces[l.id] = l | |||
| 57 | + | s.lockSurfaceChanged() | |||
| 58 | + | res.OnDestroy = l.unmap | |||
| 59 | + | l.sendConfigure() | |||
| 60 | + | return l, nil | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | // sendConfigure uses exact logical dimensions, not the physical output mode. | |||
| 64 | + | // Output inventory changes are routed here by the lock owner. | |||
| 65 | + | func (l *lockSurface) sendConfigure() { | |||
| 66 | + | if l.closed || l.output == nil || !l.resource.Alive() { | |||
| 67 | + | return | |||
| 68 | + | } | |||
| 69 | + | l.server.serial++ | |||
| 70 | + | // Serial zero is reserved internally as "no ACK". | |||
| 71 | + | if l.server.serial == 0 { | |||
| 72 | + | l.server.serial++ | |||
| 73 | + | } | |||
| 74 | + | c := lockConfigure{serial: l.server.serial, width: l.output.place.Width, height: l.output.place.Height} | |||
| 75 | + | l.serials = append(l.serials, c) | |||
| 76 | + | l.resource.SendConfigure(c.serial, uint32(c.width), uint32(c.height)) | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | func (l *lockSurface) AckConfigure(r *extsessionlock.ExtSessionLockSurfaceV1, serial uint32) { | |||
| 80 | + | for i, c := range l.serials { | |||
| 81 | + | if c.serial == serial { | |||
| 82 | + | l.ack = c | |||
| 83 | + | l.serials = l.serials[i+1:] | |||
| 84 | + | return | |||
| 85 | + | } | |||
| 86 | + | } | |||
| 87 | + | r.PostError(uint32(extsessionlock.ExtSessionLockSurfaceV1ErrorInvalidSerial), "unknown or consumed configure serial") | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | func (*lockSurface) Destroy(*extsessionlock.ExtSessionLockSurfaceV1) {} | |||
| 91 | + | ||||
| 92 | + | // lockSurfaceChanged publishes validated placements for the admitted owner. | |||
| 93 | + | // Role commits never release protection or certify physical presentation. | |||
| 94 | + | func (s *Server) lockSurfaceChanged() { | |||
| 95 | + | l := s.sessionLock | |||
| 96 | + | if l == nil { | |||
| 97 | + | return | |||
| 98 | + | } | |||
| 99 | + | placements := make([]ports.LockSurfacePlacement, 0, len(l.surfaces)) | |||
| 100 | + | // Before locked, only compositor black is displayed. Protocol configure | |||
| 101 | + | // and buffer commits may still prepare the UI while proofs are pending. | |||
| 102 | + | if l.locked { | |||
| 103 | + | for _, surface := range l.surfaces { | |||
| 104 | + | if surface.mapped && !surface.closed && surface.output != nil && surface.resource.Alive() { | |||
| 105 | + | p := surface.output.place | |||
| 106 | + | c := surface.surface.content | |||
| 107 | + | if c.LogicalW == p.Width && c.LogicalH == p.Height { | |||
| 108 | + | placements = append(placements, ports.LockSurfacePlacement{ID: surface.id, Output: surface.output.name(), Width: p.Width, Height: p.Height}) | |||
| 109 | + | } | |||
| 110 | + | } | |||
| 111 | + | } | |||
| 112 | + | } | |||
| 113 | + | slices.SortFunc(placements, func(a, b ports.LockSurfacePlacement) int { return cmp.Compare(a.ID, b.ID) }) | |||
| 114 | + | s.emit(ports.SessionLockChanged{State: l.state, Surfaces: placements}) | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | // lockBuffer is the buffer after all earlier captured commits, not merely the | |||
| 118 | + | // currently applied buffer. A queued attach is sticky for subsequent commits. | |||
| 119 | + | func (s *surface) lockBuffer() *wayland.Buffer { | |||
| 120 | + | if s.next.attached { | |||
| 121 | + | return s.next.buffer | |||
| 122 | + | } | |||
| 123 | + | for i := len(s.queue) - 1; i >= 0; i-- { | |||
| 124 | + | if s.queue[i].attached { | |||
| 125 | + | return s.queue[i].buffer | |||
| 126 | + | } | |||
| 127 | + | } | |||
| 128 | + | return s.current | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | // checkCommit runs before capture or fence acquisition. Waiting behind FIFO or | |||
| 132 | + | // an acquire fence must not let a later ACK legalize this commit. | |||
| 133 | + | func (l *lockSurface) checkCommit() bool { | |||
| 134 | + | s := l.surface | |||
| 135 | + | g := &surface{server: s.server, wl: s.wl, viewport: s.viewport, bufferScale: max(s.next.scale, 1), transform: s.next.transform} | |||
| 136 | + | if v := s.viewport; v != nil { | |||
| 137 | + | g.committedViewport = viewportState{destW: v.pendingW, destH: v.pendingH, dest: v.pendingSet, src: v.pendingSrc, crop: v.pendingCrop} | |||
| 138 | + | } | |||
| 139 | + | return l.checkGeometry(l.ack, s.lockBuffer(), g) | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | func (l *lockSurface) checkGeometry(ack lockConfigure, b *wayland.Buffer, g *surface) bool { | |||
| 143 | + | if ack.serial == 0 { | |||
| 144 | + | l.resource.PostError(uint32(extsessionlock.ExtSessionLockSurfaceV1ErrorCommitBeforeFirstAck), "commit before first configure ACK") | |||
| 145 | + | return false | |||
| 146 | + | } | |||
| 147 | + | if b == nil { | |||
| 148 | + | l.resource.PostError(uint32(extsessionlock.ExtSessionLockSurfaceV1ErrorNullBuffer), "null lock buffer") | |||
| 149 | + | return false | |||
| 150 | + | } | |||
| 151 | + | state := l.server.buffers[b.Resource] | |||
| 152 | + | var bw, bh int | |||
| 153 | + | if state != nil { | |||
| 154 | + | bw, bh = state.size() | |||
| 155 | + | } else if sameBuffer(b, l.surface.current) && l.surface.has { | |||
| 156 | + | // Destroying an applied wl_buffer does not detach its content. The | |||
| 157 | + | // retained physical size remains authoritative for no-attach commits. | |||
| 158 | + | bw, bh = l.surface.content.Width, l.surface.content.Height | |||
| 159 | + | } else { | |||
| 160 | + | l.resource.PostError(uint32(extsessionlock.ExtSessionLockSurfaceV1ErrorDimensionsMismatch), "unknown lock buffer dimensions") | |||
| 161 | + | return false | |||
| 162 | + | } | |||
| 163 | + | if !g.validateViewport(bw, bh) { | |||
| 164 | + | return false | |||
| 165 | + | } | |||
| 166 | + | w, h := g.logicalSize(bw, bh) | |||
| 167 | + | if w != ack.width || h != ack.height { | |||
| 168 | + | l.resource.PostError(uint32(extsessionlock.ExtSessionLockSurfaceV1ErrorDimensionsMismatch), "lock buffer does not match acknowledged logical dimensions") | |||
| 169 | + | return false | |||
| 170 | + | } | |||
| 171 | + | return true | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | // checkCaptured validates only captured ACK and geometry, never live requests. | |||
| 175 | + | func (l *lockSurface) checkCaptured(u *update) bool { | |||
| 176 | + | s := l.surface | |||
| 177 | + | b := s.current | |||
| 178 | + | if u.attached { | |||
| 179 | + | b = u.buffer | |||
| 180 | + | } | |||
| 181 | + | g := &surface{server: s.server, wl: s.wl, viewport: u.vp, bufferScale: max(u.scale, 1), transform: u.transform} | |||
| 182 | + | if u.vp != nil { | |||
| 183 | + | g.committedViewport = viewportState{destW: u.vpW, destH: u.vpH, dest: u.vpSet, src: u.vpSrc, crop: u.vpCrop} | |||
| 184 | + | } | |||
| 185 | + | return l.checkGeometry(u.lockAck, b, g) | |||
| 186 | + | } | |||
| 187 | + | ||||
| 188 | + | func (l *lockSurface) commit(buffer bool) { | |||
| 189 | + | if l.surface.destroyed || !buffer { | |||
| 190 | + | l.unmap() | |||
| 191 | + | return | |||
| 192 | + | } | |||
| 193 | + | if !l.closed { | |||
| 194 | + | l.mapped = true | |||
| 195 | + | l.server.lockSurfaceChanged() | |||
| 196 | + | } | |||
| 197 | + | } | |||
| 198 | + | ||||
| 199 | + | // dropLockDeps retires the synchronized child prefixes captured by this root. | |||
| 200 | + | // Incoming graph links keep retired nodes readable until the root releases its | |||
| 201 | + | // refs. Later, uncaptured child commits remain owned by their own queues. | |||
| 202 | + | func (s *surface) dropLockDeps() { | |||
| 203 | + | prefixes := make(map[*surface]int) | |||
| 204 | + | var visit func(*update) | |||
| 205 | + | seen := make(map[*update]bool) | |||
| 206 | + | visit = func(u *update) { | |||
| 207 | + | if u == nil || u.retired || seen[u] { | |||
| 208 | + | return | |||
| 209 | + | } | |||
| 210 | + | seen[u] = true | |||
| 211 | + | visit(u.prev) | |||
| 212 | + | for _, dep := range u.deps { | |||
| 213 | + | visit(dep) | |||
| 214 | + | if dep.retired || dep.owner == s { | |||
| 215 | + | continue | |||
| 216 | + | } | |||
| 217 | + | for i, queued := range dep.owner.queue { | |||
| 218 | + | if queued == dep { | |||
| 219 | + | prefixes[dep.owner] = max(prefixes[dep.owner], i+1) | |||
| 220 | + | break | |||
| 221 | + | } | |||
| 222 | + | } | |||
| 223 | + | } | |||
| 224 | + | } | |||
| 225 | + | for _, u := range s.queue { | |||
| 226 | + | visit(u) | |||
| 227 | + | } | |||
| 228 | + | for child, n := range prefixes { | |||
| 229 | + | child.dropQueuePrefix(n) | |||
| 230 | + | } | |||
| 231 | + | } | |||
| 232 | + | ||||
| 233 | + | // unmap also handles output loss/owner teardown. Role identity is permanent. | |||
| 234 | + | // Drop queued graphs through their normal retirement path and release current | |||
| 235 | + | // content while the mapped identity is still available to the hold machinery. | |||
| 236 | + | func (l *lockSurface) unmap() { | |||
| 237 | + | if l.closed { | |||
| 238 | + | return | |||
| 239 | + | } | |||
| 240 | + | s := l.surface | |||
| 241 | + | // Send leaves while outgoing role identity remains resolvable. Destroying | |||
| 242 | + | // the lock role may leave its wl_surface and seat resources alive. | |||
| 243 | + | if l.server.seat.focused == l.id { | |||
| 244 | + | l.server.changeFocus(0) | |||
| 245 | + | } | |||
| 246 | + | if l.server.seat.pointerFocus == l.id { | |||
| 247 | + | l.server.changePointerFocus(0, 0, 0) | |||
| 248 | + | } | |||
| 249 | + | s.dropQueue() | |||
| 250 | + | s.dropSync(s.next.sync) | |||
| 251 | + | s.next.sync = nil | |||
| 252 | + | if s.current != nil { | |||
| 253 | + | l.server.releaseBuffer(s, s.current, s.hold) | |||
| 254 | + | } | |||
| 255 | + | s.current, s.next.buffer, s.hold = nil, nil, syncHold{} | |||
| 256 | + | s.next.attached = false | |||
| 257 | + | s.content, s.has = ports.SurfaceContent{}, false | |||
| 258 | + | l.mapped, l.closed = false, true | |||
| 259 | + | l.serials = nil | |||
| 260 | + | delete(l.server.lockSurfaces, l.id) | |||
| 261 | + | s.lock, s.role = nil, nil | |||
| 262 | + | l.server.emitContent(ports.SurfaceContent{ID: l.id}, damage{full: true}) | |||
| 263 | + | l.server.lockSurfaceChanged() | |||
| 264 | + | if l.onUnmap != nil { | |||
| 265 | + | l.onUnmap(l) | |||
| 266 | + | } | |||
| 267 | + | } | |||
A internal/adapters/wayland/session_lock_surface_test.go +599 -0
| @@ -0,0 +1,599 @@ | |||||
| 1 | + | package wayland | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | "time" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/purego-libwayland/protocol/extsessionlock" | |||
| 8 | + | "github.com/bnema/purego-libwayland/protocol/linuxdrmsyncobj" | |||
| 9 | + | "github.com/bnema/purego-libwayland/protocol/viewporter" | |||
| 10 | + | "github.com/bnema/purego-libwayland/protocol/wayland" | |||
| 11 | + | "github.com/bnema/purego-libwayland/protocol/wlrlayershell" | |||
| 12 | + | "github.com/bnema/purego-libwayland/protocol/xdgshell" | |||
| 13 | + | "github.com/bnema/wlturbo" | |||
| 14 | + | "golang.org/x/sys/unix" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | // This is a real wire proxy, not a double for a project interface. | |||
| 18 | + | type lockConfigureProxy struct { | |||
| 19 | + | wlturbo.BaseProxy | |||
| 20 | + | configures chan lockConfigure | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | func (p *lockConfigureProxy) Dispatch(e *wlturbo.Event) { | |||
| 24 | + | if e.Opcode == uint16(extsessionlock.ExtSessionLockSurfaceV1EventConfigure) { | |||
| 25 | + | p.configures <- lockConfigure{serial: e.Uint32(), width: int(e.Uint32()), height: int(e.Uint32())} | |||
| 26 | + | } | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | func lockTestSurface(t *testing.T, s *Server, c *wlturbo.Display) (uint32, uint32, uint32, *surface, *lockSurface, <-chan lockConfigure) { | |||
| 30 | + | t.Helper() | |||
| 31 | + | comp := bindVersion(t, c, "wl_compositor", 6) | |||
| 32 | + | surfID := c.AllocateID() | |||
| 33 | + | requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surfID) | |||
| 34 | + | registerProtocol(t, c, surfID) | |||
| 35 | + | if err := c.Roundtrip(); err != nil { | |||
| 36 | + | t.Fatal(err) | |||
| 37 | + | } | |||
| 38 | + | ownerID, lockID := c.AllocateID(), c.AllocateID() | |||
| 39 | + | registerProtocol(t, c, ownerID) | |||
| 40 | + | setWireSchema(c, ownerID, "ext_session_lock_v1", 1) | |||
| 41 | + | setWireSchema(c, lockID, "ext_session_lock_surface_v1", 1) | |||
| 42 | + | configures := make(chan lockConfigure, 8) | |||
| 43 | + | p := &lockConfigureProxy{configures: configures} | |||
| 44 | + | p.SetID(lockID) | |||
| 45 | + | registerWireProxy(c, p) | |||
| 46 | + | var surf *surface | |||
| 47 | + | var l *lockSurface | |||
| 48 | + | s.display.Do(func() { | |||
| 49 | + | for _, v := range s.surfaces { | |||
| 50 | + | if v.wl.ID() == surfID { | |||
| 51 | + | surf = v | |||
| 52 | + | } | |||
| 53 | + | } | |||
| 54 | + | if surf == nil { | |||
| 55 | + | t.Error("surface missing") | |||
| 56 | + | return | |||
| 57 | + | } | |||
| 58 | + | // The role factory takes an already created owner resource. No manager | |||
| 59 | + | // or test global is needed and no admission behavior is simulated. | |||
| 60 | + | r, err := surf.wl.Client().CreateResource(extsessionlock.ExtSessionLockV1Interface, 1, ownerID, nil) | |||
| 61 | + | if err != nil { | |||
| 62 | + | t.Error(err) | |||
| 63 | + | return | |||
| 64 | + | } | |||
| 65 | + | l, err = s.newLockSurface(extsessionlock.WrapExtSessionLockV1(r), lockID, surf, s.outputs[0], nil) | |||
| 66 | + | if err != nil { | |||
| 67 | + | t.Error(err) | |||
| 68 | + | } | |||
| 69 | + | }) | |||
| 70 | + | if l == nil { | |||
| 71 | + | t.Fatal("lock role missing") | |||
| 72 | + | } | |||
| 73 | + | if err := c.Roundtrip(); err != nil { | |||
| 74 | + | t.Fatal(err) | |||
| 75 | + | } | |||
| 76 | + | return surfID, ownerID, lockID, surf, l, configures | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | func lockViewport(t *testing.T, c *wlturbo.Display, surfID uint32, w, h int) uint32 { | |||
| 80 | + | t.Helper() | |||
| 81 | + | manager := bindProtocol(t, c, "wp_viewporter") | |||
| 82 | + | vp := c.AllocateID() | |||
| 83 | + | registerProtocol(t, c, vp) | |||
| 84 | + | requestProtocol(t, c, manager, viewporter.WpViewporterRequestGetViewport, vp, surfID) | |||
| 85 | + | requestProtocol(t, c, vp, viewporter.WpViewportRequestSetDestination, int32(w), int32(h)) | |||
| 86 | + | return vp | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | func TestLockSurfaceInitialConfigureAndCommitErrors(t *testing.T) { | |||
| 90 | + | for _, scenario := range []string{"before ack", "queued before ack", "null", "dimensions", "unknown ack", "reused ack", "old ack"} { | |||
| 91 | + | t.Run(scenario, func(t *testing.T) { | |||
| 92 | + | s, _, _, dir := lifecycleServer(t) | |||
| 93 | + | c := protocolClient(t, s, dir) | |||
| 94 | + | surfID, _, lockID, surf, l, configures := lockTestSurface(t, s, c) | |||
| 95 | + | cfg := <-configures | |||
| 96 | + | if cfg.width != testOutputs[0].Width || cfg.height != testOutputs[0].Height || cfg.serial == 0 { | |||
| 97 | + | t.Fatalf("configure = %+v", cfg) | |||
| 98 | + | } | |||
| 99 | + | code := extsessionlock.ExtSessionLockSurfaceV1ErrorInvalidSerial | |||
| 100 | + | switch scenario { | |||
| 101 | + | case "before ack", "queued before ack": | |||
| 102 | + | if scenario == "queued before ack" { | |||
| 103 | + | s.display.Do(func() { surf.barrier = true; surf.next.wait = true }) | |||
| 104 | + | } | |||
| 105 | + | requestProtocol(t, c, surfID, wayland.SurfaceRequestCommit) | |||
| 106 | + | // This later ACK must not legalize an earlier FIFO commit. | |||
| 107 | + | requestProtocol(t, c, lockID, extsessionlock.ExtSessionLockSurfaceV1RequestAckConfigure, cfg.serial) | |||
| 108 | + | code = extsessionlock.ExtSessionLockSurfaceV1ErrorCommitBeforeFirstAck | |||
| 109 | + | case "null": | |||
| 110 | + | requestProtocol(t, c, lockID, extsessionlock.ExtSessionLockSurfaceV1RequestAckConfigure, cfg.serial) | |||
| 111 | + | requestProtocol(t, c, surfID, wayland.SurfaceRequestCommit) | |||
| 112 | + | code = extsessionlock.ExtSessionLockSurfaceV1ErrorNullBuffer | |||
| 113 | + | case "dimensions": | |||
| 114 | + | requestProtocol(t, c, lockID, extsessionlock.ExtSessionLockSurfaceV1RequestAckConfigure, cfg.serial) | |||
| 115 | + | requestProtocol(t, c, surfID, wayland.SurfaceRequestAttach, shmBuffer(t, c), int32(0), int32(0)) | |||
| 116 | + | requestProtocol(t, c, surfID, wayland.SurfaceRequestCommit) | |||
| 117 | + | code = extsessionlock.ExtSessionLockSurfaceV1ErrorDimensionsMismatch | |||
| 118 | + | case "unknown ack": | |||
| 119 | + | requestProtocol(t, c, lockID, extsessionlock.ExtSessionLockSurfaceV1RequestAckConfigure, uint32(0)) | |||
| 120 | + | case "reused ack": | |||
| 121 | + | requestProtocol(t, c, lockID, extsessionlock.ExtSessionLockSurfaceV1RequestAckConfigure, cfg.serial) | |||
| 122 | + | requestProtocol(t, c, lockID, extsessionlock.ExtSessionLockSurfaceV1RequestAckConfigure, cfg.serial) | |||
| 123 | + | case "old ack": | |||
| 124 | + | s.display.Do(l.sendConfigure) | |||
| 125 | + | if err := c.Roundtrip(); err != nil { | |||
| 126 | + | t.Fatal(err) | |||
| 127 | + | } | |||
| 128 | + | newer := <-configures | |||
| 129 | + | requestProtocol(t, c, lockID, extsessionlock.ExtSessionLockSurfaceV1RequestAckConfigure, newer.serial) | |||
| 130 | + | requestProtocol(t, c, lockID, extsessionlock.ExtSessionLockSurfaceV1RequestAckConfigure, cfg.serial) | |||
| 131 | + | } | |||
| 132 | + | expectProtocolError(t, c, lockID, uint32(code)) | |||
| 133 | + | }) | |||
| 134 | + | } | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | func TestLockSurfaceCapturedAckGeometryAndDestroy(t *testing.T) { | |||
| 138 | + | s, _, _, contents, dir := contentServer(t) | |||
| 139 | + | c := protocolClient(t, s, dir) | |||
| 140 | + | surfID, _, lockID, surf, l, configures := lockTestSurface(t, s, c) | |||
| 141 | + | cfg := <-configures | |||
| 142 | + | vpID := lockViewport(t, c, surfID, cfg.width, cfg.height) | |||
| 143 | + | requestProtocol(t, c, lockID, extsessionlock.ExtSessionLockSurfaceV1RequestAckConfigure, cfg.serial) | |||
| 144 | + | requestProtocol(t, c, surfID, wayland.SurfaceRequestAttach, shmBuffer(t, c), int32(0), int32(0)) | |||
| 145 | + | if err := c.Roundtrip(); err != nil { | |||
| 146 | + | t.Fatal(err) | |||
| 147 | + | } | |||
| 148 | + | // Capture a real buffer and geometry behind a future commit-time gate. | |||
| 149 | + | s.display.Do(func() { | |||
| 150 | + | surf.next.at = time.Now().Add(time.Hour) | |||
| 151 | + | surf.Commit(surf.wl) | |||
| 152 | + | if len(surf.queue) != 1 || surf.queue[0].lockAck != cfg { | |||
| 153 | + | t.Errorf("captured ACK missing") | |||
| 154 | + | } | |||
| 155 | + | l.output.place.Width++ | |||
| 156 | + | l.sendConfigure() | |||
| 157 | + | }) | |||
| 158 | + | if err := c.Roundtrip(); err != nil { | |||
| 159 | + | t.Fatal(err) | |||
| 160 | + | } | |||
| 161 | + | newer := <-configures | |||
| 162 | + | requestProtocol(t, c, lockID, extsessionlock.ExtSessionLockSurfaceV1RequestAckConfigure, newer.serial) | |||
| 163 | + | requestProtocol(t, c, vpID, viewporter.WpViewportRequestSetDestination, int32(newer.width), int32(newer.height)) | |||
| 164 | + | if err := c.Roundtrip(); err != nil { | |||
| 165 | + | t.Fatal(err) | |||
| 166 | + | } | |||
| 167 | + | s.display.Do(func() { | |||
| 168 | + | u := surf.queue[0] | |||
| 169 | + | u.at = time.Time{} | |||
| 170 | + | u.applyGraph() | |||
| 171 | + | if !l.mapped || surf.committedViewport.destW != int32(cfg.width) || l.ack != newer { | |||
| 172 | + | t.Errorf("captured commit read live geometry/ACK") | |||
| 173 | + | } | |||
| 174 | + | // Queue another update and destroy: retirement must clear all holds. | |||
| 175 | + | surf.next.at = time.Now().Add(time.Hour) | |||
| 176 | + | surf.Commit(surf.wl) | |||
| 177 | + | }) | |||
| 178 | + | requestProtocol(t, c, lockID, extsessionlock.ExtSessionLockSurfaceV1RequestDestroy) | |||
| 179 | + | if err := c.Roundtrip(); err != nil { | |||
| 180 | + | t.Fatal(err) | |||
| 181 | + | } | |||
| 182 | + | s.display.Do(func() { | |||
| 183 | + | if surf.kind != roleSessionLock || surf.lock != nil || len(surf.queue) != 0 || surf.current != nil || len(s.lockSurfaces) != 0 || !l.closed { | |||
| 184 | + | t.Error("destroy lost permanent role or retained queued content") | |||
| 185 | + | } | |||
| 186 | + | }) | |||
| 187 | + | select { | |||
| 188 | + | case content := <-contents: | |||
| 189 | + | if !content.Empty() { | |||
| 190 | + | select { | |||
| 191 | + | case content = <-contents: | |||
| 192 | + | if !content.Empty() { | |||
| 193 | + | t.Error("destroy did not emit empty content") | |||
| 194 | + | } | |||
| 195 | + | case <-time.After(time.Second): | |||
| 196 | + | t.Fatal("no empty content") | |||
| 197 | + | } | |||
| 198 | + | } | |||
| 199 | + | case <-time.After(time.Second): | |||
| 200 | + | t.Fatal("no content emitted") | |||
| 201 | + | } | |||
| 202 | + | wm := bindProtocol(t, c, "xdg_wm_base") | |||
| 203 | + | requestProtocol(t, c, wm, xdgshell.WmBaseRequestGetXdgSurface, c.AllocateID(), surfID) | |||
| 204 | + | expectProtocolError(t, c, wm, uint32(xdgshell.WmBaseErrorRole)) | |||
| 205 | + | } | |||
| 206 | + | ||||
| 207 | + | func TestLockSurfaceRejectsHistoricalBuffer(t *testing.T) { | |||
| 208 | + | s, _, _, dir := lifecycleServer(t) | |||
| 209 | + | c := protocolClient(t, s, dir) | |||
| 210 | + | comp := bindProtocol(t, c, "wl_compositor") | |||
| 211 | + | surfID := c.AllocateID() | |||
| 212 | + | requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surfID) | |||
| 213 | + | registerProtocol(t, c, surfID) | |||
| 214 | + | requestProtocol(t, c, surfID, wayland.SurfaceRequestAttach, shmBuffer(t, c), int32(0), int32(0)) | |||
| 215 | + | requestProtocol(t, c, surfID, wayland.SurfaceRequestCommit) | |||
| 216 | + | requestProtocol(t, c, surfID, wayland.SurfaceRequestAttach, uint32(0), int32(0), int32(0)) | |||
| 217 | + | requestProtocol(t, c, surfID, wayland.SurfaceRequestCommit) | |||
| 218 | + | if err := c.Roundtrip(); err != nil { | |||
| 219 | + | t.Fatal(err) | |||
| 220 | + | } | |||
| 221 | + | ownerID, id := c.AllocateID(), c.AllocateID() | |||
| 222 | + | registerProtocol(t, c, ownerID) | |||
| 223 | + | s.display.Do(func() { | |||
| 224 | + | for _, surf := range s.surfaces { | |||
| 225 | + | if surf.wl.ID() != surfID { | |||
| 226 | + | continue | |||
| 227 | + | } | |||
| 228 | + | r, err := surf.wl.Client().CreateResource(extsessionlock.ExtSessionLockV1Interface, 1, ownerID, nil) | |||
| 229 | + | if err != nil { | |||
| 230 | + | t.Error(err) | |||
| 231 | + | return | |||
| 232 | + | } | |||
| 233 | + | if l, err := s.newLockSurface(extsessionlock.WrapExtSessionLockV1(r), id, surf, s.outputs[0], nil); l != nil || err != nil { | |||
| 234 | + | t.Error("accepted historical buffer") | |||
| 235 | + | } | |||
| 236 | + | } | |||
| 237 | + | }) | |||
| 238 | + | expectProtocolError(t, c, ownerID, uint32(extsessionlock.ExtSessionLockV1ErrorAlreadyConstructed)) | |||
| 239 | + | } | |||
| 240 | + | ||||
| 241 | + | func lockSizedBuffer(t *testing.T, c *wlturbo.Display, w, h int) uint32 { | |||
| 242 | + | t.Helper() | |||
| 243 | + | shm := bindProtocol(t, c, "wl_shm") | |||
| 244 | + | registerProtocol(t, c, shm) | |||
| 245 | + | fd, err := unix.MemfdCreate("lock-geometry", 0) | |||
| 246 | + | if err != nil { | |||
| 247 | + | t.Fatal(err) | |||
| 248 | + | } | |||
| 249 | + | defer unix.Close(fd) | |||
| 250 | + | size := int32(w * h * 4) | |||
| 251 | + | if err := unix.Ftruncate(fd, int64(size)); err != nil { | |||
| 252 | + | t.Fatal(err) | |||
| 253 | + | } | |||
| 254 | + | pool, buffer := c.AllocateID(), c.AllocateID() | |||
| 255 | + | registerProtocol(t, c, pool) | |||
| 256 | + | registerProtocol(t, c, buffer) | |||
| 257 | + | if err := wireRequest(c, shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, size); err != nil { | |||
| 258 | + | t.Fatal(err) | |||
| 259 | + | } | |||
| 260 | + | requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buffer, int32(0), int32(w), int32(h), int32(w*4), uint32(0)) | |||
| 261 | + | return buffer | |||
| 262 | + | } | |||
| 263 | + | ||||
| 264 | + | func TestLockSurfaceScaleTransform(t *testing.T) { | |||
| 265 | + | s, _, _, dir := lifecycleServer(t) | |||
| 266 | + | c := protocolClient(t, s, dir) | |||
| 267 | + | surfID, _, lockID, surf, l, configures := lockTestSurface(t, s, c) | |||
| 268 | + | <-configures | |||
| 269 | + | s.display.Do(func() { l.output.place.Width, l.output.place.Height = 3, 2; l.sendConfigure() }) | |||
| 270 | + | if err := c.Roundtrip(); err != nil { | |||
| 271 | + | t.Fatal(err) | |||
| 272 | + | } | |||
| 273 | + | cfg := <-configures | |||
| 274 | + | requestProtocol(t, c, lockID, extsessionlock.ExtSessionLockSurfaceV1RequestAckConfigure, cfg.serial) | |||
| 275 | + | buffer := lockSizedBuffer(t, c, 4, 6) | |||
| 276 | + | requestProtocol(t, c, surfID, wayland.SurfaceRequestSetBufferScale, int32(2)) | |||
| 277 | + | requestProtocol(t, c, surfID, wayland.SurfaceRequestSetBufferTransform, int32(1)) | |||
| 278 | + | requestProtocol(t, c, surfID, wayland.SurfaceRequestAttach, buffer, int32(0), int32(0)) | |||
| 279 | + | requestProtocol(t, c, surfID, wayland.SurfaceRequestCommit) | |||
| 280 | + | if err := c.Roundtrip(); err != nil { | |||
| 281 | + | t.Fatal(err) | |||
| 282 | + | } | |||
| 283 | + | s.display.Do(func() { | |||
| 284 | + | if !l.mapped || surf.content.LogicalW != 3 || surf.content.LogicalH != 2 { | |||
| 285 | + | t.Errorf("scaled transformed content = %+v", surf.content) | |||
| 286 | + | } | |||
| 287 | + | }) | |||
| 288 | + | requestProtocol(t, c, surfID, wayland.SurfaceRequestDestroy) | |||
| 289 | + | if err := c.Roundtrip(); err != nil { | |||
| 290 | + | t.Fatal(err) | |||
| 291 | + | } | |||
| 292 | + | s.display.Do(func() { | |||
| 293 | + | if !l.closed || len(s.lockSurfaces) != 0 { | |||
| 294 | + | t.Error("wl_surface destruction did not unmap") | |||
| 295 | + | } | |||
| 296 | + | }) | |||
| 297 | + | } | |||
| 298 | + | ||||
| 299 | + | func TestLockSurfaceDestroyedReplacementAndViewportRetainsGeometry(t *testing.T) { | |||
| 300 | + | s, _, _, dir := lifecycleServer(t) | |||
| 301 | + | c := protocolClient(t, s, dir) | |||
| 302 | + | surfID, _, lockID, surf, l, configures := lockTestSurface(t, s, c) | |||
| 303 | + | cfg := <-configures | |||
| 304 | + | vp := lockViewport(t, c, surfID, cfg.width, cfg.height) | |||
| 305 | + | requestProtocol(t, c, lockID, extsessionlock.ExtSessionLockSurfaceV1RequestAckConfigure, cfg.serial) | |||
| 306 | + | first := shmBuffer(t, c) | |||
| 307 | + | requestProtocol(t, c, surfID, wayland.SurfaceRequestAttach, first, int32(0), int32(0)) | |||
| 308 | + | requestProtocol(t, c, surfID, wayland.SurfaceRequestCommit) | |||
| 309 | + | if err := c.Roundtrip(); err != nil { | |||
| 310 | + | t.Fatal(err) | |||
| 311 | + | } | |||
| 312 | + | s.display.Do(func() { l.output.place.Width++; l.sendConfigure() }) | |||
| 313 | + | if err := c.Roundtrip(); err != nil { | |||
| 314 | + | t.Fatal(err) | |||
| 315 | + | } | |||
| 316 | + | newer := <-configures | |||
| 317 | + | requestProtocol(t, c, lockID, extsessionlock.ExtSessionLockSurfaceV1RequestAckConfigure, newer.serial) | |||
| 318 | + | requestProtocol(t, c, vp, viewporter.WpViewportRequestSetDestination, int32(newer.width), int32(newer.height)) | |||
| 319 | + | replacement := shmBuffer(t, c) | |||
| 320 | + | requestProtocol(t, c, surfID, wayland.SurfaceRequestAttach, replacement, int32(0), int32(0)) | |||
| 321 | + | if err := c.Roundtrip(); err != nil { | |||
| 322 | + | t.Fatal(err) | |||
| 323 | + | } | |||
| 324 | + | s.display.Do(func() { surf.next.at = time.Now().Add(time.Hour); surf.Commit(surf.wl) }) | |||
| 325 | + | requestProtocol(t, c, replacement, wayland.BufferRequestDestroy) | |||
| 326 | + | requestProtocol(t, c, vp, viewporter.WpViewportRequestDestroy) | |||
| 327 | + | if err := c.Roundtrip(); err != nil { | |||
| 328 | + | t.Fatal(err) | |||
| 329 | + | } | |||
| 330 | + | s.display.Do(func() { | |||
| 331 | + | surf.queue[0].at = time.Time{} | |||
| 332 | + | surf.queue[0].applyGraph() | |||
| 333 | + | if !l.mapped || surf.current.ID() != first || surf.content.LogicalW != cfg.width || surf.committedViewport.destW != int32(cfg.width) { | |||
| 334 | + | t.Error("destroyed replacement changed retained geometry") | |||
| 335 | + | } | |||
| 336 | + | }) | |||
| 337 | + | } | |||
| 338 | + | ||||
| 339 | + | func TestLockSurfaceDestroyedCurrentBufferAllowsNoAttachCommit(t *testing.T) { | |||
| 340 | + | s, _, _, dir := lifecycleServer(t) | |||
| 341 | + | c := protocolClient(t, s, dir) | |||
| 342 | + | surfID, _, lockID, surf, l, configures := lockTestSurface(t, s, c) | |||
| 343 | + | cfg := <-configures | |||
| 344 | + | lockViewport(t, c, surfID, cfg.width, cfg.height) | |||
| 345 | + | requestProtocol(t, c, lockID, extsessionlock.ExtSessionLockSurfaceV1RequestAckConfigure, cfg.serial) | |||
| 346 | + | buffer := shmBuffer(t, c) | |||
| 347 | + | requestProtocol(t, c, surfID, wayland.SurfaceRequestAttach, buffer, int32(0), int32(0)) | |||
| 348 | + | requestProtocol(t, c, surfID, wayland.SurfaceRequestCommit) | |||
| 349 | + | if err := c.Roundtrip(); err != nil { | |||
| 350 | + | t.Fatal(err) | |||
| 351 | + | } | |||
| 352 | + | requestProtocol(t, c, buffer, wayland.BufferRequestDestroy) | |||
| 353 | + | callback := c.AllocateID() | |||
| 354 | + | registerProtocol(t, c, callback) | |||
| 355 | + | requestProtocol(t, c, surfID, wayland.SurfaceRequestFrame, callback) | |||
| 356 | + | requestProtocol(t, c, surfID, wayland.SurfaceRequestCommit) | |||
| 357 | + | if err := c.Roundtrip(); err != nil { | |||
| 358 | + | t.Fatal(err) | |||
| 359 | + | } | |||
| 360 | + | s.display.Do(func() { | |||
| 361 | + | if !l.mapped || !surf.has || surf.content.LogicalW != cfg.width { | |||
| 362 | + | t.Error("retained destroyed buffer unmapped") | |||
| 363 | + | } | |||
| 364 | + | }) | |||
| 365 | + | } | |||
| 366 | + | ||||
| 367 | + | func TestLockSurfaceDestroyBlocksLayerReuse(t *testing.T) { | |||
| 368 | + | s, _, _, dir := lifecycleServer(t) | |||
| 369 | + | c := protocolClient(t, s, dir) | |||
| 370 | + | surfID, _, lockID, _, _, _ := lockTestSurface(t, s, c) | |||
| 371 | + | requestProtocol(t, c, lockID, extsessionlock.ExtSessionLockSurfaceV1RequestDestroy) | |||
| 372 | + | if err := c.Roundtrip(); err != nil { | |||
| 373 | + | t.Fatal(err) | |||
| 374 | + | } | |||
| 375 | + | shell := bindVersion(t, c, "zwlr_layer_shell_v1", 4) | |||
| 376 | + | requestProtocol(t, c, shell, wlrlayershell.ZwlrLayerShellV1RequestGetLayerSurface, c.AllocateID(), surfID, uint32(0), uint32(2), "role-reuse") | |||
| 377 | + | expectProtocolError(t, c, shell, uint32(wlrlayershell.ZwlrLayerShellV1ErrorAlreadyConstructed)) | |||
| 378 | + | } | |||
| 379 | + | ||||
| 380 | + | func TestLockSurfaceDestroyRetiresFencedChildDependencies(t *testing.T) { | |||
| 381 | + | for _, destroy := range []string{"role", "surface", "role ready"} { | |||
| 382 | + | t.Run(destroy, func(t *testing.T) { | |||
| 383 | + | h := newSyncHarness(t) | |||
| 384 | + | h.releases = map[uint32]*syncReleaseProxy{} | |||
| 385 | + | c, s := h.c, h.s | |||
| 386 | + | rootID, _, lockID, root, _, configs := lockTestSurface(t, s, c) | |||
| 387 | + | cfg := <-configs | |||
| 388 | + | lockViewport(t, c, rootID, cfg.width, cfg.height) | |||
| 389 | + | requestProtocol(t, c, lockID, extsessionlock.ExtSessionLockSurfaceV1RequestAckConfigure, cfg.serial) | |||
| 390 | + | rootBuffer := shmBuffer(t, c) | |||
| 391 | + | requestProtocol(t, c, rootID, wayland.SurfaceRequestAttach, rootBuffer, int32(0), int32(0)) | |||
| 392 | + | requestProtocol(t, c, rootID, wayland.SurfaceRequestCommit) | |||
| 393 | + | if err := c.Roundtrip(); err != nil { | |||
| 394 | + | t.Fatal(err) | |||
| 395 | + | } | |||
| 396 | + | comp := bindProtocol(t, c, "wl_compositor") | |||
| 397 | + | subcomp := bindProtocol(t, c, "wl_subcompositor") | |||
| 398 | + | mgr := bindProtocol(t, c, "wp_linux_drm_syncobj_manager_v1") | |||
| 399 | + | childID := c.AllocateID() | |||
| 400 | + | registerProtocol(t, c, childID) | |||
| 401 | + | requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, childID) | |||
| 402 | + | sub := c.AllocateID() | |||
| 403 | + | registerProtocol(t, c, sub) | |||
| 404 | + | requestProtocol(t, c, subcomp, wayland.SubcompositorRequestGetSubsurface, sub, childID, rootID) | |||
| 405 | + | syncID := c.AllocateID() | |||
| 406 | + | registerProtocol(t, c, syncID) | |||
| 407 | + | requestProtocol(t, c, mgr, linuxdrmsyncobj.WpLinuxDrmSyncobjManagerV1RequestGetSurface, syncID, childID) | |||
| 408 | + | h.surf, h.syncSurf = childID, syncID | |||
| 409 | + | buffer := h.dmabuf() | |||
| 410 | + | h.commit(buffer, 31, 32) | |||
| 411 | + | var child *surface | |||
| 412 | + | var dep, parent *update | |||
| 413 | + | var wait *syncWait | |||
| 414 | + | var tl *timeline | |||
| 415 | + | s.display.Do(func() { | |||
| 416 | + | for _, v := range s.surfaces { | |||
| 417 | + | if v.wl.ID() == childID { | |||
| 418 | + | child = v | |||
| 419 | + | } | |||
| 420 | + | } | |||
| 421 | + | root.next.at = time.Now().Add(time.Hour) | |||
| 422 | + | root.Commit(root.wl) | |||
| 423 | + | dep, parent = child.queue[0], root.queue[0] | |||
| 424 | + | dep.refs++ // Pin for inspection just like another graph consumer. | |||
| 425 | + | parent.refs++ | |||
| 426 | + | wait, tl = dep.sync.wait, dep.sync.acquire.tl | |||
| 427 | + | if !dep.synced || !dep.bound || len(parent.deps) != 1 || tl.uses != 2 { | |||
| 428 | + | t.Error("child was not captured with its fence") | |||
| 429 | + | } | |||
| 430 | + | }) | |||
| 431 | + | if destroy == "role ready" { | |||
| 432 | + | h.fire(31) | |||
| 433 | + | deadline := time.NewTimer(time.Second) | |||
| 434 | + | defer deadline.Stop() | |||
| 435 | + | ready := false | |||
| 436 | + | for !ready { | |||
| 437 | + | s.display.Do(func() { ready = child.syncReady(dep.sync) }) | |||
| 438 | + | if ready { | |||
| 439 | + | break | |||
| 440 | + | } | |||
| 441 | + | select { | |||
| 442 | + | case <-deadline.C: | |||
| 443 | + | t.Fatal("acquire waiter did not become ready") | |||
| 444 | + | case <-time.After(time.Millisecond): | |||
| 445 | + | } | |||
| 446 | + | } | |||
| 447 | + | } | |||
| 448 | + | // A later child commit is not owned by the captured root graph. | |||
| 449 | + | requestProtocol(t, c, childID, wayland.SurfaceRequestCommit) | |||
| 450 | + | if err := c.Roundtrip(); err != nil { | |||
| 451 | + | t.Fatal(err) | |||
| 452 | + | } | |||
| 453 | + | if destroy != "surface" { | |||
| 454 | + | requestProtocol(t, c, lockID, extsessionlock.ExtSessionLockSurfaceV1RequestDestroy) | |||
| 455 | + | } else { | |||
| 456 | + | requestProtocol(t, c, rootID, wayland.SurfaceRequestDestroy) | |||
| 457 | + | } | |||
| 458 | + | if err := c.Roundtrip(); err != nil { | |||
| 459 | + | t.Fatal(err) | |||
| 460 | + | } | |||
| 461 | + | s.display.Do(func() { | |||
| 462 | + | remaining, refs := 1, 2 // later child commit retains its prev link | |||
| 463 | + | if destroy == "surface" { | |||
| 464 | + | remaining, refs = 0, 1 // parent destruction detaches the child | |||
| 465 | + | } | |||
| 466 | + | if len(root.queue) != 0 || len(child.queue) != remaining || !dep.retired || !parent.retired || dep.refs != refs || parent.refs != 1 || !wait.cancelled.Load() || tl.uses != 0 { | |||
| 467 | + | t.Errorf("dependency teardown: child=%d dep refs=%d parent refs=%d uses=%d cancelled=%v", len(child.queue), dep.refs, parent.refs, tl.uses, wait.cancelled.Load()) | |||
| 468 | + | } | |||
| 469 | + | dep.applyGraph() | |||
| 470 | + | parent.applyGraph() | |||
| 471 | + | if child.current != nil || root.current != nil { | |||
| 472 | + | t.Error("retired graph replayed") | |||
| 473 | + | } | |||
| 474 | + | child.dropQueue() | |||
| 475 | + | if dep.refs != 1 { | |||
| 476 | + | t.Errorf("retired dependency refs after later commit retirement = %d", dep.refs) | |||
| 477 | + | } | |||
| 478 | + | dep.releaseRef() | |||
| 479 | + | parent.releaseRef() | |||
| 480 | + | }) | |||
| 481 | + | count := 0 | |||
| 482 | + | for _, point := range h.signalled() { | |||
| 483 | + | if point == 32 { | |||
| 484 | + | count++ | |||
| 485 | + | } | |||
| 486 | + | } | |||
| 487 | + | want := 0 | |||
| 488 | + | if destroy == "role ready" { | |||
| 489 | + | want = 1 | |||
| 490 | + | } | |||
| 491 | + | if count != want { | |||
| 492 | + | t.Errorf("release signals = %d, want %d", count, want) | |||
| 493 | + | } | |||
| 494 | + | select { | |||
| 495 | + | case <-h.releases[buffer].released: | |||
| 496 | + | t.Error("explicit-sync buffer received wl_buffer.release") | |||
| 497 | + | default: | |||
| 498 | + | } | |||
| 499 | + | }) | |||
| 500 | + | } | |||
| 501 | + | } | |||
| 502 | + | ||||
| 503 | + | func TestLockSurfaceChildPrefixKeepsSharedBufferOwnership(t *testing.T) { | |||
| 504 | + | for _, preserved := range []string{"suffix", "pending", "implicit suffix"} { | |||
| 505 | + | t.Run(preserved, func(t *testing.T) { | |||
| 506 | + | var s *Server | |||
| 507 | + | var c *wlturbo.Display | |||
| 508 | + | var h *syncHarness | |||
| 509 | + | if preserved == "implicit suffix" { | |||
| 510 | + | h = newSyncHarness(t) | |||
| 511 | + | h.releases = map[uint32]*syncReleaseProxy{} | |||
| 512 | + | s, c = h.s, h.c | |||
| 513 | + | } else { | |||
| 514 | + | var dir string | |||
| 515 | + | s, _, _, dir = lifecycleServer(t) | |||
| 516 | + | c = protocolClient(t, s, dir) | |||
| 517 | + | } | |||
| 518 | + | rootID, _, lockID, root, _, configs := lockTestSurface(t, s, c) | |||
| 519 | + | cfg := <-configs | |||
| 520 | + | lockViewport(t, c, rootID, cfg.width, cfg.height) | |||
| 521 | + | requestProtocol(t, c, lockID, extsessionlock.ExtSessionLockSurfaceV1RequestAckConfigure, cfg.serial) | |||
| 522 | + | requestProtocol(t, c, rootID, wayland.SurfaceRequestAttach, shmBuffer(t, c), int32(0), int32(0)) | |||
| 523 | + | requestProtocol(t, c, rootID, wayland.SurfaceRequestCommit) | |||
| 524 | + | comp := bindProtocol(t, c, "wl_compositor") | |||
| 525 | + | subcomp := bindProtocol(t, c, "wl_subcompositor") | |||
| 526 | + | childID := c.AllocateID() | |||
| 527 | + | registerProtocol(t, c, childID) | |||
| 528 | + | requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, childID) | |||
| 529 | + | sub := c.AllocateID() | |||
| 530 | + | registerProtocol(t, c, sub) | |||
| 531 | + | requestProtocol(t, c, subcomp, wayland.SubcompositorRequestGetSubsurface, sub, childID, rootID) | |||
| 532 | + | var buffer uint32 | |||
| 533 | + | if h != nil { | |||
| 534 | + | buffer = h.dmabuf() | |||
| 535 | + | } else { | |||
| 536 | + | buffer = shmBuffer(t, c) | |||
| 537 | + | } | |||
| 538 | + | releases := &syncReleaseProxy{released: make(chan struct{}, 4)} | |||
| 539 | + | releases.SetID(buffer) | |||
| 540 | + | registerWireProxy(c, releases) | |||
| 541 | + | requestProtocol(t, c, childID, wayland.SurfaceRequestAttach, buffer, int32(0), int32(0)) | |||
| 542 | + | requestProtocol(t, c, childID, wayland.SurfaceRequestCommit) | |||
| 543 | + | if err := c.Roundtrip(); err != nil { | |||
| 544 | + | t.Fatal(err) | |||
| 545 | + | } | |||
| 546 | + | s.display.Do(func() { root.next.at = time.Now().Add(time.Hour); root.Commit(root.wl) }) | |||
| 547 | + | requestProtocol(t, c, childID, wayland.SurfaceRequestAttach, buffer, int32(0), int32(0)) | |||
| 548 | + | if preserved != "pending" { | |||
| 549 | + | requestProtocol(t, c, childID, wayland.SurfaceRequestCommit) | |||
| 550 | + | } | |||
| 551 | + | requestProtocol(t, c, lockID, extsessionlock.ExtSessionLockSurfaceV1RequestDestroy) | |||
| 552 | + | if err := c.Roundtrip(); err != nil { | |||
| 553 | + | t.Fatal(err) | |||
| 554 | + | } | |||
| 555 | + | select { | |||
| 556 | + | case <-releases.released: | |||
| 557 | + | t.Fatal("captured prefix released shared buffer early") | |||
| 558 | + | default: | |||
| 559 | + | } | |||
| 560 | + | // Convert the pending reference to an update, then retire the last | |||
| 561 | + | // reference through the same normal queue path. | |||
| 562 | + | if preserved == "pending" { | |||
| 563 | + | requestProtocol(t, c, childID, wayland.SurfaceRequestCommit) | |||
| 564 | + | if err := c.Roundtrip(); err != nil { | |||
| 565 | + | t.Fatal(err) | |||
| 566 | + | } | |||
| 567 | + | } | |||
| 568 | + | s.display.Do(func() { | |||
| 569 | + | child := s.surfaceOf(findTestSurface(s, childID)) | |||
| 570 | + | if len(child.queue) != 1 { | |||
| 571 | + | t.Errorf("preserved queue length = %d", len(child.queue)) | |||
| 572 | + | } | |||
| 573 | + | child.dropQueue() | |||
| 574 | + | }) | |||
| 575 | + | if err := c.Roundtrip(); err != nil { | |||
| 576 | + | t.Fatal(err) | |||
| 577 | + | } | |||
| 578 | + | select { | |||
| 579 | + | case <-releases.released: | |||
| 580 | + | default: | |||
| 581 | + | t.Fatal("last queued reference did not release buffer") | |||
| 582 | + | } | |||
| 583 | + | select { | |||
| 584 | + | case <-releases.released: | |||
| 585 | + | t.Fatal("buffer released twice") | |||
| 586 | + | default: | |||
| 587 | + | } | |||
| 588 | + | }) | |||
| 589 | + | } | |||
| 590 | + | } | |||
| 591 | + | ||||
| 592 | + | func findTestSurface(s *Server, id uint32) *wayland.Surface { | |||
| 593 | + | for _, surf := range s.surfaces { | |||
| 594 | + | if surf.wl.ID() == id { | |||
| 595 | + | return surf.wl | |||
| 596 | + | } | |||
| 597 | + | } | |||
| 598 | + | return nil | |||
| 599 | + | } | |||
A internal/adapters/wayland/session_lock_test.go +112 -0
| @@ -0,0 +1,112 @@ | |||||
| 1 | + | package wayland | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/neferwl/internal/adapters/sessionsecurity" | |||
| 9 | + | "github.com/bnema/neferwl/internal/logging" | |||
| 10 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 11 | + | "github.com/bnema/wlturbo" | |||
| 12 | + | lockclient "github.com/bnema/wlturbo/protocol/extsessionlock" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | func lockManagerServer(t *testing.T) (*Server, *sessionsecurity.Gate, chan ports.SecurityState, chan ports.SecurityBackendEvent, string) { | |||
| 16 | + | t.Helper() | |||
| 17 | + | gate := &sessionsecurity.Gate{} | |||
| 18 | + | changes := make(chan ports.SecurityState, 1) | |||
| 19 | + | events := make(chan ports.SecurityBackendEvent, 16) | |||
| 20 | + | dir := t.TempDir() | |||
| 21 | + | s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs, Security: gate}, Channels{SecurityChanges: changes, SecurityEvents: events}, logging.For(context.Background(), "wayland")) | |||
| 22 | + | if err != nil { | |||
| 23 | + | t.Fatal(err) | |||
| 24 | + | } | |||
| 25 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 26 | + | done := make(chan error, 1) | |||
| 27 | + | go func() { done <- s.Run(ctx) }() | |||
| 28 | + | t.Cleanup(func() { | |||
| 29 | + | cancel() | |||
| 30 | + | select { | |||
| 31 | + | case err := <-done: | |||
| 32 | + | if err != nil { | |||
| 33 | + | t.Error(err) | |||
| 34 | + | } | |||
| 35 | + | case <-time.After(3 * time.Second): | |||
| 36 | + | t.Error("session-lock server did not stop") | |||
| 37 | + | } | |||
| 38 | + | }) | |||
| 39 | + | return s, gate, changes, events, dir | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | func bindLockManager(t *testing.T, c *wlturbo.Display) *lockclient.ExtSessionLockManager { | |||
| 43 | + | t.Helper() | |||
| 44 | + | manager := lockclient.NewExtSessionLockManager(c.Context()) | |||
| 45 | + | g, ok := c.Registry().FindGlobal(lockclient.ExtSessionLockManagerInterface) | |||
| 46 | + | if !ok { | |||
| 47 | + | t.Fatal("missing test lock manager") | |||
| 48 | + | } | |||
| 49 | + | if err := c.Registry().Bind(g.Name, g.Interface, 1, manager); err != nil { | |||
| 50 | + | t.Fatal(err) | |||
| 51 | + | } | |||
| 52 | + | return manager | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | func TestSessionLockWaitsForEveryBarrierAndOwnerDeathStaysProtected(t *testing.T) { | |||
| 56 | + | s, gate, changes, _, dir := lockManagerServer(t) | |||
| 57 | + | c := protocolClient(t, s, dir) | |||
| 58 | + | manager := bindLockManager(t, c) | |||
| 59 | + | lock, err := manager.Lock() | |||
| 60 | + | if err != nil { | |||
| 61 | + | t.Fatal(err) | |||
| 62 | + | } | |||
| 63 | + | locked := 0 | |||
| 64 | + | lock.OnLocked(func() { locked++ }) | |||
| 65 | + | if err := c.Roundtrip(); err != nil { | |||
| 66 | + | t.Fatal(err) | |||
| 67 | + | } | |||
| 68 | + | var state ports.SecurityState | |||
| 69 | + | select { | |||
| 70 | + | case state = <-changes: | |||
| 71 | + | case <-time.After(2 * time.Second): | |||
| 72 | + | t.Fatal("no backend transition") | |||
| 73 | + | } | |||
| 74 | + | if !state.Protected || !gate.Snapshot().Protected || locked != 0 { | |||
| 75 | + | t.Fatalf("early lock %+v events %d", state, locked) | |||
| 76 | + | } | |||
| 77 | + | if !s.display.Do(func() { | |||
| 78 | + | s.applySecurityEvent(ports.SecurityOutputAdded{Instance: 1, Output: "OUT-1"}) | |||
| 79 | + | s.applySecurityEvent(ports.SecurityOutputAdded{Instance: 2, Output: "OUT-2"}) | |||
| 80 | + | s.applySecurityEvent(ports.SecurityBackendBarrier{Generation: state.Generation}) | |||
| 81 | + | s.applySecurityEvent(ports.SecurityOutputProof{Proof: ports.OutputProtection{Generation: state.Generation, Instance: 1, Kind: ports.ProtectionProtectedFrame}}) | |||
| 82 | + | }) { | |||
| 83 | + | t.Fatal("display stopped") | |||
| 84 | + | } | |||
| 85 | + | if err := c.Roundtrip(); err != nil { | |||
| 86 | + | t.Fatal(err) | |||
| 87 | + | } | |||
| 88 | + | if locked != 0 { | |||
| 89 | + | t.Fatal("locked without second output proof") | |||
| 90 | + | } | |||
| 91 | + | if !s.display.Do(func() { | |||
| 92 | + | s.applySecurityEvent(ports.SecurityOutputProof{Proof: ports.OutputProtection{Generation: state.Generation, Instance: 2, Kind: ports.ProtectionInactiveOutput}}) | |||
| 93 | + | }) { | |||
| 94 | + | t.Fatal("display stopped") | |||
| 95 | + | } | |||
| 96 | + | if err := c.Roundtrip(); err != nil { | |||
| 97 | + | t.Fatal(err) | |||
| 98 | + | } | |||
| 99 | + | if locked != 1 { | |||
| 100 | + | t.Fatalf("locked events %d, want 1", locked) | |||
| 101 | + | } | |||
| 102 | + | // Closing the owner connection never opens the session. | |||
| 103 | + | if err := c.Close(); err != nil { | |||
| 104 | + | t.Fatal(err) | |||
| 105 | + | } | |||
| 106 | + | if !s.display.Do(func() {}) { | |||
| 107 | + | t.Fatal("display stopped") | |||
| 108 | + | } | |||
| 109 | + | if !gate.Snapshot().Protected { | |||
| 110 | + | t.Fatal("owner death unlocked") | |||
| 111 | + | } | |||
| 112 | + | } | |||
M internal/adapters/wayland/slot_test.go +6 -2
| @@ -12,13 +12,17 @@ func TestWindowMappedCarriesSlot(t *testing.T) { | |||||
| 12 | env := newMockprocEnv(t) | 12 | env := newMockprocEnv(t) | ||
| 13 | // The test client runs in this process: its PID is ours. | 13 | // The test client runs in this process: its PID is ours. | ||
| 14 | env.EXPECT().Lookup(os.Getpid(), ports.SlotEnv).Return("7", true).Once() | 14 | env.EXPECT().Lookup(os.Getpid(), ports.SlotEnv).Return("7", true).Once() | ||
| 15 | - | s, events, commands, dir := keyboardServer(t, func(s *Server) { s.env = env }) | 15 | + | s, events, _, dir := keyboardServer(t, func(s *Server) { s.env = env }) |
| 16 | mapWindow := toplevelMapper(t, protocolClient(t, s, dir), events) | 16 | mapWindow := toplevelMapper(t, protocolClient(t, s, dir), events) | ||
| 17 | // No slot pending: /proc is not read (the mock would fail on a call). | 17 | // No slot pending: /proc is not read (the mock would fail on a call). | ||
| 18 | if w := mapWindow(); w.Slot != "" || w.PID != os.Getpid() { | 18 | if w := mapWindow(); w.Slot != "" || w.PID != os.Getpid() { | ||
| 19 | t.Fatalf("slot %q pid %d", w.Slot, w.PID) | 19 | t.Fatalf("slot %q pid %d", w.Slot, w.PID) | ||
| 20 | } | 20 | } | ||
| 21 | - | commands <- ports.SlotsPending{Pending: true} | 21 | + | // This tests environment lookup, not command transport. Apply the gate |
| 22 | + | // synchronously before mapping over the independent protocol connection. | |||
| 23 | + | if !s.display.Do(func() { s.apply(ports.SlotsPending{Pending: true}) }) { | |||
| 24 | + | t.Fatal("display stopped before applying SlotsPending") | |||
| 25 | + | } | |||
| 22 | if w := mapWindow(); w.Slot != "7" { | 26 | if w := mapWindow(); w.Slot != "7" { | ||
| 23 | t.Fatalf("slot %q", w.Slot) | 27 | t.Fatalf("slot %q", w.Slot) | ||
| 24 | } | 28 | } | ||
M internal/adapters/wayland/subsurface_test.go +4 -4
| @@ -171,7 +171,7 @@ func TestSubsurfaceSupersededBufferReleased(t *testing.T) { | |||||
| 171 | first := shmBuffer(t, c) | 171 | first := shmBuffer(t, c) | ||
| 172 | p := &syncReleaseProxy{released: make(chan struct{}, 4)} | 172 | p := &syncReleaseProxy{released: make(chan struct{}, 4)} | ||
| 173 | p.SetID(first) | 173 | p.SetID(first) | ||
| 174 | - | c.Context().Register(p) | 174 | + | registerWireProxy(c, p) |
| 175 | for _, b := range []uint32{first, shmBuffer(t, c)} { | 175 | for _, b := range []uint32{first, shmBuffer(t, c)} { | ||
| 176 | requestProtocol(t, c, child, wayland.SurfaceRequestAttach, b, int32(0), int32(0)) | 176 | requestProtocol(t, c, child, wayland.SurfaceRequestAttach, b, int32(0), int32(0)) | ||
| 177 | requestProtocol(t, c, child, wayland.SurfaceRequestCommit) | 177 | requestProtocol(t, c, child, wayland.SurfaceRequestCommit) | ||
| @@ -479,7 +479,7 @@ func TestSubsurfaceContent(t *testing.T) { | |||||
| 479 | registerProtocol(t, c, pool) | 479 | registerProtocol(t, c, pool) | ||
| 480 | registerProtocol(t, c, big) | 480 | registerProtocol(t, c, big) | ||
| 481 | registerProtocol(t, c, small) | 481 | registerProtocol(t, c, small) | ||
| 482 | - | if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(64)); err != nil { | 482 | + | if err := wireRequest(c, shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(64)); err != nil { |
| 483 | t.Fatal(err) | 483 | t.Fatal(err) | ||
| 484 | } | 484 | } | ||
| 485 | requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, big, int32(0), int32(4), int32(4), int32(16), uint32(0)) | 485 | requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, big, int32(0), int32(4), int32(4), int32(16), uint32(0)) | ||
| @@ -498,7 +498,7 @@ func TestSubsurfaceContent(t *testing.T) { | |||||
| 498 | serials := make(chan uint32, 4) | 498 | serials := make(chan uint32, 4) | ||
| 499 | xp := &configureProxy{serial: serials} | 499 | xp := &configureProxy{serial: serials} | ||
| 500 | xp.SetID(xdg) | 500 | xp.SetID(xdg) | ||
| 501 | - | c.Context().Register(xp) | 501 | + | registerWireProxy(c, xp) |
| 502 | top := c.AllocateID() | 502 | top := c.AllocateID() | ||
| 503 | registerProtocol(t, c, top) | 503 | registerProtocol(t, c, top) | ||
| 504 | requestProtocol(t, c, xdg, xdgshell.SurfaceRequestGetToplevel, top) | 504 | requestProtocol(t, c, xdg, xdgshell.SurfaceRequestGetToplevel, top) | ||
| @@ -615,7 +615,7 @@ func TestSubsurfaceGraphSupersededFeedback(t *testing.T) { | |||||
| 615 | first := &feedbackEvents{out: make(chan presentedEvent, 2)} | 615 | first := &feedbackEvents{out: make(chan presentedEvent, 2)} | ||
| 616 | id := c.AllocateID() | 616 | id := c.AllocateID() | ||
| 617 | first.SetID(id) | 617 | first.SetID(id) | ||
| 618 | - | c.Context().Register(first) | 618 | + | registerWireProxy(c, first) |
| 619 | requestProtocol(t, c, pres, presentationtime.WpPresentationRequestFeedback, child, id) | 619 | requestProtocol(t, c, pres, presentationtime.WpPresentationRequestFeedback, child, id) | ||
| 620 | requestProtocol(t, c, child, wayland.SurfaceRequestAttach, shmBuffer(t, c), int32(0), int32(0)) | 620 | requestProtocol(t, c, child, wayland.SurfaceRequestAttach, shmBuffer(t, c), int32(0), int32(0)) | ||
| 621 | requestProtocol(t, c, child, wayland.SurfaceRequestCommit) | 621 | requestProtocol(t, c, child, wayland.SurfaceRequestCommit) | ||
M internal/adapters/wayland/surface.go +32 -6
| @@ -3,6 +3,7 @@ package wayland | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "image" | 4 | "image" | ||
| 5 | "math" | 5 | "math" | ||
| 6 | + | "os" | |||
| 6 | "slices" | 7 | "slices" | ||
| 7 | "time" | 8 | "time" | ||
| 8 | 9 | ||||
| @@ -23,6 +24,7 @@ const ( | |||||
| 23 | roleLayer | 24 | roleLayer | ||
| 24 | roleCursor | 25 | roleCursor | ||
| 25 | roleInputPopup | 26 | roleInputPopup | ||
| 27 | + | roleSessionLock | |||
| 26 | ) | 28 | ) | ||
| 27 | 29 | ||||
| 28 | type surface struct { | 30 | type surface struct { | ||
| @@ -40,11 +42,14 @@ type surface struct { | |||||
| 40 | kind roleKind | 42 | kind roleKind | ||
| 41 | xdg *xdgSurface | 43 | xdg *xdgSurface | ||
| 42 | layer *layerSurface | 44 | layer *layerSurface | ||
| 43 | - | server *Server | 45 | + | lock *lockSurface |
| 44 | - | current *wayland.Buffer | 46 | + | // bufferCommitted is permanent, including commits later discarded by FIFO. |
| 45 | - | lastW, lastH int // last logged buffer size | 47 | + | bufferCommitted bool |
| 46 | - | role func(bool) | 48 | + | server *Server |
| 47 | - | destroyed bool | 49 | + | current *wayland.Buffer |
| 50 | + | lastW, lastH int // last logged buffer size | |||
| 51 | + | role func(bool) | |||
| 52 | + | destroyed bool | |||
| 48 | // hotX, hotY is the click point of a cursor surface (logical). | 53 | // hotX, hotY is the click point of a cursor surface (logical). | ||
| 49 | hotX, hotY int | 54 | hotX, hotY int | ||
| 50 | // on is the output the surface entered; scale is the last scale sent. | 55 | // on is the output the surface entered; scale is the last scale sent. | ||
| @@ -135,6 +140,7 @@ type pendingCommit struct { | |||||
| 135 | type commitSync struct { | 140 | type commitSync struct { | ||
| 136 | acquire, release syncPoint | 141 | acquire, release syncPoint | ||
| 137 | wait *syncWait | 142 | wait *syncWait | ||
| 143 | + | fence *os.File // exported before application; owned until apply/drop | |||
| 138 | } | 144 | } | ||
| 139 | 145 | ||||
| 140 | // damage is what one commit changed in a surface's buffer. | 146 | // damage is what one commit changed in a surface's buffer. | ||
| @@ -173,6 +179,9 @@ func (s *surface) windowID() ports.WindowID { | |||||
| 173 | if s.layer != nil && s.layer.mapped { | 179 | if s.layer != nil && s.layer.mapped { | ||
| 174 | return s.layer.id | 180 | return s.layer.id | ||
| 175 | } | 181 | } | ||
| 182 | + | if s.lock != nil && s.lock.mapped { | |||
| 183 | + | return s.lock.id | |||
| 184 | + | } | |||
| 176 | return 0 | 185 | return 0 | ||
| 177 | } | 186 | } | ||
| 178 | 187 | ||||
| @@ -332,6 +341,12 @@ func (s *surface) Frame(r *wayland.Surface, id uint32) { | |||||
| 332 | } | 341 | } | ||
| 333 | } | 342 | } | ||
| 334 | func (s *surface) Commit(*wayland.Surface) { | 343 | func (s *surface) Commit(*wayland.Surface) { | ||
| 344 | + | if s.next.attached && s.next.buffer != nil { | |||
| 345 | + | s.bufferCommitted = true | |||
| 346 | + | } | |||
| 347 | + | if s.lock != nil && !s.lock.checkCommit() { | |||
| 348 | + | return | |||
| 349 | + | } | |||
| 335 | if s.layer != nil && s.next.attached && s.next.buffer != nil && !s.layer.acked { | 350 | if s.layer != nil && s.next.attached && s.next.buffer != nil && !s.layer.acked { | ||
| 336 | s.layer.resource.PostError(uint32(wlrlayershell.ZwlrLayerSurfaceV1ErrorInvalidSurfaceState), "buffer before configure ack") | 351 | s.layer.resource.PostError(uint32(wlrlayershell.ZwlrLayerSurfaceV1ErrorInvalidSurfaceState), "buffer before configure ack") | ||
| 337 | return | 352 | return | ||
| @@ -388,7 +403,10 @@ func (s *surface) applyCommit(u *update) { | |||||
| 388 | s.async = u.async | 403 | s.async = u.async | ||
| 389 | s.color = u.color | 404 | s.color = u.color | ||
| 390 | s.representation = u.representation | 405 | s.representation = u.representation | ||
| 391 | - | if u.vp != nil && u.vp.resource != nil && u.vp.resource.Resource.Alive() { | 406 | + | if s.lock != nil && s.commitSkipped { |
| 407 | + | // A discarded replacement cannot change retained lock geometry, even | |||
| 408 | + | // if its viewport was destroyed before capture or before application. | |||
| 409 | + | } else if u.vp != nil && (s.lock != nil || u.vp.resource != nil && u.vp.resource.Resource.Alive()) { | |||
| 392 | s.committedViewport.destW, s.committedViewport.destH, s.committedViewport.dest = u.vpW, u.vpH, u.vpSet | 410 | s.committedViewport.destW, s.committedViewport.destH, s.committedViewport.dest = u.vpW, u.vpH, u.vpSet | ||
| 393 | s.committedViewport.src, s.committedViewport.crop = u.vpSrc, u.vpCrop | 411 | s.committedViewport.src, s.committedViewport.crop = u.vpSrc, u.vpCrop | ||
| 394 | } else { | 412 | } else { | ||
| @@ -413,6 +431,9 @@ func (s *surface) applyCommit(u *update) { | |||||
| 413 | if len(u.callbacks) > 0 { | 431 | if len(u.callbacks) > 0 { | ||
| 414 | s.server.queueFrames(s, u.callbacks) | 432 | s.server.queueFrames(s, u.callbacks) | ||
| 415 | } | 433 | } | ||
| 434 | + | if s.lock != nil && !s.commitSkipped && !s.lock.checkCaptured(u) { | |||
| 435 | + | return | |||
| 436 | + | } | |||
| 416 | if s.role != nil { | 437 | if s.role != nil { | ||
| 417 | if u.layer != nil && s.layer == u.layer { | 438 | if u.layer != nil && s.layer == u.layer { | ||
| 418 | u.layer.commitState(u.layerNext, s.current != nil) | 439 | u.layer.commitState(u.layerNext, s.current != nil) | ||
| @@ -488,6 +509,11 @@ func (s *surface) applyCommit(u *update) { | |||||
| 488 | if s.xdg != nil && s.xdg.window != nil { | 509 | if s.xdg != nil && s.xdg.window != nil { | ||
| 489 | s.xdg.window.afterCommit() | 510 | s.xdg.window.afterCommit() | ||
| 490 | } | 511 | } | ||
| 512 | + | if s.lock != nil { | |||
| 513 | + | // Role callbacks precede content construction; publish lock placement | |||
| 514 | + | // only once validated current dimensions are available. | |||
| 515 | + | s.server.lockSurfaceChanged() | |||
| 516 | + | } | |||
| 491 | reshaped := s.has && (s.content.LogicalW != oldW || s.content.LogicalH != oldH || s.content.Source != oldSource || s.content.Transform != oldTransform) | 517 | reshaped := s.has && (s.content.LogicalW != oldW || s.content.LogicalH != oldH || s.content.Source != oldSource || s.content.Transform != oldTransform) | ||
| 492 | // Opacity changes how every pixel blends, so it repaints the whole surface. | 518 | // Opacity changes how every pixel blends, so it repaints the whole surface. | ||
| 493 | opacity := s.has && (s.content.Opaque != oldOpaque || s.content.Fade != oldFade) | 519 | opacity := s.has && (s.content.Opaque != oldOpaque || s.content.Fade != oldFade) | ||
M internal/adapters/wayland/syncobj.go +98 -20
| @@ -192,7 +192,19 @@ func (s *surface) pendingIsSHM() bool { | |||||
| 192 | // syncWaiter watches acquire points: each gets an eventfd the kernel | 192 | // syncWaiter watches acquire points: each gets an eventfd the kernel | ||
| 193 | // writes once the point is signalled, and the pacer is woken to apply the | 193 | // writes once the point is signalled, and the pacer is woken to apply the | ||
| 194 | // commits that waited for it. | 194 | // commits that waited for it. | ||
| 195 | + | type acquirePoller interface { | |||
| 196 | + | poll([]unix.PollFd, int) (int, error) | |||
| 197 | + | } | |||
| 198 | + | ||||
| 199 | + | type unixAcquirePoller struct{} | |||
| 200 | + | ||||
| 201 | + | func (unixAcquirePoller) poll(fds []unix.PollFd, timeout int) (int, error) { | |||
| 202 | + | return unix.Poll(fds, timeout) | |||
| 203 | + | } | |||
| 204 | + | ||||
| 195 | type syncWaiter struct { | 205 | type syncWaiter struct { | ||
| 206 | + | poller acquirePoller | |||
| 207 | + | terminal bool // polling ended in failure; protected by mu | |||
| 196 | mu sync.Mutex | 208 | mu sync.Mutex | ||
| 197 | ready map[*syncWait]bool | 209 | ready map[*syncWait]bool | ||
| 198 | wake func() | 210 | wake func() | ||
| @@ -207,6 +219,7 @@ type syncWaiter struct { | |||||
| 207 | type syncWait struct { | 219 | type syncWait struct { | ||
| 208 | efd int | 220 | efd int | ||
| 209 | done bool // the eventfd fired or the wait was cancelled (pacer, under Do) | 221 | done bool // the eventfd fired or the wait was cancelled (pacer, under Do) | ||
| 222 | + | failed bool // observation failed; done must not authorize publication | |||
| 210 | cancelled atomic.Bool // run stops polling it and closes efd | 223 | cancelled atomic.Bool // run stops polling it and closes efd | ||
| 211 | } | 224 | } | ||
| 212 | 225 | ||||
| @@ -215,28 +228,38 @@ func newSyncWaiter(wake func()) (*syncWaiter, error) { | |||||
| 215 | if err != nil { | 228 | if err != nil { | ||
| 216 | return nil, err | 229 | return nil, err | ||
| 217 | } | 230 | } | ||
| 218 | - | return &syncWaiter{ready: map[*syncWait]bool{}, wake: wake, pipeR: r, pipeW: w, log: zerowrap.Default()}, nil | 231 | + | return &syncWaiter{poller: unixAcquirePoller{}, ready: map[*syncWait]bool{}, wake: wake, pipeR: r, pipeW: w, log: zerowrap.Default()}, nil |
| 219 | } | 232 | } | ||
| 220 | 233 | ||||
| 221 | // watch registers an eventfd for p; the wait is ready once it fires. | 234 | // watch registers an eventfd for p; the wait is ready once it fires. | ||
| 222 | func (sw *syncWaiter) watch(p syncPoint) (*syncWait, error) { | 235 | func (sw *syncWaiter) watch(p syncPoint) (*syncWait, error) { | ||
| 236 | + | if sw.stopped() { | |||
| 237 | + | return &syncWait{efd: -1, done: true, failed: true}, unix.EIO | |||
| 238 | + | } | |||
| 223 | ready, err := p.tl.dev.signalled(p.tl.handle, p.point) | 239 | ready, err := p.tl.dev.signalled(p.tl.handle, p.point) | ||
| 224 | if err != nil || ready { | 240 | if err != nil || ready { | ||
| 225 | - | return &syncWait{efd: -1, done: ready}, err | 241 | + | return &syncWait{efd: -1, done: true, failed: err != nil}, err |
| 226 | } | 242 | } | ||
| 227 | efd, err := unix.Eventfd(0, unix.EFD_CLOEXEC|unix.EFD_NONBLOCK) | 243 | efd, err := unix.Eventfd(0, unix.EFD_CLOEXEC|unix.EFD_NONBLOCK) | ||
| 228 | if err != nil { | 244 | if err != nil { | ||
| 229 | - | return nil, err | 245 | + | return &syncWait{efd: -1, done: true, failed: true}, err |
| 230 | } | 246 | } | ||
| 231 | if err := p.tl.dev.eventfd(p.tl.handle, p.point, efd); err != nil { | 247 | if err := p.tl.dev.eventfd(p.tl.handle, p.point, efd); err != nil { | ||
| 232 | unix.Close(efd) | 248 | unix.Close(efd) | ||
| 233 | - | return nil, err | 249 | + | return &syncWait{efd: -1, done: true, failed: true}, err |
| 234 | } | 250 | } | ||
| 235 | - | return sw.add(efd), nil | 251 | + | w := sw.add(efd) |
| 252 | + | if w.failed { | |||
| 253 | + | return w, unix.EIO | |||
| 254 | + | } | |||
| 255 | + | return w, nil | |||
| 236 | } | 256 | } | ||
| 237 | 257 | ||||
| 238 | // watchImplicit waits for a dma-buf's exclusive fences without blocking dispatch. | 258 | // watchImplicit waits for a dma-buf's exclusive fences without blocking dispatch. | ||
| 239 | func (sw *syncWaiter) watchImplicit(b *ports.DMABuf, waits *[4]*syncWait) error { | 259 | func (sw *syncWaiter) watchImplicit(b *ports.DMABuf, waits *[4]*syncWait) error { | ||
| 260 | + | if sw.stopped() { | |||
| 261 | + | return unix.EIO | |||
| 262 | + | } | |||
| 240 | var fds [4]unix.PollFd | 263 | var fds [4]unix.PollFd | ||
| 241 | for i, p := range b.Planes { | 264 | for i, p := range b.Planes { | ||
| 242 | if i >= len(fds) { | 265 | if i >= len(fds) { | ||
| @@ -258,7 +281,7 @@ func (sw *syncWaiter) watchImplicit(b *ports.DMABuf, waits *[4]*syncWait) error | |||||
| 258 | } | 281 | } | ||
| 259 | if fd.Revents&(unix.POLLERR|unix.POLLNVAL|unix.POLLHUP) != 0 { | 282 | if fd.Revents&(unix.POLLERR|unix.POLLNVAL|unix.POLLHUP) != 0 { | ||
| 260 | sw.warnPollError() | 283 | sw.warnPollError() | ||
| 261 | - | continue | 284 | + | return unix.EIO |
| 262 | } | 285 | } | ||
| 263 | dup, err := unix.FcntlInt(uintptr(fd.Fd), unix.F_DUPFD_CLOEXEC, 0) | 286 | dup, err := unix.FcntlInt(uintptr(fd.Fd), unix.F_DUPFD_CLOEXEC, 0) | ||
| 264 | if err != nil { | 287 | if err != nil { | ||
| @@ -269,6 +292,15 @@ func (sw *syncWaiter) watchImplicit(b *ports.DMABuf, waits *[4]*syncWait) error | |||||
| 269 | return err | 292 | return err | ||
| 270 | } | 293 | } | ||
| 271 | waits[i] = sw.add(dup) | 294 | waits[i] = sw.add(dup) | ||
| 295 | + | if waits[i].failed { | |||
| 296 | + | for j, w := range waits { | |||
| 297 | + | if w != nil { | |||
| 298 | + | sw.cancel(w) | |||
| 299 | + | waits[j] = nil | |||
| 300 | + | } | |||
| 301 | + | } | |||
| 302 | + | return unix.EIO | |||
| 303 | + | } | |||
| 272 | } | 304 | } | ||
| 273 | return nil | 305 | return nil | ||
| 274 | } | 306 | } | ||
| @@ -282,6 +314,11 @@ func (sw *syncWaiter) warnPollError() { | |||||
| 282 | func (sw *syncWaiter) add(fd int) *syncWait { | 314 | func (sw *syncWaiter) add(fd int) *syncWait { | ||
| 283 | w := &syncWait{efd: fd} | 315 | w := &syncWait{efd: fd} | ||
| 284 | sw.mu.Lock() | 316 | sw.mu.Lock() | ||
| 317 | + | if sw.terminal { | |||
| 318 | + | sw.mu.Unlock() | |||
| 319 | + | unix.Close(fd) | |||
| 320 | + | return &syncWait{efd: -1, done: true, failed: true} | |||
| 321 | + | } | |||
| 285 | sw.added = append(sw.added, w) | 322 | sw.added = append(sw.added, w) | ||
| 286 | sw.mu.Unlock() | 323 | sw.mu.Unlock() | ||
| 287 | _, _ = sw.pipeW.Write([]byte{0}) | 324 | _, _ = sw.pipeW.Write([]byte{0}) | ||
| @@ -292,9 +329,9 @@ func (sw *syncWaiter) add(fd int) *syncWait { | |||||
| 292 | func (sw *syncWaiter) fired(w *syncWait) bool { | 329 | func (sw *syncWaiter) fired(w *syncWait) bool { | ||
| 293 | sw.mu.Lock() | 330 | sw.mu.Lock() | ||
| 294 | defer sw.mu.Unlock() | 331 | defer sw.mu.Unlock() | ||
| 295 | - | if sw.ready[w] { | 332 | + | if ready, ok := sw.ready[w]; ok { |
| 296 | delete(sw.ready, w) | 333 | delete(sw.ready, w) | ||
| 297 | - | w.done = true | 334 | + | w.done, w.failed = true, !ready |
| 298 | } | 335 | } | ||
| 299 | return w.done | 336 | return w.done | ||
| 300 | } | 337 | } | ||
| @@ -312,6 +349,28 @@ func (sw *syncWaiter) cancel(w *syncWait) { | |||||
| 312 | _, _ = sw.pipeW.Write([]byte{0}) // run closes its eventfd | 349 | _, _ = sw.pipeW.Write([]byte{0}) // run closes its eventfd | ||
| 313 | } | 350 | } | ||
| 314 | 351 | ||||
| 352 | + | func (sw *syncWaiter) stopped() bool { | |||
| 353 | + | sw.mu.Lock() | |||
| 354 | + | defer sw.mu.Unlock() | |||
| 355 | + | return sw.terminal | |||
| 356 | + | } | |||
| 357 | + | ||||
| 358 | + | // fail publishes terminal observations before run closes its descriptors. | |||
| 359 | + | // add checks the same mutex, so no new wait can miss this failure handoff. | |||
| 360 | + | func (sw *syncWaiter) fail(waits []*syncWait) { | |||
| 361 | + | sw.mu.Lock() | |||
| 362 | + | sw.terminal = true | |||
| 363 | + | for _, group := range [][]*syncWait{waits, sw.added} { | |||
| 364 | + | for _, w := range group { | |||
| 365 | + | if !w.cancelled.Load() { | |||
| 366 | + | sw.ready[w] = false | |||
| 367 | + | } | |||
| 368 | + | } | |||
| 369 | + | } | |||
| 370 | + | sw.mu.Unlock() | |||
| 371 | + | sw.wake() | |||
| 372 | + | } | |||
| 373 | + | ||||
| 315 | // run polls every watched eventfd; it owns and closes them. | 374 | // run polls every watched eventfd; it owns and closes them. | ||
| 316 | func (sw *syncWaiter) run(ctx context.Context) { | 375 | func (sw *syncWaiter) run(ctx context.Context) { | ||
| 317 | defer sw.pipeR.Close() | 376 | defer sw.pipeR.Close() | ||
| @@ -339,8 +398,9 @@ func (sw *syncWaiter) run(ctx context.Context) { | |||||
| 339 | for _, w := range waits { | 398 | for _, w := range waits { | ||
| 340 | fds = append(fds, unix.PollFd{Fd: int32(w.efd), Events: unix.POLLIN}) | 399 | fds = append(fds, unix.PollFd{Fd: int32(w.efd), Events: unix.POLLIN}) | ||
| 341 | } | 400 | } | ||
| 342 | - | n, err := unix.Poll(fds, 100) | 401 | + | n, err := sw.poller.poll(fds, 100) |
| 343 | if err != nil && !errors.Is(err, unix.EINTR) { | 402 | if err != nil && !errors.Is(err, unix.EINTR) { | ||
| 403 | + | sw.fail(waits) | |||
| 344 | return | 404 | return | ||
| 345 | } | 405 | } | ||
| 346 | if n <= 0 { | 406 | if n <= 0 { | ||
| @@ -364,7 +424,7 @@ func (sw *syncWaiter) run(ctx context.Context) { | |||||
| 364 | unix.Close(w.efd) | 424 | unix.Close(w.efd) | ||
| 365 | sw.mu.Lock() | 425 | sw.mu.Lock() | ||
| 366 | if !w.cancelled.Load() { | 426 | if !w.cancelled.Load() { | ||
| 367 | - | sw.ready[w] = true | 427 | + | sw.ready[w] = revents&unix.POLLIN != 0 |
| 368 | fired = true | 428 | fired = true | ||
| 369 | } | 429 | } | ||
| 370 | sw.mu.Unlock() | 430 | sw.mu.Unlock() | ||
| @@ -432,12 +492,11 @@ func (s *surface) takeSyncPoints() { | |||||
| 432 | cs.release.tl.uses++ | 492 | cs.release.tl.uses++ | ||
| 433 | w, err := s.server.syncWait.watch(cs.acquire) | 493 | w, err := s.server.syncWait.watch(cs.acquire) | ||
| 434 | if err != nil { | 494 | if err != nil { | ||
| 435 | - | // The point cannot be watched: apply now, readers wait on the | 495 | + | // Keep points owned by the captured commit for safe discard. An |
| 436 | - | // fence (or its absence) when they read the buffer. | 496 | + | // unobservable acquire must never reach native rendering. |
| 437 | s.server.log.Warn().Str("component", "wayland").Err(err).Msg("acquire point") | 497 | s.server.log.Warn().Str("component", "wayland").Err(err).Msg("acquire point") | ||
| 438 | - | } else { | |||
| 439 | - | cs.wait = w | |||
| 440 | } | 498 | } | ||
| 499 | + | cs.wait = w | |||
| 441 | s.next.sync = cs | 500 | s.next.sync = cs | ||
| 442 | } | 501 | } | ||
| 443 | 502 | ||||
| @@ -453,7 +512,11 @@ func (s *surface) dropSync(cs *commitSync) { | |||||
| 453 | if cs == nil { | 512 | if cs == nil { | ||
| 454 | return | 513 | return | ||
| 455 | } | 514 | } | ||
| 456 | - | ready := cs.wait != nil && s.server.syncWait.fired(cs.wait) | 515 | + | if cs.fence != nil { |
| 516 | + | cs.fence.Close() | |||
| 517 | + | cs.fence = nil | |||
| 518 | + | } | |||
| 519 | + | ready := cs.wait != nil && s.server.syncWait.fired(cs.wait) && !cs.wait.failed | |||
| 457 | if cs.wait != nil { | 520 | if cs.wait != nil { | ||
| 458 | s.server.syncWait.cancel(cs.wait) | 521 | s.server.syncWait.cancel(cs.wait) | ||
| 459 | } | 522 | } | ||
| @@ -467,6 +530,24 @@ func (s *surface) dropSync(cs *commitSync) { | |||||
| 467 | s.server.dropTimeline(cs.release.tl) | 530 | s.server.dropTimeline(cs.release.tl) | ||
| 468 | } | 531 | } | ||
| 469 | 532 | ||||
| 533 | + | // prepareSync exports only after observed readiness, before any current state | |||
| 534 | + | // changes. Export failure must not fall back to native implicit synchronization. | |||
| 535 | + | func (s *surface) prepareSync(cs *commitSync) bool { | |||
| 536 | + | if cs == nil || cs.fence != nil { | |||
| 537 | + | return true | |||
| 538 | + | } | |||
| 539 | + | f, err := cs.acquire.tl.dev.exportSyncFile(cs.acquire.tl.handle, cs.acquire.point) | |||
| 540 | + | if err != nil || f == nil { | |||
| 541 | + | if f != nil { | |||
| 542 | + | f.Close() | |||
| 543 | + | } | |||
| 544 | + | s.server.log.Warn().Str("component", "wayland").Err(err).Msg("acquire fence export rejected") | |||
| 545 | + | return false | |||
| 546 | + | } | |||
| 547 | + | cs.fence = f | |||
| 548 | + | return true | |||
| 549 | + | } | |||
| 550 | + | ||||
| 470 | // applySync makes a commit's points the current buffer's: its acquire | 551 | // applySync makes a commit's points the current buffer's: its acquire | ||
| 471 | // fence goes with the content, its release point waits for release. | 552 | // fence goes with the content, its release point waits for release. | ||
| 472 | func (s *surface) applySync(cs *commitSync) { | 553 | func (s *surface) applySync(cs *commitSync) { | ||
| @@ -476,11 +557,8 @@ func (s *surface) applySync(cs *commitSync) { | |||||
| 476 | if cs.wait != nil { | 557 | if cs.wait != nil { | ||
| 477 | s.server.syncWait.cancel(cs.wait) | 558 | s.server.syncWait.cancel(cs.wait) | ||
| 478 | } | 559 | } | ||
| 479 | - | f, err := cs.acquire.tl.dev.exportSyncFile(cs.acquire.tl.handle, cs.acquire.point) | 560 | + | f := cs.fence |
| 480 | - | if err != nil { | 561 | + | cs.fence = nil |
| 481 | - | s.server.log.Warn().Str("component", "wayland").Err(err).Msg("acquire fence") | |||
| 482 | - | f = nil | |||
| 483 | - | } | |||
| 484 | cs.acquire.tl.uses-- | 562 | cs.acquire.tl.uses-- | ||
| 485 | s.server.dropTimeline(cs.acquire.tl) | 563 | s.server.dropTimeline(cs.acquire.tl) | ||
| 486 | s.hold = syncHold{acquire: f, release: cs.release} | 564 | s.hold = syncHold{acquire: f, release: cs.release} | ||
M internal/adapters/wayland/syncobj_test.go +4 -4
| @@ -135,7 +135,7 @@ func newSyncHarness(t *testing.T) *syncHarness { | |||||
| 135 | defer unix.Close(fd) | 135 | defer unix.Close(fd) | ||
| 136 | h.timeline = h.c.AllocateID() | 136 | h.timeline = h.c.AllocateID() | ||
| 137 | registerProtocol(t, h.c, h.timeline) | 137 | registerProtocol(t, h.c, h.timeline) | ||
| 138 | - | if err := h.c.SendRequestWithFDs(mgr, uint16(linuxdrmsyncobj.WpLinuxDrmSyncobjManagerV1RequestImportTimeline), []int{fd}, h.timeline); err != nil { | 138 | + | if err := wireRequest(h.c, mgr, uint16(linuxdrmsyncobj.WpLinuxDrmSyncobjManagerV1RequestImportTimeline), []int{fd}, h.timeline); err != nil { |
| 139 | t.Fatal(err) | 139 | t.Fatal(err) | ||
| 140 | } | 140 | } | ||
| 141 | for deadline := time.Now().Add(2 * time.Second); ; { | 141 | for deadline := time.Now().Add(2 * time.Second); ; { | ||
| @@ -167,13 +167,13 @@ func (h *syncHarness) dmabuf() uint32 { | |||||
| 167 | p := c.AllocateID() | 167 | p := c.AllocateID() | ||
| 168 | requestProtocol(t, c, dm, linuxdmabuf.ZwpLinuxDmabufV1RequestCreateParams, p) | 168 | requestProtocol(t, c, dm, linuxdmabuf.ZwpLinuxDmabufV1RequestCreateParams, p) | ||
| 169 | registerProtocol(t, c, p) | 169 | registerProtocol(t, c, p) | ||
| 170 | - | if err := c.SendRequestWithFDs(p, uint16(linuxdmabuf.ZwpLinuxBufferParamsV1RequestAdd), []int{fd}, uint32(0), uint32(0), uint32(256), uint32(0), uint32(0)); err != nil { | 170 | + | if err := wireRequest(c, p, uint16(linuxdmabuf.ZwpLinuxBufferParamsV1RequestAdd), []int{fd}, uint32(0), uint32(0), uint32(256), uint32(0), uint32(0)); err != nil { |
| 171 | t.Fatal(err) | 171 | t.Fatal(err) | ||
| 172 | } | 172 | } | ||
| 173 | b := c.AllocateID() | 173 | b := c.AllocateID() | ||
| 174 | rel := &syncReleaseProxy{released: make(chan struct{}, 4)} | 174 | rel := &syncReleaseProxy{released: make(chan struct{}, 4)} | ||
| 175 | rel.SetID(b) | 175 | rel.SetID(b) | ||
| 176 | - | c.Context().Register(rel) | 176 | + | registerWireProxy(c, rel) |
| 177 | requestProtocol(t, c, p, linuxdmabuf.ZwpLinuxBufferParamsV1RequestCreateImmed, b, int32(64), int32(16), linearARGB.Format, uint32(0)) | 177 | requestProtocol(t, c, p, linuxdmabuf.ZwpLinuxBufferParamsV1RequestCreateImmed, b, int32(64), int32(16), linearARGB.Format, uint32(0)) | ||
| 178 | h.releases[b] = rel | 178 | h.releases[b] = rel | ||
| 179 | return b | 179 | return b | ||
| @@ -479,7 +479,7 @@ func TestSyncobjInvalidTimeline(t *testing.T) { | |||||
| 479 | mgr := bindProtocol(t, c, "wp_linux_drm_syncobj_manager_v1") | 479 | mgr := bindProtocol(t, c, "wp_linux_drm_syncobj_manager_v1") | ||
| 480 | fd, _ := unix.MemfdCreate("bad", 0) | 480 | fd, _ := unix.MemfdCreate("bad", 0) | ||
| 481 | defer unix.Close(fd) | 481 | defer unix.Close(fd) | ||
| 482 | - | if err := c.SendRequestWithFDs(mgr, uint16(linuxdrmsyncobj.WpLinuxDrmSyncobjManagerV1RequestImportTimeline), []int{fd}, c.AllocateID()); err != nil { | 482 | + | if err := wireRequest(c, mgr, uint16(linuxdrmsyncobj.WpLinuxDrmSyncobjManagerV1RequestImportTimeline), []int{fd}, c.AllocateID()); err != nil { |
| 483 | t.Fatal(err) | 483 | t.Fatal(err) | ||
| 484 | } | 484 | } | ||
| 485 | expectProtocolError(t, c, mgr, uint32(linuxdrmsyncobj.WpLinuxDrmSyncobjManagerV1ErrorInvalidTimeline)) | 485 | expectProtocolError(t, c, mgr, uint32(linuxdrmsyncobj.WpLinuxDrmSyncobjManagerV1ErrorInvalidTimeline)) | ||
M internal/adapters/wayland/syncwait_test.go +6 -6
| @@ -27,8 +27,8 @@ func TestSyncWaitAlreadySignalled(t *testing.T) { | |||||
| 27 | } | 27 | } | ||
| 28 | } | 28 | } | ||
| 29 | 29 | ||||
| 30 | - | // An invalid poll result is not readiness; it must not hold the surface. | 30 | + | // An invalid poll result is an error, never permission to publish. |
| 31 | - | func TestImplicitPollErrorReadyAndWarnOnce(t *testing.T) { | 31 | + | func TestImplicitPollErrorRejectedAndWarnOnce(t *testing.T) { |
| 32 | sw, err := newSyncWaiter(func() {}) | 32 | sw, err := newSyncWaiter(func() {}) | ||
| 33 | if err != nil { | 33 | if err != nil { | ||
| 34 | t.Fatal(err) | 34 | t.Fatal(err) | ||
| @@ -42,8 +42,8 @@ func TestImplicitPollErrorReadyAndWarnOnce(t *testing.T) { | |||||
| 42 | b := &ports.DMABuf{Planes: []ports.DMABufPlane{{File: os.NewFile(uintptr(math.MaxInt32), "invalid")}}} | 42 | b := &ports.DMABuf{Planes: []ports.DMABufPlane{{File: os.NewFile(uintptr(math.MaxInt32), "invalid")}}} | ||
| 43 | var waits [4]*syncWait | 43 | var waits [4]*syncWait | ||
| 44 | for range 2 { | 44 | for range 2 { | ||
| 45 | - | if err := sw.watchImplicit(b, &waits); err != nil || waits[0] != nil { | 45 | + | if err := sw.watchImplicit(b, &waits); err == nil || waits[0] != nil { |
| 46 | - | t.Fatalf("poll error should not register wait: %v %+v", err, waits) | 46 | + | t.Fatalf("poll error should reject without registering wait: %v %+v", err, waits) |
| 47 | } | 47 | } | ||
| 48 | } | 48 | } | ||
| 49 | if n := bytes.Count(output.Bytes(), []byte("implicit fence poll error")); n != 1 { | 49 | if n := bytes.Count(output.Bytes(), []byte("implicit fence poll error")); n != 1 { | ||
| @@ -76,8 +76,8 @@ func TestImplicitPollHangupWakes(t *testing.T) { | |||||
| 76 | for !sw.fired(waits[0]) && time.Now().Before(deadline) { | 76 | for !sw.fired(waits[0]) && time.Now().Before(deadline) { | ||
| 77 | time.Sleep(time.Millisecond) | 77 | time.Sleep(time.Millisecond) | ||
| 78 | } | 78 | } | ||
| 79 | - | if !sw.fired(waits[0]) { | 79 | + | if !sw.fired(waits[0]) || !waits[0].failed { |
| 80 | - | t.Fatal("hangup never woke waiter") | 80 | + | t.Fatal("hangup must wake waiter as failed, not ready to publish") |
| 81 | } | 81 | } | ||
| 82 | } | 82 | } | ||
| 83 | 83 | ||||
M internal/adapters/wayland/textinput.go +41 -6
| @@ -87,6 +87,9 @@ func (t *textInput) Disable(*textinput.ZwpTextInputV3) { | |||||
| 87 | // SetSurroundingText ignores text past the spec limit or a cursor or | 87 | // SetSurroundingText ignores text past the spec limit or a cursor or | ||
| 88 | // anchor outside it. | 88 | // anchor outside it. | ||
| 89 | func (t *textInput) SetSurroundingText(_ *textinput.ZwpTextInputV3, text string, cursor, anchor int32) { | 89 | func (t *textInput) SetSurroundingText(_ *textinput.ZwpTextInputV3, text string, cursor, anchor int32) { | ||
| 90 | + | if t.server.protected() { | |||
| 91 | + | return | |||
| 92 | + | } | |||
| 90 | if len(text) > maxIMEText || cursor < 0 || anchor < 0 || int(cursor) > len(text) || int(anchor) > len(text) { | 93 | if len(text) > maxIMEText || cursor < 0 || anchor < 0 || int(cursor) > len(text) || int(anchor) > len(text) { | ||
| 91 | t.server.log.Debug().Int("bytes", len(text)).Msg("surrounding text ignored") | 94 | t.server.log.Debug().Int("bytes", len(text)).Msg("surrounding text ignored") | ||
| 92 | return | 95 | return | ||
| @@ -111,6 +114,10 @@ func (t *textInput) SetCursorRectangle(_ *textinput.ZwpTextInputV3, x, y, w, h i | |||||
| 111 | // serial, but after leave the state is ignored until the next enter. | 114 | // serial, but after leave the state is ignored until the next enter. | ||
| 112 | func (t *textInput) Commit(*textinput.ZwpTextInputV3) { | 115 | func (t *textInput) Commit(*textinput.ZwpTextInputV3) { | ||
| 113 | t.serial++ | 116 | t.serial++ | ||
| 117 | + | if t.server.protected() { | |||
| 118 | + | t.pending, t.current, t.toggled = textState{}, textState{}, false | |||
| 119 | + | return | |||
| 120 | + | } | |||
| 114 | if t.focus == nil { | 121 | if t.focus == nil { | ||
| 115 | return | 122 | return | ||
| 116 | } | 123 | } | ||
| @@ -128,6 +135,9 @@ func (*textInput) HideInputPanel(*textinput.ZwpTextInputV3) {} | |||||
| 128 | 135 | ||||
| 129 | // enter focuses the text input on surf; its state starts over. | 136 | // enter focuses the text input on surf; its state starts over. | ||
| 130 | func (t *textInput) enter(surf *wayland.Surface) { | 137 | func (t *textInput) enter(surf *wayland.Surface) { | ||
| 138 | + | if t.server.protected() { | |||
| 139 | + | return | |||
| 140 | + | } | |||
| 131 | t.focus = surf | 141 | t.focus = surf | ||
| 132 | t.pending, t.current, t.toggled = textState{}, textState{}, false | 142 | t.pending, t.current, t.toggled = textState{}, textState{}, false | ||
| 133 | t.res.SendEnter(surf) | 143 | t.res.SendEnter(surf) | ||
| @@ -149,6 +159,9 @@ func (t *textInput) leave() { | |||||
| 149 | // textCommitted activates, updates or deactivates the input method for a | 159 | // textCommitted activates, updates or deactivates the input method for a | ||
| 150 | // committed text input. Enabling a second text input is ignored. | 160 | // committed text input. Enabling a second text input is ignored. | ||
| 151 | func (s *Server) textCommitted(t *textInput, reenabled bool) { | 161 | func (s *Server) textCommitted(t *textInput, reenabled bool) { | ||
| 162 | + | if s.protected() { | |||
| 163 | + | return | |||
| 164 | + | } | |||
| 152 | switch { | 165 | switch { | ||
| 153 | case t.current.enabled && s.activeText == nil: | 166 | case t.current.enabled && s.activeText == nil: | ||
| 154 | s.activeText = t | 167 | s.activeText = t | ||
| @@ -198,7 +211,7 @@ func (s *Server) deactivateText() { | |||||
| 198 | // activateIM sends activate and the active text input's state. | 211 | // activateIM sends activate and the active text input's state. | ||
| 199 | func (s *Server) activateIM() { | 212 | func (s *Server) activateIM() { | ||
| 200 | im := s.inputMethod | 213 | im := s.inputMethod | ||
| 201 | - | if im == nil || s.activeText == nil { | 214 | + | if s.protected() || im == nil || s.activeText == nil { |
| 202 | return | 215 | return | ||
| 203 | } | 216 | } | ||
| 204 | im.pending = imState{} | 217 | im.pending = imState{} | ||
| @@ -210,7 +223,7 @@ func (s *Server) activateIM() { | |||||
| 210 | // sendIMState sends the active text input's state, then done. | 223 | // sendIMState sends the active text input's state, then done. | ||
| 211 | func (s *Server) sendIMState() { | 224 | func (s *Server) sendIMState() { | ||
| 212 | im, t := s.inputMethod, s.activeText | 225 | im, t := s.inputMethod, s.activeText | ||
| 213 | - | if im == nil || t == nil { | 226 | + | if s.protected() || im == nil || t == nil { |
| 214 | return | 227 | return | ||
| 215 | } | 228 | } | ||
| 216 | c := t.current | 229 | c := t.current | ||
| @@ -304,14 +317,14 @@ func (im *inputMethod) done() { | |||||
| 304 | } | 317 | } | ||
| 305 | 318 | ||||
| 306 | func (im *inputMethod) CommitString(_ *inputmethod.ZwpInputMethodV2, text string) { | 319 | func (im *inputMethod) CommitString(_ *inputmethod.ZwpInputMethodV2, text string) { | ||
| 307 | - | if im.inert || !im.fits(text) { | 320 | + | if im.server.protected() || im.inert || !im.fits(text) { |
| 308 | return | 321 | return | ||
| 309 | } | 322 | } | ||
| 310 | im.pending.commit, im.pending.hasCommit = text, true | 323 | im.pending.commit, im.pending.hasCommit = text, true | ||
| 311 | } | 324 | } | ||
| 312 | 325 | ||||
| 313 | func (im *inputMethod) SetPreeditString(_ *inputmethod.ZwpInputMethodV2, text string, begin, end int32) { | 326 | func (im *inputMethod) SetPreeditString(_ *inputmethod.ZwpInputMethodV2, text string, begin, end int32) { | ||
| 314 | - | if im.inert || !im.fits(text) { | 327 | + | if im.server.protected() || im.inert || !im.fits(text) { |
| 315 | return | 328 | return | ||
| 316 | } | 329 | } | ||
| 317 | p := &im.pending | 330 | p := &im.pending | ||
| @@ -327,7 +340,7 @@ func (im *inputMethod) fits(text string) bool { | |||||
| 327 | } | 340 | } | ||
| 328 | 341 | ||||
| 329 | func (im *inputMethod) DeleteSurroundingText(_ *inputmethod.ZwpInputMethodV2, before, after uint32) { | 342 | func (im *inputMethod) DeleteSurroundingText(_ *inputmethod.ZwpInputMethodV2, before, after uint32) { | ||
| 330 | - | if im.inert { | 343 | + | if im.server.protected() || im.inert { |
| 331 | return | 344 | return | ||
| 332 | } | 345 | } | ||
| 333 | im.pending.before, im.pending.after = before, after | 346 | im.pending.before, im.pending.after = before, after | ||
| @@ -345,6 +358,9 @@ func (im *inputMethod) Commit(_ *inputmethod.ZwpInputMethodV2, serial uint32) { | |||||
| 345 | st := im.pending | 358 | st := im.pending | ||
| 346 | im.pending = imState{} | 359 | im.pending = imState{} | ||
| 347 | s := im.server | 360 | s := im.server | ||
| 361 | + | if s.protected() { | |||
| 362 | + | return | |||
| 363 | + | } | |||
| 348 | t := s.activeText | 364 | t := s.activeText | ||
| 349 | if t == nil { | 365 | if t == nil { | ||
| 350 | return | 366 | return | ||
| @@ -406,7 +422,7 @@ func (im *inputMethod) GetInputPopupSurface(r *inputmethod.ZwpInputMethodV2, id | |||||
| 406 | func (im *inputMethod) GrabKeyboard(r *inputmethod.ZwpInputMethodV2, id uint32) { | 422 | func (im *inputMethod) GrabKeyboard(r *inputmethod.ZwpInputMethodV2, id uint32) { | ||
| 407 | g := &keyboardGrab{} | 423 | g := &keyboardGrab{} | ||
| 408 | res, err := inputmethod.NewZwpInputMethodKeyboardGrabV2(r.Client(), r.Version(), id, g) | 424 | res, err := inputmethod.NewZwpInputMethodKeyboardGrabV2(r.Client(), r.Version(), id, g) | ||
| 409 | - | if err != nil || im.inert || im.grab != nil { | 425 | + | if err != nil || im.server.protected() || im.inert || im.grab != nil { |
| 410 | return | 426 | return | ||
| 411 | } | 427 | } | ||
| 412 | g.res, im.grab = res, g | 428 | g.res, im.grab = res, g | ||
| @@ -439,6 +455,9 @@ func (*inputPopup) Destroy(*inputmethod.ZwpInputPopupSurfaceV2) {} | |||||
| 439 | 455 | ||||
| 440 | // sendRect tells the popup where the active text cursor is. | 456 | // sendRect tells the popup where the active text cursor is. | ||
| 441 | func (p *inputPopup) sendRect() { | 457 | func (p *inputPopup) sendRect() { | ||
| 458 | + | if p.im.server.protected() { | |||
| 459 | + | return | |||
| 460 | + | } | |||
| 442 | t := p.im.server.activeText | 461 | t := p.im.server.activeText | ||
| 443 | if t == nil || !t.current.hasRect { | 462 | if t == nil || !t.current.hasRect { | ||
| 444 | return | 463 | return | ||
| @@ -455,6 +474,9 @@ func (*keyboardGrab) Release(*inputmethod.ZwpInputMethodKeyboardGrabV2) {} | |||||
| 455 | 474 | ||||
| 456 | // grab is the live keyboard grab, nil without one. | 475 | // grab is the live keyboard grab, nil without one. | ||
| 457 | func (s *Server) grab() *keyboardGrab { | 476 | func (s *Server) grab() *keyboardGrab { | ||
| 477 | + | if s.protected() { | |||
| 478 | + | return nil | |||
| 479 | + | } | |||
| 458 | if im := s.inputMethod; im != nil { | 480 | if im := s.inputMethod; im != nil { | ||
| 459 | return im.grab | 481 | return im.grab | ||
| 460 | } | 482 | } | ||
| @@ -467,6 +489,13 @@ func (s *Server) grab() *keyboardGrab { | |||||
| 467 | // does, even after the grab ends. | 489 | // does, even after the grab ends. | ||
| 468 | func (s *Server) grabKey(c ports.ForwardKey) bool { | 490 | func (s *Server) grabKey(c ports.ForwardKey) bool { | ||
| 469 | code := c.Key.Keycode | 491 | code := c.Key.Keycode | ||
| 492 | + | if s.protected() { | |||
| 493 | + | if !c.Key.Pressed && s.seat.grabKeys[code] { | |||
| 494 | + | delete(s.seat.grabKeys, code) | |||
| 495 | + | return true | |||
| 496 | + | } | |||
| 497 | + | return false // protected seat routing belongs to the parent | |||
| 498 | + | } | |||
| 470 | g := s.grab() | 499 | g := s.grab() | ||
| 471 | owned := !c.Key.Pressed && s.seat.grabKeys[code] | 500 | owned := !c.Key.Pressed && s.seat.grabKeys[code] | ||
| 472 | if owned { | 501 | if owned { | ||
| @@ -500,6 +529,9 @@ func (s *Server) grabKey(c ports.ForwardKey) bool { | |||||
| 500 | // grabReleaseDropped keeps the focused client's modifiers right when the | 529 | // grabReleaseDropped keeps the focused client's modifiers right when the | ||
| 501 | // release of a grab-owned key is dropped. | 530 | // release of a grab-owned key is dropped. | ||
| 502 | func (s *Server) grabReleaseDropped(m ports.ModState) { | 531 | func (s *Server) grabReleaseDropped(m ports.ModState) { | ||
| 532 | + | if s.protected() { | |||
| 533 | + | return | |||
| 534 | + | } | |||
| 503 | if m == s.seat.modState { | 535 | if m == s.seat.modState { | ||
| 504 | return | 536 | return | ||
| 505 | } | 537 | } | ||
| @@ -512,6 +544,9 @@ func (s *Server) grabReleaseDropped(m ports.ModState) { | |||||
| 512 | 544 | ||||
| 513 | // grabEnded gives the focused client the seat modifiers it missed. | 545 | // grabEnded gives the focused client the seat modifiers it missed. | ||
| 514 | func (s *Server) grabEnded() { | 546 | func (s *Server) grabEnded() { | ||
| 547 | + | if s.protected() { | |||
| 548 | + | return | |||
| 549 | + | } | |||
| 515 | s.serial++ | 550 | s.serial++ | ||
| 516 | for _, k := range s.clientKeyboards(s.focusClient()) { | 551 | for _, k := range s.clientKeyboards(s.focusClient()) { | ||
| 517 | s.sendModifiers(k) | 552 | s.sendModifiers(k) | ||
M internal/adapters/wayland/textinput_test.go +3 -3
| @@ -63,7 +63,7 @@ func newIMELog(c *wlturbo.Display, id uint32, sigs ...string) *imeLog { | |||||
| 63 | l.sigs = append(l.sigs, struct{ name, args string }{name, args}) | 63 | l.sigs = append(l.sigs, struct{ name, args string }{name, args}) | ||
| 64 | } | 64 | } | ||
| 65 | l.SetID(id) | 65 | l.SetID(id) | ||
| 66 | - | c.Context().Register(l) | 66 | + | registerWireProxy(c, l) |
| 67 | return l | 67 | return l | ||
| 68 | } | 68 | } | ||
| 69 | 69 | ||||
| @@ -90,7 +90,7 @@ func newTextApp(t *testing.T, s *Server, events chan ports.ClientEvent, commands | |||||
| 90 | registerProtocol(t, c, seat) | 90 | registerProtocol(t, c, seat) | ||
| 91 | keys := &eventsProxy{} | 91 | keys := &eventsProxy{} | ||
| 92 | keys.SetID(c.AllocateID()) | 92 | keys.SetID(c.AllocateID()) | ||
| 93 | - | c.Context().Register(keys) | 93 | + | registerWireProxy(c, keys) |
| 94 | requestProtocol(t, c, seat, wayland.SeatRequestGetKeyboard, keys.ID()) | 94 | requestProtocol(t, c, seat, wayland.SeatRequestGetKeyboard, keys.ID()) | ||
| 95 | w, surf, xdg := surfaceMapper(t, c, events)() | 95 | w, surf, xdg := surfaceMapper(t, c, events)() | ||
| 96 | manager := bindProtocol(t, c, "zwp_text_input_manager_v3") | 96 | manager := bindProtocol(t, c, "zwp_text_input_manager_v3") | ||
| @@ -459,7 +459,7 @@ func TestTextInputLeaveBeforeEnter(t *testing.T) { | |||||
| 459 | for _, tag := range []string{"t1", "t2"} { | 459 | for _, tag := range []string{"t1", "t2"} { | ||
| 460 | p := &tagged{tag: tag, into: log} | 460 | p := &tagged{tag: tag, into: log} | ||
| 461 | p.SetID(c.AllocateID()) | 461 | p.SetID(c.AllocateID()) | ||
| 462 | - | c.Context().Register(p) | 462 | + | registerWireProxy(c, p) |
| 463 | requestProtocol(t, c, manager, textinput.ZwpTextInputManagerV3RequestGetTextInput, p.ID(), seat) | 463 | requestProtocol(t, c, manager, textinput.ZwpTextInputManagerV3RequestGetTextInput, p.ID(), seat) | ||
| 464 | } | 464 | } | ||
| 465 | commands <- ports.FocusWindow{ID: a.ID} | 465 | commands <- ports.FocusWindow{ID: a.ID} | ||
M internal/adapters/wayland/tiled_playback_test.go +2 -2
| @@ -47,7 +47,7 @@ func TestHeadlessTiledSHMCallbackAndCopyBudget(t *testing.T) { | |||||
| 47 | } | 47 | } | ||
| 48 | pool := c.AllocateID() | 48 | pool := c.AllocateID() | ||
| 49 | registerProtocol(t, c, pool) | 49 | registerProtocol(t, c, pool) | ||
| 50 | - | if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(size)); err != nil { | 50 | + | if err := wireRequest(c, shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(size)); err != nil { |
| 51 | t.Fatal(err) | 51 | t.Fatal(err) | ||
| 52 | } | 52 | } | ||
| 53 | children := make([]uint32, tiles+1) | 53 | children := make([]uint32, tiles+1) | ||
| @@ -112,7 +112,7 @@ func TestHeadlessTiledSHMCallbackAndCopyBudget(t *testing.T) { | |||||
| 112 | cb := c.AllocateID() | 112 | cb := c.AllocateID() | ||
| 113 | p := &callbackBudgetProxy{done: done} | 113 | p := &callbackBudgetProxy{done: done} | ||
| 114 | p.SetID(cb) | 114 | p.SetID(cb) | ||
| 115 | - | c.Context().Register(p) | 115 | + | registerWireProxy(c, p) |
| 116 | requestProtocol(t, c, children[tiles], wayland.SurfaceRequestFrame, cb) | 116 | requestProtocol(t, c, children[tiles], wayland.SurfaceRequestFrame, cb) | ||
| 117 | requestProtocol(t, c, children[tiles], wayland.SurfaceRequestAttach, buffers[tiles], int32(0), int32(0)) | 117 | requestProtocol(t, c, children[tiles], wayland.SurfaceRequestAttach, buffers[tiles], int32(0), int32(0)) | ||
| 118 | requestProtocol(t, c, children[tiles], wayland.SurfaceRequestCommit) | 118 | requestProtocol(t, c, children[tiles], wayland.SurfaceRequestCommit) | ||
M internal/adapters/wayland/transform_test.go +3 -3
| @@ -26,7 +26,7 @@ func newTransformClient(t *testing.T) transformClient { | |||||
| 26 | c := protocolClient(t, s, dir) | 26 | c := protocolClient(t, s, dir) | ||
| 27 | // set_buffer_transform needs wl_compositor v2. | 27 | // set_buffer_transform needs wl_compositor v2. | ||
| 28 | g, _ := c.Registry().FindGlobal("wl_compositor") | 28 | g, _ := c.Registry().FindGlobal("wl_compositor") | ||
| 29 | - | comp, err := c.Registry().BindID(g.Name, g.Interface, 6) | 29 | + | comp, err := bindWireID(c, g.Name, g.Interface, 6) |
| 30 | if err != nil { | 30 | if err != nil { | ||
| 31 | t.Fatal(err) | 31 | t.Fatal(err) | ||
| 32 | } | 32 | } | ||
| @@ -44,7 +44,7 @@ func newTransformClient(t *testing.T) transformClient { | |||||
| 44 | serials := make(chan uint32, 4) | 44 | serials := make(chan uint32, 4) | ||
| 45 | cp := &configureProxy{serial: serials} | 45 | cp := &configureProxy{serial: serials} | ||
| 46 | cp.SetID(xdg) | 46 | cp.SetID(xdg) | ||
| 47 | - | c.Context().Register(cp) | 47 | + | registerWireProxy(c, cp) |
| 48 | requestProtocol(t, c, wm, xdgshell.WmBaseRequestGetXdgSurface, xdg, surf) | 48 | requestProtocol(t, c, wm, xdgshell.WmBaseRequestGetXdgSurface, xdg, surf) | ||
| 49 | top := c.AllocateID() | 49 | top := c.AllocateID() | ||
| 50 | registerProtocol(t, c, top) | 50 | registerProtocol(t, c, top) | ||
| @@ -66,7 +66,7 @@ func newTransformClient(t *testing.T) transformClient { | |||||
| 66 | pool, bf := c.AllocateID(), c.AllocateID() | 66 | pool, bf := c.AllocateID(), c.AllocateID() | ||
| 67 | registerProtocol(t, c, pool) | 67 | registerProtocol(t, c, pool) | ||
| 68 | registerProtocol(t, c, bf) | 68 | registerProtocol(t, c, bf) | ||
| 69 | - | if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, size); err != nil { | 69 | + | if err := wireRequest(c, shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, size); err != nil { |
| 70 | t.Fatal(err) | 70 | t.Fatal(err) | ||
| 71 | } | 71 | } | ||
| 72 | // argb8888: not opaque by format. | 72 | // argb8888: not opaque by format. | ||
M internal/adapters/wayland/virtualkeyboard.go +55 -4
| @@ -25,8 +25,10 @@ type virtualKeyboard struct { | |||||
| 25 | text string // the keymap, compared with the active one | 25 | text string // the keymap, compared with the active one | ||
| 26 | mods ports.ModState // last modifiers request, zero before one | 26 | mods ports.ModState // last modifiers request, zero before one | ||
| 27 | // pressed are keys held on focus; a focus change releases them by leave. | 27 | // pressed are keys held on focus; a focus change releases them by leave. | ||
| 28 | - | pressed map[uint32]bool | 28 | + | pressed map[uint32]bool |
| 29 | - | focus ports.WindowID | 29 | + | focus ports.WindowID |
| 30 | + | generation ports.LockGeneration // epoch of held keys; never release across protection | |||
| 31 | + | dropReleases bool // after an epoch change, ignore releases without a new press | |||
| 30 | } | 32 | } | ||
| 31 | 33 | ||||
| 32 | // registerVirtualKeyboard exposes the global to every client, as wlroots | 34 | // registerVirtualKeyboard exposes the global to every client, as wlroots | ||
| @@ -52,6 +54,17 @@ func (m virtualKeyboardManager) CreateVirtualKeyboard(r *virtualkeyboard.ZwpVirt | |||||
| 52 | // Keymap copies the client keymap into a sealed memfd: other clients never | 54 | // Keymap copies the client keymap into a sealed memfd: other clients never | ||
| 53 | // see the client's fd. An unreadable or malformed keymap is ignored. | 55 | // see the client's fd. An unreadable or malformed keymap is ignored. | ||
| 54 | func (k *virtualKeyboard) Keymap(_ *virtualkeyboard.ZwpVirtualKeyboardV1, format uint32, fd int, size uint32) { | 56 | func (k *virtualKeyboard) Keymap(_ *virtualkeyboard.ZwpVirtualKeyboardV1, format uint32, fd int, size uint32) { | ||
| 57 | + | if k.server.protected() { | |||
| 58 | + | unix.Close(fd) | |||
| 59 | + | k.admitted() // discard held state and ownership without delivery | |||
| 60 | + | if k.fd >= 0 { | |||
| 61 | + | unix.Close(k.fd) | |||
| 62 | + | } | |||
| 63 | + | k.fd, k.size, k.text = -1, 0, "" | |||
| 64 | + | // A rejected replacement must not leave an old layout usable after | |||
| 65 | + | // unlock. The client must send a fresh keymap before typing again. | |||
| 66 | + | return | |||
| 67 | + | } | |||
| 55 | text, ok := readKeymap(fd, size) | 68 | text, ok := readKeymap(fd, size) | ||
| 56 | unix.Close(fd) | 69 | unix.Close(fd) | ||
| 57 | if format != uint32(wayland.KeyboardKeymapFormatXkbV1) || !ok { | 70 | if format != uint32(wayland.KeyboardKeymapFormatXkbV1) || !ok { | ||
| @@ -102,6 +115,9 @@ func readKeymap(fd int, size uint32) (string, bool) { | |||||
| 102 | // keyboards switches the focused client to k's keymap and returns its | 115 | // keyboards switches the focused client to k's keymap and returns its | ||
| 103 | // keyboards, or nil after a no_keymap error or without focus. | 116 | // keyboards, or nil after a no_keymap error or without focus. | ||
| 104 | func (k *virtualKeyboard) keyboards(r *virtualkeyboard.ZwpVirtualKeyboardV1) []*wayland.Keyboard { | 117 | func (k *virtualKeyboard) keyboards(r *virtualkeyboard.ZwpVirtualKeyboardV1) []*wayland.Keyboard { | ||
| 118 | + | if !k.admitted() { | |||
| 119 | + | return nil | |||
| 120 | + | } | |||
| 105 | if k.fd < 0 { | 121 | if k.fd < 0 { | ||
| 106 | r.PostError(uint32(virtualkeyboard.ZwpVirtualKeyboardV1ErrorNoKeymap), "no keymap") | 122 | r.PostError(uint32(virtualkeyboard.ZwpVirtualKeyboardV1ErrorNoKeymap), "no keymap") | ||
| 107 | return nil | 123 | return nil | ||
| @@ -121,6 +137,9 @@ func (k *virtualKeyboard) keyboards(r *virtualkeyboard.ZwpVirtualKeyboardV1) []* | |||||
| 121 | } | 137 | } | ||
| 122 | 138 | ||||
| 123 | func (k *virtualKeyboard) Key(r *virtualkeyboard.ZwpVirtualKeyboardV1, time, key, state uint32) { | 139 | func (k *virtualKeyboard) Key(r *virtualkeyboard.ZwpVirtualKeyboardV1, time, key, state uint32) { | ||
| 140 | + | if !k.admitted() || state != 1 && k.dropReleases && !k.pressed[key] { | |||
| 141 | + | return | |||
| 142 | + | } | |||
| 124 | keyboards := k.keyboards(r) | 143 | keyboards := k.keyboards(r) | ||
| 125 | if keyboards == nil { | 144 | if keyboards == nil { | ||
| 126 | return | 145 | return | ||
| @@ -136,19 +155,46 @@ func (k *virtualKeyboard) Key(r *virtualkeyboard.ZwpVirtualKeyboardV1, time, key | |||||
| 136 | } | 155 | } | ||
| 137 | 156 | ||||
| 138 | func (k *virtualKeyboard) Modifiers(r *virtualkeyboard.ZwpVirtualKeyboardV1, depressed, latched, locked, group uint32) { | 157 | func (k *virtualKeyboard) Modifiers(r *virtualkeyboard.ZwpVirtualKeyboardV1, depressed, latched, locked, group uint32) { | ||
| 158 | + | if !k.admitted() { | |||
| 159 | + | return | |||
| 160 | + | } | |||
| 139 | k.mods = ports.ModState{Depressed: depressed, Latched: latched, Locked: locked, Group: group} | 161 | k.mods = ports.ModState{Depressed: depressed, Latched: latched, Locked: locked, Group: group} | ||
| 140 | for _, kb := range k.keyboards(r) { | 162 | for _, kb := range k.keyboards(r) { | ||
| 141 | kb.SendModifiers(k.server.serial, depressed, latched, locked, group) | 163 | kb.SendModifiers(k.server.serial, depressed, latched, locked, group) | ||
| 142 | } | 164 | } | ||
| 143 | } | 165 | } | ||
| 144 | 166 | ||||
| 167 | + | // admitted forgets old held state even if protection began and ended between | |||
| 168 | + | // this keyboard's requests. Snapshot is coherent; DISPLAY owns admission. | |||
| 169 | + | func (k *virtualKeyboard) admitted() bool { | |||
| 170 | + | state := ports.SecurityState{} | |||
| 171 | + | if k.server.security != nil { | |||
| 172 | + | state = k.server.security.Snapshot() | |||
| 173 | + | } | |||
| 174 | + | if state.Protected || state.Generation != k.generation { | |||
| 175 | + | clear(k.pressed) | |||
| 176 | + | k.mods = ports.ModState{} | |||
| 177 | + | k.focus = 0 | |||
| 178 | + | k.generation = state.Generation | |||
| 179 | + | k.dropReleases = true | |||
| 180 | + | if k.server.seat.keymapOwner == k { | |||
| 181 | + | // Forget ownership silently; the parent resets focus and restores | |||
| 182 | + | // the seat layout at the lock transition. | |||
| 183 | + | k.server.seat.keymapOwner = nil | |||
| 184 | + | } | |||
| 185 | + | } | |||
| 186 | + | return !state.Protected | |||
| 187 | + | } | |||
| 188 | + | ||||
| 145 | func (*virtualKeyboard) Destroy(*virtualkeyboard.ZwpVirtualKeyboardV1) {} | 189 | func (*virtualKeyboard) Destroy(*virtualkeyboard.ZwpVirtualKeyboardV1) {} | ||
| 146 | 190 | ||||
| 147 | // release runs when the resource dies, by request or client disconnect: | 191 | // release runs when the resource dies, by request or client disconnect: | ||
| 148 | // keys still held on the focused client are released. | 192 | // keys still held on the focused client are released. | ||
| 149 | func (k *virtualKeyboard) release() { | 193 | func (k *virtualKeyboard) release() { | ||
| 150 | s := k.server | 194 | s := k.server | ||
| 151 | - | if len(k.pressed) > 0 && k.focus == s.seat.focused { | 195 | + | generation := k.generation |
| 196 | + | admitted := k.admitted() && generation == k.generation | |||
| 197 | + | if admitted && len(k.pressed) > 0 && k.focus == s.seat.focused { | |||
| 152 | _, keyboards := s.focusTarget(s.seat.focused) | 198 | _, keyboards := s.focusTarget(s.seat.focused) | ||
| 153 | var ts unix.Timespec | 199 | var ts unix.Timespec | ||
| 154 | _ = unix.ClockGettime(unix.CLOCK_MONOTONIC, &ts) | 200 | _ = unix.ClockGettime(unix.CLOCK_MONOTONIC, &ts) | ||
| @@ -162,7 +208,12 @@ func (k *virtualKeyboard) release() { | |||||
| 162 | } | 208 | } | ||
| 163 | } | 209 | } | ||
| 164 | if s.seat.keymapOwner == k { | 210 | if s.seat.keymapOwner == k { | ||
| 165 | - | s.useKeymap(nil) | 211 | + | if admitted { |
| 212 | + | s.useKeymap(nil) | |||
| 213 | + | } else { | |||
| 214 | + | // Drop ownership without delivering keymap/modifiers into the locker. | |||
| 215 | + | s.seat.keymapOwner = nil | |||
| 216 | + | } | |||
| 166 | } | 217 | } | ||
| 167 | if k.fd >= 0 { | 218 | if k.fd >= 0 { | ||
| 168 | unix.Close(k.fd) | 219 | unix.Close(k.fd) | ||
M internal/adapters/wayland/virtualkeyboard_test.go +4 -4
| @@ -34,7 +34,7 @@ func sendVirtualKeymap(t *testing.T, c *wlturbo.Display, kb uint32, keymap strin | |||||
| 34 | t.Fatal(err) | 34 | t.Fatal(err) | ||
| 35 | } | 35 | } | ||
| 36 | defer unix.Close(fd) | 36 | defer unix.Close(fd) | ||
| 37 | - | if err := c.SendRequestWithFDs(kb, uint16(virtualkeyboard.ZwpVirtualKeyboardV1RequestKeymap), []int{fd}, uint32(wayland.KeyboardKeymapFormatXkbV1), size); err != nil { | 37 | + | if err := wireRequest(c, kb, uint16(virtualkeyboard.ZwpVirtualKeyboardV1RequestKeymap), []int{fd}, uint32(wayland.KeyboardKeymapFormatXkbV1), size); err != nil { |
| 38 | t.Fatal(err) | 38 | t.Fatal(err) | ||
| 39 | } | 39 | } | ||
| 40 | } | 40 | } | ||
| @@ -48,7 +48,7 @@ func TestVirtualKeyboardTypesIntoFocus(t *testing.T) { | |||||
| 48 | registerProtocol(t, target, seat) | 48 | registerProtocol(t, target, seat) | ||
| 49 | proxy := &eventsProxy{} | 49 | proxy := &eventsProxy{} | ||
| 50 | proxy.SetID(target.AllocateID()) | 50 | proxy.SetID(target.AllocateID()) | ||
| 51 | - | target.Context().Register(proxy) | 51 | + | registerWireProxy(target, proxy) |
| 52 | requestProtocol(t, target, seat, wayland.SeatRequestGetKeyboard, proxy.ID()) | 52 | requestProtocol(t, target, seat, wayland.SeatRequestGetKeyboard, proxy.ID()) | ||
| 53 | w := toplevelMapper(t, target, events)() | 53 | w := toplevelMapper(t, target, events)() | ||
| 54 | commands <- ports.FocusWindow{ID: w.ID} | 54 | commands <- ports.FocusWindow{ID: w.ID} | ||
| @@ -104,7 +104,7 @@ func focusedTarget(t *testing.T, s *Server, events chan ports.ClientEvent, comma | |||||
| 104 | registerProtocol(t, target, seat) | 104 | registerProtocol(t, target, seat) | ||
| 105 | proxy := &eventsProxy{} | 105 | proxy := &eventsProxy{} | ||
| 106 | proxy.SetID(target.AllocateID()) | 106 | proxy.SetID(target.AllocateID()) | ||
| 107 | - | target.Context().Register(proxy) | 107 | + | registerWireProxy(target, proxy) |
| 108 | requestProtocol(t, target, seat, wayland.SeatRequestGetKeyboard, proxy.ID()) | 108 | requestProtocol(t, target, seat, wayland.SeatRequestGetKeyboard, proxy.ID()) | ||
| 109 | w := toplevelMapper(t, target, events)() | 109 | w := toplevelMapper(t, target, events)() | ||
| 110 | commands <- ports.FocusWindow{ID: w.ID} | 110 | commands <- ports.FocusWindow{ID: w.ID} | ||
| @@ -167,7 +167,7 @@ func TestVirtualKeymapLateKeyboardAndRejected(t *testing.T) { | |||||
| 167 | registerProtocol(t, target, seat) | 167 | registerProtocol(t, target, seat) | ||
| 168 | late := &eventsProxy{} | 168 | late := &eventsProxy{} | ||
| 169 | late.SetID(target.AllocateID()) | 169 | late.SetID(target.AllocateID()) | ||
| 170 | - | target.Context().Register(late) | 170 | + | registerWireProxy(target, late) |
| 171 | requestProtocol(t, target, seat, wayland.SeatRequestGetKeyboard, late.ID()) | 171 | requestProtocol(t, target, seat, wayland.SeatRequestGetKeyboard, late.ID()) | ||
| 172 | roundtrip(t, target) | 172 | roundtrip(t, target) | ||
| 173 | if len(late.keymaps) != 1 || late.keymaps[0] != virtual { | 173 | if len(late.keymaps) != 1 || late.keymaps[0] != virtual { | ||
A internal/adapters/wayland/wire_client_test.go +435 -0
| @@ -0,0 +1,435 @@ | |||||
| 1 | + | package wayland | |||
| 2 | + | ||||
| 3 | + | // Raw-wire test clients intentionally keep raw requests (including invalid ones). | |||
| 4 | + | // Their receivers nevertheless obey the real client's context, schema and | |||
| 5 | + | // destructor contracts. Metadata comes from generated protocol descriptions; | |||
| 6 | + | // request opcode numbers alone never determine object type or destruction. | |||
| 7 | + | import ( | |||
| 8 | + | "encoding/binary" | |||
| 9 | + | "strings" | |||
| 10 | + | "sync" | |||
| 11 | + | "testing" | |||
| 12 | + | ||||
| 13 | + | wire_capturesession "github.com/bnema/neferwl/internal/adapters/wayland/capturesession" | |||
| 14 | + | wire_alphamodifier "github.com/bnema/purego-libwayland/protocol/alphamodifier" | |||
| 15 | + | wire_colormanagement "github.com/bnema/purego-libwayland/protocol/colormanagement" | |||
| 16 | + | wire_colorrepresentation "github.com/bnema/purego-libwayland/protocol/colorrepresentation" | |||
| 17 | + | wire_committiming "github.com/bnema/purego-libwayland/protocol/committiming" | |||
| 18 | + | wire_cursorshape "github.com/bnema/purego-libwayland/protocol/cursorshape" | |||
| 19 | + | wire_drmlease "github.com/bnema/purego-libwayland/protocol/drmlease" | |||
| 20 | + | wire_extdatacontrol "github.com/bnema/purego-libwayland/protocol/extdatacontrol" | |||
| 21 | + | wire_extidlenotify "github.com/bnema/purego-libwayland/protocol/extidlenotify" | |||
| 22 | + | wire_extimagecapturesource "github.com/bnema/purego-libwayland/protocol/extimagecapturesource" | |||
| 23 | + | wire_extimagecopycapture "github.com/bnema/purego-libwayland/protocol/extimagecopycapture" | |||
| 24 | + | wire_extsessionlock "github.com/bnema/purego-libwayland/protocol/extsessionlock" | |||
| 25 | + | wire_extworkspace "github.com/bnema/purego-libwayland/protocol/extworkspace" | |||
| 26 | + | wire_fifo "github.com/bnema/purego-libwayland/protocol/fifo" | |||
| 27 | + | wire_fractionalscale "github.com/bnema/purego-libwayland/protocol/fractionalscale" | |||
| 28 | + | wire_idleinhibit "github.com/bnema/purego-libwayland/protocol/idleinhibit" | |||
| 29 | + | wire_inputmethod "github.com/bnema/purego-libwayland/protocol/inputmethod" | |||
| 30 | + | wire_kdedecoration "github.com/bnema/purego-libwayland/protocol/kdedecoration" | |||
| 31 | + | wire_keyboardshortcutsinhibit "github.com/bnema/purego-libwayland/protocol/keyboardshortcutsinhibit" | |||
| 32 | + | wire_linuxdmabuf "github.com/bnema/purego-libwayland/protocol/linuxdmabuf" | |||
| 33 | + | wire_linuxdrmsyncobj "github.com/bnema/purego-libwayland/protocol/linuxdrmsyncobj" | |||
| 34 | + | wire_pointerconstraints "github.com/bnema/purego-libwayland/protocol/pointerconstraints" | |||
| 35 | + | wire_pointerwarp "github.com/bnema/purego-libwayland/protocol/pointerwarp" | |||
| 36 | + | wire_presentationtime "github.com/bnema/purego-libwayland/protocol/presentationtime" | |||
| 37 | + | wire_primaryselection "github.com/bnema/purego-libwayland/protocol/primaryselection" | |||
| 38 | + | wire_relativepointer "github.com/bnema/purego-libwayland/protocol/relativepointer" | |||
| 39 | + | wire_tearingcontrol "github.com/bnema/purego-libwayland/protocol/tearingcontrol" | |||
| 40 | + | wire_textinput "github.com/bnema/purego-libwayland/protocol/textinput" | |||
| 41 | + | wire_viewporter "github.com/bnema/purego-libwayland/protocol/viewporter" | |||
| 42 | + | wire_virtualkeyboard "github.com/bnema/purego-libwayland/protocol/virtualkeyboard" | |||
| 43 | + | wire_wayland "github.com/bnema/purego-libwayland/protocol/wayland" | |||
| 44 | + | wire_wlrforeigntoplevel "github.com/bnema/purego-libwayland/protocol/wlrforeigntoplevel" | |||
| 45 | + | wire_wlrlayershell "github.com/bnema/purego-libwayland/protocol/wlrlayershell" | |||
| 46 | + | wire_wlroutputmanagement "github.com/bnema/purego-libwayland/protocol/wlroutputmanagement" | |||
| 47 | + | wire_wlroutputpower "github.com/bnema/purego-libwayland/protocol/wlroutputpower" | |||
| 48 | + | wire_wlrscreencopy "github.com/bnema/purego-libwayland/protocol/wlrscreencopy" | |||
| 49 | + | wire_xdgactivation "github.com/bnema/purego-libwayland/protocol/xdgactivation" | |||
| 50 | + | wire_xdgoutput "github.com/bnema/purego-libwayland/protocol/xdgoutput" | |||
| 51 | + | wire_xdgshell "github.com/bnema/purego-libwayland/protocol/xdgshell" | |||
| 52 | + | "github.com/bnema/purego-libwayland/server" | |||
| 53 | + | "github.com/bnema/wlturbo" | |||
| 54 | + | client_alphamodifier "github.com/bnema/wlturbo/protocol/alphamodifier" | |||
| 55 | + | client_colormanagement "github.com/bnema/wlturbo/protocol/colormanagement" | |||
| 56 | + | client_colorrepresentation "github.com/bnema/wlturbo/protocol/colorrepresentation" | |||
| 57 | + | client_committiming "github.com/bnema/wlturbo/protocol/committiming" | |||
| 58 | + | client_core "github.com/bnema/wlturbo/protocol/core" | |||
| 59 | + | client_cursorshape "github.com/bnema/wlturbo/protocol/cursorshape" | |||
| 60 | + | client_datacontrol "github.com/bnema/wlturbo/protocol/datacontrol" | |||
| 61 | + | client_drmlease "github.com/bnema/wlturbo/protocol/drmlease" | |||
| 62 | + | client_drmsyncobj "github.com/bnema/wlturbo/protocol/drmsyncobj" | |||
| 63 | + | client_extsessionlock "github.com/bnema/wlturbo/protocol/extsessionlock" | |||
| 64 | + | client_fifo "github.com/bnema/wlturbo/protocol/fifo" | |||
| 65 | + | client_foreigntoplevel "github.com/bnema/wlturbo/protocol/foreigntoplevel" | |||
| 66 | + | client_fractionalscale "github.com/bnema/wlturbo/protocol/fractionalscale" | |||
| 67 | + | client_idleinhibit "github.com/bnema/wlturbo/protocol/idleinhibit" | |||
| 68 | + | client_idlenotify "github.com/bnema/wlturbo/protocol/idlenotify" | |||
| 69 | + | client_imagecapturesource "github.com/bnema/wlturbo/protocol/imagecapturesource" | |||
| 70 | + | client_imagecopycapture "github.com/bnema/wlturbo/protocol/imagecopycapture" | |||
| 71 | + | client_inputmethod "github.com/bnema/wlturbo/protocol/inputmethod" | |||
| 72 | + | client_kdeserverdecoration "github.com/bnema/wlturbo/protocol/kdeserverdecoration" | |||
| 73 | + | client_layershell "github.com/bnema/wlturbo/protocol/layershell" | |||
| 74 | + | client_linuxdmabuf "github.com/bnema/wlturbo/protocol/linuxdmabuf" | |||
| 75 | + | client_outputmanagement "github.com/bnema/wlturbo/protocol/outputmanagement" | |||
| 76 | + | client_outputpower "github.com/bnema/wlturbo/protocol/outputpower" | |||
| 77 | + | client_pointerconstraints "github.com/bnema/wlturbo/protocol/pointerconstraints" | |||
| 78 | + | client_pointerwarp "github.com/bnema/wlturbo/protocol/pointerwarp" | |||
| 79 | + | client_presentation "github.com/bnema/wlturbo/protocol/presentation" | |||
| 80 | + | client_primaryselection "github.com/bnema/wlturbo/protocol/primaryselection" | |||
| 81 | + | client_relativepointer "github.com/bnema/wlturbo/protocol/relativepointer" | |||
| 82 | + | client_screencopy "github.com/bnema/wlturbo/protocol/screencopy" | |||
| 83 | + | client_shortcutsinhibit "github.com/bnema/wlturbo/protocol/shortcutsinhibit" | |||
| 84 | + | client_tearingcontrol "github.com/bnema/wlturbo/protocol/tearingcontrol" | |||
| 85 | + | client_textinput "github.com/bnema/wlturbo/protocol/textinput" | |||
| 86 | + | client_viewporter "github.com/bnema/wlturbo/protocol/viewporter" | |||
| 87 | + | client_virtualkeyboard "github.com/bnema/wlturbo/protocol/virtualkeyboard" | |||
| 88 | + | client_workspace "github.com/bnema/wlturbo/protocol/workspace" | |||
| 89 | + | client_xdgactivation "github.com/bnema/wlturbo/protocol/xdgactivation" | |||
| 90 | + | client_xdgoutput "github.com/bnema/wlturbo/protocol/xdgoutput" | |||
| 91 | + | client_xdgshell "github.com/bnema/wlturbo/protocol/xdgshell" | |||
| 92 | + | ) | |||
| 93 | + | ||||
| 94 | + | type wireSignature interface{ EventSignature(uint16) (string, bool) } | |||
| 95 | + | type wireSchema struct { | |||
| 96 | + | iface *server.Interface | |||
| 97 | + | events wireSignature | |||
| 98 | + | requestDestructors map[uint16]bool | |||
| 99 | + | eventDestructors map[uint16]bool | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | var wireSchemas = map[string]wireSchema{ | |||
| 103 | + | "ext_data_control_device_v1": {wire_extdatacontrol.ExtDataControlDeviceV1Interface, &client_datacontrol.ExtDataControlDevice{}, map[uint16]bool{1: true}, nil}, | |||
| 104 | + | "ext_data_control_manager_v1": {wire_extdatacontrol.ExtDataControlManagerV1Interface, &client_datacontrol.ExtDataControlManager{}, map[uint16]bool{2: true}, nil}, | |||
| 105 | + | "ext_data_control_offer_v1": {wire_extdatacontrol.ExtDataControlOfferV1Interface, &client_datacontrol.ExtDataControlOffer{}, map[uint16]bool{1: true}, nil}, | |||
| 106 | + | "ext_data_control_source_v1": {wire_extdatacontrol.ExtDataControlSourceV1Interface, &client_datacontrol.ExtDataControlSource{}, map[uint16]bool{1: true}, nil}, | |||
| 107 | + | "ext_foreign_toplevel_image_capture_source_manager_v1": {wire_extimagecapturesource.ExtForeignToplevelImageCaptureSourceManagerV1Interface, &client_imagecapturesource.ExtForeignToplevelImageCaptureSourceManager{}, map[uint16]bool{1: true}, nil}, | |||
| 108 | + | "ext_idle_notification_v1": {wire_extidlenotify.ExtIdleNotificationV1Interface, &client_idlenotify.ExtIdleNotification{}, map[uint16]bool{0: true}, nil}, | |||
| 109 | + | "ext_idle_notifier_v1": {wire_extidlenotify.ExtIdleNotifierV1Interface, &client_idlenotify.ExtIdleNotifier{}, map[uint16]bool{0: true}, nil}, | |||
| 110 | + | "ext_image_capture_source_v1": {wire_extimagecapturesource.ExtImageCaptureSourceV1Interface, &client_imagecapturesource.ExtImageCaptureSource{}, map[uint16]bool{0: true}, nil}, | |||
| 111 | + | "ext_image_copy_capture_cursor_session_v1": {wire_extimagecopycapture.ExtImageCopyCaptureCursorSessionV1Interface, &client_imagecopycapture.ExtImageCopyCaptureCursorSession{}, map[uint16]bool{0: true}, nil}, | |||
| 112 | + | "ext_image_copy_capture_frame_v1": {wire_extimagecopycapture.ExtImageCopyCaptureFrameV1Interface, &client_imagecopycapture.ExtImageCopyCaptureFrame{}, map[uint16]bool{0: true}, nil}, | |||
| 113 | + | "ext_image_copy_capture_manager_v1": {wire_extimagecopycapture.ExtImageCopyCaptureManagerV1Interface, &client_imagecopycapture.ExtImageCopyCaptureManager{}, map[uint16]bool{2: true}, nil}, | |||
| 114 | + | "ext_image_copy_capture_session_v1": {wire_extimagecopycapture.ExtImageCopyCaptureSessionV1Interface, &client_imagecopycapture.ExtImageCopyCaptureSession{}, map[uint16]bool{1: true}, nil}, | |||
| 115 | + | "ext_output_image_capture_source_manager_v1": {wire_extimagecapturesource.ExtOutputImageCaptureSourceManagerV1Interface, &client_imagecapturesource.ExtOutputImageCaptureSourceManager{}, map[uint16]bool{1: true}, nil}, | |||
| 116 | + | "ext_session_lock_manager_v1": {wire_extsessionlock.ExtSessionLockManagerV1Interface, &client_extsessionlock.ExtSessionLockManager{}, map[uint16]bool{0: true}, nil}, | |||
| 117 | + | "ext_session_lock_surface_v1": {wire_extsessionlock.ExtSessionLockSurfaceV1Interface, &client_extsessionlock.ExtSessionLockSurface{}, map[uint16]bool{0: true}, nil}, | |||
| 118 | + | "ext_session_lock_v1": {wire_extsessionlock.ExtSessionLockV1Interface, &client_extsessionlock.ExtSessionLock{}, map[uint16]bool{0: true, 2: true}, nil}, | |||
| 119 | + | "ext_workspace_group_handle_v1": {wire_extworkspace.ExtWorkspaceGroupHandleV1Interface, &client_workspace.ExtWorkspaceGroupHandle{}, map[uint16]bool{1: true}, nil}, | |||
| 120 | + | "ext_workspace_handle_v1": {wire_extworkspace.ExtWorkspaceHandleV1Interface, &client_workspace.ExtWorkspaceHandle{}, map[uint16]bool{0: true}, nil}, | |||
| 121 | + | "ext_workspace_manager_v1": {wire_extworkspace.ExtWorkspaceManagerV1Interface, &client_workspace.ExtWorkspaceManager{}, nil, map[uint16]bool{3: true}}, | |||
| 122 | + | "neferwl_capture_layer_v1": {wire_capturesession.NeferwlCaptureLayerV1Interface, nil, map[uint16]bool{0: true}, nil}, | |||
| 123 | + | "neferwl_capture_manager_v1": {wire_capturesession.NeferwlCaptureManagerV1Interface, nil, map[uint16]bool{0: true}, nil}, | |||
| 124 | + | "neferwl_capture_session_v1": {wire_capturesession.NeferwlCaptureSessionV1Interface, nil, map[uint16]bool{0: true}, nil}, | |||
| 125 | + | "org_kde_kwin_server_decoration": {wire_kdedecoration.OrgKdeKwinServerDecorationInterface, &client_kdeserverdecoration.OrgKdeKwinServerDecoration{}, map[uint16]bool{0: true}, nil}, | |||
| 126 | + | "org_kde_kwin_server_decoration_manager": {wire_kdedecoration.OrgKdeKwinServerDecorationManagerInterface, &client_kdeserverdecoration.OrgKdeKwinServerDecorationManager{}, nil, nil}, | |||
| 127 | + | "wl_buffer": {wire_wayland.BufferInterface, &client_core.Buffer{}, map[uint16]bool{0: true}, nil}, | |||
| 128 | + | "wl_callback": {wire_wayland.CallbackInterface, &client_core.Callback{}, nil, map[uint16]bool{0: true}}, | |||
| 129 | + | "wl_compositor": {wire_wayland.CompositorInterface, &client_core.Compositor{}, map[uint16]bool{2: true}, nil}, | |||
| 130 | + | "wl_data_device": {wire_wayland.DataDeviceInterface, &client_core.DataDevice{}, map[uint16]bool{2: true}, nil}, | |||
| 131 | + | "wl_data_device_manager": {wire_wayland.DataDeviceManagerInterface, &client_core.DataDeviceManager{}, map[uint16]bool{2: true}, nil}, | |||
| 132 | + | "wl_data_offer": {wire_wayland.DataOfferInterface, &client_core.DataOffer{}, map[uint16]bool{2: true}, nil}, | |||
| 133 | + | "wl_data_source": {wire_wayland.DataSourceInterface, &client_core.DataSource{}, map[uint16]bool{1: true}, nil}, | |||
| 134 | + | "wl_display": {wire_wayland.DisplayInterface, nil, nil, nil}, | |||
| 135 | + | "wl_fixes": {wire_wayland.FixesInterface, &client_core.Fixes{}, map[uint16]bool{0: true}, nil}, | |||
| 136 | + | "wl_keyboard": {wire_wayland.KeyboardInterface, &client_core.Keyboard{}, map[uint16]bool{0: true}, nil}, | |||
| 137 | + | "wl_output": {wire_wayland.OutputInterface, &client_core.Output{}, map[uint16]bool{0: true}, nil}, | |||
| 138 | + | "wl_pointer": {wire_wayland.PointerInterface, &client_core.Pointer{}, map[uint16]bool{1: true}, nil}, | |||
| 139 | + | "wl_region": {wire_wayland.RegionInterface, &client_core.Region{}, map[uint16]bool{0: true}, nil}, | |||
| 140 | + | "wl_registry": {wire_wayland.RegistryInterface, nil, nil, nil}, | |||
| 141 | + | "wl_seat": {wire_wayland.SeatInterface, &client_core.Seat{}, map[uint16]bool{3: true}, nil}, | |||
| 142 | + | "wl_shell": {wire_wayland.ShellInterface, &client_core.Shell{}, nil, nil}, | |||
| 143 | + | "wl_shell_surface": {wire_wayland.ShellSurfaceInterface, &client_core.ShellSurface{}, nil, nil}, | |||
| 144 | + | "wl_shm": {wire_wayland.ShmInterface, &client_core.Shm{}, map[uint16]bool{1: true}, nil}, | |||
| 145 | + | "wl_shm_pool": {wire_wayland.ShmPoolInterface, &client_core.ShmPool{}, map[uint16]bool{1: true}, nil}, | |||
| 146 | + | "wl_subcompositor": {wire_wayland.SubcompositorInterface, &client_core.Subcompositor{}, map[uint16]bool{0: true}, nil}, | |||
| 147 | + | "wl_subsurface": {wire_wayland.SubsurfaceInterface, &client_core.Subsurface{}, map[uint16]bool{0: true}, nil}, | |||
| 148 | + | "wl_surface": {wire_wayland.SurfaceInterface, &client_core.Surface{}, map[uint16]bool{0: true}, nil}, | |||
| 149 | + | "wl_touch": {wire_wayland.TouchInterface, &client_core.Touch{}, map[uint16]bool{0: true}, nil}, | |||
| 150 | + | "wp_alpha_modifier_surface_v1": {wire_alphamodifier.WpAlphaModifierSurfaceV1Interface, &client_alphamodifier.WpAlphaModifierSurface{}, map[uint16]bool{0: true}, nil}, | |||
| 151 | + | "wp_alpha_modifier_v1": {wire_alphamodifier.WpAlphaModifierV1Interface, &client_alphamodifier.WpAlphaModifier{}, map[uint16]bool{0: true}, nil}, | |||
| 152 | + | "wp_color_management_output_v1": {wire_colormanagement.WpColorManagementOutputV1Interface, &client_colormanagement.WpColorManagementOutput{}, map[uint16]bool{0: true}, nil}, | |||
| 153 | + | "wp_color_management_surface_feedback_v1": {wire_colormanagement.WpColorManagementSurfaceFeedbackV1Interface, &client_colormanagement.WpColorManagementSurfaceFeedback{}, map[uint16]bool{0: true}, nil}, | |||
| 154 | + | "wp_color_management_surface_v1": {wire_colormanagement.WpColorManagementSurfaceV1Interface, &client_colormanagement.WpColorManagementSurface{}, map[uint16]bool{0: true}, nil}, | |||
| 155 | + | "wp_color_manager_v1": {wire_colormanagement.WpColorManagerV1Interface, &client_colormanagement.WpColorManager{}, map[uint16]bool{0: true}, nil}, | |||
| 156 | + | "wp_color_representation_manager_v1": {wire_colorrepresentation.WpColorRepresentationManagerV1Interface, &client_colorrepresentation.WpColorRepresentationManager{}, map[uint16]bool{0: true}, nil}, | |||
| 157 | + | "wp_color_representation_surface_v1": {wire_colorrepresentation.WpColorRepresentationSurfaceV1Interface, &client_colorrepresentation.WpColorRepresentationSurface{}, map[uint16]bool{0: true}, nil}, | |||
| 158 | + | "wp_commit_timer_v1": {wire_committiming.WpCommitTimerV1Interface, &client_committiming.WpCommitTimer{}, map[uint16]bool{1: true}, nil}, | |||
| 159 | + | "wp_commit_timing_manager_v1": {wire_committiming.WpCommitTimingManagerV1Interface, &client_committiming.WpCommitTimingManager{}, map[uint16]bool{0: true}, nil}, | |||
| 160 | + | "wp_cursor_shape_device_v1": {wire_cursorshape.WpCursorShapeDeviceV1Interface, &client_cursorshape.WpCursorShapeDevice{}, map[uint16]bool{0: true}, nil}, | |||
| 161 | + | "wp_cursor_shape_manager_v1": {wire_cursorshape.WpCursorShapeManagerV1Interface, &client_cursorshape.WpCursorShapeManager{}, map[uint16]bool{0: true}, nil}, | |||
| 162 | + | "wp_drm_lease_connector_v1": {wire_drmlease.WpDrmLeaseConnectorV1Interface, &client_drmlease.WpDrmLeaseConnector{}, map[uint16]bool{0: true}, nil}, | |||
| 163 | + | "wp_drm_lease_device_v1": {wire_drmlease.WpDrmLeaseDeviceV1Interface, &client_drmlease.WpDrmLeaseDevice{}, nil, map[uint16]bool{3: true}}, | |||
| 164 | + | "wp_drm_lease_request_v1": {wire_drmlease.WpDrmLeaseRequestV1Interface, &client_drmlease.WpDrmLeaseRequest{}, map[uint16]bool{1: true}, nil}, | |||
| 165 | + | "wp_drm_lease_v1": {wire_drmlease.WpDrmLeaseV1Interface, &client_drmlease.WpDrmLease{}, map[uint16]bool{0: true}, nil}, | |||
| 166 | + | "wp_fifo_manager_v1": {wire_fifo.WpFifoManagerV1Interface, &client_fifo.WpFifoManager{}, map[uint16]bool{0: true}, nil}, | |||
| 167 | + | "wp_fifo_v1": {wire_fifo.WpFifoV1Interface, &client_fifo.WpFifo{}, map[uint16]bool{2: true}, nil}, | |||
| 168 | + | "wp_fractional_scale_manager_v1": {wire_fractionalscale.WpFractionalScaleManagerV1Interface, &client_fractionalscale.WpFractionalScaleManager{}, map[uint16]bool{0: true}, nil}, | |||
| 169 | + | "wp_fractional_scale_v1": {wire_fractionalscale.WpFractionalScaleV1Interface, &client_fractionalscale.WpFractionalScale{}, map[uint16]bool{0: true}, nil}, | |||
| 170 | + | "wp_image_description_creator_icc_v1": {wire_colormanagement.WpImageDescriptionCreatorIccV1Interface, &client_colormanagement.WpImageDescriptionCreatorIcc{}, map[uint16]bool{0: true}, nil}, | |||
| 171 | + | "wp_image_description_creator_params_v1": {wire_colormanagement.WpImageDescriptionCreatorParamsV1Interface, &client_colormanagement.WpImageDescriptionCreatorParams{}, map[uint16]bool{0: true}, nil}, | |||
| 172 | + | "wp_image_description_info_v1": {wire_colormanagement.WpImageDescriptionInfoV1Interface, &client_colormanagement.WpImageDescriptionInfo{}, nil, map[uint16]bool{0: true}}, | |||
| 173 | + | "wp_image_description_reference_v1": {wire_colormanagement.WpImageDescriptionReferenceV1Interface, &client_colormanagement.WpImageDescriptionReference{}, map[uint16]bool{0: true}, nil}, | |||
| 174 | + | "wp_image_description_v1": {wire_colormanagement.WpImageDescriptionV1Interface, &client_colormanagement.WpImageDescription{}, map[uint16]bool{0: true}, nil}, | |||
| 175 | + | "wp_linux_drm_syncobj_manager_v1": {wire_linuxdrmsyncobj.WpLinuxDrmSyncobjManagerV1Interface, &client_drmsyncobj.WpLinuxDrmSyncobjManager{}, map[uint16]bool{0: true}, nil}, | |||
| 176 | + | "wp_linux_drm_syncobj_surface_v1": {wire_linuxdrmsyncobj.WpLinuxDrmSyncobjSurfaceV1Interface, &client_drmsyncobj.WpLinuxDrmSyncobjSurface{}, map[uint16]bool{0: true}, nil}, | |||
| 177 | + | "wp_linux_drm_syncobj_timeline_v1": {wire_linuxdrmsyncobj.WpLinuxDrmSyncobjTimelineV1Interface, &client_drmsyncobj.WpLinuxDrmSyncobjTimeline{}, map[uint16]bool{0: true}, nil}, | |||
| 178 | + | "wp_pointer_warp_v1": {wire_pointerwarp.WpPointerWarpV1Interface, &client_pointerwarp.WpPointerWarp{}, map[uint16]bool{0: true}, nil}, | |||
| 179 | + | "wp_presentation": {wire_presentationtime.WpPresentationInterface, &client_presentation.WpPresentation{}, map[uint16]bool{0: true}, nil}, | |||
| 180 | + | "wp_presentation_feedback": {wire_presentationtime.WpPresentationFeedbackInterface, &client_presentation.WpPresentationFeedback{}, nil, map[uint16]bool{1: true, 2: true}}, | |||
| 181 | + | "wp_tearing_control_manager_v1": {wire_tearingcontrol.WpTearingControlManagerV1Interface, &client_tearingcontrol.WpTearingControlManager{}, map[uint16]bool{0: true}, nil}, | |||
| 182 | + | "wp_tearing_control_v1": {wire_tearingcontrol.WpTearingControlV1Interface, &client_tearingcontrol.WpTearingControl{}, map[uint16]bool{1: true}, nil}, | |||
| 183 | + | "wp_viewport": {wire_viewporter.WpViewportInterface, &client_viewporter.WpViewport{}, map[uint16]bool{0: true}, nil}, | |||
| 184 | + | "wp_viewporter": {wire_viewporter.WpViewporterInterface, &client_viewporter.WpViewporter{}, map[uint16]bool{0: true}, nil}, | |||
| 185 | + | "xdg_activation_token_v1": {wire_xdgactivation.ActivationTokenV1Interface, &client_xdgactivation.XdgActivationToken{}, map[uint16]bool{4: true}, nil}, | |||
| 186 | + | "xdg_activation_v1": {wire_xdgactivation.ActivationV1Interface, &client_xdgactivation.XdgActivation{}, map[uint16]bool{0: true}, nil}, | |||
| 187 | + | "xdg_popup": {wire_xdgshell.PopupInterface, &client_xdgshell.XdgPopup{}, map[uint16]bool{0: true}, nil}, | |||
| 188 | + | "xdg_positioner": {wire_xdgshell.PositionerInterface, &client_xdgshell.XdgPositioner{}, map[uint16]bool{0: true}, nil}, | |||
| 189 | + | "xdg_surface": {wire_xdgshell.SurfaceInterface, &client_xdgshell.XdgSurface{}, map[uint16]bool{0: true}, nil}, | |||
| 190 | + | "xdg_toplevel": {wire_xdgshell.ToplevelInterface, &client_xdgshell.XdgToplevel{}, map[uint16]bool{0: true}, nil}, | |||
| 191 | + | "xdg_wm_base": {wire_xdgshell.WmBaseInterface, &client_xdgshell.XdgWmBase{}, map[uint16]bool{0: true}, nil}, | |||
| 192 | + | "zwlr_foreign_toplevel_handle_v1": {wire_wlrforeigntoplevel.ZwlrForeignToplevelHandleV1Interface, &client_foreigntoplevel.ForeignToplevelHandle{}, map[uint16]bool{7: true}, nil}, | |||
| 193 | + | "zwlr_foreign_toplevel_manager_v1": {wire_wlrforeigntoplevel.ZwlrForeignToplevelManagerV1Interface, &client_foreigntoplevel.ForeignToplevelManager{}, nil, map[uint16]bool{1: true}}, | |||
| 194 | + | "zwlr_layer_shell_v1": {wire_wlrlayershell.ZwlrLayerShellV1Interface, &client_layershell.LayerShell{}, map[uint16]bool{1: true}, nil}, | |||
| 195 | + | "zwlr_layer_surface_v1": {wire_wlrlayershell.ZwlrLayerSurfaceV1Interface, &client_layershell.LayerSurface{}, map[uint16]bool{7: true}, nil}, | |||
| 196 | + | "zwlr_output_configuration_head_v1": {wire_wlroutputmanagement.ZwlrOutputConfigurationHeadV1Interface, &client_outputmanagement.OutputConfigurationHead{}, nil, nil}, | |||
| 197 | + | "zwlr_output_configuration_v1": {wire_wlroutputmanagement.ZwlrOutputConfigurationV1Interface, &client_outputmanagement.OutputConfiguration{}, map[uint16]bool{4: true}, nil}, | |||
| 198 | + | "zwlr_output_head_v1": {wire_wlroutputmanagement.ZwlrOutputHeadV1Interface, &client_outputmanagement.OutputHead{}, map[uint16]bool{0: true}, nil}, | |||
| 199 | + | "zwlr_output_manager_v1": {wire_wlroutputmanagement.ZwlrOutputManagerV1Interface, &client_outputmanagement.OutputManager{}, nil, map[uint16]bool{2: true}}, | |||
| 200 | + | "zwlr_output_mode_v1": {wire_wlroutputmanagement.ZwlrOutputModeV1Interface, &client_outputmanagement.OutputMode{}, map[uint16]bool{0: true}, nil}, | |||
| 201 | + | "zwlr_output_power_manager_v1": {wire_wlroutputpower.ZwlrOutputPowerManagerV1Interface, &client_outputpower.OutputPowerManager{}, map[uint16]bool{1: true}, nil}, | |||
| 202 | + | "zwlr_output_power_v1": {wire_wlroutputpower.ZwlrOutputPowerV1Interface, &client_outputpower.OutputPower{}, map[uint16]bool{1: true}, nil}, | |||
| 203 | + | "zwlr_screencopy_frame_v1": {wire_wlrscreencopy.ZwlrScreencopyFrameV1Interface, &client_screencopy.ScreencopyFrame{}, map[uint16]bool{1: true}, nil}, | |||
| 204 | + | "zwlr_screencopy_manager_v1": {wire_wlrscreencopy.ZwlrScreencopyManagerV1Interface, &client_screencopy.ScreencopyManager{}, map[uint16]bool{2: true}, nil}, | |||
| 205 | + | "zwp_confined_pointer_v1": {wire_pointerconstraints.ZwpConfinedPointerV1Interface, &client_pointerconstraints.ConfinedPointer{}, map[uint16]bool{0: true}, nil}, | |||
| 206 | + | "zwp_idle_inhibit_manager_v1": {wire_idleinhibit.ZwpIdleInhibitManagerV1Interface, &client_idleinhibit.IdleInhibitManager{}, map[uint16]bool{0: true}, nil}, | |||
| 207 | + | "zwp_idle_inhibitor_v1": {wire_idleinhibit.ZwpIdleInhibitorV1Interface, &client_idleinhibit.IdleInhibitor{}, map[uint16]bool{0: true}, nil}, | |||
| 208 | + | "zwp_input_method_keyboard_grab_v2": {wire_inputmethod.ZwpInputMethodKeyboardGrabV2Interface, &client_inputmethod.InputMethodKeyboardGrab{}, map[uint16]bool{0: true}, nil}, | |||
| 209 | + | "zwp_input_method_manager_v2": {wire_inputmethod.ZwpInputMethodManagerV2Interface, &client_inputmethod.InputMethodManager{}, map[uint16]bool{1: true}, nil}, | |||
| 210 | + | "zwp_input_method_v2": {wire_inputmethod.ZwpInputMethodV2Interface, &client_inputmethod.InputMethod{}, map[uint16]bool{6: true}, nil}, | |||
| 211 | + | "zwp_input_popup_surface_v2": {wire_inputmethod.ZwpInputPopupSurfaceV2Interface, &client_inputmethod.InputPopupSurface{}, map[uint16]bool{0: true}, nil}, | |||
| 212 | + | "zwp_keyboard_shortcuts_inhibit_manager_v1": {wire_keyboardshortcutsinhibit.ZwpKeyboardShortcutsInhibitManagerV1Interface, &client_shortcutsinhibit.KeyboardShortcutsInhibitManager{}, map[uint16]bool{0: true}, nil}, | |||
| 213 | + | "zwp_keyboard_shortcuts_inhibitor_v1": {wire_keyboardshortcutsinhibit.ZwpKeyboardShortcutsInhibitorV1Interface, &client_shortcutsinhibit.KeyboardShortcutsInhibitor{}, map[uint16]bool{0: true}, nil}, | |||
| 214 | + | "zwp_linux_buffer_params_v1": {wire_linuxdmabuf.ZwpLinuxBufferParamsV1Interface, &client_linuxdmabuf.LinuxBufferParams{}, map[uint16]bool{0: true}, nil}, | |||
| 215 | + | "zwp_linux_dmabuf_feedback_v1": {wire_linuxdmabuf.ZwpLinuxDmabufFeedbackV1Interface, &client_linuxdmabuf.LinuxDmabufFeedback{}, map[uint16]bool{0: true}, nil}, | |||
| 216 | + | "zwp_linux_dmabuf_v1": {wire_linuxdmabuf.ZwpLinuxDmabufV1Interface, &client_linuxdmabuf.LinuxDmabuf{}, map[uint16]bool{0: true}, nil}, | |||
| 217 | + | "zwp_locked_pointer_v1": {wire_pointerconstraints.ZwpLockedPointerV1Interface, &client_pointerconstraints.LockedPointer{}, map[uint16]bool{0: true}, nil}, | |||
| 218 | + | "zwp_pointer_constraints_v1": {wire_pointerconstraints.ZwpPointerConstraintsV1Interface, &client_pointerconstraints.PointerConstraints{}, map[uint16]bool{0: true}, nil}, | |||
| 219 | + | "zwp_primary_selection_device_manager_v1": {wire_primaryselection.ZwpPrimarySelectionDeviceManagerV1Interface, &client_primaryselection.PrimarySelectionDeviceManager{}, map[uint16]bool{2: true}, nil}, | |||
| 220 | + | "zwp_primary_selection_device_v1": {wire_primaryselection.ZwpPrimarySelectionDeviceV1Interface, &client_primaryselection.PrimarySelectionDevice{}, map[uint16]bool{1: true}, nil}, | |||
| 221 | + | "zwp_primary_selection_offer_v1": {wire_primaryselection.ZwpPrimarySelectionOfferV1Interface, &client_primaryselection.PrimarySelectionOffer{}, map[uint16]bool{1: true}, nil}, | |||
| 222 | + | "zwp_primary_selection_source_v1": {wire_primaryselection.ZwpPrimarySelectionSourceV1Interface, &client_primaryselection.PrimarySelectionSource{}, map[uint16]bool{1: true}, nil}, | |||
| 223 | + | "zwp_relative_pointer_manager_v1": {wire_relativepointer.ZwpRelativePointerManagerV1Interface, &client_relativepointer.RelativePointerManager{}, map[uint16]bool{0: true}, nil}, | |||
| 224 | + | "zwp_relative_pointer_v1": {wire_relativepointer.ZwpRelativePointerV1Interface, &client_relativepointer.RelativePointer{}, map[uint16]bool{0: true}, nil}, | |||
| 225 | + | "zwp_text_input_manager_v3": {wire_textinput.ZwpTextInputManagerV3Interface, &client_textinput.TextInputManagerV3{}, map[uint16]bool{0: true}, nil}, | |||
| 226 | + | "zwp_text_input_v3": {wire_textinput.ZwpTextInputV3Interface, &client_textinput.TextInputV3{}, map[uint16]bool{0: true}, nil}, | |||
| 227 | + | "zwp_virtual_keyboard_manager_v1": {wire_virtualkeyboard.ZwpVirtualKeyboardManagerV1Interface, &client_virtualkeyboard.VirtualKeyboardManager{}, nil, nil}, | |||
| 228 | + | "zwp_virtual_keyboard_v1": {wire_virtualkeyboard.ZwpVirtualKeyboardV1Interface, &client_virtualkeyboard.VirtualKeyboard{}, map[uint16]bool{3: true}, nil}, | |||
| 229 | + | "zxdg_output_manager_v1": {wire_xdgoutput.ZxdgOutputManagerV1Interface, &client_xdgoutput.ZxdgOutputManager{}, map[uint16]bool{0: true}, nil}, | |||
| 230 | + | "zxdg_output_v1": {wire_xdgoutput.ZxdgOutputV1Interface, &client_xdgoutput.ZxdgOutput{}, map[uint16]bool{0: true}, nil}, | |||
| 231 | + | } | |||
| 232 | + | ||||
| 233 | + | type wireObjectKey struct { | |||
| 234 | + | c *wlturbo.Display | |||
| 235 | + | id uint32 | |||
| 236 | + | } | |||
| 237 | + | ||||
| 238 | + | var wireObjects sync.Map // wireObjectKey -> *wireReceiver; display cleanup removes all entries | |||
| 239 | + | ||||
| 240 | + | // wireReceiver is a real wire event receiver, not a replacement project service. | |||
| 241 | + | // It can keep a quiet receiver until a test installs its observer at the same ID. | |||
| 242 | + | type wireReceiver struct { | |||
| 243 | + | wlturbo.BaseProxy | |||
| 244 | + | c *wlturbo.Display | |||
| 245 | + | schema wireSchema | |||
| 246 | + | observer wlturbo.Proxy | |||
| 247 | + | } | |||
| 248 | + | ||||
| 249 | + | func (p *wireReceiver) EventSignature(op uint16) (string, bool) { | |||
| 250 | + | if p.schema.events != nil { | |||
| 251 | + | return p.schema.events.EventSignature(op) | |||
| 252 | + | } | |||
| 253 | + | if p.schema.iface == nil || int(op) >= len(p.schema.iface.Events) { | |||
| 254 | + | return "", false | |||
| 255 | + | } | |||
| 256 | + | return wireSignatureText(p.schema.iface.Events[op].Signature), true | |||
| 257 | + | } | |||
| 258 | + | func wireSignatureText(sig string) string { | |||
| 259 | + | var b strings.Builder | |||
| 260 | + | for _, ch := range sig { | |||
| 261 | + | switch ch { | |||
| 262 | + | case 'i': | |||
| 263 | + | b.WriteString("int,") | |||
| 264 | + | case 'u': | |||
| 265 | + | b.WriteString("uint,") | |||
| 266 | + | case 'f': | |||
| 267 | + | b.WriteString("fixed,") | |||
| 268 | + | case 's': | |||
| 269 | + | b.WriteString("string,") | |||
| 270 | + | case 'o': | |||
| 271 | + | b.WriteString("object,") | |||
| 272 | + | case 'n': | |||
| 273 | + | b.WriteString("new_id,") | |||
| 274 | + | case 'a': | |||
| 275 | + | b.WriteString("array,") | |||
| 276 | + | case 'h': | |||
| 277 | + | b.WriteString("fd,") | |||
| 278 | + | } | |||
| 279 | + | } | |||
| 280 | + | return b.String() | |||
| 281 | + | } | |||
| 282 | + | func (p *wireReceiver) Dispatch(e *wlturbo.Event) { | |||
| 283 | + | // Server-created objects must exist before an observer reads their events. | |||
| 284 | + | if p.schema.iface != nil && int(e.Opcode) < len(p.schema.iface.Events) { | |||
| 285 | + | m := p.schema.iface.Events[e.Opcode] | |||
| 286 | + | // A separate cursor preserves the observer's event offset and FD ownership. | |||
| 287 | + | pos := 0 | |||
| 288 | + | arg := 0 | |||
| 289 | + | for _, ch := range m.Signature { | |||
| 290 | + | if ch == '?' || ch >= '0' && ch <= '9' { | |||
| 291 | + | continue | |||
| 292 | + | } | |||
| 293 | + | if ch == 'n' && arg < len(m.Types) && m.Types[arg] != nil && pos+4 <= len(e.Data()) { | |||
| 294 | + | id := binary.NativeEndian.Uint32(e.Data()[pos:]) | |||
| 295 | + | setWireSchema(p.c, id, m.Types[arg].Name, p.Version()) | |||
| 296 | + | } | |||
| 297 | + | switch ch { | |||
| 298 | + | case 'h': | |||
| 299 | + | case 's', 'a': | |||
| 300 | + | if pos+4 <= len(e.Data()) { | |||
| 301 | + | n := int(binary.NativeEndian.Uint32(e.Data()[pos:])) | |||
| 302 | + | pos += 4 + (n+3)&^3 | |||
| 303 | + | } | |||
| 304 | + | default: | |||
| 305 | + | pos += 4 | |||
| 306 | + | } | |||
| 307 | + | arg++ | |||
| 308 | + | } | |||
| 309 | + | } | |||
| 310 | + | if p.observer != nil { | |||
| 311 | + | p.observer.Dispatch(e) | |||
| 312 | + | } | |||
| 313 | + | if p.schema.eventDestructors[e.Opcode] { | |||
| 314 | + | p.c.Context().Unregister(p) | |||
| 315 | + | wireObjects.Delete(wireObjectKey{p.c, p.ID()}) | |||
| 316 | + | } | |||
| 317 | + | } | |||
| 318 | + | func wireObject(c *wlturbo.Display, id uint32) *wireReceiver { | |||
| 319 | + | k := wireObjectKey{c, id} | |||
| 320 | + | if v, ok := wireObjects.Load(k); ok { | |||
| 321 | + | return v.(*wireReceiver) | |||
| 322 | + | } | |||
| 323 | + | p := &wireReceiver{c: c} | |||
| 324 | + | p.SetID(id) | |||
| 325 | + | p.SetContext(c.Context()) | |||
| 326 | + | actual, _ := wireObjects.LoadOrStore(k, p) | |||
| 327 | + | return actual.(*wireReceiver) | |||
| 328 | + | } | |||
| 329 | + | func registerWireProxy(c *wlturbo.Display, observer wlturbo.Proxy) { | |||
| 330 | + | p := wireObject(c, observer.ID()) | |||
| 331 | + | if setter, ok := observer.(interface{ SetContext(*wlturbo.Context) }); ok { | |||
| 332 | + | setter.SetContext(c.Context()) | |||
| 333 | + | } | |||
| 334 | + | p.observer = observer | |||
| 335 | + | c.Context().Register(p) | |||
| 336 | + | } | |||
| 337 | + | func setWireSchema(c *wlturbo.Display, id uint32, iface string, version uint32) { | |||
| 338 | + | schema, ok := wireSchemas[iface] | |||
| 339 | + | if !ok { | |||
| 340 | + | panic("missing raw-wire test schema: " + iface) | |||
| 341 | + | } | |||
| 342 | + | p := wireObject(c, id) | |||
| 343 | + | p.schema = schema | |||
| 344 | + | p.SetVersion(version) | |||
| 345 | + | if setter, ok := p.observer.(interface{ SetVersion(uint32) }); ok { | |||
| 346 | + | setter.SetVersion(version) | |||
| 347 | + | } | |||
| 348 | + | c.Context().Register(p) | |||
| 349 | + | } | |||
| 350 | + | func bindWireID(c *wlturbo.Display, name uint32, iface string, version uint32) (uint32, error) { | |||
| 351 | + | id, err := c.Registry().BindID(name, iface, version) | |||
| 352 | + | if err == nil { | |||
| 353 | + | setWireSchema(c, id, iface, version) | |||
| 354 | + | } | |||
| 355 | + | return id, err | |||
| 356 | + | } | |||
| 357 | + | func wireRequest(c *wlturbo.Display, id uint32, op uint16, fds []int, args ...any) error { | |||
| 358 | + | v, ok := wireObjects.Load(wireObjectKey{c, id}) | |||
| 359 | + | if !ok { | |||
| 360 | + | return c.SendRequestWithFDs(id, op, fds, args...) | |||
| 361 | + | } // intentional nonexistent target | |||
| 362 | + | p := v.(*wireReceiver) | |||
| 363 | + | if p.schema.iface != nil && int(op) < len(p.schema.iface.Requests) { | |||
| 364 | + | m := p.schema.iface.Requests[op] | |||
| 365 | + | arg, typeIndex := 0, 0 | |||
| 366 | + | for _, ch := range m.Signature { | |||
| 367 | + | if ch == '?' || ch >= '0' && ch <= '9' { | |||
| 368 | + | continue | |||
| 369 | + | } | |||
| 370 | + | // Raw Display.SendRequestWithFDs omits FD placeholders from args. | |||
| 371 | + | if ch == 'h' { | |||
| 372 | + | typeIndex++ | |||
| 373 | + | continue | |||
| 374 | + | } | |||
| 375 | + | if ch == 'n' && typeIndex < len(m.Types) && m.Types[typeIndex] != nil && arg < len(args) { | |||
| 376 | + | if child, ok := args[arg].(uint32); ok && child != 0 { | |||
| 377 | + | setWireSchema(c, child, m.Types[typeIndex].Name, p.Version()) | |||
| 378 | + | } | |||
| 379 | + | } | |||
| 380 | + | arg++ | |||
| 381 | + | typeIndex++ | |||
| 382 | + | } | |||
| 383 | + | if p.schema.requestDestructors[op] { | |||
| 384 | + | err := c.Context().SendDestructorWithFDs(p, uint32(op), fds, args...) | |||
| 385 | + | if err == nil { | |||
| 386 | + | wireObjects.Delete(wireObjectKey{c, id}) | |||
| 387 | + | } | |||
| 388 | + | return err | |||
| 389 | + | } | |||
| 390 | + | } | |||
| 391 | + | return c.SendRequestWithFDs(id, op, fds, args...) | |||
| 392 | + | } | |||
| 393 | + | func forgetWireClient(c *wlturbo.Display) { | |||
| 394 | + | wireObjects.Range(func(k, v any) bool { | |||
| 395 | + | if k.(wireObjectKey).c == c { | |||
| 396 | + | wireObjects.Delete(k) | |||
| 397 | + | } | |||
| 398 | + | return true | |||
| 399 | + | }) | |||
| 400 | + | } | |||
| 401 | + | ||||
| 402 | + | // retireWireObject handles protocol-defined destruction caused by another | |||
| 403 | + | // object (for example configuration heads). No request is sent on the child. | |||
| 404 | + | func retireWireObject(c *wlturbo.Display, id uint32) { | |||
| 405 | + | if v, ok := wireObjects.LoadAndDelete(wireObjectKey{c, id}); ok { | |||
| 406 | + | c.Context().Unregister(v.(*wireReceiver)) | |||
| 407 | + | } | |||
| 408 | + | } | |||
| 409 | + | ||||
| 410 | + | // Catch drift between the generated server descriptions used for new_id | |||
| 411 | + | // bookkeeping and the canonical generated client schemas used by dispatch. | |||
| 412 | + | func TestRawWireSchemasMatchGeneratedProtocols(t *testing.T) { | |||
| 413 | + | for name, schema := range wireSchemas { | |||
| 414 | + | t.Run(name, func(t *testing.T) { | |||
| 415 | + | if schema.events != nil { | |||
| 416 | + | for op, event := range schema.iface.Events { | |||
| 417 | + | got, ok := schema.events.EventSignature(uint16(op)) | |||
| 418 | + | if !ok || got != wireSignatureText(event.Signature) { | |||
| 419 | + | t.Fatalf("event %s schema=%q supported=%t want=%q", event.Name, got, ok, wireSignatureText(event.Signature)) | |||
| 420 | + | } | |||
| 421 | + | } | |||
| 422 | + | } | |||
| 423 | + | for op := range schema.requestDestructors { | |||
| 424 | + | if int(op) >= len(schema.iface.Requests) { | |||
| 425 | + | t.Fatalf("destructor %d outside %s", op, name) | |||
| 426 | + | } | |||
| 427 | + | } | |||
| 428 | + | for op := range schema.eventDestructors { | |||
| 429 | + | if int(op) >= len(schema.iface.Events) { | |||
| 430 | + | t.Fatalf("event destructor %d outside %s", op, name) | |||
| 431 | + | } | |||
| 432 | + | } | |||
| 433 | + | }) | |||
| 434 | + | } | |||
| 435 | + | } | |||
M internal/adapters/wayland/workspaces.go +22 -1
| @@ -41,6 +41,10 @@ func registerWorkspaces(d *server.Display, s *Server) error { | |||||
| 41 | }) | 41 | }) | ||
| 42 | } | 42 | } | ||
| 43 | func (m *workspaceManager) Commit(*ext.ExtWorkspaceManagerV1) { | 43 | func (m *workspaceManager) Commit(*ext.ExtWorkspaceManagerV1) { | ||
| 44 | + | if m.s.protected() { | |||
| 45 | + | m.pending = nil | |||
| 46 | + | return | |||
| 47 | + | } | |||
| 44 | ids := make([]uint64, 0, len(m.pending)) | 48 | ids := make([]uint64, 0, len(m.pending)) | ||
| 45 | for _, id := range m.pending { | 49 | for _, id := range m.pending { | ||
| 46 | if h := m.handles[id]; h != nil && h.res.Alive() { | 50 | if h := m.handles[id]; h != nil && h.res.Alive() { | ||
| @@ -59,6 +63,9 @@ func (m *workspaceManager) Stop(*ext.ExtWorkspaceManagerV1) { | |||||
| 59 | func (*workspaceManager) CreateWorkspace(*ext.ExtWorkspaceGroupHandleV1, string) {} | 63 | func (*workspaceManager) CreateWorkspace(*ext.ExtWorkspaceGroupHandleV1, string) {} | ||
| 60 | func (*workspaceManager) Destroy(*ext.ExtWorkspaceGroupHandleV1) {} | 64 | func (*workspaceManager) Destroy(*ext.ExtWorkspaceGroupHandleV1) {} | ||
| 61 | func (m *workspaceManager) Activate(r *ext.ExtWorkspaceHandleV1) { | 65 | func (m *workspaceManager) Activate(r *ext.ExtWorkspaceHandleV1) { | ||
| 66 | + | if m.s.protected() { | |||
| 67 | + | return | |||
| 68 | + | } | |||
| 62 | for id, h := range m.handles { | 69 | for id, h := range m.handles { | ||
| 63 | if h.res.Resource == r.Resource { | 70 | if h.res.Resource == r.Resource { | ||
| 64 | m.pending = append(m.pending, id) | 71 | m.pending = append(m.pending, id) | ||
| @@ -79,6 +86,9 @@ func (*workspaceRequests) Remove(*ext.ExtWorkspaceHandleV1) | |||||
| 79 | // outputBound announces every wl_output resource bound by this client for an | 86 | // outputBound announces every wl_output resource bound by this client for an | ||
| 80 | // assigned output. A client may bind the same global more than once. | 87 | // assigned output. A client may bind the same global more than once. | ||
| 81 | func (m *workspaceManager) outputBound(name string) bool { | 88 | func (m *workspaceManager) outputBound(name string) bool { | ||
| 89 | + | if m.s.protected() { | |||
| 90 | + | return false | |||
| 91 | + | } | |||
| 82 | g := m.groups[name] | 92 | g := m.groups[name] | ||
| 83 | o := m.s.outputByNameExact(name) | 93 | o := m.s.outputByNameExact(name) | ||
| 84 | if g == nil || !g.res.Alive() || o == nil { | 94 | if g == nil || !g.res.Alive() || o == nil { | ||
| @@ -128,10 +138,17 @@ func (s *Server) forwardWorkspaces(ctx context.Context) { | |||||
| 128 | } | 138 | } | ||
| 129 | func (s *Server) updateWorkspaces(snapshot ports.Workspaces) { | 139 | func (s *Server) updateWorkspaces(snapshot ports.Workspaces) { | ||
| 130 | s.workspaceSnapshot = snapshot | 140 | s.workspaceSnapshot = snapshot | ||
| 141 | + | s.updateWorkspaceManagers(snapshot) | |||
| 142 | + | s.updateCaptureWorkspaces(snapshot) | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | // updateWorkspaceManagers also refreshes inventory on protection transitions. | |||
| 146 | + | // Keep the last sent state while protected: old client knowledge cannot be | |||
| 147 | + | // revoked, but an unlock refresh must compare against it, not suppressed updates. | |||
| 148 | + | func (s *Server) updateWorkspaceManagers(snapshot ports.Workspaces) { | |||
| 131 | for _, m := range s.workspaceManagers { | 149 | for _, m := range s.workspaceManagers { | ||
| 132 | m.update(snapshot) | 150 | m.update(snapshot) | ||
| 133 | } | 151 | } | ||
| 134 | - | s.updateCaptureWorkspaces(snapshot) | |||
| 135 | } | 152 | } | ||
| 136 | func workspaceState(w ports.WorkspaceInfo) uint32 { | 153 | func workspaceState(w ports.WorkspaceInfo) uint32 { | ||
| 137 | var state uint32 | 154 | var state uint32 | ||
| @@ -149,6 +166,10 @@ func (m *workspaceManager) sendCoordinates(r *ext.ExtWorkspaceHandleV1, index in | |||||
| 149 | r.SendCoordinates(b[:]) | 166 | r.SendCoordinates(b[:]) | ||
| 150 | } | 167 | } | ||
| 151 | func (m *workspaceManager) update(snapshot ports.Workspaces) { | 168 | func (m *workspaceManager) update(snapshot ports.Workspaces) { | ||
| 169 | + | if m.s.protected() { | |||
| 170 | + | m.pending = nil | |||
| 171 | + | return | |||
| 172 | + | } | |||
| 152 | if !m.res.Alive() { | 173 | if !m.res.Alive() { | ||
| 153 | return | 174 | return | ||
| 154 | } | 175 | } | ||
A internal/adapters/wayland/workspaces_sensitive_test.go +134 -0
| @@ -0,0 +1,134 @@ | |||||
| 1 | + | package wayland | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 7 | + | ext "github.com/bnema/purego-libwayland/protocol/extworkspace" | |||
| 8 | + | "github.com/bnema/wlturbo" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // protectedWorkspaceEvents is a real wire client recorder. Child inventory | |||
| 12 | + | // proxies decode names as well as opcodes so unlock refresh is verified. | |||
| 13 | + | type protectedWorkspaceEvents struct { | |||
| 14 | + | wlturbo.BaseProxy | |||
| 15 | + | client *wlturbo.Display | |||
| 16 | + | handles []uint32 | |||
| 17 | + | names []string | |||
| 18 | + | events []uint32 | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | func (p *protectedWorkspaceEvents) Dispatch(e *wlturbo.Event) { | |||
| 22 | + | p.events = append(p.events, uint32(e.Opcode)) | |||
| 23 | + | if e.Opcode != uint16(ext.ExtWorkspaceManagerV1EventWorkspaceGroup) && e.Opcode != uint16(ext.ExtWorkspaceManagerV1EventWorkspace) { | |||
| 24 | + | return | |||
| 25 | + | } | |||
| 26 | + | id := e.Uint32() | |||
| 27 | + | group := e.Opcode == uint16(ext.ExtWorkspaceManagerV1EventWorkspaceGroup) | |||
| 28 | + | child := &protectedWorkspaceChildEvents{owner: p, group: group} | |||
| 29 | + | child.SetID(id) | |||
| 30 | + | registerWireProxy(p.client, child) | |||
| 31 | + | if !group { | |||
| 32 | + | p.handles = append(p.handles, id) | |||
| 33 | + | } | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | type protectedWorkspaceChildEvents struct { | |||
| 37 | + | wlturbo.BaseProxy | |||
| 38 | + | owner *protectedWorkspaceEvents | |||
| 39 | + | group bool | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | func (p *protectedWorkspaceChildEvents) Dispatch(e *wlturbo.Event) { | |||
| 43 | + | kind := uint32(2) | |||
| 44 | + | if p.group { | |||
| 45 | + | kind = 1 | |||
| 46 | + | } | |||
| 47 | + | p.owner.events = append(p.owner.events, kind<<8|uint32(e.Opcode)) | |||
| 48 | + | if !p.group && e.Opcode == uint16(ext.ExtWorkspaceHandleV1EventName) { | |||
| 49 | + | p.owner.names = append(p.owner.names, e.String()) | |||
| 50 | + | } | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | func bindProtectedWorkspaceRecorder(t *testing.T, c *wlturbo.Display) *protectedWorkspaceEvents { | |||
| 54 | + | t.Helper() | |||
| 55 | + | p := &protectedWorkspaceEvents{client: c} | |||
| 56 | + | p.SetID(bindProtocol(t, c, "ext_workspace_manager_v1")) | |||
| 57 | + | registerWireProxy(c, p) | |||
| 58 | + | roundtrip(t, c) | |||
| 59 | + | return p | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | func TestProtectedWorkspaceInventoryAndActivation(t *testing.T) { | |||
| 63 | + | s, events, _, dir := lifecycleServer(t) | |||
| 64 | + | state := installSecurity(t, s) | |||
| 65 | + | snapshot := ports.Workspaces{Outputs: []ports.WorkspaceOutput{{Name: "HEADLESS-1", Workspaces: []ports.WorkspaceInfo{{ID: 42, Name: "workspace-before", Active: true}}}}} | |||
| 66 | + | if !s.display.Do(func() { s.updateWorkspaces(snapshot) }) { | |||
| 67 | + | t.Fatal("display stopped") | |||
| 68 | + | } | |||
| 69 | + | existingClient := protocolClient(t, s, dir) | |||
| 70 | + | existing := bindProtectedWorkspaceRecorder(t, existingClient) | |||
| 71 | + | if len(existing.handles) != 1 || len(existing.names) != 1 || existing.names[0] != "workspace-before" { | |||
| 72 | + | t.Fatalf("unlocked inventory: %+v", existing) | |||
| 73 | + | } | |||
| 74 | + | handle := existing.handles[0] | |||
| 75 | + | existing.events, existing.names = nil, nil | |||
| 76 | + | // A pre-protection uncommitted activation is discarded by transition refresh. | |||
| 77 | + | requestProtocol(t, existingClient, handle, ext.ExtWorkspaceHandleV1RequestActivate) | |||
| 78 | + | roundtrip(t, existingClient) | |||
| 79 | + | protect(t, s, state, 1, true) | |||
| 80 | + | if !s.display.Do(func() { s.updateWorkspaceManagers(s.workspaceSnapshot) }) { | |||
| 81 | + | t.Fatal("display stopped") | |||
| 82 | + | } | |||
| 83 | + | snapshot.Outputs[0].Workspaces[0].Name = "workspace-current" | |||
| 84 | + | snapshot.Outputs[0].Workspaces[0].Active = false | |||
| 85 | + | snapshot.Outputs[0].Workspaces[0].Hidden = true | |||
| 86 | + | if !s.display.Do(func() { s.updateWorkspaces(snapshot) }) { | |||
| 87 | + | t.Fatal("display stopped") | |||
| 88 | + | } | |||
| 89 | + | lateClient := protocolClient(t, s, dir) | |||
| 90 | + | late := bindProtectedWorkspaceRecorder(t, lateClient) | |||
| 91 | + | roundtrip(t, existingClient) | |||
| 92 | + | if len(existing.events) != 0 || len(late.events) != 0 || len(late.handles) != 0 { | |||
| 93 | + | t.Fatalf("protected inventory: existing=%v late=%v", existing.events, late.events) | |||
| 94 | + | } | |||
| 95 | + | // Commit cannot admit pre-lock pending requests; requests made while locked | |||
| 96 | + | // must not linger and execute after unlock either. | |||
| 97 | + | requestProtocol(t, existingClient, existing.ID(), ext.ExtWorkspaceManagerV1RequestCommit) | |||
| 98 | + | requestProtocol(t, existingClient, handle, ext.ExtWorkspaceHandleV1RequestActivate) | |||
| 99 | + | roundtrip(t, existingClient) | |||
| 100 | + | select { | |||
| 101 | + | case ev := <-events: | |||
| 102 | + | t.Fatalf("protected activation emitted: %v", ev) | |||
| 103 | + | default: | |||
| 104 | + | } | |||
| 105 | + | protect(t, s, state, 2, false) | |||
| 106 | + | if !s.display.Do(func() { s.updateWorkspaceManagers(s.workspaceSnapshot) }) { | |||
| 107 | + | t.Fatal("display stopped") | |||
| 108 | + | } | |||
| 109 | + | roundtrip(t, existingClient, lateClient) | |||
| 110 | + | for _, p := range []*protectedWorkspaceEvents{existing, late} { | |||
| 111 | + | if len(p.names) != 1 || p.names[0] != "workspace-current" { | |||
| 112 | + | t.Fatalf("unlock names %v", p.names) | |||
| 113 | + | } | |||
| 114 | + | } | |||
| 115 | + | requestProtocol(t, existingClient, existing.ID(), ext.ExtWorkspaceManagerV1RequestCommit) | |||
| 116 | + | roundtrip(t, existingClient) | |||
| 117 | + | select { | |||
| 118 | + | case ev := <-events: | |||
| 119 | + | t.Fatalf("protected activation survived unlock: %v", ev) | |||
| 120 | + | default: | |||
| 121 | + | } | |||
| 122 | + | requestProtocol(t, existingClient, handle, ext.ExtWorkspaceHandleV1RequestActivate) | |||
| 123 | + | requestProtocol(t, existingClient, existing.ID(), ext.ExtWorkspaceManagerV1RequestCommit) | |||
| 124 | + | roundtrip(t, existingClient) | |||
| 125 | + | select { | |||
| 126 | + | case ev := <-events: | |||
| 127 | + | activation, ok := ev.(ports.WorkspaceActivate) | |||
| 128 | + | if !ok || len(activation.IDs) != 1 || activation.IDs[0] != 42 { | |||
| 129 | + | t.Fatalf("unlocked activation: %v", ev) | |||
| 130 | + | } | |||
| 131 | + | default: | |||
| 132 | + | t.Fatal("unlocked activation not emitted") | |||
| 133 | + | } | |||
| 134 | + | } | |||
M internal/adapters/wayland/workspaces_test.go +3 -3
| @@ -48,7 +48,7 @@ func (p *workspaceEvents) Dispatch(e *wlturbo.Event) { | |||||
| 48 | v[1] = e.Uint32() | 48 | v[1] = e.Uint32() | ||
| 49 | child := &workspaceChildEvents{events: p.events, idEvents: p.idEvents, order: p.order, group: e.Opcode == uint16(ext.ExtWorkspaceManagerV1EventWorkspaceGroup)} | 49 | child := &workspaceChildEvents{events: p.events, idEvents: p.idEvents, order: p.order, group: e.Opcode == uint16(ext.ExtWorkspaceManagerV1EventWorkspaceGroup)} | ||
| 50 | child.SetID(v[1]) | 50 | child.SetID(v[1]) | ||
| 51 | - | p.client.Context().Register(child) | 51 | + | registerWireProxy(p.client, child) |
| 52 | } | 52 | } | ||
| 53 | p.order.add(v[0]) | 53 | p.order.add(v[0]) | ||
| 54 | p.events <- v | 54 | p.events <- v | ||
| @@ -107,7 +107,7 @@ func TestWorkspaceProtocol(t *testing.T) { | |||||
| 107 | managerID := bindProtocol(t, c, "ext_workspace_manager_v1") | 107 | managerID := bindProtocol(t, c, "ext_workspace_manager_v1") | ||
| 108 | p := &workspaceEvents{client: c, events: make(chan [2]uint32, 128), idEvents: make(chan string, 16)} | 108 | p := &workspaceEvents{client: c, events: make(chan [2]uint32, 128), idEvents: make(chan string, 16)} | ||
| 109 | p.SetID(managerID) | 109 | p.SetID(managerID) | ||
| 110 | - | c.Context().Register(p) | 110 | + | registerWireProxy(c, p) |
| 111 | var handle, secondHandle uint32 | 111 | var handle, secondHandle uint32 | ||
| 112 | for i := 0; i < 8 && secondHandle == 0; i++ { | 112 | for i := 0; i < 8 && secondHandle == 0; i++ { | ||
| 113 | if err := c.Roundtrip(); err != nil { | 113 | if err := c.Roundtrip(); err != nil { | ||
| @@ -246,7 +246,7 @@ func TestWorkspaceConfiguredIDAndBatchDone(t *testing.T) { | |||||
| 246 | managerID := bindProtocol(t, c, "ext_workspace_manager_v1") | 246 | managerID := bindProtocol(t, c, "ext_workspace_manager_v1") | ||
| 247 | p := &workspaceEvents{client: c, events: make(chan [2]uint32, 128), idEvents: make(chan string, 16), order: &workspaceEventOrder{}} | 247 | p := &workspaceEvents{client: c, events: make(chan [2]uint32, 128), idEvents: make(chan string, 16), order: &workspaceEventOrder{}} | ||
| 248 | p.SetID(managerID) | 248 | p.SetID(managerID) | ||
| 249 | - | c.Context().Register(p) | 249 | + | registerWireProxy(c, p) |
| 250 | if err := c.Roundtrip(); err != nil { | 250 | if err := c.Roundtrip(); err != nil { | ||
| 251 | t.Fatal(err) | 251 | t.Fatal(err) | ||
| 252 | } | 252 | } | ||
A internal/adapters/xkb/security_test.go +167 -0
| @@ -0,0 +1,167 @@ | |||||
| 1 | + | package xkb | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func secureKey(t *testing.T, k *Keymap, code uint32, down bool, state ports.SecurityState) ports.KeyEvent { | |||
| 10 | + | t.Helper() | |||
| 11 | + | ev, deliver, err := k.KeySecure(code, down, 123, state) | |||
| 12 | + | if err != nil || !deliver { | |||
| 13 | + | t.Fatalf("key %d down=%v: deliver=%v err=%v", code, down, deliver, err) | |||
| 14 | + | } | |||
| 15 | + | return ev | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | func suppressedKey(t *testing.T, k *Keymap, code uint32, down bool, state ports.SecurityState) { | |||
| 19 | + | t.Helper() | |||
| 20 | + | ev, deliver, err := k.KeySecure(code, down, 123, state) | |||
| 21 | + | if err != nil || deliver || ev != (ports.KeyEvent{}) { | |||
| 22 | + | t.Fatalf("inherited key %d down=%v leaked: %+v deliver=%v err=%v", code, down, ev, deliver, err) | |||
| 23 | + | } | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | func TestSecurityEpochQuarantinesHeldKeysAcrossAcquireAndRelease(t *testing.T) { | |||
| 27 | + | k := newTest(t, "us") | |||
| 28 | + | initial := ports.SecurityState{} | |||
| 29 | + | locked := ports.SecurityState{Generation: 1, Protected: true} | |||
| 30 | + | unlocked := ports.SecurityState{Generation: 2} | |||
| 31 | + | for _, code := range []uint32{125, 42, 29, 56, 30} { | |||
| 32 | + | secureKey(t, k, code, true, initial) | |||
| 33 | + | } | |||
| 34 | + | for _, code := range []uint32{125, 42, 29, 56, 30} { | |||
| 35 | + | suppressedKey(t, k, code, true, locked) // repeats cannot reintroduce held modifiers | |||
| 36 | + | } | |||
| 37 | + | if ev := secureKey(t, k, 16, true, locked); ev.Keysym != "q" || ev.Mods != 0 || ev.State != (ports.ModState{}) { | |||
| 38 | + | t.Fatalf("password inherited native state: %+v", ev) | |||
| 39 | + | } | |||
| 40 | + | secureKey(t, k, 16, false, locked) | |||
| 41 | + | secureKey(t, k, 54, true, locked) // password-era right Shift | |||
| 42 | + | if ev := secureKey(t, k, 16, true, unlocked); ev.Keysym != "q" || ev.Mods != 0 || ev.State != (ports.ModState{}) { | |||
| 43 | + | t.Fatalf("desktop inherited password/native state: %+v", ev) | |||
| 44 | + | } | |||
| 45 | + | secureKey(t, k, 16, false, unlocked) | |||
| 46 | + | for _, code := range []uint32{30, 56, 29, 42, 125, 54} { | |||
| 47 | + | suppressedKey(t, k, code, false, unlocked) | |||
| 48 | + | } | |||
| 49 | + | secureKey(t, k, 125, true, unlocked) | |||
| 50 | + | if ev := secureKey(t, k, 16, true, unlocked); ev.Mods&ports.ModSuper == 0 || ev.State.Depressed == 0 { | |||
| 51 | + | t.Fatalf("fresh desktop Super broken: %+v", ev) | |||
| 52 | + | } | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | func TestSecurityEpochResetsLocksButFreshCapsAndNumLockWork(t *testing.T) { | |||
| 56 | + | k := newTest(t, "us") | |||
| 57 | + | initial := ports.SecurityState{} | |||
| 58 | + | locked := ports.SecurityState{Generation: 1, Protected: true} | |||
| 59 | + | for _, code := range []uint32{58, 69} { | |||
| 60 | + | secureKey(t, k, code, true, initial) | |||
| 61 | + | secureKey(t, k, code, false, initial) | |||
| 62 | + | } | |||
| 63 | + | if ev := secureKey(t, k, 30, true, initial); ev.Keysym != "A" || ev.State.Locked == 0 { | |||
| 64 | + | t.Fatalf("baseline caps: %+v", ev) | |||
| 65 | + | } | |||
| 66 | + | secureKey(t, k, 30, false, initial) | |||
| 67 | + | if ev := secureKey(t, k, 30, true, locked); ev.Keysym != "a" || ev.State.Locked != 0 { | |||
| 68 | + | t.Fatalf("old locks crossed epoch: %+v", ev) | |||
| 69 | + | } | |||
| 70 | + | secureKey(t, k, 30, false, locked) | |||
| 71 | + | secureKey(t, k, 58, true, locked) | |||
| 72 | + | secureKey(t, k, 58, false, locked) | |||
| 73 | + | if ev := secureKey(t, k, 30, true, locked); ev.Keysym != "A" || ev.State.Locked == 0 { | |||
| 74 | + | t.Fatalf("fresh protected caps broken: %+v", ev) | |||
| 75 | + | } | |||
| 76 | + | secureKey(t, k, 30, false, locked) | |||
| 77 | + | secureKey(t, k, 69, true, locked) | |||
| 78 | + | secureKey(t, k, 69, false, locked) | |||
| 79 | + | if ev := secureKey(t, k, 79, true, locked); ev.Keysym != "KP_1" || ev.State.Locked == 0 { | |||
| 80 | + | t.Fatalf("fresh protected numlock broken: %+v", ev) | |||
| 81 | + | } | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | func TestSecurityEpochUsesActualRemappedModifiers(t *testing.T) { | |||
| 85 | + | k, err := New(RMLVO{Layout: "us", Options: "ctrl:swapcaps"}) | |||
| 86 | + | if err != nil { | |||
| 87 | + | t.Fatal(err) | |||
| 88 | + | } | |||
| 89 | + | defer k.Close() | |||
| 90 | + | initial, locked := ports.SecurityState{}, ports.SecurityState{Generation: 1, Protected: true} | |||
| 91 | + | secureKey(t, k, 58, true, initial) // physical Caps is Control in this map | |||
| 92 | + | if ev := secureKey(t, k, 16, true, locked); ev.Mods != 0 || ev.State != (ports.ModState{}) { | |||
| 93 | + | t.Fatalf("remapped inherited Control leaked: %+v", ev) | |||
| 94 | + | } | |||
| 95 | + | secureKey(t, k, 16, false, locked) | |||
| 96 | + | suppressedKey(t, k, 58, false, locked) | |||
| 97 | + | secureKey(t, k, 58, true, locked) | |||
| 98 | + | if ev := secureKey(t, k, 16, true, locked); ev.Mods&ports.ModCtrl == 0 || ev.State.Depressed == 0 { | |||
| 99 | + | t.Fatalf("fresh remapped Control broken: %+v", ev) | |||
| 100 | + | } | |||
| 101 | + | secureKey(t, k, 16, false, locked) | |||
| 102 | + | secureKey(t, k, 58, false, locked) | |||
| 103 | + | secureKey(t, k, 29, true, locked) // physical Control is Caps in this map | |||
| 104 | + | secureKey(t, k, 29, false, locked) | |||
| 105 | + | if ev := secureKey(t, k, 30, true, locked); ev.Keysym != "A" || ev.State.Locked == 0 { | |||
| 106 | + | t.Fatalf("fresh remapped Caps broken: %+v", ev) | |||
| 107 | + | } | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | func TestSecurityEpochAltGrAndAltReleaseOrder(t *testing.T) { | |||
| 111 | + | for _, releases := range [][]uint32{{100, 56}, {56, 100}} { | |||
| 112 | + | k := newTest(t, "fr") | |||
| 113 | + | locked := ports.SecurityState{Generation: 1, Protected: true} | |||
| 114 | + | secureKey(t, k, 100, true, locked) // ISO_Level3_Shift, not guessed Alt | |||
| 115 | + | secureKey(t, k, 56, true, locked) | |||
| 116 | + | if ev := secureKey(t, k, 11, true, locked); ev.Keysym != "at" || ev.Mods&ports.ModAlt == 0 || ev.State.Depressed == 0 { | |||
| 117 | + | t.Fatalf("fresh AltGr+Alt not mapped by XKB: %+v", ev) | |||
| 118 | + | } | |||
| 119 | + | secureKey(t, k, 11, false, locked) | |||
| 120 | + | for _, code := range releases { | |||
| 121 | + | secureKey(t, k, code, false, locked) | |||
| 122 | + | } | |||
| 123 | + | if ev := secureKey(t, k, 16, true, locked); ev.Keysym != "a" || ev.Mods != 0 || ev.State.Depressed != 0 { | |||
| 124 | + | t.Fatalf("release order left modifiers: %+v", ev) | |||
| 125 | + | } | |||
| 126 | + | } | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | func TestKeymapReplacementRetainsQuarantine(t *testing.T) { | |||
| 130 | + | previous := newTest(t, "us") | |||
| 131 | + | locked := ports.SecurityState{Generation: 1, Protected: true} | |||
| 132 | + | secureKey(t, previous, 125, true, locked) | |||
| 133 | + | next := newTest(t, "fr") | |||
| 134 | + | next.QuarantineFrom(previous) | |||
| 135 | + | suppressedKey(t, next, 125, true, locked) | |||
| 136 | + | if ev := secureKey(t, next, 16, true, locked); ev.Keysym != "a" || ev.Mods != 0 || ev.State.Depressed != 0 { | |||
| 137 | + | t.Fatalf("replacement replayed old held state: %+v", ev) | |||
| 138 | + | } | |||
| 139 | + | suppressedKey(t, next, 125, false, locked) | |||
| 140 | + | secureKey(t, next, 125, true, locked) | |||
| 141 | + | if ev := secureKey(t, next, 30, true, locked); ev.Mods&ports.ModSuper == 0 { | |||
| 142 | + | t.Fatalf("fresh key after replacement broken: %+v", ev) | |||
| 143 | + | } | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | func TestSecurityStateChangeResetsAltGrAndAltWithoutMaskInference(t *testing.T) { | |||
| 147 | + | k := newTest(t, "fr") | |||
| 148 | + | initial := ports.SecurityState{} | |||
| 149 | + | locked := ports.SecurityState{Generation: 1, Protected: true} | |||
| 150 | + | secureKey(t, k, 100, true, initial) | |||
| 151 | + | secureKey(t, k, 56, true, initial) | |||
| 152 | + | if ev := secureKey(t, k, 11, true, locked); ev.Keysym != "agrave" || ev.Mods != 0 || ev.State != (ports.ModState{}) { | |||
| 153 | + | t.Fatalf("inherited AltGr/Alt crossed acquisition: %+v", ev) | |||
| 154 | + | } | |||
| 155 | + | secureKey(t, k, 11, false, locked) | |||
| 156 | + | suppressedKey(t, k, 56, false, locked) | |||
| 157 | + | suppressedKey(t, k, 100, false, locked) | |||
| 158 | + | secureKey(t, k, 100, true, locked) | |||
| 159 | + | // Full state comparison is conservative even if a caller changes the | |||
| 160 | + | // protection bit without advancing the generation. | |||
| 161 | + | changed := locked | |||
| 162 | + | changed.Protected = false | |||
| 163 | + | if ev := secureKey(t, k, 11, true, changed); ev.Keysym != "agrave" || ev.Mods != 0 || ev.State != (ports.ModState{}) { | |||
| 164 | + | t.Fatalf("state change missed native reset: %+v", ev) | |||
| 165 | + | } | |||
| 166 | + | suppressedKey(t, k, 100, false, changed) | |||
| 167 | + | } | |||
M internal/adapters/xkb/xkb.go +76 -15
| @@ -27,18 +27,23 @@ type Keymap struct { | |||||
| 27 | contextUnref func(uintptr) | 27 | contextUnref func(uintptr) | ||
| 28 | keymapUnref func(uintptr) | 28 | keymapUnref func(uintptr) | ||
| 29 | stateUnref func(uintptr) | 29 | stateUnref func(uintptr) | ||
| 30 | - | keymapString func(uintptr, uint32) unsafe.Pointer | 30 | + | stateNew func(uintptr) uintptr |
| 31 | - | free func(uintptr) | 31 | + | // Secure tracking belongs to the same producer goroutine as native state. |
| 32 | - | getSym func(uintptr, uint32) uint32 | 32 | + | epoch ports.SecurityState |
| 33 | - | symName func(uint32, uintptr, uintptr) int32 | 33 | + | epochSet bool |
| 34 | - | updateKey func(uintptr, uint32, uint32) uint32 | 34 | + | held, quarantined map[uint32]bool |
| 35 | - | serializeMods func(uintptr, uint32) uint32 | 35 | + | keymapString func(uintptr, uint32) unsafe.Pointer |
| 36 | - | serializeLayout func(uintptr, uint32) uint32 | 36 | + | free func(uintptr) |
| 37 | - | modActive func(uintptr, uintptr, uint32) int32 | 37 | + | getSym func(uintptr, uint32) uint32 |
| 38 | - | minCode func(uintptr) uint32 | 38 | + | symName func(uint32, uintptr, uintptr) int32 |
| 39 | - | maxCode func(uintptr) uint32 | 39 | + | updateKey func(uintptr, uint32, uint32) uint32 |
| 40 | - | symsLevel func(uintptr, uint32, uint32, uint32, *unsafe.Pointer) int32 | 40 | + | serializeMods func(uintptr, uint32) uint32 |
| 41 | - | fromName func(uintptr, uint32) uint32 | 41 | + | serializeLayout func(uintptr, uint32) uint32 |
| 42 | + | modActive func(uintptr, uintptr, uint32) int32 | |||
| 43 | + | minCode func(uintptr) uint32 | |||
| 44 | + | maxCode func(uintptr) uint32 | |||
| 45 | + | symsLevel func(uintptr, uint32, uint32, uint32, *unsafe.Pointer) int32 | |||
| 46 | + | fromName func(uintptr, uint32) uint32 | |||
| 42 | } | 47 | } | ||
| 43 | 48 | ||||
| 44 | type ruleNames struct{ rules, model, layout, variant, options uintptr } | 49 | type ruleNames struct{ rules, model, layout, variant, options uintptr } | ||
| @@ -60,13 +65,12 @@ func New(names RMLVO) (_ *Keymap, err error) { | |||||
| 60 | } | 65 | } | ||
| 61 | var contextNew func(uint32) uintptr | 66 | var contextNew func(uint32) uintptr | ||
| 62 | var keymapNew func(uintptr, *ruleNames, uint32) uintptr | 67 | var keymapNew func(uintptr, *ruleNames, uint32) uintptr | ||
| 63 | - | var stateNew func(uintptr) uintptr | |||
| 64 | bind := func(fn any, name string) { purego.RegisterLibFunc(fn, k.lib, "xkb_"+name) } | 68 | bind := func(fn any, name string) { purego.RegisterLibFunc(fn, k.lib, "xkb_"+name) } | ||
| 65 | bind(&contextNew, "context_new") | 69 | bind(&contextNew, "context_new") | ||
| 66 | bind(&k.contextUnref, "context_unref") | 70 | bind(&k.contextUnref, "context_unref") | ||
| 67 | bind(&keymapNew, "keymap_new_from_names") | 71 | bind(&keymapNew, "keymap_new_from_names") | ||
| 68 | bind(&k.keymapUnref, "keymap_unref") | 72 | bind(&k.keymapUnref, "keymap_unref") | ||
| 69 | - | bind(&stateNew, "state_new") | 73 | + | bind(&k.stateNew, "state_new") |
| 70 | bind(&k.stateUnref, "state_unref") | 74 | bind(&k.stateUnref, "state_unref") | ||
| 71 | bind(&k.keymapString, "keymap_get_as_string") | 75 | bind(&k.keymapString, "keymap_get_as_string") | ||
| 72 | bind(&k.getSym, "state_key_get_one_sym") | 76 | bind(&k.getSym, "state_key_get_one_sym") | ||
| @@ -104,7 +108,7 @@ func New(names RMLVO) (_ *Keymap, err error) { | |||||
| 104 | if k.mapPtr == 0 { | 108 | if k.mapPtr == 0 { | ||
| 105 | return nil, fmt.Errorf("xkb_keymap_new_from_names failed") | 109 | return nil, fmt.Errorf("xkb_keymap_new_from_names failed") | ||
| 106 | } | 110 | } | ||
| 107 | - | k.state = stateNew(k.mapPtr) | 111 | + | k.state = k.stateNew(k.mapPtr) |
| 108 | if k.state == 0 { | 112 | if k.state == 0 { | ||
| 109 | return nil, fmt.Errorf("xkb_state_new failed") | 113 | return nil, fmt.Errorf("xkb_state_new failed") | ||
| 110 | } | 114 | } | ||
| @@ -160,6 +164,63 @@ func (k *Keymap) Key(evdevCode uint32, pressed bool, timeMsec uint32) ports.KeyE | |||||
| 160 | return event | 164 | return event | ||
| 161 | } | 165 | } | ||
| 162 | 166 | ||||
| 167 | + | // KeySecure resets native modifier/lock/layout state before translating in a | |||
| 168 | + | // new security epoch. Keys physically held across a transition are suppressed | |||
| 169 | + | // (including repeats and their eventual release), never replayed into XKB. | |||
| 170 | + | // On reset failure no event is translated against the old native state. | |||
| 171 | + | func (k *Keymap) KeySecure(code uint32, pressed bool, timeMsec uint32, epoch ports.SecurityState) (ports.KeyEvent, bool, error) { | |||
| 172 | + | if !k.epochSet || k.epoch != epoch { | |||
| 173 | + | next := k.stateNew(k.mapPtr) | |||
| 174 | + | if next == 0 { | |||
| 175 | + | // Even a failed translation must remember physical presses so a | |||
| 176 | + | // later successful reset cannot admit their repeats. | |||
| 177 | + | if k.held == nil { | |||
| 178 | + | k.held = make(map[uint32]bool) | |||
| 179 | + | } | |||
| 180 | + | if pressed { | |||
| 181 | + | k.held[code] = true | |||
| 182 | + | } else { | |||
| 183 | + | delete(k.held, code) | |||
| 184 | + | } | |||
| 185 | + | return ports.KeyEvent{}, false, fmt.Errorf("xkb_state_new failed during security reset") | |||
| 186 | + | } | |||
| 187 | + | old := k.state | |||
| 188 | + | k.state = next | |||
| 189 | + | k.stateUnref(old) | |||
| 190 | + | k.epoch, k.epochSet = epoch, true | |||
| 191 | + | if k.held == nil { | |||
| 192 | + | k.held = make(map[uint32]bool) | |||
| 193 | + | } | |||
| 194 | + | k.quarantined = make(map[uint32]bool, len(k.held)) | |||
| 195 | + | for held := range k.held { | |||
| 196 | + | k.quarantined[held] = true | |||
| 197 | + | } | |||
| 198 | + | } | |||
| 199 | + | blocked := k.quarantined[code] | |||
| 200 | + | if pressed { | |||
| 201 | + | k.held[code] = true | |||
| 202 | + | } else { | |||
| 203 | + | delete(k.held, code) | |||
| 204 | + | delete(k.quarantined, code) | |||
| 205 | + | } | |||
| 206 | + | if blocked { | |||
| 207 | + | return ports.KeyEvent{}, false, nil | |||
| 208 | + | } | |||
| 209 | + | return k.Key(code, pressed, timeMsec), true, nil | |||
| 210 | + | } | |||
| 211 | + | ||||
| 212 | + | // QuarantineFrom transfers physical held-key tracking when a producer replaces | |||
| 213 | + | // its keymap. No old native state or key presses are replayed into the new map. | |||
| 214 | + | // Call before closing previous; both maps belong to the calling goroutine. | |||
| 215 | + | func (k *Keymap) QuarantineFrom(previous *Keymap) { | |||
| 216 | + | k.epoch, k.epochSet = previous.epoch, previous.epochSet | |||
| 217 | + | k.held = make(map[uint32]bool, len(previous.held)) | |||
| 218 | + | k.quarantined = make(map[uint32]bool, len(previous.held)) | |||
| 219 | + | for code := range previous.held { | |||
| 220 | + | k.held[code], k.quarantined[code] = true, true | |||
| 221 | + | } | |||
| 222 | + | } | |||
| 223 | + | ||||
| 163 | // levelName is the first keysym name of a key at one shift level, or "". | 224 | // levelName is the first keysym name of a key at one shift level, or "". | ||
| 164 | func (k *Keymap) levelName(code, layout, level uint32) string { | 225 | func (k *Keymap) levelName(code, layout, level uint32) string { | ||
| 165 | var ptr unsafe.Pointer | 226 | var ptr unsafe.Pointer | ||
M internal/app/app.go +15 -5
| @@ -19,6 +19,7 @@ import ( | |||||
| 19 | "github.com/bnema/neferwl/internal/adapters/launcher" | 19 | "github.com/bnema/neferwl/internal/adapters/launcher" | ||
| 20 | "github.com/bnema/neferwl/internal/adapters/libinput" | 20 | "github.com/bnema/neferwl/internal/adapters/libinput" | ||
| 21 | "github.com/bnema/neferwl/internal/adapters/sched" | 21 | "github.com/bnema/neferwl/internal/adapters/sched" | ||
| 22 | + | "github.com/bnema/neferwl/internal/adapters/sessionsecurity" | |||
| 22 | "github.com/bnema/neferwl/internal/adapters/statefile" | 23 | "github.com/bnema/neferwl/internal/adapters/statefile" | ||
| 23 | "github.com/bnema/neferwl/internal/adapters/vulkan" | 24 | "github.com/bnema/neferwl/internal/adapters/vulkan" | ||
| 24 | "github.com/bnema/neferwl/internal/adapters/wayland" | 25 | "github.com/bnema/neferwl/internal/adapters/wayland" | ||
| @@ -78,6 +79,9 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||||
| 78 | } | 79 | } | ||
| 79 | defer hw.close() | 80 | defer hw.close() | ||
| 80 | } | 81 | } | ||
| 82 | + | security := &sessionsecurity.Gate{} | |||
| 83 | + | securityChanges := make(chan ports.SecurityState, 8) | |||
| 84 | + | securityEvents := make(chan ports.SecurityBackendEvent, 64) | |||
| 81 | client := make(chan ports.ClientEvent, 32) | 85 | client := make(chan ports.ClientEvent, 32) | ||
| 82 | input := make(chan ports.InputEvent, 32) | 86 | input := make(chan ports.InputEvent, 32) | ||
| 83 | output := make(chan ports.OutputEvent, 32) | 87 | output := make(chan ports.OutputEvent, 32) | ||
| @@ -113,7 +117,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||||
| 113 | // Only real sessions save scales: headless runs never touch the config file. | 117 | // Only real sessions save scales: headless runs never touch the config file. | ||
| 114 | scales = make(chan ports.ScaleChanged, 8) | 118 | scales = make(chan ports.ScaleChanged, 8) | ||
| 115 | } | 119 | } | ||
| 116 | - | ch := core.Channels{Scales: scales, Client: client, Input: input, Output: output, Config: configChanges, Commands: commands, Spawn: spawn, Scenes: scenes, Layouts: layouts, Constraints: constraints, State: states, Workspaces: workspaces, ConfigErrors: configErrors, Terminal: !opts.NoTerminal, Clock: clock.System{}, Frames: frames} | 120 | + | ch := core.Channels{Security: security, Scales: scales, Client: client, Input: input, Output: output, Config: configChanges, Commands: commands, Spawn: spawn, Scenes: scenes, Layouts: layouts, Constraints: constraints, State: states, Workspaces: workspaces, ConfigErrors: configErrors, Terminal: !opts.NoTerminal, Clock: clock.System{}, Frames: frames} |
| 117 | c, err := core.New(opts.Config, ch) | 121 | c, err := core.New(opts.Config, ch) | ||
| 118 | if err != nil { | 122 | if err != nil { | ||
| 119 | return err | 123 | return err | ||
| @@ -127,7 +131,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||||
| 127 | // Every output renders on the same GPU: its formats are the clients'. | 131 | // Every output renders on the same GPU: its formats are the clients'. | ||
| 128 | dmabuf := vulkan.Probe() | 132 | dmabuf := vulkan.Probe() | ||
| 129 | log.Info().Int("formats", len(dmabuf.Formats)).Msg("dmabuf") | 133 | log.Info().Int("formats", len(dmabuf.Formats)).Msg("dmabuf") | ||
| 130 | - | server, err := wayland.New(wayland.Options{RuntimeDir: runtimeDir, DMABuf: dmabuf, SyncobjNode: renderNode(dmabuf.Device), Keymap: keymap, RepeatRate: opts.Config.Keyboard.RepeatRate, RepeatDelay: opts.Config.Keyboard.RepeatDelay}, wayland.Channels{Events: client, Commands: commands, Workspaces: workspaces, Contents: contents, Cursors: cursorChanges, Presented: presented, Captures: captures, Captured: captured, OutputFormats: outputFormats, OutputHeads: outputHeads, LeaseRequests: leaseRequests, LeaseEvents: leaseEvents, OutputApply: applyOutput, OutputApplied: appliedOutput}, logging.For(ctx, "wayland")) | 134 | + | server, err := wayland.New(wayland.Options{Security: security, RuntimeDir: runtimeDir, DMABuf: dmabuf, SyncobjNode: renderNode(dmabuf.Device), Keymap: keymap, RepeatRate: opts.Config.Keyboard.RepeatRate, RepeatDelay: opts.Config.Keyboard.RepeatDelay}, wayland.Channels{SecurityChanges: securityChanges, SecurityEvents: securityEvents, Events: client, Commands: commands, Workspaces: workspaces, Contents: contents, Cursors: cursorChanges, Presented: presented, Captures: captures, Captured: captured, OutputFormats: outputFormats, OutputHeads: outputHeads, LeaseRequests: leaseRequests, LeaseEvents: leaseEvents, OutputApply: applyOutput, OutputApplied: appliedOutput}, logging.For(ctx, "wayland")) |
| 131 | if err != nil { | 135 | if err != nil { | ||
| 132 | km.Close() | 136 | km.Close() | ||
| 133 | return err | 137 | return err | ||
| @@ -151,6 +155,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||||
| 151 | defer launcher.UnexportSession(childEnv, logging.For(ctx, "launcher")) | 155 | defer launcher.UnexportSession(childEnv, logging.For(ctx, "launcher")) | ||
| 152 | } | 156 | } | ||
| 153 | child := launcher.New(childEnv, logging.For(ctx, "launcher")) | 157 | child := launcher.New(childEnv, logging.For(ctx, "launcher")) | ||
| 158 | + | child.Security = security | |||
| 154 | if inject != nil { | 159 | if inject != nil { | ||
| 155 | inject(input) | 160 | inject(input) | ||
| 156 | } | 161 | } | ||
| @@ -202,11 +207,16 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||||
| 202 | log.Warn().Str("component", "sched").Err(err).Msg("input scheduling") | 207 | log.Warn().Str("component", "sched").Err(err).Msg("input scheduling") | ||
| 203 | } | 208 | } | ||
| 204 | } | 209 | } | ||
| 205 | - | return libinput.Run(ctx, libinput.Options{Seat: hw.seat, SeatName: hw.seat.Name(), Keymap: km, Keymaps: keymaps, Layout: layout, Layouts: layouts, Constraints: constraints, Touchpad: opts.Config.Touchpad, Touchpads: touchpads, Active: hw.seat.Subscribe(), MoveCursor: curs.move, Log: logging.For(ctx, "input"), LogMotion: logging.Enabled(ctx, "input-motion"), LogKeys: logging.Enabled(ctx, "input-keys")}, input) | 210 | + | return libinput.Run(ctx, libinput.Options{Security: security, Seat: hw.seat, SeatName: hw.seat.Name(), Keymap: km, Keymaps: keymaps, Layout: layout, Layouts: layouts, Constraints: constraints, Touchpad: opts.Config.Touchpad, Touchpads: touchpads, Active: hw.seat.Subscribe(), MoveCursor: curs.move, Log: logging.For(ctx, "input"), LogMotion: logging.Enabled(ctx, "input-motion"), LogKeys: logging.Enabled(ctx, "input-keys")}, input) |
| 206 | }) | 211 | }) | ||
| 207 | return | 212 | return | ||
| 208 | } | 213 | } | ||
| 209 | - | _ = headlessinput.Run(ctx, km, keymaps, script, input, layouts, curs.move, logging.For(ctx, "input")) | 214 | + | if err := headlessinput.RunSecure(ctx, km, keymaps, script, input, layouts, curs.move, logging.For(ctx, "input"), security); err != nil && !errors.Is(err, context.Canceled) { |
| 215 | + | select { | |||
| 216 | + | case done <- err: | |||
| 217 | + | case <-ctx.Done(): | |||
| 218 | + | } | |||
| 219 | + | } | |||
| 210 | }() | 220 | }() | ||
| 211 | if opts.Script != nil { | 221 | if opts.Script != nil { | ||
| 212 | go func() { <-ctx.Done(); _ = opts.Script.Close() }() | 222 | go func() { <-ctx.Done(); _ = opts.Script.Close() }() | ||
| @@ -251,7 +261,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||||
| 251 | 261 | ||||
| 252 | go func() { | 262 | go func() { | ||
| 253 | defer workers.Done() | 263 | defer workers.Done() | ||
| 254 | - | outputIO := outputChannels{events: output, scenes: renderScenes, contents: contents, cursorChanges: cursorChanges, presented: flips, captures: captures, captured: captured, formats: outputFormats, heads: outputHeads, leaseRequests: leaseRequests, leaseEvents: leaseEvents, reloads: filtered, configured: configChanges, requests: applyOutput, replies: appliedOutput} | 264 | + | outputIO := outputChannels{security: security, securityChanges: securityChanges, securityEvents: securityEvents, events: output, scenes: renderScenes, contents: contents, cursorChanges: cursorChanges, presented: flips, captures: captures, captured: captured, formats: outputFormats, heads: outputHeads, leaseRequests: leaseRequests, leaseEvents: leaseEvents, reloads: filtered, configured: configChanges, requests: applyOutput, replies: appliedOutput} |
| 255 | apply := newOutputApply(newOutputOverrides(opts.Config, hw == nil), logging.For(ctx, "app")) | 265 | apply := newOutputApply(newOutputOverrides(opts.Config, hw == nil), logging.For(ctx, "app")) | ||
| 256 | renderLog := logging.For(ctx, "render") | 266 | renderLog := logging.For(ctx, "render") | ||
| 257 | newRenderer := func(w, h int) (ports.Renderer, error) { | 267 | newRenderer := func(w, h int) (ports.Renderer, error) { | ||
M internal/app/app_test.go +3 -3
| @@ -87,7 +87,7 @@ func TestQuitJoinsWorkers(t *testing.T) { | |||||
| 87 | done := make(chan error, 1) | 87 | done := make(chan error, 1) | ||
| 88 | go func() { | 88 | go func() { | ||
| 89 | done <- run(context.Background(), Options{Backend: "headless", NoXwayland: true, Config: config.Defaults(), NoTerminal: true}, func(input chan<- ports.InputEvent) { | 89 | done <- run(context.Background(), Options{Backend: "headless", NoXwayland: true, Config: config.Defaults(), NoTerminal: true}, func(input chan<- ports.InputEvent) { | ||
| 90 | - | input <- ports.KeyEvent{Keysym: "BackSpace", Mods: ports.ModCtrl | ports.ModAlt, Pressed: true} | 90 | + | input <- ports.SecurityInput{State: ports.SecurityState{}, Event: ports.KeyEvent{Keysym: "BackSpace", Mods: ports.ModCtrl | ports.ModAlt, Pressed: true}} |
| 91 | }) | 91 | }) | ||
| 92 | }() | 92 | }() | ||
| 93 | select { | 93 | select { | ||
| @@ -133,7 +133,7 @@ func TestHeadlessSpawnClose(t *testing.T) { | |||||
| 133 | t.Error("Run did not stop") | 133 | t.Error("Run did not stop") | ||
| 134 | } | 134 | } | ||
| 135 | }() | 135 | }() | ||
| 136 | - | input <- ports.KeyEvent{Keysym: "Return", Mods: ports.ModSuper, Pressed: true} | 136 | + | input <- ports.SecurityInput{State: ports.SecurityState{}, Event: ports.KeyEvent{Keysym: "Return", Mods: ports.ModSuper, Pressed: true}} |
| 137 | waitScene := func(count int, timeout time.Duration) { | 137 | waitScene := func(count int, timeout time.Duration) { | ||
| 138 | t.Helper() | 138 | t.Helper() | ||
| 139 | timer := time.NewTimer(timeout) | 139 | timer := time.NewTimer(timeout) | ||
| @@ -157,7 +157,7 @@ func TestHeadlessSpawnClose(t *testing.T) { | |||||
| 157 | } | 157 | } | ||
| 158 | } | 158 | } | ||
| 159 | waitScene(1, 10*time.Second) | 159 | waitScene(1, 10*time.Second) | ||
| 160 | - | input <- ports.KeyEvent{Keysym: "q", Mods: ports.ModSuper, Pressed: true} | 160 | + | input <- ports.SecurityInput{State: ports.SecurityState{}, Event: ports.KeyEvent{Keysym: "q", Mods: ports.ModSuper, Pressed: true}} |
| 161 | waitScene(0, 5*time.Second) | 161 | waitScene(0, 5*time.Second) | ||
| 162 | } | 162 | } | ||
| 163 | 163 | ||||
M internal/app/capture_test.go +1 -1
| @@ -144,7 +144,7 @@ func TestOutputStopWithFullReplies(t *testing.T) { | |||||
| 144 | replies := make(chan ports.CaptureDone, 1) | 144 | replies := make(chan ports.CaptureDone, 1) | ||
| 145 | replies <- ports.CaptureDone{} | 145 | replies <- ports.CaptureDone{} | ||
| 146 | set := newOutputSet(ctx, replies) | 146 | set := newOutputSet(ctx, replies) | ||
| 147 | - | set.start(ctx, "test", func(ctx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, _ <-chan ports.CursorChange, requests <-chan ports.CaptureRequest) error { | 147 | + | set.start(ctx, "test", func(ctx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, _ <-chan ports.CursorChange, requests <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, _ ports.OutputInstance) error { |
| 148 | <-ctx.Done() | 148 | <-ctx.Done() | ||
| 149 | return nil | 149 | return nil | ||
| 150 | }) | 150 | }) | ||
M internal/app/drm.go +199 -11
| @@ -26,6 +26,87 @@ import ( | |||||
| 26 | 26 | ||||
| 27 | var errTimeout = errors.New("timeout") | 27 | var errTimeout = errors.New("timeout") | ||
| 28 | 28 | ||||
| 29 | + | // securityLeaseCard keeps the native operation and its tracked lease inventory | |||
| 30 | + | // together. A failed revoke must remain in LeaseIDs for the next barrier attempt. | |||
| 31 | + | type securityLeaseCard interface { | |||
| 32 | + | Path() string | |||
| 33 | + | Lease([]string) (*os.File, uint32, error) | |||
| 34 | + | LeaseIDs() []uint32 | |||
| 35 | + | Revoke(uint32) error | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | func securitySnapshot(gate ports.SessionSecurity) ports.SecurityState { | |||
| 39 | + | if gate == nil { | |||
| 40 | + | return ports.SecurityState{} | |||
| 41 | + | } | |||
| 42 | + | return gate.Snapshot() | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | // guardedLease denies requests pending at acquisition and closes any FD created | |||
| 46 | + | // across a transition, even when the native call succeeds. Failed revocations | |||
| 47 | + | // stay tracked by the card and prevent the later backend barrier. | |||
| 48 | + | func guardedLease(c securityLeaseCard, gate ports.SessionSecurity, connectors []string) (*os.File, uint32, error) { | |||
| 49 | + | before := securitySnapshot(gate) | |||
| 50 | + | if before.Protected { | |||
| 51 | + | return nil, 0, errors.New("session protected") | |||
| 52 | + | } | |||
| 53 | + | fd, id, err := c.Lease(connectors) | |||
| 54 | + | if err != nil { | |||
| 55 | + | return fd, id, err | |||
| 56 | + | } | |||
| 57 | + | after := securitySnapshot(gate) | |||
| 58 | + | if after.Protected || after.Generation != before.Generation { | |||
| 59 | + | if fd != nil { | |||
| 60 | + | fd.Close() | |||
| 61 | + | } | |||
| 62 | + | return nil, 0, errors.Join(errors.New("session transition during lease"), c.Revoke(id)) | |||
| 63 | + | } | |||
| 64 | + | return fd, id, nil | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | func revokeSecurityLeases(c securityLeaseCard, finished func(uint32)) error { | |||
| 68 | + | var errs []error | |||
| 69 | + | for _, id := range c.LeaseIDs() { | |||
| 70 | + | if err := c.Revoke(id); err != nil { | |||
| 71 | + | errs = append(errs, fmt.Errorf("%s lease %d: %w", c.Path(), id, err)) | |||
| 72 | + | } else { | |||
| 73 | + | finished(id) | |||
| 74 | + | } | |||
| 75 | + | } | |||
| 76 | + | if ids := c.LeaseIDs(); len(ids) != 0 { | |||
| 77 | + | errs = append(errs, fmt.Errorf("%s leases still active: %v", c.Path(), ids)) | |||
| 78 | + | } | |||
| 79 | + | return errors.Join(errs...) | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | // registerDiscardedOutput accounts for physical output lifetimes that cannot | |||
| 83 | + | // enter connector-name routing. They still require protection just like owners. | |||
| 84 | + | // The same backend-owned counter is used by outputSet.start; no instance reuse. | |||
| 85 | + | func registerDiscardedOutput(set *outputSet, name string) (ports.OutputInstance, error) { | |||
| 86 | + | if set.nextInstance == ^ports.OutputInstance(0) { | |||
| 87 | + | set.startErr = errors.New("output instance exhausted") | |||
| 88 | + | return 0, set.startErr | |||
| 89 | + | } | |||
| 90 | + | set.nextInstance++ | |||
| 91 | + | instance := set.nextInstance | |||
| 92 | + | if !set.securityEvent(ports.SecurityOutputAdded{Instance: instance, Output: name}) { | |||
| 93 | + | return instance, set.ctx.Err() | |||
| 94 | + | } | |||
| 95 | + | return instance, nil | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | // retireDiscardedOutput consumes only affirmative terminal evidence. A failed | |||
| 99 | + | // close remains in authoritative inventory, even though it has no render owner. | |||
| 100 | + | func retireDiscardedOutput(set *outputSet, instance ports.OutputInstance, inactive bool) error { | |||
| 101 | + | if !inactive { | |||
| 102 | + | return fmt.Errorf("discarded output %d shutdown inactivity unconfirmed", instance) | |||
| 103 | + | } | |||
| 104 | + | if !set.securityEvent(ports.SecurityOutputRemoved{Instance: instance}) { | |||
| 105 | + | return set.ctx.Err() | |||
| 106 | + | } | |||
| 107 | + | return nil | |||
| 108 | + | } | |||
| 109 | + | ||||
| 29 | // applyTimeout bounds how long outputs take to apply a configuration. | 110 | // applyTimeout bounds how long outputs take to apply a configuration. | ||
| 30 | const applyTimeout = 5 * time.Second | 111 | const applyTimeout = 5 * time.Second | ||
| 31 | 112 | ||||
| @@ -165,6 +246,9 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm | |||||
| 165 | active := b.seat.Subscribe() | 246 | active := b.seat.Subscribe() | ||
| 166 | defer b.seat.Unsubscribe(active) | 247 | defer b.seat.Unsubscribe(active) | ||
| 167 | seatActive := true // openDRM waits for the first seat enable | 248 | seatActive := true // openDRM waits for the first seat enable | ||
| 249 | + | set := newOutputSet(ctx, ch.captured) | |||
| 250 | + | set.wireSecurity(ch) | |||
| 251 | + | protected := func() bool { return set.state.Protected || securitySnapshot(ch.security).Protected } | |||
| 168 | clientFDs := map[*drmCard]*os.File{} | 252 | clientFDs := map[*drmCard]*os.File{} | ||
| 169 | lastLeaseConnectors := map[*drmCard][]ports.LeaseConnector{} | 253 | lastLeaseConnectors := map[*drmCard][]ports.LeaseConnector{} | ||
| 170 | defer func() { | 254 | defer func() { | ||
| @@ -184,12 +268,14 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm | |||||
| 184 | sendLease(ports.LeaseFinished{Card: c.Path(), LeaseID: id}) | 268 | sendLease(ports.LeaseFinished{Card: c.Path(), LeaseID: id}) | ||
| 185 | } | 269 | } | ||
| 186 | var next []ports.LeaseConnector | 270 | var next []ports.LeaseConnector | ||
| 187 | - | if seatActive { | 271 | + | if seatActive && !protected() { |
| 188 | next = c.Leasable() | 272 | next = c.Leasable() | ||
| 189 | } | 273 | } | ||
| 190 | if len(next) == 0 { | 274 | if len(next) == 0 { | ||
| 191 | - | if clientFDs[c] != nil { | 275 | + | if protected() || clientFDs[c] != nil { |
| 192 | sendLease(ports.LeaseConnectors{Card: c.Path()}) | 276 | sendLease(ports.LeaseConnectors{Card: c.Path()}) | ||
| 277 | + | } | |||
| 278 | + | if clientFDs[c] != nil { | |||
| 193 | clientFDs[c].Close() | 279 | clientFDs[c].Close() | ||
| 194 | delete(clientFDs, c) | 280 | delete(clientFDs, c) | ||
| 195 | } | 281 | } | ||
| @@ -222,8 +308,8 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm | |||||
| 222 | log.Warn().Err(err).Msg("hotplug watch disabled") | 308 | log.Warn().Err(err).Msg("hotplug watch disabled") | ||
| 223 | } | 309 | } | ||
| 224 | }() | 310 | }() | ||
| 225 | - | set := newOutputSet(ctx, ch.captured) | |||
| 226 | cards := map[string]*drmCard{} | 311 | cards := map[string]*drmCard{} | ||
| 312 | + | outputs := map[string]*drm.Output{} // owner result, read only after done | |||
| 227 | send := func(ev ports.OutputEvent) { | 313 | send := func(ev ports.OutputEvent) { | ||
| 228 | select { | 314 | select { | ||
| 229 | case ch.events <- ev: | 315 | case ch.events <- ev: | ||
| @@ -250,6 +336,38 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm | |||||
| 250 | timer.Stop() | 336 | timer.Stop() | ||
| 251 | defer timer.Stop() | 337 | defer timer.Stop() | ||
| 252 | var deadline <-chan time.Time | 338 | var deadline <-chan time.Time | ||
| 339 | + | // A small bounded retry budget avoids a busy loop on failed KMS revokes. | |||
| 340 | + | revokeTimer := b.clock.NewTimer(time.Second) | |||
| 341 | + | revokeTimer.Stop() | |||
| 342 | + | defer revokeTimer.Stop() | |||
| 343 | + | var revokeDeadline <-chan time.Time | |||
| 344 | + | var revokeAttempts int | |||
| 345 | + | var discardedOutputErr error // first unconfirmed lifetime; inventory retains every instance | |||
| 346 | + | barrier := func() { | |||
| 347 | + | if !set.state.Protected { | |||
| 348 | + | return | |||
| 349 | + | } | |||
| 350 | + | var errs []error | |||
| 351 | + | for _, c := range b.cards { | |||
| 352 | + | errs = append(errs, revokeSecurityLeases(c, func(id uint32) { | |||
| 353 | + | sendLease(ports.LeaseFinished{Card: c.Path(), LeaseID: id}) | |||
| 354 | + | })) | |||
| 355 | + | publishLeases(c) | |||
| 356 | + | } | |||
| 357 | + | errs = append(errs, discardedOutputErr) | |||
| 358 | + | err := errors.Join(errs...) | |||
| 359 | + | // Registration runs synchronously in scan before any barrier. A gate | |||
| 360 | + | // transition racing native revocation belongs to a later round. | |||
| 361 | + | if securitySnapshot(ch.security).Generation > set.state.Generation { | |||
| 362 | + | return | |||
| 363 | + | } | |||
| 364 | + | set.securityEvent(ports.SecurityBackendBarrier{Generation: set.state.Generation, Err: err}) | |||
| 365 | + | revokeAttempts++ | |||
| 366 | + | if err != nil && revokeAttempts < 3 { | |||
| 367 | + | revokeTimer.Reset(time.Second) | |||
| 368 | + | revokeDeadline = revokeTimer.C() | |||
| 369 | + | } | |||
| 370 | + | } | |||
| 253 | var op uint64 | 371 | var op uint64 | ||
| 254 | complete := func(d applyDecision) { | 372 | complete := func(d applyDecision) { | ||
| 255 | if !d.reply { | 373 | if !d.reply { | ||
| @@ -303,15 +421,37 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm | |||||
| 303 | stop(name, true) | 421 | stop(name, true) | ||
| 304 | } | 422 | } | ||
| 305 | for _, o := range added { | 423 | for _, o := range added { | ||
| 424 | + | // Every physical lifetime gets the gate before any early exit: | |||
| 425 | + | // Close must not restore saved desktop, even on a name collision. | |||
| 426 | + | o.Security = ch.security | |||
| 306 | name := o.Info().Name | 427 | name := o.Info().Name | ||
| 307 | if other := cards[name]; other != nil && other != c { | 428 | if other := cards[name]; other != nil && other != c { | ||
| 308 | // Core and clients know outputs by connector name. | 429 | // Core and clients know outputs by connector name. | ||
| 309 | log.Warn().Str("connector", name).Str("card", c.Path()).Msg("connector name already used by another card; ignored") | 430 | log.Warn().Str("connector", name).Str("card", c.Path()).Msg("connector name already used by another card; ignored") | ||
| 310 | - | o.Close() | 431 | + | instance, registerErr := registerDiscardedOutput(set, name) |
| 432 | + | o.Instance = instance | |||
| 433 | + | o.Close() // synchronous final KMS disable; never starts a renderer | |||
| 434 | + | var shutdownErr error | |||
| 435 | + | if registerErr == nil { | |||
| 436 | + | shutdownErr = retireDiscardedOutput(set, instance, o.InactiveOnClose()) | |||
| 437 | + | } | |||
| 438 | + | if err := errors.Join(registerErr, shutdownErr); err != nil { | |||
| 439 | + | if discardedOutputErr == nil { | |||
| 440 | + | discardedOutputErr = err | |||
| 441 | + | } | |||
| 442 | + | scanErrors = append(scanErrors, err) | |||
| 443 | + | log.Error().Err(err).Str("connector", name).Str("card", c.Path()).Msg("discarded output protection unconfirmed") | |||
| 444 | + | // An exhausted instance cannot represent an unsafe lifetime. | |||
| 445 | + | // Stop the backend rather than ever acknowledge this inventory. | |||
| 446 | + | if instance == 0 { | |||
| 447 | + | cancel() | |||
| 448 | + | } | |||
| 449 | + | } | |||
| 311 | c.Release(name) | 450 | c.Release(name) | ||
| 312 | continue | 451 | continue | ||
| 313 | } | 452 | } | ||
| 314 | cards[name] = c | 453 | cards[name] = c | ||
| 454 | + | outputs[name] = o | |||
| 315 | readySources[name] = o.Ready() | 455 | readySources[name] = o.Ready() | ||
| 316 | if progress.active && !want(currentConfig).Disabled[name] { | 456 | if progress.active && !want(currentConfig).Disabled[name] { | ||
| 317 | progress.required[name] = true | 457 | progress.required[name] = true | ||
| @@ -337,8 +477,9 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm | |||||
| 337 | } | 477 | } | ||
| 338 | active := b.seat.Subscribe() | 478 | active := b.seat.Subscribe() | ||
| 339 | realtime := currentConfig.Performance.Realtime | 479 | realtime := currentConfig.Performance.Realtime | ||
| 340 | - | set.start(ctx, name, func(octx context.Context, sc <-chan ports.Scene, cc <-chan ports.SurfaceContent, cu <-chan ports.CursorChange, cap <-chan ports.CaptureRequest) error { | 480 | + | started := set.start(ctx, name, func(octx context.Context, sc <-chan ports.Scene, cc <-chan ports.SurfaceContent, cu <-chan ports.CursorChange, cap <-chan ports.CaptureRequest, secure <-chan ports.SecurityState, instance ports.OutputInstance) error { |
| 341 | defer b.seat.Unsubscribe(active) | 481 | defer b.seat.Unsubscribe(active) | ||
| 482 | + | o.Security, o.SecurityChanges, o.Instance = ch.security, secure, instance | |||
| 342 | return safe("output "+name, func() error { | 483 | return safe("output "+name, func() error { | ||
| 343 | defer o.Close() | 484 | defer o.Close() | ||
| 344 | if realtime { | 485 | if realtime { | ||
| @@ -351,12 +492,19 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm | |||||
| 351 | } | 492 | } | ||
| 352 | return o.Run(octx, newRenderer, loadCursor, active, sc, cc, cu, ch.presented, cap, ch.captured) | 493 | return o.Run(octx, newRenderer, loadCursor, active, sc, cc, cu, ch.presented, cap, ch.captured) | ||
| 353 | }) | 494 | }) | ||
| 354 | - | }) | 495 | + | }, &o.SecurityEvents) |
| 496 | + | if !started { | |||
| 497 | + | b.seat.Unsubscribe(active) | |||
| 498 | + | o.Close() | |||
| 499 | + | c.Release(name) | |||
| 500 | + | continue | |||
| 501 | + | } | |||
| 355 | send(ports.OutputAdded{Info: o.Info()}) | 502 | send(ports.OutputAdded{Info: o.Info()}) | ||
| 356 | } | 503 | } | ||
| 357 | } | 504 | } | ||
| 358 | publishInventory() | 505 | publishInventory() | ||
| 359 | complete(progress.scanError(errors.Join(scanErrors...))) | 506 | complete(progress.scanError(errors.Join(scanErrors...))) | ||
| 507 | + | barrier() | |||
| 360 | } | 508 | } | ||
| 361 | scan() | 509 | scan() | ||
| 362 | if len(set.outs) == 0 { | 510 | if len(set.outs) == 0 { | ||
| @@ -404,12 +552,38 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm | |||||
| 404 | configs, configNext := apply.configOut(ch.configured), apply.config | 552 | configs, configNext := apply.configOut(ch.configured), apply.config | ||
| 405 | replies, replyNext := apply.replyOut(ch.replies) | 553 | replies, replyNext := apply.replyOut(ch.replies) | ||
| 406 | heads, headsNext := apply.headsOut(ch.heads) | 554 | heads, headsNext := apply.headsOut(ch.heads) | ||
| 555 | + | contents, contentOut, contentNext, contentOwner := set.contentOut(ch.contents) | |||
| 407 | select { | 556 | select { | ||
| 408 | case <-ctx.Done(): | 557 | case <-ctx.Done(): | ||
| 409 | return set.wait() | 558 | return set.wait() | ||
| 410 | case err := <-readerErr: | 559 | case err := <-readerErr: | ||
| 411 | // No more flips on that card: its outputs would freeze. | 560 | // No more flips on that card: its outputs would freeze. | ||
| 412 | return errors.Join(err, set.wait()) | 561 | return errors.Join(err, set.wait()) | ||
| 562 | + | case state, ok := <-ch.securityChanges: | |||
| 563 | + | if !ok { | |||
| 564 | + | ch.securityChanges = nil | |||
| 565 | + | continue | |||
| 566 | + | } | |||
| 567 | + | if state.Generation < set.state.Generation { | |||
| 568 | + | continue | |||
| 569 | + | } | |||
| 570 | + | if set.setSecurity(state) { | |||
| 571 | + | revokeAttempts = 0 | |||
| 572 | + | } | |||
| 573 | + | // start can refresh the gate while scanning; its queued transition | |||
| 574 | + | // must still receive a barrier even if forwarding already occurred. | |||
| 575 | + | revokeTimer.Stop() | |||
| 576 | + | revokeDeadline = nil | |||
| 577 | + | if set.state.Protected { | |||
| 578 | + | barrier() | |||
| 579 | + | } else { | |||
| 580 | + | for _, c := range b.cards { | |||
| 581 | + | publishLeases(c) | |||
| 582 | + | } | |||
| 583 | + | } | |||
| 584 | + | case <-revokeDeadline: | |||
| 585 | + | revokeDeadline = nil | |||
| 586 | + | barrier() | |||
| 413 | case <-hotplug: | 587 | case <-hotplug: | ||
| 414 | log.Info().Msg("hotplug") | 588 | log.Info().Msg("hotplug") | ||
| 415 | scan() | 589 | scan() | ||
| @@ -442,14 +616,14 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm | |||||
| 442 | switch req := event.(type) { | 616 | switch req := event.(type) { | ||
| 443 | case ports.LeaseRequest: | 617 | case ports.LeaseRequest: | ||
| 444 | reply := ports.LeaseReply{ID: req.ID, Err: fmt.Errorf("unknown card %s", req.Card)} | 618 | reply := ports.LeaseReply{ID: req.ID, Err: fmt.Errorf("unknown card %s", req.Card)} | ||
| 445 | - | if !seatActive { | 619 | + | if !seatActive || protected() { |
| 446 | - | reply.Err = errors.New("DRM seat inactive") | 620 | + | reply.Err = errors.New("DRM seat inactive or session protected") |
| 447 | sendLease(reply) | 621 | sendLease(reply) | ||
| 448 | break | 622 | break | ||
| 449 | } | 623 | } | ||
| 450 | for _, c := range b.cards { | 624 | for _, c := range b.cards { | ||
| 451 | if c.Path() == req.Card { | 625 | if c.Path() == req.Card { | ||
| 452 | - | reply.FD, reply.LeaseID, reply.Err = c.Lease(req.Connectors) | 626 | + | reply.FD, reply.LeaseID, reply.Err = guardedLease(c, ch.security, req.Connectors) |
| 453 | publishLeases(c) | 627 | publishLeases(c) | ||
| 454 | break | 628 | break | ||
| 455 | } | 629 | } | ||
| @@ -483,14 +657,28 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm | |||||
| 483 | complete(progress.timeout(op)) | 657 | complete(progress.timeout(op)) | ||
| 484 | case s := <-ch.scenes: | 658 | case s := <-ch.scenes: | ||
| 485 | set.scenes(s) | 659 | set.scenes(s) | ||
| 486 | - | case c := <-ch.contents: | 660 | + | case c := <-contents: |
| 487 | set.content(c) | 661 | set.content(c) | ||
| 662 | + | case contentOut <- contentNext: | |||
| 663 | + | contentOwner.contentSent() | |||
| 488 | case q := <-ch.captures: | 664 | case q := <-ch.captures: | ||
| 489 | set.routeCapture(q) | 665 | set.routeCapture(q) | ||
| 490 | case c := <-ch.cursorChanges: | 666 | case c := <-ch.cursorChanges: | ||
| 491 | set.setCursor(c) | 667 | set.setCursor(c) | ||
| 492 | - | case name := <-set.stopped: | 668 | + | case stopped := <-set.stopped: |
| 669 | + | if !set.stoppedCurrent(stopped) { | |||
| 670 | + | continue | |||
| 671 | + | } | |||
| 672 | + | name := stopped.name | |||
| 673 | + | output := outputs[name] | |||
| 493 | err := set.finish(name) | 674 | err := set.finish(name) | ||
| 675 | + | // finish observes done after the deferred Close. Only affirmative | |||
| 676 | + | // terminal KMS inactivity retires this exact lifetime; renderer | |||
| 677 | + | // errors and missing connector inventory are not physical proof. | |||
| 678 | + | if output != nil && output.InactiveOnClose() { | |||
| 679 | + | set.securityEvent(ports.SecurityOutputRemoved{Instance: stopped.instance}) | |||
| 680 | + | } | |||
| 681 | + | delete(outputs, name) | |||
| 494 | if ctx.Err() != nil { | 682 | if ctx.Err() != nil { | ||
| 495 | return errors.Join(err, set.wait()) | 683 | return errors.Join(err, set.wait()) | ||
| 496 | } | 684 | } | ||
M internal/app/headless.go +29 -6
| @@ -16,6 +16,7 @@ import ( | |||||
| 16 | // are fixed, so apply answers every configuration without backend work. | 16 | // are fixed, so apply answers every configuration without backend work. | ||
| 17 | func runHeadless(ctx context.Context, sizes [][2]int, shots string, hdr bool, apply *outputApply, ch outputChannels, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error { | 17 | func runHeadless(ctx context.Context, sizes [][2]int, shots string, hdr bool, apply *outputApply, ch outputChannels, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error { | ||
| 18 | set := newOutputSet(ctx, ch.captured) | 18 | set := newOutputSet(ctx, ch.captured) | ||
| 19 | + | set.wireSecurity(ch) | |||
| 19 | inventory := ports.OutputHeads{} | 20 | inventory := ports.OutputHeads{} | ||
| 20 | for i, size := range sizes { | 21 | for i, size := range sizes { | ||
| 21 | name := fmt.Sprintf("HEADLESS-%d", i+1) | 22 | name := fmt.Sprintf("HEADLESS-%d", i+1) | ||
| @@ -32,15 +33,23 @@ func runHeadless(ctx context.Context, sizes [][2]int, shots string, hdr bool, ap | |||||
| 32 | cur := &headless.Cursor{} | 33 | cur := &headless.Cursor{} | ||
| 33 | curs.set(name, cur) | 34 | curs.set(name, cur) | ||
| 34 | opts := headless.Options{Cursor: cur, LoadCursor: loadCursor, Width: size[0], Height: size[1], ScreenshotDir: dir, HDR: hdr, Formats: ch.formats, Log: log, NewRenderer: newRenderer, NewCaptureRenderer: newRenderer, Name: name, Presented: ch.presented, Captured: ch.captured} | 35 | opts := headless.Options{Cursor: cur, LoadCursor: loadCursor, Width: size[0], Height: size[1], ScreenshotDir: dir, HDR: hdr, Formats: ch.formats, Log: log, NewRenderer: newRenderer, NewCaptureRenderer: newRenderer, Name: name, Presented: ch.presented, Captured: ch.captured} | ||
| 35 | - | set.start(ctx, name, func(octx context.Context, sc <-chan ports.Scene, cc <-chan ports.SurfaceContent, cu <-chan ports.CursorChange, cap <-chan ports.CaptureRequest) error { | 36 | + | started := set.start(ctx, name, func(octx context.Context, sc <-chan ports.Scene, cc <-chan ports.SurfaceContent, cu <-chan ports.CursorChange, cap <-chan ports.CaptureRequest, secure <-chan ports.SecurityState, instance ports.OutputInstance) error { |
| 37 | + | opts.Security, opts.SecurityChanges, opts.Instance = ch.security, secure, instance | |||
| 36 | return headless.Run(octx, opts, sc, cc, cu, cap) | 38 | return headless.Run(octx, opts, sc, cc, cu, cap) | ||
| 37 | - | }) | 39 | + | }, &opts.SecurityEvents) |
| 40 | + | if !started { | |||
| 41 | + | return set.wait() | |||
| 42 | + | } | |||
| 38 | select { | 43 | select { | ||
| 39 | case ch.events <- ports.OutputAdded{Info: ports.OutputInfo{Name: name, Width: size[0], Height: size[1]}}: | 44 | case ch.events <- ports.OutputAdded{Info: ports.OutputInfo{Name: name, Width: size[0], Height: size[1]}}: | ||
| 40 | case <-ctx.Done(): | 45 | case <-ctx.Done(): | ||
| 41 | return set.wait() | 46 | return set.wait() | ||
| 42 | } | 47 | } | ||
| 43 | } | 48 | } | ||
| 49 | + | // All virtual lifetimes have registered before this no-lease barrier. | |||
| 50 | + | if set.state.Protected { | |||
| 51 | + | set.securityEvent(ports.SecurityBackendBarrier{Generation: set.state.Generation}) | |||
| 52 | + | } | |||
| 44 | apply.heads(inventory) | 53 | apply.heads(inventory) | ||
| 45 | // The pointer starts centred on the first output, like libinput's. | 54 | // The pointer starts centred on the first output, like libinput's. | ||
| 46 | curs.move("HEADLESS-1", float64(sizes[0][0])/2, float64(sizes[0][1])/2) | 55 | curs.move("HEADLESS-1", float64(sizes[0][0])/2, float64(sizes[0][1])/2) | ||
| @@ -48,9 +57,18 @@ func runHeadless(ctx context.Context, sizes [][2]int, shots string, hdr bool, ap | |||||
| 48 | configs, configNext := apply.configOut(ch.configured), apply.config | 57 | configs, configNext := apply.configOut(ch.configured), apply.config | ||
| 49 | replies, replyNext := apply.replyOut(ch.replies) | 58 | replies, replyNext := apply.replyOut(ch.replies) | ||
| 50 | heads, headsNext := apply.headsOut(ch.heads) | 59 | heads, headsNext := apply.headsOut(ch.heads) | ||
| 60 | + | contents, contentOut, contentNext, contentOwner := set.contentOut(ch.contents) | |||
| 51 | select { | 61 | select { | ||
| 52 | case <-ctx.Done(): | 62 | case <-ctx.Done(): | ||
| 53 | return set.wait() | 63 | return set.wait() | ||
| 64 | + | case state, ok := <-ch.securityChanges: | |||
| 65 | + | if !ok { | |||
| 66 | + | ch.securityChanges = nil | |||
| 67 | + | continue | |||
| 68 | + | } | |||
| 69 | + | if set.setSecurity(state) && set.state.Protected { | |||
| 70 | + | set.securityEvent(ports.SecurityBackendBarrier{Generation: set.state.Generation}) | |||
| 71 | + | } | |||
| 54 | case ev := <-ch.reloads: | 72 | case ev := <-ch.reloads: | ||
| 55 | apply.reload(ev.Config) | 73 | apply.reload(ev.Config) | ||
| 56 | case req := <-ch.requests: | 74 | case req := <-ch.requests: | ||
| @@ -63,15 +81,20 @@ func runHeadless(ctx context.Context, sizes [][2]int, shots string, hdr bool, ap | |||||
| 63 | apply.headsSent() | 81 | apply.headsSent() | ||
| 64 | case s := <-ch.scenes: | 82 | case s := <-ch.scenes: | ||
| 65 | set.scenes(s) | 83 | set.scenes(s) | ||
| 66 | - | case c := <-ch.contents: | 84 | + | case c := <-contents: |
| 67 | set.content(c) | 85 | set.content(c) | ||
| 86 | + | case contentOut <- contentNext: | |||
| 87 | + | contentOwner.contentSent() | |||
| 68 | case q := <-ch.captures: | 88 | case q := <-ch.captures: | ||
| 69 | set.routeCapture(q) | 89 | set.routeCapture(q) | ||
| 70 | case c := <-ch.cursorChanges: | 90 | case c := <-ch.cursorChanges: | ||
| 71 | set.setCursor(c) | 91 | set.setCursor(c) | ||
| 72 | - | case name := <-set.stopped: | 92 | + | case stopped := <-set.stopped: |
| 73 | - | // A headless output only stops on error: the run ends. | 93 | + | if !set.stoppedCurrent(stopped) { |
| 74 | - | err := set.finish(name) | 94 | + | continue |
| 95 | + | } | |||
| 96 | + | // An owner error is not protection or removal evidence. | |||
| 97 | + | err := set.finish(stopped.name) | |||
| 75 | return joinErr(err, set.wait()) | 98 | return joinErr(err, set.wait()) | ||
| 76 | } | 99 | } | ||
| 77 | } | 100 | } | ||
M internal/app/outputapply_e2e_test.go +59 -77
| @@ -11,50 +11,9 @@ import ( | |||||
| 11 | "github.com/bnema/neferwl/internal/ports" | 11 | "github.com/bnema/neferwl/internal/ports" | ||
| 12 | wlr "github.com/bnema/purego-libwayland/protocol/wlroutputmanagement" | 12 | wlr "github.com/bnema/purego-libwayland/protocol/wlroutputmanagement" | ||
| 13 | "github.com/bnema/wlturbo" | 13 | "github.com/bnema/wlturbo" | ||
| 14 | + | client "github.com/bnema/wlturbo/protocol/outputmanagement" | |||
| 14 | ) | 15 | ) | ||
| 15 | 16 | ||||
| 16 | - | // managerEvents records zwlr_output_manager_v1 head and done events. | |||
| 17 | - | type managerEvents struct { | |||
| 18 | - | wlturbo.BaseProxy | |||
| 19 | - | c *wlturbo.Display | |||
| 20 | - | heads []uint32 | |||
| 21 | - | done uint32 | |||
| 22 | - | } | |||
| 23 | - | ||||
| 24 | - | func (p *managerEvents) Dispatch(e *wlturbo.Event) { | |||
| 25 | - | switch uint32(e.Opcode) { | |||
| 26 | - | case wlr.ZwlrOutputManagerV1EventHead: | |||
| 27 | - | h := &ignoredEvents{c: p.c} | |||
| 28 | - | h.SetID(e.Uint32()) | |||
| 29 | - | p.c.Context().Register(h) | |||
| 30 | - | p.heads = append(p.heads, h.ID()) | |||
| 31 | - | case wlr.ZwlrOutputManagerV1EventDone: | |||
| 32 | - | p.done = e.Uint32() | |||
| 33 | - | } | |||
| 34 | - | } | |||
| 35 | - | ||||
| 36 | - | // ignoredEvents drops the events of head, mode and configuration head objects. | |||
| 37 | - | type ignoredEvents struct { | |||
| 38 | - | wlturbo.BaseProxy | |||
| 39 | - | c *wlturbo.Display | |||
| 40 | - | } | |||
| 41 | - | ||||
| 42 | - | func (p *ignoredEvents) Dispatch(e *wlturbo.Event) { | |||
| 43 | - | if e.Opcode == uint16(wlr.ZwlrOutputHeadV1EventMode) { | |||
| 44 | - | m := &ignoredEvents{c: p.c} | |||
| 45 | - | m.SetID(e.Uint32()) | |||
| 46 | - | p.c.Context().Register(m) | |||
| 47 | - | } | |||
| 48 | - | } | |||
| 49 | - | ||||
| 50 | - | // configurationEvents records the result of a zwlr_output_configuration_v1. | |||
| 51 | - | type configurationEvents struct { | |||
| 52 | - | wlturbo.BaseProxy | |||
| 53 | - | result int // 0 pending, then 1 + the event opcode | |||
| 54 | - | } | |||
| 55 | - | ||||
| 56 | - | func (p *configurationEvents) Dispatch(e *wlturbo.Event) { p.result = int(e.Opcode) + 1 } | |||
| 57 | - | ||||
| 58 | // A wlr-output-management client goes through Wayland, the headless owner | 17 | // A wlr-output-management client goes through Wayland, the headless owner | ||
| 59 | // loop and core: an accepted scale reaches the scene, a disable is refused. | 18 | // loop and core: an accepted scale reaches the scene, a disable is refused. | ||
| 60 | func TestHeadlessOutputManagementEndToEnd(t *testing.T) { | 19 | func TestHeadlessOutputManagementEndToEnd(t *testing.T) { | ||
| @@ -119,17 +78,12 @@ func TestHeadlessOutputManagementEndToEnd(t *testing.T) { | |||||
| 119 | if err := c.Roundtrip(); err != nil { | 78 | if err := c.Roundtrip(); err != nil { | ||
| 120 | t.Fatal(err) | 79 | t.Fatal(err) | ||
| 121 | } | 80 | } | ||
| 122 | - | g, ok := c.Registry().FindGlobal("zwlr_output_manager_v1") | 81 | + | manager := client.NewOutputManager(c.Context()) |
| 123 | - | if !ok { | 82 | + | var heads []*client.OutputHead |
| 124 | - | t.Fatal("no zwlr_output_manager_v1") | 83 | + | var serial uint32 |
| 125 | - | } | 84 | + | manager.OnHead(func(head *client.OutputHead) { heads = append(heads, head) }) |
| 126 | - | id, err := c.Registry().BindID(g.Name, g.Interface, 4) | 85 | + | manager.OnDone(func(value uint32) { serial = value }) |
| 127 | - | if err != nil { | 86 | + | bindTestClient(t, c, client.OutputManagerInterface, 4, manager) |
| 128 | - | t.Fatal(err) | |||
| 129 | - | } | |||
| 130 | - | manager := &managerEvents{c: c} | |||
| 131 | - | manager.SetID(id) | |||
| 132 | - | c.Context().Register(manager) | |||
| 133 | roundtrip := func(until func() bool) { | 87 | roundtrip := func(until func() bool) { | ||
| 134 | t.Helper() | 88 | t.Helper() | ||
| 135 | // The owner loops answer asynchronously: roundtrip until they do, | 89 | // The owner loops answer asynchronously: roundtrip until they do, | ||
| @@ -141,44 +95,72 @@ func TestHeadlessOutputManagementEndToEnd(t *testing.T) { | |||||
| 141 | } | 95 | } | ||
| 142 | } | 96 | } | ||
| 143 | // The inventory reaches Wayland through the owner loop's outbox. | 97 | // The inventory reaches Wayland through the owner loop's outbox. | ||
| 144 | - | roundtrip(func() bool { return manager.done != 0 && len(manager.heads) == 1 }) | 98 | + | roundtrip(func() bool { return serial != 0 && len(heads) == 1 }) |
| 145 | - | head, serial := manager.heads[0], manager.done | 99 | + | head, initialSerial := heads[0], serial |
| 146 | - | configure := func(set func(cfg uint32)) int { | 100 | + | configure := func(set func(*client.OutputConfiguration) *client.OutputConfigurationHead) int { |
| 147 | t.Helper() | 101 | t.Helper() | ||
| 148 | - | cfg := &configurationEvents{} | 102 | + | // Inventory and core layout are independent startup publications. A |
| 149 | - | cfg.SetID(c.AllocateID()) | 103 | + | // done serial can become stale before create_configuration reaches the |
| 150 | - | c.Context().Register(cfg) | 104 | + | // server. Only protocol cancellation is retryable, never a failed apply. |
| 151 | - | if err := c.SendRequest(id, uint16(wlr.ZwlrOutputManagerV1RequestCreateConfiguration), cfg.ID(), manager.done); err != nil { | 105 | + | for attempt := 0; attempt < 3; attempt++ { |
| 152 | - | t.Fatal(err) | 106 | + | requested := serial |
| 153 | - | } | 107 | + | cfg, err := manager.CreateConfiguration(requested) |
| 154 | - | set(cfg.ID()) | 108 | + | if err != nil { |
| 155 | - | if err := c.SendRequest(cfg.ID(), uint16(wlr.ZwlrOutputConfigurationV1RequestApply)); err != nil { | 109 | + | t.Fatal(err) |
| 156 | - | t.Fatal(err) | 110 | + | } |
| 111 | + | result := -1 | |||
| 112 | + | cfg.OnSucceeded(func() { result = int(wlr.ZwlrOutputConfigurationV1EventSucceeded) }) | |||
| 113 | + | cfg.OnFailed(func() { result = int(wlr.ZwlrOutputConfigurationV1EventFailed) }) | |||
| 114 | + | cfg.OnCancelled(func() { result = int(wlr.ZwlrOutputConfigurationV1EventCancelled) }) | |||
| 115 | + | // A configuration cancelled on creation accepts only its destructor. | |||
| 116 | + | if err := c.Roundtrip(); err != nil { | |||
| 117 | + | t.Fatal(err) | |||
| 118 | + | } | |||
| 119 | + | var child *client.OutputConfigurationHead | |||
| 120 | + | if result < 0 { | |||
| 121 | + | child = set(cfg) | |||
| 122 | + | if err := cfg.Apply(); err != nil { | |||
| 123 | + | t.Fatal(err) | |||
| 124 | + | } | |||
| 125 | + | roundtrip(func() bool { return result >= 0 }) | |||
| 126 | + | } | |||
| 127 | + | // Configuration heads have no destructor request of their own. | |||
| 128 | + | if err := cfg.Destroy(); err != nil { | |||
| 129 | + | t.Fatal(err) | |||
| 130 | + | } | |||
| 131 | + | if child != nil { | |||
| 132 | + | c.Context().Unregister(child) | |||
| 133 | + | } | |||
| 134 | + | if result != int(wlr.ZwlrOutputConfigurationV1EventCancelled) { | |||
| 135 | + | return result | |||
| 136 | + | } | |||
| 137 | + | if serial == requested { | |||
| 138 | + | t.Fatalf("configuration cancelled without a newer inventory serial: %d", requested) | |||
| 139 | + | } | |||
| 157 | } | 140 | } | ||
| 158 | - | roundtrip(func() bool { return cfg.result != 0 }) | 141 | + | t.Fatal("output inventory kept changing across three configurations") |
| 159 | - | return cfg.result - 1 | 142 | + | return -1 |
| 160 | } | 143 | } | ||
| 161 | - | got := configure(func(cfg uint32) { | 144 | + | got := configure(func(cfg *client.OutputConfiguration) *client.OutputConfigurationHead { |
| 162 | - | ch := &ignoredEvents{c: c} | 145 | + | ch, err := cfg.EnableHead(head) |
| 163 | - | ch.SetID(c.AllocateID()) | 146 | + | if err != nil { |
| 164 | - | c.Context().Register(ch) | |||
| 165 | - | if err := c.SendRequest(cfg, uint16(wlr.ZwlrOutputConfigurationV1RequestEnableHead), ch.ID(), head); err != nil { | |||
| 166 | t.Fatal(err) | 147 | t.Fatal(err) | ||
| 167 | } | 148 | } | ||
| 168 | - | if err := c.SendRequest(ch.ID(), uint16(wlr.ZwlrOutputConfigurationHeadV1RequestSetScale), int32(512)); err != nil { | 149 | + | if err := ch.SetScale(wlturbo.NewFixed(2)); err != nil { |
| 169 | t.Fatal(err) | 150 | t.Fatal(err) | ||
| 170 | } | 151 | } | ||
| 152 | + | return ch | |||
| 171 | }) | 153 | }) | ||
| 172 | if got != int(wlr.ZwlrOutputConfigurationV1EventSucceeded) { | 154 | if got != int(wlr.ZwlrOutputConfigurationV1EventSucceeded) { | ||
| 173 | t.Fatalf("scale 2: event %d", got) | 155 | t.Fatalf("scale 2: event %d", got) | ||
| 174 | } | 156 | } | ||
| 175 | scene(func(s ports.Scene) bool { return s.Scale == 2 }) | 157 | scene(func(s ports.Scene) bool { return s.Scale == 2 }) | ||
| 176 | - | // A headless output cannot be disabled. The new scale gives a new serial. | 158 | + | roundtrip(func() bool { return serial != initialSerial }) |
| 177 | - | roundtrip(func() bool { return manager.done != serial }) | 159 | + | got = configure(func(cfg *client.OutputConfiguration) *client.OutputConfigurationHead { |
| 178 | - | got = configure(func(cfg uint32) { | 160 | + | if err := cfg.DisableHead(head); err != nil { |
| 179 | - | if err := c.SendRequest(cfg, uint16(wlr.ZwlrOutputConfigurationV1RequestDisableHead), head); err != nil { | |||
| 180 | t.Fatal(err) | 161 | t.Fatal(err) | ||
| 181 | } | 162 | } | ||
| 163 | + | return nil | |||
| 182 | }) | 164 | }) | ||
| 183 | if got != int(wlr.ZwlrOutputConfigurationV1EventFailed) { | 165 | if got != int(wlr.ZwlrOutputConfigurationV1EventFailed) { | ||
| 184 | t.Fatalf("disable: event %d", got) | 166 | t.Fatalf("disable: event %d", got) | ||
M internal/app/outputrouting.go +17 -14
| @@ -3,21 +3,24 @@ package app | |||||
| 3 | import "github.com/bnema/neferwl/internal/ports" | 3 | import "github.com/bnema/neferwl/internal/ports" | ||
| 4 | 4 | ||||
| 5 | // outputChannels is the shared app-to-backend wiring. The backend loop remains | 5 | // outputChannels is the shared app-to-backend wiring. The backend loop remains | ||
| 6 | - | // the sole owner of outputSet and outputApply; routing does not introduce a | 6 | + | // the sole owner of outputSet and outputApply. Security producers use bounded |
| 7 | - | // forwarding queue. heads, configured and replies are fed from the | 7 | + | // lossless FIFO forwarders; content backpressure stays inside the backend select. |
| 8 | - | // outputApply outbox. | 8 | + | // heads, configured and replies are fed from the outputApply outbox. |
| 9 | type outputChannels struct { | 9 | type outputChannels struct { | ||
| 10 | - | leaseRequests <-chan ports.LeaseMessage | 10 | + | security ports.SessionSecurity |
| 11 | - | leaseEvents chan<- ports.LeaseMessage | 11 | + | securityChanges <-chan ports.SecurityState |
| 12 | - | events chan<- ports.OutputEvent | 12 | + | securityEvents chan<- ports.SecurityBackendEvent |
| 13 | - | scenes <-chan []ports.Scene | 13 | + | leaseRequests <-chan ports.LeaseMessage |
| 14 | - | contents <-chan ports.SurfaceContent | 14 | + | leaseEvents chan<- ports.LeaseMessage |
| 15 | - | cursorChanges <-chan ports.CursorChange | 15 | + | events chan<- ports.OutputEvent |
| 16 | - | presented chan<- ports.OutputPresented | 16 | + | scenes <-chan []ports.Scene |
| 17 | - | captures <-chan ports.CaptureRequest | 17 | + | contents <-chan ports.SurfaceContent |
| 18 | - | captured chan<- ports.CaptureDone | 18 | + | cursorChanges <-chan ports.CursorChange |
| 19 | - | formats chan<- ports.OutputFormats | 19 | + | presented chan<- ports.OutputPresented |
| 20 | - | heads chan<- ports.OutputHeads | 20 | + | captures <-chan ports.CaptureRequest |
| 21 | + | captured chan<- ports.CaptureDone | |||
| 22 | + | formats chan<- ports.OutputFormats | |||
| 23 | + | heads chan<- ports.OutputHeads | |||
| 21 | // reloads carries file configuration; configured carries the effective | 24 | // reloads carries file configuration; configured carries the effective | ||
| 22 | // configuration, overrides included, to core. | 25 | // configuration, overrides included, to core. | ||
| 23 | reloads <-chan ports.ConfigChanged | 26 | reloads <-chan ports.ConfigChanged | ||
M internal/app/outputrouting_test.go +2 -2
| @@ -14,7 +14,7 @@ func TestOutputRouting(t *testing.T) { | |||||
| 14 | replies := make(chan ports.CaptureDone, 2) | 14 | replies := make(chan ports.CaptureDone, 2) | ||
| 15 | set := newOutputSet(ctx, replies) | 15 | set := newOutputSet(ctx, replies) | ||
| 16 | for _, name := range []string{"A", "B"} { | 16 | for _, name := range []string{"A", "B"} { | ||
| 17 | - | set.start(ctx, name, func(ctx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest) error { | 17 | + | set.start(ctx, name, func(ctx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, _ ports.OutputInstance) error { |
| 18 | <-ctx.Done() | 18 | <-ctx.Done() | ||
| 19 | return nil | 19 | return nil | ||
| 20 | }) | 20 | }) | ||
| @@ -78,7 +78,7 @@ func TestOutputRoutingAllocations(t *testing.T) { | |||||
| 78 | set.content(content) | 78 | set.content(content) | ||
| 79 | ctx, cancel := context.WithCancel(context.Background()) | 79 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 80 | defer cancel() | 80 | defer cancel() | ||
| 81 | - | set.start(ctx, "A", func(ctx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest) error { | 81 | + | set.start(ctx, "A", func(ctx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, _ ports.OutputInstance) error { |
| 82 | <-ctx.Done() | 82 | <-ctx.Done() | ||
| 83 | return nil | 83 | return nil | ||
| 84 | }) | 84 | }) | ||
M internal/app/outputs.go +196 -34
| @@ -12,7 +12,7 @@ import ( | |||||
| 12 | 12 | ||||
| 13 | // outputRun drives one output until ctx ends: it renders the scenes and | 13 | // outputRun drives one output until ctx ends: it renders the scenes and | ||
| 14 | // surface contents it receives, with the cursor the client asks for. | 14 | // surface contents it receives, with the cursor the client asks for. | ||
| 15 | - | type outputRun func(ctx context.Context, scenes <-chan ports.Scene, contents <-chan ports.SurfaceContent, cursor <-chan ports.CursorChange, captures <-chan ports.CaptureRequest) error | 15 | + | type outputRun func(ctx context.Context, scenes <-chan ports.Scene, contents <-chan ports.SurfaceContent, cursor <-chan ports.CursorChange, captures <-chan ports.CaptureRequest, security <-chan ports.SecurityState, instance ports.OutputInstance) error |
| 16 | 16 | ||||
| 17 | // outputSet owns the running outputs and feeds them. Core sends one scene | 17 | // outputSet owns the running outputs and feeds them. Core sends one scene | ||
| 18 | // per output; each goes to its output, latest first. Surface contents go to | 18 | // per output; each goes to its output, latest first. Surface contents go to | ||
| @@ -20,50 +20,181 @@ type outputRun func(ctx context.Context, scenes <-chan ports.Scene, contents <-c | |||||
| 20 | // each window is kept so a new output starts with every window drawn. | 20 | // each window is kept so a new output starts with every window drawn. | ||
| 21 | // Only the goroutine running loop touches the set. | 21 | // Only the goroutine running loop touches the set. | ||
| 22 | type outputSet struct { | 22 | type outputSet struct { | ||
| 23 | - | outs map[string]*runningOutput | 23 | + | outs map[string]*runningOutput |
| 24 | - | latest map[ports.WindowID]ports.SurfaceContent | 24 | + | latest map[ports.WindowID]ports.SurfaceContent |
| 25 | - | cursor ports.CursorChange | 25 | + | cursor ports.CursorChange |
| 26 | - | stopped chan string | 26 | + | stopped chan outputStopped |
| 27 | - | captured chan<- ports.CaptureDone | 27 | + | captured chan<- ports.CaptureDone |
| 28 | - | ctx context.Context | 28 | + | ctx context.Context |
| 29 | - | quit chan struct{} // closed by wait: nobody reads stopped any more | 29 | + | quit chan struct{} // closed by wait: nobody reads stopped any more |
| 30 | + | security ports.SessionSecurity | |||
| 31 | + | securityEvents chan<- ports.SecurityBackendEvent | |||
| 32 | + | state ports.SecurityState | |||
| 33 | + | nextInstance ports.OutputInstance | |||
| 34 | + | startErr error | |||
| 35 | + | barriers chan ports.SecurityBackendEvent | |||
| 36 | + | forwarders sync.WaitGroup | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | // A stop is tied to a lifetime, never just a reusable connector name. | |||
| 40 | + | type outputStopped struct { | |||
| 41 | + | name string | |||
| 42 | + | instance ports.OutputInstance | |||
| 30 | } | 43 | } | ||
| 31 | 44 | ||||
| 32 | type runningOutput struct { | 45 | type runningOutput struct { | ||
| 33 | - | scenes chan ports.Scene | 46 | + | instance ports.OutputInstance |
| 34 | - | contents chan ports.SurfaceContent | 47 | + | security chan ports.SecurityState |
| 35 | - | cursor chan ports.CursorChange | 48 | + | securityEvents chan ports.SecurityBackendEvent |
| 36 | - | captures chan ports.CaptureRequest | 49 | + | pending []ports.SurfaceContent |
| 37 | - | ctx context.Context | 50 | + | scenes chan ports.Scene |
| 38 | - | stop context.CancelFunc | 51 | + | contents chan ports.SurfaceContent |
| 39 | - | err error | 52 | + | cursor chan ports.CursorChange |
| 40 | - | done chan struct{} | 53 | + | captures chan ports.CaptureRequest |
| 54 | + | ctx context.Context | |||
| 55 | + | stop context.CancelFunc | |||
| 56 | + | err error | |||
| 57 | + | done chan struct{} | |||
| 41 | } | 58 | } | ||
| 42 | 59 | ||||
| 43 | func newOutputSet(ctx context.Context, captured chan<- ports.CaptureDone) *outputSet { | 60 | func newOutputSet(ctx context.Context, captured chan<- ports.CaptureDone) *outputSet { | ||
| 44 | - | return &outputSet{captured: captured, ctx: ctx, outs: map[string]*runningOutput{}, latest: map[ports.WindowID]ports.SurfaceContent{}, stopped: make(chan string), quit: make(chan struct{})} | 61 | + | return &outputSet{captured: captured, ctx: ctx, outs: map[string]*runningOutput{}, latest: map[ports.WindowID]ports.SurfaceContent{}, stopped: make(chan outputStopped), quit: make(chan struct{})} |
| 45 | } | 62 | } | ||
| 46 | 63 | ||||
| 47 | // start runs an output in its own goroutine and replays the window contents. | 64 | // start runs an output in its own goroutine and replays the window contents. | ||
| 48 | - | func (s *outputSet) start(ctx context.Context, name string, run outputRun) { | 65 | + | func (s *outputSet) start(ctx context.Context, name string, run outputRun, wire ...*chan<- ports.SecurityBackendEvent) bool { |
| 66 | + | // Exhaustion is permanent: never wrap to zero or reuse a lifetime. | |||
| 67 | + | if s.nextInstance == ^ports.OutputInstance(0) { | |||
| 68 | + | s.startErr = fmt.Errorf("output instance exhausted") | |||
| 69 | + | return false | |||
| 70 | + | } | |||
| 71 | + | // Register authoritatively before an owner can render or replay contents. | |||
| 72 | + | s.nextInstance++ | |||
| 73 | + | instance := s.nextInstance | |||
| 74 | + | if !s.securityEvent(ports.SecurityOutputAdded{Instance: instance, Output: name}) { | |||
| 75 | + | return false | |||
| 76 | + | } | |||
| 77 | + | if s.security != nil { | |||
| 78 | + | s.setSecurity(s.security.Snapshot()) | |||
| 79 | + | } | |||
| 49 | octx, stop := context.WithCancel(ctx) | 80 | octx, stop := context.WithCancel(ctx) | ||
| 50 | - | r := &runningOutput{scenes: make(chan ports.Scene, 1), contents: make(chan ports.SurfaceContent, 64), cursor: make(chan ports.CursorChange, 1), captures: make(chan ports.CaptureRequest, 16), ctx: octx, stop: stop, done: make(chan struct{})} | 81 | + | r := &runningOutput{instance: instance, security: make(chan ports.SecurityState, 1), scenes: make(chan ports.Scene, 1), contents: make(chan ports.SurfaceContent, 64), cursor: make(chan ports.CursorChange, 1), captures: make(chan ports.CaptureRequest, 16), ctx: octx, stop: stop, done: make(chan struct{})} |
| 82 | + | r.security <- s.state // available before any old scene/content replay | |||
| 83 | + | r.securityEvents = s.forwardSecurity() | |||
| 84 | + | for _, destination := range wire { | |||
| 85 | + | *destination = r.securityEvents | |||
| 86 | + | } | |||
| 87 | + | for _, c := range s.latest { | |||
| 88 | + | select { | |||
| 89 | + | case r.contents <- c: | |||
| 90 | + | default: | |||
| 91 | + | r.pending = append(r.pending, c) | |||
| 92 | + | } | |||
| 93 | + | } | |||
| 51 | s.outs[name] = r | 94 | s.outs[name] = r | ||
| 52 | go func() { | 95 | go func() { | ||
| 53 | - | r.err = run(octx, r.scenes, r.contents, r.cursor, r.captures) | 96 | + | r.err = run(octx, r.scenes, r.contents, r.cursor, r.captures, r.security, r.instance) |
| 97 | + | if r.securityEvents != nil { | |||
| 98 | + | close(r.securityEvents) | |||
| 99 | + | } | |||
| 54 | stop() | 100 | stop() | ||
| 55 | close(r.done) | 101 | close(r.done) | ||
| 56 | select { | 102 | select { | ||
| 57 | - | case s.stopped <- name: | 103 | + | case s.stopped <- outputStopped{name: name, instance: instance}: |
| 58 | case <-s.quit: | 104 | case <-s.quit: | ||
| 59 | } | 105 | } | ||
| 60 | }() | 106 | }() | ||
| 61 | r.cursor <- s.cursor | 107 | r.cursor <- s.cursor | ||
| 62 | - | for _, c := range s.latest { | 108 | + | return true |
| 63 | - | r.send(c) | 109 | + | } |
| 110 | + | ||||
| 111 | + | func (s *outputSet) wireSecurity(ch outputChannels) { | |||
| 112 | + | s.security, s.securityEvents = ch.security, ch.securityEvents | |||
| 113 | + | if ch.securityEvents != nil { | |||
| 114 | + | s.barriers = s.forwardSecurity() | |||
| 115 | + | } | |||
| 116 | + | if s.security != nil { | |||
| 117 | + | s.setSecurity(s.security.Snapshot()) | |||
| 64 | } | 118 | } | ||
| 65 | } | 119 | } | ||
| 66 | 120 | ||||
| 121 | + | // securityEvent registers/removes directly, while barriers use a bounded FIFO. | |||
| 122 | + | // No authoritative event is merged or discarded during an active backend. | |||
| 123 | + | func (s *outputSet) securityEvent(ev ports.SecurityBackendEvent) bool { | |||
| 124 | + | if s.securityEvents == nil { | |||
| 125 | + | return true // security disabled | |||
| 126 | + | } | |||
| 127 | + | destination := s.securityEvents | |||
| 128 | + | if _, ok := ev.(ports.SecurityBackendBarrier); ok && s.barriers != nil { | |||
| 129 | + | destination = s.barriers | |||
| 130 | + | } | |||
| 131 | + | select { | |||
| 132 | + | case destination <- ev: | |||
| 133 | + | return true | |||
| 134 | + | case <-s.ctx.Done(): | |||
| 135 | + | return false | |||
| 136 | + | } | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | // Each producer has a finite, FIFO outbox. Saturation applies backpressure; | |||
| 140 | + | // events are never merged. Registration/removal bypass it for lifecycle ordering. | |||
| 141 | + | func (s *outputSet) forwardSecurity() chan ports.SecurityBackendEvent { | |||
| 142 | + | if s.securityEvents == nil { | |||
| 143 | + | return nil | |||
| 144 | + | } | |||
| 145 | + | incoming := make(chan ports.SecurityBackendEvent, 16) | |||
| 146 | + | s.forwarders.Add(1) | |||
| 147 | + | go func() { | |||
| 148 | + | defer s.forwarders.Done() | |||
| 149 | + | for { | |||
| 150 | + | select { | |||
| 151 | + | case ev, ok := <-incoming: | |||
| 152 | + | if !ok { | |||
| 153 | + | return | |||
| 154 | + | } | |||
| 155 | + | select { | |||
| 156 | + | case s.securityEvents <- ev: | |||
| 157 | + | case <-s.ctx.Done(): | |||
| 158 | + | return | |||
| 159 | + | case <-s.quit: | |||
| 160 | + | return | |||
| 161 | + | } | |||
| 162 | + | case <-s.ctx.Done(): | |||
| 163 | + | return | |||
| 164 | + | case <-s.quit: | |||
| 165 | + | return | |||
| 166 | + | } | |||
| 167 | + | } | |||
| 168 | + | }() | |||
| 169 | + | return incoming | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | func (s *outputSet) setSecurity(state ports.SecurityState) bool { | |||
| 173 | + | if s.security != nil { | |||
| 174 | + | if newest := s.security.Snapshot(); newest.Generation > state.Generation { | |||
| 175 | + | state = newest | |||
| 176 | + | } | |||
| 177 | + | } | |||
| 178 | + | if state.Generation < s.state.Generation || state == s.state { | |||
| 179 | + | return false | |||
| 180 | + | } | |||
| 181 | + | s.state = state | |||
| 182 | + | for _, r := range s.outs { | |||
| 183 | + | select { | |||
| 184 | + | case <-r.security: | |||
| 185 | + | default: | |||
| 186 | + | } | |||
| 187 | + | r.security <- state | |||
| 188 | + | } | |||
| 189 | + | return true | |||
| 190 | + | } | |||
| 191 | + | ||||
| 192 | + | // stoppedCurrent rejects delayed stop messages from a previous incarnation. | |||
| 193 | + | func (s *outputSet) stoppedCurrent(stopped outputStopped) bool { | |||
| 194 | + | r := s.outs[stopped.name] | |||
| 195 | + | return r != nil && r.instance == stopped.instance | |||
| 196 | + | } | |||
| 197 | + | ||||
| 67 | // setCursor gives every output the latest cursor, replacing an unread one. | 198 | // setCursor gives every output the latest cursor, replacing an unread one. | ||
| 68 | func (s *outputSet) setCursor(c ports.CursorChange) { | 199 | func (s *outputSet) setCursor(c ports.CursorChange) { | ||
| 69 | s.cursor = c | 200 | s.cursor = c | ||
| @@ -76,15 +207,6 @@ func (s *outputSet) setCursor(c ports.CursorChange) { | |||||
| 76 | } | 207 | } | ||
| 77 | } | 208 | } | ||
| 78 | 209 | ||||
| 79 | - | // send hands content to the output unless it has stopped. | |||
| 80 | - | func (r *runningOutput) send(c ports.SurfaceContent) { | |||
| 81 | - | select { | |||
| 82 | - | case r.contents <- c: | |||
| 83 | - | case <-r.ctx.Done(): | |||
| 84 | - | case <-r.done: | |||
| 85 | - | } | |||
| 86 | - | } | |||
| 87 | - | ||||
| 88 | // scenes routes each scene to its output, replacing an unread one. | 210 | // scenes routes each scene to its output, replacing an unread one. | ||
| 89 | func (s *outputSet) scenes(set []ports.Scene) { | 211 | func (s *outputSet) scenes(set []ports.Scene) { | ||
| 90 | for _, sc := range set { | 212 | for _, sc := range set { | ||
| @@ -105,7 +227,41 @@ func (s *outputSet) content(c ports.SurfaceContent) { | |||||
| 105 | s.latest[c.ID] = c | 227 | s.latest[c.ID] = c | ||
| 106 | } | 228 | } | ||
| 107 | for _, r := range s.outs { | 229 | for _, r := range s.outs { | ||
| 108 | - | r.send(c) | 230 | + | if r.ctx.Err() != nil { |
| 231 | + | continue | |||
| 232 | + | } | |||
| 233 | + | if len(r.pending) == 0 { | |||
| 234 | + | select { | |||
| 235 | + | case r.contents <- c: | |||
| 236 | + | continue | |||
| 237 | + | default: | |||
| 238 | + | } | |||
| 239 | + | } | |||
| 240 | + | // The backend disabled intake until the preceding broadcast drained. | |||
| 241 | + | if cap(r.pending) == 0 { | |||
| 242 | + | r.pending = make([]ports.SurfaceContent, 0, 1) | |||
| 243 | + | } | |||
| 244 | + | r.pending = append(r.pending, c) | |||
| 245 | + | } | |||
| 246 | + | } | |||
| 247 | + | ||||
| 248 | + | // At most one admitted broadcast waits behind each owner's finite replay. | |||
| 249 | + | // Disable new content intake until all copies have been delivered. | |||
| 250 | + | func (s *outputSet) contentOut(incoming <-chan ports.SurfaceContent) (<-chan ports.SurfaceContent, chan<- ports.SurfaceContent, ports.SurfaceContent, *runningOutput) { | |||
| 251 | + | for _, r := range s.outs { | |||
| 252 | + | if len(r.pending) != 0 && r.ctx.Err() == nil { | |||
| 253 | + | return nil, r.contents, r.pending[0], r | |||
| 254 | + | } | |||
| 255 | + | } | |||
| 256 | + | return incoming, nil, ports.SurfaceContent{}, nil | |||
| 257 | + | } | |||
| 258 | + | ||||
| 259 | + | func (r *runningOutput) contentSent() { | |||
| 260 | + | r.pending[0] = ports.SurfaceContent{} | |||
| 261 | + | if len(r.pending) == 1 { | |||
| 262 | + | r.pending = r.pending[:0] // retain the one-slot broadcast buffer | |||
| 263 | + | } else { | |||
| 264 | + | r.pending = r.pending[1:] | |||
| 109 | } | 265 | } | ||
| 110 | } | 266 | } | ||
| 111 | 267 | ||||
| @@ -124,8 +280,14 @@ func (s *outputSet) finish(name string) error { | |||||
| 124 | 280 | ||||
| 125 | // wait stops every output and returns the first error. | 281 | // wait stops every output and returns the first error. | ||
| 126 | func (s *outputSet) wait() error { | 282 | func (s *outputSet) wait() error { | ||
| 127 | - | var first error | 283 | + | var first error = s.startErr |
| 284 | + | // Cancel producers before closing the forwarding quit path. Owners may | |||
| 285 | + | // be backpressured on a full FIFO, and need their context to exit. | |||
| 286 | + | for _, r := range s.outs { | |||
| 287 | + | r.stop() | |||
| 288 | + | } | |||
| 128 | close(s.quit) | 289 | close(s.quit) | ||
| 290 | + | s.forwarders.Wait() | |||
| 129 | for name, r := range s.outs { | 291 | for name, r := range s.outs { | ||
| 130 | r.stop() | 292 | r.stop() | ||
| 131 | <-r.done | 293 | <-r.done | ||
M internal/app/outputs_test.go +1 -1
| @@ -17,7 +17,7 @@ func TestOutputSetCursor(t *testing.T) { | |||||
| 17 | run := func(name string) outputRun { | 17 | run := func(name string) outputRun { | ||
| 18 | got[name] = make(chan ports.CursorChange, 8) | 18 | got[name] = make(chan ports.CursorChange, 8) | ||
| 19 | out := got[name] | 19 | out := got[name] | ||
| 20 | - | return func(ctx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, cursor <-chan ports.CursorChange, _ <-chan ports.CaptureRequest) error { | 20 | + | return func(ctx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, cursor <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, _ ports.OutputInstance) error { |
| 21 | for { | 21 | for { | ||
| 22 | select { | 22 | select { | ||
| 23 | case <-ctx.Done(): | 23 | case <-ctx.Done(): | ||
A internal/app/security_duplicate_test.go +122 -0
| @@ -0,0 +1,122 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/neferwl/internal/adapters/sessionsecurity" | |||
| 8 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 9 | + | "github.com/bnema/neferwl/internal/sessionlock" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // Native Close validation is exercised by DRM's generated-KMS tests. This | |||
| 13 | + | // boundary test proves that even a name-discarded physical lifetime enters the | |||
| 14 | + | // authoritative ledger, and only affirmative inactivity can retire it. | |||
| 15 | + | func TestSecurityDuplicatePhysicalLifetimeRequiresConfirmedInactive(t *testing.T) { | |||
| 16 | + | for _, inactive := range []bool{false, true} { | |||
| 17 | + | name := "unconfirmed" | |||
| 18 | + | if inactive { | |||
| 19 | + | name = "confirmed" | |||
| 20 | + | } | |||
| 21 | + | t.Run(name, func(t *testing.T) { | |||
| 22 | + | gate := &sessionsecurity.Gate{} | |||
| 23 | + | state, err := gate.Engage() | |||
| 24 | + | if err != nil { | |||
| 25 | + | t.Fatal(err) | |||
| 26 | + | } | |||
| 27 | + | events := make(chan ports.SecurityBackendEvent, 4) | |||
| 28 | + | set := newOutputSet(context.Background(), nil) | |||
| 29 | + | set.wireSecurity(outputChannels{security: gate, securityEvents: events}) | |||
| 30 | + | run := func(ctx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, _ ports.OutputInstance) error { | |||
| 31 | + | <-ctx.Done() | |||
| 32 | + | return nil | |||
| 33 | + | } | |||
| 34 | + | if !set.start(context.Background(), "DP-1", run) { | |||
| 35 | + | t.Fatal("primary output failed to start") | |||
| 36 | + | } | |||
| 37 | + | primary := (<-events).(ports.SecurityOutputAdded) | |||
| 38 | + | duplicate, err := registerDiscardedOutput(set, "DP-1") | |||
| 39 | + | if err != nil { | |||
| 40 | + | t.Fatal(err) | |||
| 41 | + | } | |||
| 42 | + | added := (<-events).(ports.SecurityOutputAdded) | |||
| 43 | + | if added.Instance != duplicate || added.Output != primary.Output || duplicate <= primary.Instance { | |||
| 44 | + | t.Fatalf("duplicate lifetime registration: primary=%+v duplicate=%+v", primary, added) | |||
| 45 | + | } | |||
| 46 | + | if len(set.outs) != 1 || set.outs["DP-1"].instance != primary.Instance { | |||
| 47 | + | t.Fatal("discarded lifetime replaced routed output") | |||
| 48 | + | } | |||
| 49 | + | var ledger sessionlock.Readiness | |||
| 50 | + | if !ledger.Add(primary.Instance) || !ledger.Add(duplicate) || !ledger.Begin(state.Generation) { | |||
| 51 | + | t.Fatal("ledger setup failed") | |||
| 52 | + | } | |||
| 53 | + | ledger.BackendBarrier(state.Generation) | |||
| 54 | + | ledger.CaptureBarrier(state.Generation) | |||
| 55 | + | ledger.Record(ports.OutputProtection{Generation: state.Generation, Instance: primary.Instance, Kind: ports.ProtectionProtectedFrame}) | |||
| 56 | + | if ledger.Ready() { | |||
| 57 | + | t.Fatal("primary proof hid duplicate physical output") | |||
| 58 | + | } | |||
| 59 | + | err = retireDiscardedOutput(set, duplicate, inactive) | |||
| 60 | + | if inactive { | |||
| 61 | + | if err != nil { | |||
| 62 | + | t.Fatal(err) | |||
| 63 | + | } | |||
| 64 | + | removed := (<-events).(ports.SecurityOutputRemoved) | |||
| 65 | + | if removed.Instance != duplicate { | |||
| 66 | + | t.Fatal("removed primary instead of duplicate") | |||
| 67 | + | } | |||
| 68 | + | if !ledger.Remove(removed.Instance) || !ledger.Ready() { | |||
| 69 | + | t.Fatal("confirmed inactive duplicate did not retire") | |||
| 70 | + | } | |||
| 71 | + | } else { | |||
| 72 | + | if err == nil { | |||
| 73 | + | t.Fatal("unconfirmed close accepted") | |||
| 74 | + | } | |||
| 75 | + | select { | |||
| 76 | + | case event := <-events: | |||
| 77 | + | t.Fatalf("false retirement/proof: %+v", event) | |||
| 78 | + | default: | |||
| 79 | + | } | |||
| 80 | + | if ledger.Ready() { | |||
| 81 | + | t.Fatal("unproved duplicate permitted locked") | |||
| 82 | + | } | |||
| 83 | + | } | |||
| 84 | + | if !gate.Snapshot().Protected { | |||
| 85 | + | t.Fatal("discarding duplicate released gate") | |||
| 86 | + | } | |||
| 87 | + | if err := set.wait(); err != nil { | |||
| 88 | + | t.Fatal(err) | |||
| 89 | + | } | |||
| 90 | + | }) | |||
| 91 | + | } | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | func TestSecurityDiscardedLifetimeCounterExhaustion(t *testing.T) { | |||
| 95 | + | events := make(chan ports.SecurityBackendEvent, 2) | |||
| 96 | + | set := newOutputSet(context.Background(), nil) | |||
| 97 | + | set.wireSecurity(outputChannels{securityEvents: events}) | |||
| 98 | + | set.nextInstance = ^ports.OutputInstance(0) - 1 | |||
| 99 | + | instance, err := registerDiscardedOutput(set, "DP-1") | |||
| 100 | + | if err != nil || instance != ^ports.OutputInstance(0) { | |||
| 101 | + | t.Fatalf("last lifetime: %d %v", instance, err) | |||
| 102 | + | } | |||
| 103 | + | if added := (<-events).(ports.SecurityOutputAdded); added.Instance != instance { | |||
| 104 | + | t.Fatal("registration differs") | |||
| 105 | + | } | |||
| 106 | + | for range 2 { | |||
| 107 | + | if instance, err := registerDiscardedOutput(set, "DP-1"); instance != 0 || err == nil { | |||
| 108 | + | t.Fatalf("exhaustion: %d %v", instance, err) | |||
| 109 | + | } | |||
| 110 | + | } | |||
| 111 | + | if set.nextInstance != ^ports.OutputInstance(0) { | |||
| 112 | + | t.Fatal("counter wrapped") | |||
| 113 | + | } | |||
| 114 | + | select { | |||
| 115 | + | case event := <-events: | |||
| 116 | + | t.Fatalf("exhaustion registered: %+v", event) | |||
| 117 | + | default: | |||
| 118 | + | } | |||
| 119 | + | if err := set.wait(); err == nil { | |||
| 120 | + | t.Fatal("exhaustion not retained") | |||
| 121 | + | } | |||
| 122 | + | } | |||
A internal/app/security_hotplug_test.go +392 -0
| @@ -0,0 +1,392 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "reflect" | |||
| 6 | + | "testing" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 10 | + | "github.com/bnema/neferwl/internal/sessionlock" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func hotplugEvent(t *testing.T, events <-chan ports.SecurityBackendEvent) ports.SecurityBackendEvent { | |||
| 14 | + | t.Helper() | |||
| 15 | + | select { | |||
| 16 | + | case event := <-events: | |||
| 17 | + | return event | |||
| 18 | + | case <-time.After(2 * time.Second): | |||
| 19 | + | t.Fatal("missing ordered security event") | |||
| 20 | + | return nil | |||
| 21 | + | } | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | // This switch represents the single display owner's ledger consumption, not | |||
| 25 | + | // an alternate backend implementation. Events come through real outputSet FIFO. | |||
| 26 | + | func hotplugApply(ledger *sessionlock.Readiness, event ports.SecurityBackendEvent) bool { | |||
| 27 | + | switch v := event.(type) { | |||
| 28 | + | case ports.SecurityOutputAdded: | |||
| 29 | + | return ledger.Add(v.Instance) | |||
| 30 | + | case ports.SecurityOutputRemoved: | |||
| 31 | + | return ledger.Remove(v.Instance) | |||
| 32 | + | case ports.SecurityOutputInvalidated: | |||
| 33 | + | return ledger.Invalidate(v.Generation, v.Instance) | |||
| 34 | + | case ports.SecurityOutputProof: | |||
| 35 | + | return ledger.Record(v.Proof) | |||
| 36 | + | case ports.SecurityBackendBarrier: | |||
| 37 | + | return v.Err == nil && ledger.BackendBarrier(v.Generation) | |||
| 38 | + | default: | |||
| 39 | + | return false | |||
| 40 | + | } | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | func TestSecurityHotplugLifetimeAndProofOrdering(t *testing.T) { | |||
| 44 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |||
| 45 | + | defer cancel() | |||
| 46 | + | events := make(chan ports.SecurityBackendEvent, 8) | |||
| 47 | + | set := newOutputSet(ctx, nil) | |||
| 48 | + | set.wireSecurity(outputChannels{securityEvents: events}) | |||
| 49 | + | defer func() { | |||
| 50 | + | if err := set.wait(); err != nil { | |||
| 51 | + | t.Error(err) | |||
| 52 | + | } | |||
| 53 | + | }() | |||
| 54 | + | const generation = ports.LockGeneration(41) | |||
| 55 | + | set.setSecurity(ports.SecurityState{Generation: generation, Protected: true}) | |||
| 56 | + | var ledger sessionlock.Readiness | |||
| 57 | + | if !ledger.Begin(generation) || !ledger.BackendBarrier(generation) || !ledger.CaptureBarrier(generation) { | |||
| 58 | + | t.Fatal("could not begin display readiness round") | |||
| 59 | + | } | |||
| 60 | + | // Native Close success is independently tested in DRM. Here the real | |||
| 61 | + | // owner callback synchronizes that affirmative result before the backend | |||
| 62 | + | // sends removal; mere callback exit never emits removal from outputSet. | |||
| 63 | + | start := func(closeSuccess bool) (ports.OutputInstance, chan []ports.SecurityBackendEvent, <-chan bool) { | |||
| 64 | + | t.Helper() | |||
| 65 | + | commands := make(chan []ports.SecurityBackendEvent, 1) | |||
| 66 | + | closed := make(chan bool, 1) | |||
| 67 | + | var ownerEvents chan<- ports.SecurityBackendEvent | |||
| 68 | + | if !set.start(ctx, "DP-1", func(octx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, security <-chan ports.SecurityState, instance ports.OutputInstance) error { | |||
| 69 | + | defer func() { closed <- closeSuccess }() | |||
| 70 | + | state := <-security | |||
| 71 | + | proof := ports.SecurityOutputProof{Proof: ports.OutputProtection{Generation: state.Generation, Instance: instance, Kind: ports.ProtectionProtectedFrame}} | |||
| 72 | + | select { | |||
| 73 | + | case ownerEvents <- proof: | |||
| 74 | + | case <-octx.Done(): | |||
| 75 | + | return nil | |||
| 76 | + | } | |||
| 77 | + | for { | |||
| 78 | + | select { | |||
| 79 | + | case batch := <-commands: | |||
| 80 | + | for _, event := range batch { | |||
| 81 | + | select { | |||
| 82 | + | case ownerEvents <- event: | |||
| 83 | + | case <-octx.Done(): | |||
| 84 | + | return nil | |||
| 85 | + | } | |||
| 86 | + | } | |||
| 87 | + | case <-octx.Done(): | |||
| 88 | + | return nil | |||
| 89 | + | } | |||
| 90 | + | } | |||
| 91 | + | }, &ownerEvents) { | |||
| 92 | + | t.Fatal("start refused") | |||
| 93 | + | } | |||
| 94 | + | registered := hotplugEvent(t, events) | |||
| 95 | + | added, ok := registered.(ports.SecurityOutputAdded) | |||
| 96 | + | if !ok || added.Output != "DP-1" || added.Instance == 0 || !hotplugApply(&ledger, registered) { | |||
| 97 | + | t.Fatalf("owner evidence preceded lifetime registration: %+v", registered) | |||
| 98 | + | } | |||
| 99 | + | if ledger.Ready() { | |||
| 100 | + | t.Fatal("new lifetime reused previous physical readiness") | |||
| 101 | + | } | |||
| 102 | + | proof := hotplugEvent(t, events) | |||
| 103 | + | if !hotplugApply(&ledger, proof) || !ledger.Ready() { | |||
| 104 | + | t.Fatalf("missing exact lifetime proof: %+v", proof) | |||
| 105 | + | } | |||
| 106 | + | return added.Instance, commands, closed | |||
| 107 | + | } | |||
| 108 | + | old, commands, closed := start(true) | |||
| 109 | + | // A proof already queued by this owner precedes invalidation, even if the | |||
| 110 | + | // display reads later. No separate forwarder can replay it after revoke. | |||
| 111 | + | oldProof := ports.SecurityOutputProof{Proof: ports.OutputProtection{Generation: generation, Instance: old, Kind: ports.ProtectionProtectedFrame}} | |||
| 112 | + | invalid := ports.SecurityOutputInvalidated{Generation: generation, Instance: old} | |||
| 113 | + | commands <- []ports.SecurityBackendEvent{oldProof, invalid} | |||
| 114 | + | if got := hotplugEvent(t, events); got != oldProof || !hotplugApply(&ledger, got) { | |||
| 115 | + | t.Fatalf("queued proof lost: %+v", got) | |||
| 116 | + | } | |||
| 117 | + | if got := hotplugEvent(t, events); got != invalid || !hotplugApply(&ledger, got) || ledger.Ready() { | |||
| 118 | + | t.Fatalf("invalidation reordered or ineffective: %+v", got) | |||
| 119 | + | } | |||
| 120 | + | commands <- []ports.SecurityBackendEvent{oldProof} | |||
| 121 | + | if got := hotplugEvent(t, events); got != oldProof || !hotplugApply(&ledger, got) || !ledger.Ready() { | |||
| 122 | + | t.Fatal("fresh resume proof did not restore readiness") | |||
| 123 | + | } | |||
| 124 | + | set.outs["DP-1"].stop() | |||
| 125 | + | select { | |||
| 126 | + | case stopped := <-set.stopped: | |||
| 127 | + | if stopped.instance != old || !set.stoppedCurrent(stopped) { | |||
| 128 | + | t.Fatalf("wrong stopped lifetime: %+v", stopped) | |||
| 129 | + | } | |||
| 130 | + | case <-ctx.Done(): | |||
| 131 | + | t.Fatal("owner did not stop") | |||
| 132 | + | } | |||
| 133 | + | if err := set.finish("DP-1"); err != nil { | |||
| 134 | + | t.Fatal(err) | |||
| 135 | + | } | |||
| 136 | + | if !<-closed { | |||
| 137 | + | t.Fatal("shutdown not affirmative") | |||
| 138 | + | } | |||
| 139 | + | select { | |||
| 140 | + | case got := <-events: | |||
| 141 | + | t.Fatalf("owner exit forged removal: %+v", got) | |||
| 142 | + | default: | |||
| 143 | + | } | |||
| 144 | + | if !set.securityEvent(ports.SecurityOutputRemoved{Instance: old}) { | |||
| 145 | + | t.Fatal("confirmed removal refused") | |||
| 146 | + | } | |||
| 147 | + | removal := hotplugEvent(t, events) | |||
| 148 | + | if removal != (ports.SecurityOutputRemoved{Instance: old}) || !hotplugApply(&ledger, removal) { | |||
| 149 | + | t.Fatalf("wrong removal: %+v", removal) | |||
| 150 | + | } | |||
| 151 | + | replacement, _, replacementClosed := start(false) | |||
| 152 | + | if replacement <= old || set.stoppedCurrent(outputStopped{name: "DP-1", instance: old}) { | |||
| 153 | + | t.Fatal("same-name replacement reused retired lifetime") | |||
| 154 | + | } | |||
| 155 | + | if hotplugApply(&ledger, oldProof) || hotplugApply(&ledger, invalid) { | |||
| 156 | + | t.Fatal("retired lifetime affected replacement") | |||
| 157 | + | } | |||
| 158 | + | stale := ports.SecurityOutputProof{Proof: ports.OutputProtection{Generation: generation - 1, Instance: replacement, Kind: ports.ProtectionInactiveOutput}} | |||
| 159 | + | if hotplugApply(&ledger, stale) || !ledger.Ready() { | |||
| 160 | + | t.Fatal("stale generation overwrote valid replacement proof") | |||
| 161 | + | } | |||
| 162 | + | if !ledger.Invalidate(generation, replacement) || ledger.Ready() { | |||
| 163 | + | t.Fatal("replacement invalidation ineffective") | |||
| 164 | + | } | |||
| 165 | + | set.outs["DP-1"].stop() | |||
| 166 | + | select { | |||
| 167 | + | case <-set.stopped: | |||
| 168 | + | case <-ctx.Done(): | |||
| 169 | + | t.Fatal("replacement stop blocked") | |||
| 170 | + | } | |||
| 171 | + | if err := set.finish("DP-1"); err != nil { | |||
| 172 | + | t.Fatal(err) | |||
| 173 | + | } | |||
| 174 | + | if <-replacementClosed { | |||
| 175 | + | t.Fatal("expected failed shutdown") | |||
| 176 | + | } | |||
| 177 | + | select { | |||
| 178 | + | case got := <-events: | |||
| 179 | + | t.Fatalf("failed shutdown forged removal: %+v", got) | |||
| 180 | + | default: | |||
| 181 | + | } | |||
| 182 | + | if ledger.Ready() { | |||
| 183 | + | t.Fatal("failed replacement exit erased required unproved lifetime") | |||
| 184 | + | } | |||
| 185 | + | } | |||
| 186 | + | ||||
| 187 | + | func TestSecurityOwnerFIFOSaturationDrainsOnNormalStop(t *testing.T) { | |||
| 188 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |||
| 189 | + | defer cancel() | |||
| 190 | + | events := make(chan ports.SecurityBackendEvent) // block display after registration | |||
| 191 | + | set := newOutputSet(ctx, nil) | |||
| 192 | + | set.wireSecurity(outputChannels{securityEvents: events}) | |||
| 193 | + | defer func() { | |||
| 194 | + | if err := set.wait(); err != nil { | |||
| 195 | + | t.Error(err) | |||
| 196 | + | } | |||
| 197 | + | }() | |||
| 198 | + | const generation = ports.LockGeneration(7) | |||
| 199 | + | accepted := make(chan int, 40) | |||
| 200 | + | startDone := make(chan bool, 1) | |||
| 201 | + | var ownerEvents chan<- ports.SecurityBackendEvent | |||
| 202 | + | go func() { | |||
| 203 | + | startDone <- set.start(ctx, "DP-1", func(octx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, instance ports.OutputInstance) error { | |||
| 204 | + | for i := range 40 { | |||
| 205 | + | var event ports.SecurityBackendEvent = ports.SecurityOutputInvalidated{Generation: generation, Instance: instance} | |||
| 206 | + | if i%2 == 0 { | |||
| 207 | + | event = ports.SecurityOutputProof{Proof: ports.OutputProtection{Generation: generation, Instance: instance, Kind: ports.ProtectionProtectedFrame}} | |||
| 208 | + | } | |||
| 209 | + | select { | |||
| 210 | + | case ownerEvents <- event: | |||
| 211 | + | accepted <- i | |||
| 212 | + | case <-octx.Done(): | |||
| 213 | + | return nil | |||
| 214 | + | } | |||
| 215 | + | } | |||
| 216 | + | return nil | |||
| 217 | + | }, &ownerEvents) | |||
| 218 | + | }() | |||
| 219 | + | registered := hotplugEvent(t, events).(ports.SecurityOutputAdded) | |||
| 220 | + | if !<-startDone { | |||
| 221 | + | t.Fatal("start failed") | |||
| 222 | + | } | |||
| 223 | + | if cap(set.outs["DP-1"].securityEvents) != 16 { | |||
| 224 | + | t.Fatal("owner evidence FIFO is not bounded to 16") | |||
| 225 | + | } | |||
| 226 | + | // Forwarder holds one event plus its 16-slot input. The eighteenth send | |||
| 227 | + | // must remain blocked until the display starts draining. | |||
| 228 | + | for i := range 17 { | |||
| 229 | + | select { | |||
| 230 | + | case got := <-accepted: | |||
| 231 | + | if got != i { | |||
| 232 | + | t.Fatalf("acceptance order %d want %d", got, i) | |||
| 233 | + | } | |||
| 234 | + | case <-ctx.Done(): | |||
| 235 | + | t.Fatal("FIFO failed to fill") | |||
| 236 | + | } | |||
| 237 | + | } | |||
| 238 | + | select { | |||
| 239 | + | case got := <-accepted: | |||
| 240 | + | t.Fatalf("full FIFO did not backpressure owner: %d", got) | |||
| 241 | + | case <-time.After(20 * time.Millisecond): | |||
| 242 | + | } | |||
| 243 | + | var ledger sessionlock.Readiness | |||
| 244 | + | ledger.Add(registered.Instance) | |||
| 245 | + | ledger.Begin(generation) | |||
| 246 | + | ledger.BackendBarrier(generation) | |||
| 247 | + | ledger.CaptureBarrier(generation) | |||
| 248 | + | for i := range 40 { | |||
| 249 | + | got := hotplugEvent(t, events) | |||
| 250 | + | var want ports.SecurityBackendEvent = ports.SecurityOutputInvalidated{Generation: generation, Instance: registered.Instance} | |||
| 251 | + | if i%2 == 0 { | |||
| 252 | + | want = ports.SecurityOutputProof{Proof: ports.OutputProtection{Generation: generation, Instance: registered.Instance, Kind: ports.ProtectionProtectedFrame}} | |||
| 253 | + | } | |||
| 254 | + | if !reflect.DeepEqual(got, want) || !hotplugApply(&ledger, got) || ledger.Ready() != (i%2 == 0) { | |||
| 255 | + | t.Fatalf("evidence lost/duplicated/reordered at %d: %+v", i, got) | |||
| 256 | + | } | |||
| 257 | + | } | |||
| 258 | + | select { | |||
| 259 | + | case stopped := <-set.stopped: | |||
| 260 | + | if !set.stoppedCurrent(stopped) { | |||
| 261 | + | t.Fatal("stop not current") | |||
| 262 | + | } | |||
| 263 | + | case <-ctx.Done(): | |||
| 264 | + | t.Fatal("normal owner stop blocked") | |||
| 265 | + | } | |||
| 266 | + | if err := set.finish("DP-1"); err != nil { | |||
| 267 | + | t.Fatal(err) | |||
| 268 | + | } | |||
| 269 | + | select { | |||
| 270 | + | case got := <-events: | |||
| 271 | + | t.Fatalf("duplicate evidence or removal after normal drain: %+v", got) | |||
| 272 | + | default: | |||
| 273 | + | } | |||
| 274 | + | } | |||
| 275 | + | ||||
| 276 | + | // All producer evidence is already buffered when the owner stops normally. | |||
| 277 | + | // finish must not cancel that owner's forwarder or lose its outstanding FIFO. | |||
| 278 | + | func TestSecurityOwnerBufferedEvidenceDrainsAfterFinish(t *testing.T) { | |||
| 279 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |||
| 280 | + | defer cancel() | |||
| 281 | + | events := make(chan ports.SecurityBackendEvent) | |||
| 282 | + | set := newOutputSet(ctx, nil) | |||
| 283 | + | set.wireSecurity(outputChannels{securityEvents: events}) | |||
| 284 | + | defer func() { | |||
| 285 | + | if err := set.wait(); err != nil { | |||
| 286 | + | t.Error(err) | |||
| 287 | + | } | |||
| 288 | + | }() | |||
| 289 | + | startDone := make(chan bool, 1) | |||
| 290 | + | var ownerEvents chan<- ports.SecurityBackendEvent | |||
| 291 | + | go func() { | |||
| 292 | + | startDone <- set.start(ctx, "DP-1", func(octx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, instance ports.OutputInstance) error { | |||
| 293 | + | for i := range 16 { | |||
| 294 | + | event := ports.SecurityOutputInvalidated{Generation: ports.LockGeneration(i + 1), Instance: instance} | |||
| 295 | + | select { | |||
| 296 | + | case ownerEvents <- event: | |||
| 297 | + | case <-octx.Done(): | |||
| 298 | + | return nil | |||
| 299 | + | } | |||
| 300 | + | } | |||
| 301 | + | return nil | |||
| 302 | + | }, &ownerEvents) | |||
| 303 | + | }() | |||
| 304 | + | registered := hotplugEvent(t, events).(ports.SecurityOutputAdded) | |||
| 305 | + | if !<-startDone { | |||
| 306 | + | t.Fatal("start refused") | |||
| 307 | + | } | |||
| 308 | + | select { | |||
| 309 | + | case stopped := <-set.stopped: | |||
| 310 | + | if stopped.instance != registered.Instance { | |||
| 311 | + | t.Fatal("wrong stopped lifetime") | |||
| 312 | + | } | |||
| 313 | + | case <-ctx.Done(): | |||
| 314 | + | t.Fatal("buffered owner did not finish") | |||
| 315 | + | } | |||
| 316 | + | if err := set.finish("DP-1"); err != nil { | |||
| 317 | + | t.Fatal(err) | |||
| 318 | + | } | |||
| 319 | + | for i := range 16 { | |||
| 320 | + | want := ports.SecurityOutputInvalidated{Generation: ports.LockGeneration(i + 1), Instance: registered.Instance} | |||
| 321 | + | if got := hotplugEvent(t, events); got != want { | |||
| 322 | + | t.Fatalf("buffered stopped-owner evidence %d: %+v", i, got) | |||
| 323 | + | } | |||
| 324 | + | } | |||
| 325 | + | select { | |||
| 326 | + | case got := <-events: | |||
| 327 | + | t.Fatalf("duplicate buffered evidence: %+v", got) | |||
| 328 | + | default: | |||
| 329 | + | } | |||
| 330 | + | } | |||
| 331 | + | ||||
| 332 | + | func TestSecurityOwnerFIFOSaturationCancelReleasesOwnerAndForwarders(t *testing.T) { | |||
| 333 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |||
| 334 | + | defer cancel() | |||
| 335 | + | events := make(chan ports.SecurityBackendEvent) | |||
| 336 | + | set := newOutputSet(ctx, nil) | |||
| 337 | + | set.wireSecurity(outputChannels{securityEvents: events}) | |||
| 338 | + | accepted := make(chan int, 40) | |||
| 339 | + | ownerDone := make(chan struct{}) | |||
| 340 | + | startDone := make(chan bool, 1) | |||
| 341 | + | var ownerEvents chan<- ports.SecurityBackendEvent | |||
| 342 | + | go func() { | |||
| 343 | + | startDone <- set.start(ctx, "DP-1", func(octx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, instance ports.OutputInstance) error { | |||
| 344 | + | defer close(ownerDone) | |||
| 345 | + | for i := range 40 { | |||
| 346 | + | select { | |||
| 347 | + | case ownerEvents <- ports.SecurityOutputInvalidated{Generation: 7, Instance: instance}: | |||
| 348 | + | accepted <- i | |||
| 349 | + | case <-octx.Done(): | |||
| 350 | + | return nil | |||
| 351 | + | } | |||
| 352 | + | } | |||
| 353 | + | return nil | |||
| 354 | + | }, &ownerEvents) | |||
| 355 | + | }() | |||
| 356 | + | hotplugEvent(t, events) // registered before producer starts | |||
| 357 | + | if !<-startDone { | |||
| 358 | + | t.Fatal("start failed") | |||
| 359 | + | } | |||
| 360 | + | for range 17 { | |||
| 361 | + | select { | |||
| 362 | + | case <-accepted: | |||
| 363 | + | case <-ctx.Done(): | |||
| 364 | + | t.Fatal("FIFO never filled") | |||
| 365 | + | } | |||
| 366 | + | } | |||
| 367 | + | select { | |||
| 368 | + | case <-accepted: | |||
| 369 | + | t.Fatal("producer did not block at finite FIFO") | |||
| 370 | + | case <-time.After(20 * time.Millisecond): | |||
| 371 | + | } | |||
| 372 | + | waitDone := make(chan error, 1) | |||
| 373 | + | go func() { waitDone <- set.wait() }() | |||
| 374 | + | select { | |||
| 375 | + | case err := <-waitDone: | |||
| 376 | + | if err != nil { | |||
| 377 | + | t.Fatal(err) | |||
| 378 | + | } | |||
| 379 | + | case <-ctx.Done(): | |||
| 380 | + | t.Fatal("saturated evidence leaked owner/forwarder on cancellation") | |||
| 381 | + | } | |||
| 382 | + | select { | |||
| 383 | + | case <-ownerDone: | |||
| 384 | + | default: | |||
| 385 | + | t.Fatal("wait returned before producer exited") | |||
| 386 | + | } | |||
| 387 | + | if len(set.outs) != 0 { | |||
| 388 | + | t.Fatal("stopped lifetime retained in running set") | |||
| 389 | + | } | |||
| 390 | + | // wait includes forwarders.Wait: termination is directly synchronized, | |||
| 391 | + | // rather than guessed from goroutine counts or arbitrary sleeps. | |||
| 392 | + | } | |||
A internal/app/security_test.go +317 -0
| @@ -0,0 +1,317 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "os" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |||
| 11 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | func TestSecurityRegistrationBeforeStart(t *testing.T) { | |||
| 15 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 16 | + | defer cancel() | |||
| 17 | + | events := make(chan ports.SecurityBackendEvent) | |||
| 18 | + | set := newOutputSet(ctx, nil) | |||
| 19 | + | set.wireSecurity(outputChannels{securityEvents: events}) | |||
| 20 | + | set.setSecurity(ports.SecurityState{Generation: 7, Protected: true}) | |||
| 21 | + | started := make(chan ports.OutputInstance, 1) | |||
| 22 | + | result := make(chan bool, 1) | |||
| 23 | + | go func() { | |||
| 24 | + | result <- set.start(ctx, "DP-1", func(ctx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, security <-chan ports.SecurityState, instance ports.OutputInstance) error { | |||
| 25 | + | state := <-security | |||
| 26 | + | if state != (ports.SecurityState{Generation: 7, Protected: true}) { | |||
| 27 | + | return errors.New("missing startup protection") | |||
| 28 | + | } | |||
| 29 | + | started <- instance | |||
| 30 | + | <-ctx.Done() | |||
| 31 | + | return nil | |||
| 32 | + | }) | |||
| 33 | + | }() | |||
| 34 | + | select { | |||
| 35 | + | case <-started: | |||
| 36 | + | t.Fatal("owner ran before registration") | |||
| 37 | + | default: | |||
| 38 | + | } | |||
| 39 | + | var added ports.SecurityOutputAdded | |||
| 40 | + | select { | |||
| 41 | + | case event := <-events: | |||
| 42 | + | added = event.(ports.SecurityOutputAdded) | |||
| 43 | + | case <-time.After(2 * time.Second): | |||
| 44 | + | t.Fatal("registration missing") | |||
| 45 | + | } | |||
| 46 | + | if added.Instance == 0 || added.Output != "DP-1" { | |||
| 47 | + | t.Fatalf("registration: %+v", added) | |||
| 48 | + | } | |||
| 49 | + | if !<-result { | |||
| 50 | + | t.Fatal("start failed") | |||
| 51 | + | } | |||
| 52 | + | select { | |||
| 53 | + | case instance := <-started: | |||
| 54 | + | if instance != added.Instance { | |||
| 55 | + | t.Fatal("owner lifetime differs") | |||
| 56 | + | } | |||
| 57 | + | case <-time.After(2 * time.Second): | |||
| 58 | + | t.Fatal("owner missing startup protection") | |||
| 59 | + | } | |||
| 60 | + | if err := set.wait(); err != nil { | |||
| 61 | + | t.Fatal(err) | |||
| 62 | + | } | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | func TestSecurityReplacementAndStaleState(t *testing.T) { | |||
| 66 | + | ctx := context.Background() | |||
| 67 | + | events := make(chan ports.SecurityBackendEvent, 4) | |||
| 68 | + | set := newOutputSet(ctx, nil) | |||
| 69 | + | set.wireSecurity(outputChannels{securityEvents: events}) | |||
| 70 | + | run := func(ctx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, _ ports.OutputInstance) error { | |||
| 71 | + | <-ctx.Done() | |||
| 72 | + | return nil | |||
| 73 | + | } | |||
| 74 | + | set.start(ctx, "DP-1", run) | |||
| 75 | + | old := (<-events).(ports.SecurityOutputAdded) | |||
| 76 | + | set.outs["DP-1"].stop() | |||
| 77 | + | if err := set.finish("DP-1"); err != nil { | |||
| 78 | + | t.Fatal(err) | |||
| 79 | + | } | |||
| 80 | + | set.start(ctx, "DP-1", run) | |||
| 81 | + | replacement := (<-events).(ports.SecurityOutputAdded) | |||
| 82 | + | if replacement.Instance <= old.Instance { | |||
| 83 | + | t.Fatal("reused lifetime") | |||
| 84 | + | } | |||
| 85 | + | if set.stoppedCurrent(outputStopped{name: "DP-1", instance: old.Instance}) { | |||
| 86 | + | t.Fatal("old stop matches replacement") | |||
| 87 | + | } | |||
| 88 | + | set.setSecurity(ports.SecurityState{Generation: 3, Protected: true}) | |||
| 89 | + | set.setSecurity(ports.SecurityState{Generation: 2}) | |||
| 90 | + | if state := <-set.outs["DP-1"].security; state.Generation != 3 || !state.Protected { | |||
| 91 | + | t.Fatalf("stale transition: %+v", state) | |||
| 92 | + | } | |||
| 93 | + | set.setSecurity(ports.SecurityState{Generation: 4}) | |||
| 94 | + | if state := <-set.outs["DP-1"].security; state.Generation != 4 || state.Protected { | |||
| 95 | + | t.Fatalf("unlock not forwarded: %+v", state) | |||
| 96 | + | } | |||
| 97 | + | if err := set.wait(); err != nil { | |||
| 98 | + | t.Fatal(err) | |||
| 99 | + | } | |||
| 100 | + | select { | |||
| 101 | + | case ev := <-events: | |||
| 102 | + | t.Fatalf("stop emitted false removal: %+v", ev) | |||
| 103 | + | default: | |||
| 104 | + | } | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | func TestGuardedLeaseRejectsProtected(t *testing.T) { | |||
| 108 | + | gate := portsmocks.NewMockSessionSecurity(t) | |||
| 109 | + | gate.EXPECT().Snapshot().Return(ports.SecurityState{Generation: 1, Protected: true}).Once() | |||
| 110 | + | card := newMocksecurityLeaseCard(t) | |||
| 111 | + | fd, id, err := guardedLease(card, gate, []string{"DP-1"}) | |||
| 112 | + | if fd != nil || id != 0 || err == nil { | |||
| 113 | + | t.Fatalf("protected lease: %v %d %v", fd, id, err) | |||
| 114 | + | } | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | func TestGuardedLeaseCrossingTransition(t *testing.T) { | |||
| 118 | + | for _, revokeErr := range []error{nil, errors.New("revoke failed")} { | |||
| 119 | + | t.Run(map[bool]string{false: "revoked", true: "failed"}[revokeErr != nil], func(t *testing.T) { | |||
| 120 | + | fd, err := os.CreateTemp(t.TempDir(), "lease") | |||
| 121 | + | if err != nil { | |||
| 122 | + | t.Fatal(err) | |||
| 123 | + | } | |||
| 124 | + | gate := portsmocks.NewMockSessionSecurity(t) | |||
| 125 | + | gate.EXPECT().Snapshot().Return(ports.SecurityState{}).Once() | |||
| 126 | + | gate.EXPECT().Snapshot().Return(ports.SecurityState{Generation: 1, Protected: true}).Once() | |||
| 127 | + | card := newMocksecurityLeaseCard(t) | |||
| 128 | + | card.EXPECT().Lease([]string{"DP-1"}).Return(fd, uint32(12), nil).Once() | |||
| 129 | + | card.EXPECT().Revoke(uint32(12)).Return(revokeErr).Once() | |||
| 130 | + | got, id, err := guardedLease(card, gate, []string{"DP-1"}) | |||
| 131 | + | if got != nil || id != 0 || err == nil { | |||
| 132 | + | t.Fatalf("late grant escaped: %v %d %v", got, id, err) | |||
| 133 | + | } | |||
| 134 | + | if _, statErr := fd.Stat(); !errors.Is(statErr, os.ErrClosed) { | |||
| 135 | + | t.Fatalf("FD not closed: %v", statErr) | |||
| 136 | + | } | |||
| 137 | + | if revokeErr != nil && !errors.Is(err, revokeErr) { | |||
| 138 | + | t.Fatal("revoke failure lost") | |||
| 139 | + | } | |||
| 140 | + | }) | |||
| 141 | + | } | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | func TestSecurityRevokesAllLeasesAndRetainsFailures(t *testing.T) { | |||
| 145 | + | card := newMocksecurityLeaseCard(t) | |||
| 146 | + | card.EXPECT().LeaseIDs().Return([]uint32{1, 2}).Once() | |||
| 147 | + | card.EXPECT().LeaseIDs().Return([]uint32{2}).Once() | |||
| 148 | + | card.EXPECT().Revoke(uint32(1)).Return(nil).Once() | |||
| 149 | + | failed := errors.New("native revoke failure") | |||
| 150 | + | card.EXPECT().Revoke(uint32(2)).Return(failed).Once() | |||
| 151 | + | card.EXPECT().Path().Return("card0") | |||
| 152 | + | var finished []uint32 | |||
| 153 | + | err := revokeSecurityLeases(card, func(id uint32) { finished = append(finished, id) }) | |||
| 154 | + | if !errors.Is(err, failed) || len(finished) != 1 || finished[0] != 1 { | |||
| 155 | + | t.Fatalf("revocation: %v finished %v", err, finished) | |||
| 156 | + | } | |||
| 157 | + | card.EXPECT().LeaseIDs().Return([]uint32{2}).Once() | |||
| 158 | + | card.EXPECT().LeaseIDs().Return([]uint32{}).Once() | |||
| 159 | + | card.EXPECT().Revoke(uint32(2)).Return(nil).Once() | |||
| 160 | + | if err := revokeSecurityLeases(card, func(id uint32) {}); err != nil { | |||
| 161 | + | t.Fatal(err) | |||
| 162 | + | } | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | func TestSecurityInstanceExhaustion(t *testing.T) { | |||
| 166 | + | set := newOutputSet(context.Background(), nil) | |||
| 167 | + | events := make(chan ports.SecurityBackendEvent, 2) | |||
| 168 | + | set.wireSecurity(outputChannels{securityEvents: events}) | |||
| 169 | + | set.nextInstance = ^ports.OutputInstance(0) - 1 | |||
| 170 | + | run := func(ctx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, _ ports.OutputInstance) error { | |||
| 171 | + | <-ctx.Done() | |||
| 172 | + | return nil | |||
| 173 | + | } | |||
| 174 | + | if !set.start(context.Background(), "last", run) { | |||
| 175 | + | t.Fatal("last valid lifetime refused") | |||
| 176 | + | } | |||
| 177 | + | if added := (<-events).(ports.SecurityOutputAdded); added.Instance != ^ports.OutputInstance(0) { | |||
| 178 | + | t.Fatalf("last instance: %+v", added) | |||
| 179 | + | } | |||
| 180 | + | for range 2 { | |||
| 181 | + | if set.start(context.Background(), "overflow", run) { | |||
| 182 | + | t.Fatal("exhausted counter launched owner") | |||
| 183 | + | } | |||
| 184 | + | } | |||
| 185 | + | if set.nextInstance != ^ports.OutputInstance(0) || set.startErr == nil { | |||
| 186 | + | t.Fatal("counter wrapped or error missing") | |||
| 187 | + | } | |||
| 188 | + | select { | |||
| 189 | + | case ev := <-events: | |||
| 190 | + | t.Fatalf("registered overflow: %+v", ev) | |||
| 191 | + | default: | |||
| 192 | + | } | |||
| 193 | + | if err := set.wait(); err == nil { | |||
| 194 | + | t.Fatal("exhaustion not returned") | |||
| 195 | + | } | |||
| 196 | + | } | |||
| 197 | + | ||||
| 198 | + | // The shared display event channel and an output content queue can both be | |||
| 199 | + | // saturated. Routing must retain the next content without pinning the backend | |||
| 200 | + | // outside its select; a lease barrier can then finish before either is drained. | |||
| 201 | + | func TestSecurityFullEventsAndContentsAllowLeaseBarrier(t *testing.T) { | |||
| 202 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 203 | + | defer cancel() | |||
| 204 | + | set := newOutputSet(ctx, nil) | |||
| 205 | + | events := make(chan ports.SecurityBackendEvent, 1) | |||
| 206 | + | set.wireSecurity(outputChannels{securityEvents: events}) | |||
| 207 | + | var ownerEvents chan<- ports.SecurityBackendEvent | |||
| 208 | + | set.start(ctx, "DP-1", func(ctx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, _ ports.OutputInstance) error { | |||
| 209 | + | <-ctx.Done() | |||
| 210 | + | return nil | |||
| 211 | + | }, &ownerEvents) | |||
| 212 | + | <-events // ordered registration | |||
| 213 | + | events <- ports.SecurityBackendBarrier{Generation: 99} // blocked display | |||
| 214 | + | r := set.outs["DP-1"] | |||
| 215 | + | for i := range cap(r.contents) { | |||
| 216 | + | r.contents <- ports.SurfaceContent{ID: ports.WindowID(i + 1)} | |||
| 217 | + | } | |||
| 218 | + | first := ports.SecurityOutputInvalidated{Generation: 1, Instance: r.instance} | |||
| 219 | + | second := ports.SecurityOutputProof{Proof: ports.OutputProtection{Generation: 1, Instance: r.instance, Kind: ports.ProtectionProtectedFrame}} | |||
| 220 | + | ownerEvents <- first | |||
| 221 | + | ownerEvents <- second | |||
| 222 | + | card := newMocksecurityLeaseCard(t) | |||
| 223 | + | card.EXPECT().LeaseIDs().Return([]uint32{9}).Once() | |||
| 224 | + | card.EXPECT().LeaseIDs().Return([]uint32{}).Once() | |||
| 225 | + | card.EXPECT().Revoke(uint32(9)).Return(nil).Once() | |||
| 226 | + | progressed := make(chan error, 1) | |||
| 227 | + | go func() { | |||
| 228 | + | set.content(ports.SurfaceContent{ID: 70}) | |||
| 229 | + | set.setSecurity(ports.SecurityState{Generation: 1, Protected: true}) | |||
| 230 | + | err := revokeSecurityLeases(card, func(uint32) {}) | |||
| 231 | + | set.securityEvent(ports.SecurityBackendBarrier{Generation: 1, Err: err}) | |||
| 232 | + | progressed <- err | |||
| 233 | + | }() | |||
| 234 | + | select { | |||
| 235 | + | case err := <-progressed: | |||
| 236 | + | if err != nil { | |||
| 237 | + | t.Fatal(err) | |||
| 238 | + | } | |||
| 239 | + | case <-time.After(time.Second): | |||
| 240 | + | t.Fatal("content/event backpressure blocked lease transition") | |||
| 241 | + | } | |||
| 242 | + | incoming := make(chan ports.SurfaceContent) | |||
| 243 | + | contentIn, output, next, owner := set.contentOut(incoming) | |||
| 244 | + | if contentIn != nil || output != r.contents || owner != r || next.ID != 70 || len(r.pending) != 1 { | |||
| 245 | + | t.Fatal("pending content not bounded/preserved") | |||
| 246 | + | } | |||
| 247 | + | for range cap(r.contents) { | |||
| 248 | + | <-r.contents | |||
| 249 | + | } | |||
| 250 | + | output <- next | |||
| 251 | + | owner.contentSent() | |||
| 252 | + | if got := <-r.contents; got.ID != 70 { | |||
| 253 | + | t.Fatal("lost pending content") | |||
| 254 | + | } | |||
| 255 | + | if contentIn, _, _, _ := set.contentOut(incoming); contentIn != incoming { | |||
| 256 | + | t.Fatal("content intake stayed disabled") | |||
| 257 | + | } | |||
| 258 | + | <-events // unblock display | |||
| 259 | + | var ownerSequence []ports.SecurityBackendEvent | |||
| 260 | + | for range 3 { | |||
| 261 | + | select { | |||
| 262 | + | case ev := <-events: | |||
| 263 | + | switch ev.(type) { | |||
| 264 | + | case ports.SecurityOutputInvalidated, ports.SecurityOutputProof: | |||
| 265 | + | ownerSequence = append(ownerSequence, ev) | |||
| 266 | + | } | |||
| 267 | + | case <-time.After(time.Second): | |||
| 268 | + | t.Fatal("lossless forwarder lost event") | |||
| 269 | + | } | |||
| 270 | + | } | |||
| 271 | + | if len(ownerSequence) != 2 || ownerSequence[0] != first || ownerSequence[1] != second { | |||
| 272 | + | t.Fatalf("owner order lost: %+v", ownerSequence) | |||
| 273 | + | } | |||
| 274 | + | if err := set.wait(); err != nil { | |||
| 275 | + | t.Fatal(err) | |||
| 276 | + | } | |||
| 277 | + | } | |||
| 278 | + | ||||
| 279 | + | func TestSecurityLargeReplayDoesNotBlockStart(t *testing.T) { | |||
| 280 | + | set := newOutputSet(context.Background(), nil) | |||
| 281 | + | for i := range 100 { | |||
| 282 | + | set.content(ports.SurfaceContent{ID: ports.WindowID(i + 1), SHM: &ports.SHMBuffer{}}) | |||
| 283 | + | } | |||
| 284 | + | if !set.start(context.Background(), "A", func(ctx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, _ <-chan ports.CursorChange, _ <-chan ports.CaptureRequest, _ <-chan ports.SecurityState, _ ports.OutputInstance) error { | |||
| 285 | + | <-ctx.Done() | |||
| 286 | + | return nil | |||
| 287 | + | }) { | |||
| 288 | + | t.Fatal("start failed") | |||
| 289 | + | } | |||
| 290 | + | r := set.outs["A"] | |||
| 291 | + | if len(r.contents) != cap(r.contents) || len(r.pending) != 100-cap(r.contents) { | |||
| 292 | + | t.Fatal("replay not preserved") | |||
| 293 | + | } | |||
| 294 | + | seen := map[ports.WindowID]bool{} | |||
| 295 | + | for len(seen) != 100 { | |||
| 296 | + | select { | |||
| 297 | + | case c := <-r.contents: | |||
| 298 | + | if seen[c.ID] { | |||
| 299 | + | t.Fatal("duplicate replay") | |||
| 300 | + | } | |||
| 301 | + | seen[c.ID] = true | |||
| 302 | + | default: | |||
| 303 | + | _, out, c, owner := set.contentOut(nil) | |||
| 304 | + | if out == nil { | |||
| 305 | + | t.Fatal("replay lost") | |||
| 306 | + | } | |||
| 307 | + | out <- c | |||
| 308 | + | owner.contentSent() | |||
| 309 | + | } | |||
| 310 | + | } | |||
| 311 | + | if len(r.pending) != 0 { | |||
| 312 | + | t.Fatal("replay remains") | |||
| 313 | + | } | |||
| 314 | + | if err := set.wait(); err != nil { | |||
| 315 | + | t.Fatal(err) | |||
| 316 | + | } | |||
| 317 | + | } | |||
A internal/app/securitylease_mock_test.go +256 -0
| @@ -0,0 +1,256 @@ | |||||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |||
| 2 | + | // github.com/vektra/mockery | |||
| 3 | + | // template: testify | |||
| 4 | + | ||||
| 5 | + | package app | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | "os" | |||
| 9 | + | ||||
| 10 | + | mock "github.com/stretchr/testify/mock" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // newMocksecurityLeaseCard creates a new instance of mocksecurityLeaseCard. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 14 | + | // The first argument is typically a *testing.T value. | |||
| 15 | + | func newMocksecurityLeaseCard(t interface { | |||
| 16 | + | mock.TestingT | |||
| 17 | + | Cleanup(func()) | |||
| 18 | + | }) *mocksecurityLeaseCard { | |||
| 19 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 20 | + | helper.Helper() | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | mock := &mocksecurityLeaseCard{} | |||
| 24 | + | mock.Mock.Test(t) | |||
| 25 | + | ||||
| 26 | + | t.Cleanup(func() { | |||
| 27 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 28 | + | helper.Helper() | |||
| 29 | + | } | |||
| 30 | + | mock.AssertExpectations(t) | |||
| 31 | + | }) | |||
| 32 | + | ||||
| 33 | + | return mock | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | // mocksecurityLeaseCard is an autogenerated mock type for the securityLeaseCard type | |||
| 37 | + | type mocksecurityLeaseCard struct { | |||
| 38 | + | mock.Mock | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | type mocksecurityLeaseCard_Expecter struct { | |||
| 42 | + | mock *mock.Mock | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | func (_m *mocksecurityLeaseCard) EXPECT() *mocksecurityLeaseCard_Expecter { | |||
| 46 | + | return &mocksecurityLeaseCard_Expecter{mock: &_m.Mock} | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | // Lease provides a mock function for the type mocksecurityLeaseCard | |||
| 50 | + | func (_mock *mocksecurityLeaseCard) Lease(strings []string) (*os.File, uint32, error) { | |||
| 51 | + | ret := _mock.Called(strings) | |||
| 52 | + | ||||
| 53 | + | if len(ret) == 0 { | |||
| 54 | + | panic("no return value specified for Lease") | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | var r0 *os.File | |||
| 58 | + | var r1 uint32 | |||
| 59 | + | var r2 error | |||
| 60 | + | if returnFunc, ok := ret.Get(0).(func([]string) (*os.File, uint32, error)); ok { | |||
| 61 | + | return returnFunc(strings) | |||
| 62 | + | } | |||
| 63 | + | if returnFunc, ok := ret.Get(0).(func([]string) *os.File); ok { | |||
| 64 | + | r0 = returnFunc(strings) | |||
| 65 | + | } else { | |||
| 66 | + | if ret.Get(0) != nil { | |||
| 67 | + | r0 = ret.Get(0).(*os.File) | |||
| 68 | + | } | |||
| 69 | + | } | |||
| 70 | + | if returnFunc, ok := ret.Get(1).(func([]string) uint32); ok { | |||
| 71 | + | r1 = returnFunc(strings) | |||
| 72 | + | } else { | |||
| 73 | + | r1 = ret.Get(1).(uint32) | |||
| 74 | + | } | |||
| 75 | + | if returnFunc, ok := ret.Get(2).(func([]string) error); ok { | |||
| 76 | + | r2 = returnFunc(strings) | |||
| 77 | + | } else { | |||
| 78 | + | r2 = ret.Error(2) | |||
| 79 | + | } | |||
| 80 | + | return r0, r1, r2 | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | // mocksecurityLeaseCard_Lease_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Lease' | |||
| 84 | + | type mocksecurityLeaseCard_Lease_Call struct { | |||
| 85 | + | *mock.Call | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | // Lease is a helper method to define mock.On call | |||
| 89 | + | // - strings []string | |||
| 90 | + | func (_e *mocksecurityLeaseCard_Expecter) Lease(strings any) *mocksecurityLeaseCard_Lease_Call { | |||
| 91 | + | return &mocksecurityLeaseCard_Lease_Call{Call: _e.mock.On("Lease", strings)} | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | func (_c *mocksecurityLeaseCard_Lease_Call) Run(run func(strings []string)) *mocksecurityLeaseCard_Lease_Call { | |||
| 95 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 96 | + | var arg0 []string | |||
| 97 | + | if args[0] != nil { | |||
| 98 | + | arg0 = args[0].([]string) | |||
| 99 | + | } | |||
| 100 | + | run( | |||
| 101 | + | arg0, | |||
| 102 | + | ) | |||
| 103 | + | }) | |||
| 104 | + | return _c | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | func (_c *mocksecurityLeaseCard_Lease_Call) Return(file *os.File, v uint32, err error) *mocksecurityLeaseCard_Lease_Call { | |||
| 108 | + | _c.Call.Return(file, v, err) | |||
| 109 | + | return _c | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | func (_c *mocksecurityLeaseCard_Lease_Call) RunAndReturn(run func(strings []string) (*os.File, uint32, error)) *mocksecurityLeaseCard_Lease_Call { | |||
| 113 | + | _c.Call.Return(run) | |||
| 114 | + | return _c | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | // LeaseIDs provides a mock function for the type mocksecurityLeaseCard | |||
| 118 | + | func (_mock *mocksecurityLeaseCard) LeaseIDs() []uint32 { | |||
| 119 | + | ret := _mock.Called() | |||
| 120 | + | ||||
| 121 | + | if len(ret) == 0 { | |||
| 122 | + | panic("no return value specified for LeaseIDs") | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | var r0 []uint32 | |||
| 126 | + | if returnFunc, ok := ret.Get(0).(func() []uint32); ok { | |||
| 127 | + | r0 = returnFunc() | |||
| 128 | + | } else { | |||
| 129 | + | if ret.Get(0) != nil { | |||
| 130 | + | r0 = ret.Get(0).([]uint32) | |||
| 131 | + | } | |||
| 132 | + | } | |||
| 133 | + | return r0 | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | // mocksecurityLeaseCard_LeaseIDs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LeaseIDs' | |||
| 137 | + | type mocksecurityLeaseCard_LeaseIDs_Call struct { | |||
| 138 | + | *mock.Call | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | // LeaseIDs is a helper method to define mock.On call | |||
| 142 | + | func (_e *mocksecurityLeaseCard_Expecter) LeaseIDs() *mocksecurityLeaseCard_LeaseIDs_Call { | |||
| 143 | + | return &mocksecurityLeaseCard_LeaseIDs_Call{Call: _e.mock.On("LeaseIDs")} | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | func (_c *mocksecurityLeaseCard_LeaseIDs_Call) Run(run func()) *mocksecurityLeaseCard_LeaseIDs_Call { | |||
| 147 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 148 | + | run() | |||
| 149 | + | }) | |||
| 150 | + | return _c | |||
| 151 | + | } | |||
| 152 | + | ||||
| 153 | + | func (_c *mocksecurityLeaseCard_LeaseIDs_Call) Return(uint32s []uint32) *mocksecurityLeaseCard_LeaseIDs_Call { | |||
| 154 | + | _c.Call.Return(uint32s) | |||
| 155 | + | return _c | |||
| 156 | + | } | |||
| 157 | + | ||||
| 158 | + | func (_c *mocksecurityLeaseCard_LeaseIDs_Call) RunAndReturn(run func() []uint32) *mocksecurityLeaseCard_LeaseIDs_Call { | |||
| 159 | + | _c.Call.Return(run) | |||
| 160 | + | return _c | |||
| 161 | + | } | |||
| 162 | + | ||||
| 163 | + | // Path provides a mock function for the type mocksecurityLeaseCard | |||
| 164 | + | func (_mock *mocksecurityLeaseCard) Path() string { | |||
| 165 | + | ret := _mock.Called() | |||
| 166 | + | ||||
| 167 | + | if len(ret) == 0 { | |||
| 168 | + | panic("no return value specified for Path") | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | var r0 string | |||
| 172 | + | if returnFunc, ok := ret.Get(0).(func() string); ok { | |||
| 173 | + | r0 = returnFunc() | |||
| 174 | + | } else { | |||
| 175 | + | r0 = ret.Get(0).(string) | |||
| 176 | + | } | |||
| 177 | + | return r0 | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | // mocksecurityLeaseCard_Path_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Path' | |||
| 181 | + | type mocksecurityLeaseCard_Path_Call struct { | |||
| 182 | + | *mock.Call | |||
| 183 | + | } | |||
| 184 | + | ||||
| 185 | + | // Path is a helper method to define mock.On call | |||
| 186 | + | func (_e *mocksecurityLeaseCard_Expecter) Path() *mocksecurityLeaseCard_Path_Call { | |||
| 187 | + | return &mocksecurityLeaseCard_Path_Call{Call: _e.mock.On("Path")} | |||
| 188 | + | } | |||
| 189 | + | ||||
| 190 | + | func (_c *mocksecurityLeaseCard_Path_Call) Run(run func()) *mocksecurityLeaseCard_Path_Call { | |||
| 191 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 192 | + | run() | |||
| 193 | + | }) | |||
| 194 | + | return _c | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | func (_c *mocksecurityLeaseCard_Path_Call) Return(s string) *mocksecurityLeaseCard_Path_Call { | |||
| 198 | + | _c.Call.Return(s) | |||
| 199 | + | return _c | |||
| 200 | + | } | |||
| 201 | + | ||||
| 202 | + | func (_c *mocksecurityLeaseCard_Path_Call) RunAndReturn(run func() string) *mocksecurityLeaseCard_Path_Call { | |||
| 203 | + | _c.Call.Return(run) | |||
| 204 | + | return _c | |||
| 205 | + | } | |||
| 206 | + | ||||
| 207 | + | // Revoke provides a mock function for the type mocksecurityLeaseCard | |||
| 208 | + | func (_mock *mocksecurityLeaseCard) Revoke(v uint32) error { | |||
| 209 | + | ret := _mock.Called(v) | |||
| 210 | + | ||||
| 211 | + | if len(ret) == 0 { | |||
| 212 | + | panic("no return value specified for Revoke") | |||
| 213 | + | } | |||
| 214 | + | ||||
| 215 | + | var r0 error | |||
| 216 | + | if returnFunc, ok := ret.Get(0).(func(uint32) error); ok { | |||
| 217 | + | r0 = returnFunc(v) | |||
| 218 | + | } else { | |||
| 219 | + | r0 = ret.Error(0) | |||
| 220 | + | } | |||
| 221 | + | return r0 | |||
| 222 | + | } | |||
| 223 | + | ||||
| 224 | + | // mocksecurityLeaseCard_Revoke_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Revoke' | |||
| 225 | + | type mocksecurityLeaseCard_Revoke_Call struct { | |||
| 226 | + | *mock.Call | |||
| 227 | + | } | |||
| 228 | + | ||||
| 229 | + | // Revoke is a helper method to define mock.On call | |||
| 230 | + | // - v uint32 | |||
| 231 | + | func (_e *mocksecurityLeaseCard_Expecter) Revoke(v any) *mocksecurityLeaseCard_Revoke_Call { | |||
| 232 | + | return &mocksecurityLeaseCard_Revoke_Call{Call: _e.mock.On("Revoke", v)} | |||
| 233 | + | } | |||
| 234 | + | ||||
| 235 | + | func (_c *mocksecurityLeaseCard_Revoke_Call) Run(run func(v uint32)) *mocksecurityLeaseCard_Revoke_Call { | |||
| 236 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 237 | + | var arg0 uint32 | |||
| 238 | + | if args[0] != nil { | |||
| 239 | + | arg0 = args[0].(uint32) | |||
| 240 | + | } | |||
| 241 | + | run( | |||
| 242 | + | arg0, | |||
| 243 | + | ) | |||
| 244 | + | }) | |||
| 245 | + | return _c | |||
| 246 | + | } | |||
| 247 | + | ||||
| 248 | + | func (_c *mocksecurityLeaseCard_Revoke_Call) Return(err error) *mocksecurityLeaseCard_Revoke_Call { | |||
| 249 | + | _c.Call.Return(err) | |||
| 250 | + | return _c | |||
| 251 | + | } | |||
| 252 | + | ||||
| 253 | + | func (_c *mocksecurityLeaseCard_Revoke_Call) RunAndReturn(run func(v uint32) error) *mocksecurityLeaseCard_Revoke_Call { | |||
| 254 | + | _c.Call.Return(run) | |||
| 255 | + | return _c | |||
| 256 | + | } | |||
A internal/app/session_lock_test.go +398 -0
| @@ -0,0 +1,398 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "context" | |||
| 6 | + | "io" | |||
| 7 | + | "os" | |||
| 8 | + | "path/filepath" | |||
| 9 | + | "testing" | |||
| 10 | + | "time" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/neferwl/internal/adapters/config" | |||
| 13 | + | "github.com/bnema/neferwl/internal/adapters/vulkan" | |||
| 14 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 15 | + | "github.com/bnema/wlturbo" | |||
| 16 | + | clientcore "github.com/bnema/wlturbo/protocol/core" | |||
| 17 | + | lockclient "github.com/bnema/wlturbo/protocol/extsessionlock" | |||
| 18 | + | captureclient "github.com/bnema/wlturbo/protocol/screencopy" | |||
| 19 | + | clientxdg "github.com/bnema/wlturbo/protocol/xdgshell" | |||
| 20 | + | "golang.org/x/sys/unix" | |||
| 21 | + | ) | |||
| 22 | + | ||||
| 23 | + | // Exercise the production gate, backend proofs and protocol owner together. | |||
| 24 | + | // No test-only global registration or injected security events: bufferless | |||
| 25 | + | // locked must come from both real headless owners' affirmative black proofs. | |||
| 26 | + | func TestHeadlessSessionLockEndToEnd(t *testing.T) { | |||
| 27 | + | runtime := t.TempDir() | |||
| 28 | + | t.Setenv("XDG_RUNTIME_DIR", runtime) | |||
| 29 | + | t.Setenv("XDG_STATE_HOME", t.TempDir()) | |||
| 30 | + | shots := t.TempDir() | |||
| 31 | + | scenes := make(chan []ports.Scene, 128) | |||
| 32 | + | script, writer := io.Pipe() | |||
| 33 | + | defer writer.Close() | |||
| 34 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 35 | + | done := make(chan error, 1) | |||
| 36 | + | go func() { | |||
| 37 | + | done <- Run(ctx, Options{Backend: "headless", NoXwayland: true, NoTerminal: true, Config: config.Defaults(), Sizes: [][2]int{{160, 120}, {128, 96}}, ScreenshotDir: shots, Script: script, testScenes: scenes}) | |||
| 38 | + | }() | |||
| 39 | + | // Cancellation closes clients' sockets too, bounding blocked Roundtrip. | |||
| 40 | + | watchdog := time.AfterFunc(20*time.Second, cancel) | |||
| 41 | + | exited := false | |||
| 42 | + | t.Cleanup(func() { | |||
| 43 | + | watchdog.Stop() | |||
| 44 | + | cancel() | |||
| 45 | + | if exited { | |||
| 46 | + | return | |||
| 47 | + | } | |||
| 48 | + | select { | |||
| 49 | + | case err := <-done: | |||
| 50 | + | if err != nil { | |||
| 51 | + | t.Errorf("Run: %v", err) | |||
| 52 | + | } | |||
| 53 | + | case <-time.After(3 * time.Second): | |||
| 54 | + | t.Error("Run did not stop") | |||
| 55 | + | } | |||
| 56 | + | }) | |||
| 57 | + | check := func(err error) { | |||
| 58 | + | t.Helper() | |||
| 59 | + | if err != nil { | |||
| 60 | + | t.Fatal(err) | |||
| 61 | + | } | |||
| 62 | + | } | |||
| 63 | + | waitScenes := func(what string, accept func([]ports.Scene) bool) []ports.Scene { | |||
| 64 | + | t.Helper() | |||
| 65 | + | timer := time.NewTimer(5 * time.Second) | |||
| 66 | + | defer timer.Stop() | |||
| 67 | + | for { | |||
| 68 | + | select { | |||
| 69 | + | case set := <-scenes: | |||
| 70 | + | if accept(set) { | |||
| 71 | + | return set | |||
| 72 | + | } | |||
| 73 | + | case err := <-done: | |||
| 74 | + | exited = true | |||
| 75 | + | t.Fatalf("app exited waiting for %s: %v", what, err) | |||
| 76 | + | case <-timer.C: | |||
| 77 | + | t.Fatalf("missing %s scenes", what) | |||
| 78 | + | } | |||
| 79 | + | } | |||
| 80 | + | } | |||
| 81 | + | waitScenes("initial outputs", func(set []ports.Scene) bool { return len(set) == 2 }) | |||
| 82 | + | sockets, err := filepath.Glob(filepath.Join(runtime, "wayland-*[0-9]")) | |||
| 83 | + | check(err) | |||
| 84 | + | if len(sockets) != 1 { | |||
| 85 | + | t.Fatalf("sockets: %v", sockets) | |||
| 86 | + | } | |||
| 87 | + | connect := func() *wlturbo.Display { | |||
| 88 | + | t.Helper() | |||
| 89 | + | c, err := wlturbo.Connect(sockets[0]) | |||
| 90 | + | check(err) | |||
| 91 | + | t.Cleanup(func() { _ = c.Close() }) | |||
| 92 | + | check(c.Roundtrip()) | |||
| 93 | + | return c | |||
| 94 | + | } | |||
| 95 | + | until := func(c *wlturbo.Display, what string, ready func() bool) { | |||
| 96 | + | t.Helper() | |||
| 97 | + | deadline := time.Now().Add(5 * time.Second) | |||
| 98 | + | for !ready() { | |||
| 99 | + | if time.Now().After(deadline) { | |||
| 100 | + | t.Fatalf("missing %s", what) | |||
| 101 | + | } | |||
| 102 | + | check(c.Roundtrip()) | |||
| 103 | + | } | |||
| 104 | + | } | |||
| 105 | + | desktop := connect() | |||
| 106 | + | // This fails RED until the parent authorizes production advertisement. | |||
| 107 | + | if _, ok := desktop.Registry().FindGlobal(lockclient.ExtSessionLockManagerInterface); !ok { | |||
| 108 | + | t.Fatal("production ext_session_lock_manager_v1 global missing: integration prerequisite") | |||
| 109 | + | } | |||
| 110 | + | comp := clientcore.NewCompositor(desktop.Context()) | |||
| 111 | + | shm := clientcore.NewShm(desktop.Context()) | |||
| 112 | + | seat := clientcore.NewSeat(desktop.Context()) | |||
| 113 | + | wm := clientxdg.NewXdgWmBase(desktop.Context()) | |||
| 114 | + | wm.OnPing(func(serial uint32) { check(wm.Pong(serial)) }) | |||
| 115 | + | bindTestClient(t, desktop, clientcore.CompositorInterface, 1, comp) | |||
| 116 | + | bindTestClient(t, desktop, clientcore.ShmInterface, 1, shm) | |||
| 117 | + | bindTestClient(t, desktop, clientcore.SeatInterface, 1, seat) | |||
| 118 | + | bindTestClient(t, desktop, clientxdg.XdgWmBaseInterface, 1, wm) | |||
| 119 | + | keyboard, err := seat.GetKeyboard() | |||
| 120 | + | check(err) | |||
| 121 | + | var focused uint32 | |||
| 122 | + | var desktopKeys []uint32 | |||
| 123 | + | keyboard.OnEnter(func(_ uint32, id uint32, _ []byte) { focused = id }) | |||
| 124 | + | keyboard.OnLeave(func(_ uint32, _ uint32) { focused = 0 }) | |||
| 125 | + | keyboard.OnKey(func(_, _, key, state uint32) { | |||
| 126 | + | if state == 1 { | |||
| 127 | + | desktopKeys = append(desktopKeys, key) | |||
| 128 | + | } | |||
| 129 | + | }) | |||
| 130 | + | surface, err := comp.CreateSurface() | |||
| 131 | + | check(err) | |||
| 132 | + | xdg, err := wm.GetXdgSurface(surface) | |||
| 133 | + | check(err) | |||
| 134 | + | _, err = xdg.GetToplevel() | |||
| 135 | + | check(err) | |||
| 136 | + | var serial uint32 | |||
| 137 | + | xdg.OnConfigure(func(value uint32) { serial = value }) | |||
| 138 | + | check(surface.Commit()) | |||
| 139 | + | until(desktop, "desktop configure", func() bool { return serial != 0 }) | |||
| 140 | + | check(xdg.AckConfigure(serial)) | |||
| 141 | + | buffer := sessionLockBuffer(t, shm, 1, 1, clientcore.FORMAT_ARGB8888) | |||
| 142 | + | check(surface.Attach(buffer, 0, 0)) | |||
| 143 | + | check(surface.Commit()) | |||
| 144 | + | until(desktop, "desktop focus", func() bool { return focused == surface.ID() }) | |||
| 145 | + | _, err = io.WriteString(writer, "key a\n") | |||
| 146 | + | check(err) | |||
| 147 | + | until(desktop, "initial desktop input", func() bool { return len(desktopKeys) != 0 }) | |||
| 148 | + | ||||
| 149 | + | var outputs []*clientcore.Output | |||
| 150 | + | for name, global := range desktop.Registry().GetGlobals() { | |||
| 151 | + | if global.Interface != clientcore.OutputInterface { | |||
| 152 | + | continue | |||
| 153 | + | } | |||
| 154 | + | out := clientcore.NewOutput(desktop.Context()) | |||
| 155 | + | check(desktop.Registry().Bind(name, global.Interface, 4, out)) | |||
| 156 | + | outputs = append(outputs, out) | |||
| 157 | + | } | |||
| 158 | + | if len(outputs) != 2 { | |||
| 159 | + | t.Fatalf("outputs: %d", len(outputs)) | |||
| 160 | + | } | |||
| 161 | + | captures := captureclient.NewScreencopyManager(desktop.Context()) | |||
| 162 | + | bindTestClient(t, desktop, captureclient.ScreencopyManagerInterface, 3, captures) | |||
| 163 | + | denyCapture := func() { | |||
| 164 | + | t.Helper() | |||
| 165 | + | for _, out := range outputs { | |||
| 166 | + | frame, err := captures.CaptureOutputRegion(0, out, 0, 0, 1, 1) | |||
| 167 | + | check(err) | |||
| 168 | + | failed, ready := false, false | |||
| 169 | + | frame.OnFailed(func() { failed = true }) | |||
| 170 | + | frame.OnReady(func(_, _, _ uint32) { ready = true }) | |||
| 171 | + | frame.OnBufferDone(func() { check(frame.Copy(sessionLockBuffer(t, shm, 1, 1, clientcore.FORMAT_XRGB8888))) }) | |||
| 172 | + | until(desktop, "capture refusal", func() bool { return failed || ready }) | |||
| 173 | + | if !failed || ready { | |||
| 174 | + | t.Fatal("ordinary capture succeeded while protected") | |||
| 175 | + | } | |||
| 176 | + | check(frame.Destroy()) | |||
| 177 | + | } | |||
| 178 | + | } | |||
| 179 | + | // Positive control: the same ordinary client and valid SHM destination | |||
| 180 | + | // can capture before acquisition, so refusal is not a malformed request. | |||
| 181 | + | // Software Vulkan without sync-file export (lavapipe) refuses every | |||
| 182 | + | // protocol capture by design, so the control only holds where the device | |||
| 183 | + | // can capture; the protection assertions below still run everywhere. | |||
| 184 | + | if captureSupported(t) { | |||
| 185 | + | control, err := captures.CaptureOutputRegion(0, outputs[0], 0, 0, 1, 1) | |||
| 186 | + | check(err) | |||
| 187 | + | controlReady, controlFailed := false, false | |||
| 188 | + | control.OnReady(func(_, _, _ uint32) { controlReady = true }) | |||
| 189 | + | control.OnFailed(func() { controlFailed = true }) | |||
| 190 | + | control.OnBufferDone(func() { check(control.Copy(sessionLockBuffer(t, shm, 1, 1, clientcore.FORMAT_XRGB8888))) }) | |||
| 191 | + | until(desktop, "unlocked capture control", func() bool { return controlReady || controlFailed }) | |||
| 192 | + | if !controlReady || controlFailed { | |||
| 193 | + | t.Fatal("valid unlocked capture failed") | |||
| 194 | + | } | |||
| 195 | + | check(control.Destroy()) | |||
| 196 | + | } else { | |||
| 197 | + | t.Log("Vulkan device cannot capture (no sync-file export): positive control skipped") | |||
| 198 | + | } | |||
| 199 | + | // Protocol focus/capture completion is not completion of asynchronous PNG | |||
| 200 | + | // writes. Establish both screenshot baselines before engaging protection. | |||
| 201 | + | until(desktop, "initial screenshots on both outputs", func() bool { | |||
| 202 | + | for _, name := range []string{"HEADLESS-1", "HEADLESS-2"} { | |||
| 203 | + | if _, err := os.Stat(filepath.Join(shots, name, "latest.png")); err != nil { | |||
| 204 | + | return false | |||
| 205 | + | } | |||
| 206 | + | } | |||
| 207 | + | return true | |||
| 208 | + | }) | |||
| 209 | + | acquire := func(c *wlturbo.Display) *lockclient.ExtSessionLock { | |||
| 210 | + | t.Helper() | |||
| 211 | + | manager := lockclient.NewExtSessionLockManager(c.Context()) | |||
| 212 | + | bindTestClient(t, c, lockclient.ExtSessionLockManagerInterface, 1, manager) | |||
| 213 | + | lock, err := manager.Lock() | |||
| 214 | + | check(err) | |||
| 215 | + | locked, finished := 0, false | |||
| 216 | + | lock.OnLocked(func() { locked++ }) | |||
| 217 | + | lock.OnFinished(func() { finished = true }) | |||
| 218 | + | until(c, "bufferless locked", func() bool { return locked != 0 || finished }) | |||
| 219 | + | if locked != 1 || finished { | |||
| 220 | + | t.Fatalf("lock result: locked=%d finished=%v", locked, finished) | |||
| 221 | + | } | |||
| 222 | + | check(c.Roundtrip()) | |||
| 223 | + | if locked != 1 { | |||
| 224 | + | t.Fatalf("duplicate locked: %d", locked) | |||
| 225 | + | } | |||
| 226 | + | return lock | |||
| 227 | + | } | |||
| 228 | + | locker := connect() | |||
| 229 | + | lock := acquire(locker) // deliberately no locker surfaces or buffers yet | |||
| 230 | + | protected := waitScenes("protected black", func(set []ports.Scene) bool { | |||
| 231 | + | if len(set) != 2 { | |||
| 232 | + | return false | |||
| 233 | + | } | |||
| 234 | + | for _, s := range set { | |||
| 235 | + | if !s.Security.Protected || len(s.Windows) != 0 || len(s.Layers) != 0 || s.Background != "#000000" || s.Capture != nil || s.CaptureScene != nil { | |||
| 236 | + | return false | |||
| 237 | + | } | |||
| 238 | + | } | |||
| 239 | + | return true | |||
| 240 | + | }) | |||
| 241 | + | generation := protected[0].Security.Generation | |||
| 242 | + | if generation == 0 || protected[1].Security.Generation != generation { | |||
| 243 | + | t.Fatal("incoherent protection generation") | |||
| 244 | + | } | |||
| 245 | + | check(desktop.Roundtrip()) | |||
| 246 | + | if focused != 0 { | |||
| 247 | + | t.Fatal("desktop retained keyboard focus under lock") | |||
| 248 | + | } | |||
| 249 | + | denyCapture() | |||
| 250 | + | before := sessionLockShots(t, shots) | |||
| 251 | + | for _, name := range []string{"HEADLESS-1", "HEADLESS-2"} { | |||
| 252 | + | if _, ok := before[filepath.Join(shots, name, "latest.png")]; !ok { | |||
| 253 | + | t.Fatalf("missing initial screenshot for %s", name) | |||
| 254 | + | } | |||
| 255 | + | } | |||
| 256 | + | ||||
| 257 | + | // A configured real locker surface supplies an input completion witness: | |||
| 258 | + | // receipt by the locker proves scripted key processing without timing sleeps. | |||
| 259 | + | lockComp := clientcore.NewCompositor(locker.Context()) | |||
| 260 | + | lockShm := clientcore.NewShm(locker.Context()) | |||
| 261 | + | lockSeat := clientcore.NewSeat(locker.Context()) | |||
| 262 | + | bindTestClient(t, locker, clientcore.CompositorInterface, 1, lockComp) | |||
| 263 | + | bindTestClient(t, locker, clientcore.ShmInterface, 1, lockShm) | |||
| 264 | + | bindTestClient(t, locker, clientcore.SeatInterface, 1, lockSeat) | |||
| 265 | + | global, ok := locker.Registry().FindGlobal(clientcore.OutputInterface) | |||
| 266 | + | if !ok { | |||
| 267 | + | t.Fatal("locker output missing") | |||
| 268 | + | } | |||
| 269 | + | lockOutput := clientcore.NewOutput(locker.Context()) | |||
| 270 | + | check(locker.Registry().Bind(global.Name, global.Interface, 4, lockOutput)) | |||
| 271 | + | lockSurface, err := lockComp.CreateSurface() | |||
| 272 | + | check(err) | |||
| 273 | + | role, err := lock.GetLockSurface(lockSurface, lockOutput) | |||
| 274 | + | check(err) | |||
| 275 | + | configured := false | |||
| 276 | + | role.OnConfigure(func(serial, width, height uint32) { | |||
| 277 | + | check(role.AckConfigure(serial)) | |||
| 278 | + | check(lockSurface.Attach(sessionLockBuffer(t, lockShm, int32(width), int32(height), clientcore.FORMAT_ARGB8888), 0, 0)) | |||
| 279 | + | check(lockSurface.Commit()) | |||
| 280 | + | configured = true | |||
| 281 | + | }) | |||
| 282 | + | lockKeyboard, err := lockSeat.GetKeyboard() | |||
| 283 | + | check(err) | |||
| 284 | + | lockFocused, lockKey, lockReleased := false, false, false | |||
| 285 | + | lockKeyboard.OnEnter(func(_ uint32, id uint32, _ []byte) { lockFocused = id == lockSurface.ID() }) | |||
| 286 | + | lockKeyboard.OnKey(func(_, _, key, state uint32) { | |||
| 287 | + | if key != 48 { | |||
| 288 | + | return | |||
| 289 | + | } // evdev b | |||
| 290 | + | if state == 1 { | |||
| 291 | + | lockKey = true | |||
| 292 | + | } else { | |||
| 293 | + | lockReleased = true | |||
| 294 | + | } | |||
| 295 | + | }) | |||
| 296 | + | until(locker, "locker focus", func() bool { return configured && lockFocused }) | |||
| 297 | + | desktopCount := len(desktopKeys) | |||
| 298 | + | _, err = io.WriteString(writer, "key b\n") | |||
| 299 | + | check(err) | |||
| 300 | + | until(locker, "locker input and release", func() bool { return lockKey && lockReleased }) | |||
| 301 | + | check(desktop.Roundtrip()) | |||
| 302 | + | if len(desktopKeys) != desktopCount { | |||
| 303 | + | t.Fatal("locker input leaked to desktop") | |||
| 304 | + | } | |||
| 305 | + | if after := sessionLockShots(t, shots); !equalSessionLockShots(before, after) { | |||
| 306 | + | t.Fatal("screenshots changed while protected") | |||
| 307 | + | } | |||
| 308 | + | ||||
| 309 | + | check(locker.Close()) // crash never releases the gate | |||
| 310 | + | denyCapture() | |||
| 311 | + | replacement := connect() | |||
| 312 | + | takeover := acquire(replacement) | |||
| 313 | + | newer := waitScenes("takeover protection", func(set []ports.Scene) bool { | |||
| 314 | + | return len(set) == 2 && set[0].Security.Protected && set[1].Security.Protected && set[0].Security.Generation > generation | |||
| 315 | + | }) | |||
| 316 | + | if newer[0].Security.Generation != newer[1].Security.Generation { | |||
| 317 | + | t.Fatal("takeover generation differs across outputs") | |||
| 318 | + | } | |||
| 319 | + | check(takeover.UnlockAndDestroy()) | |||
| 320 | + | check(replacement.Roundtrip()) | |||
| 321 | + | waitScenes("unlocked desktop", func(set []ports.Scene) bool { | |||
| 322 | + | return len(set) == 2 && !set[0].Security.Protected && !set[1].Security.Protected && set[0].Security.Generation > newer[0].Security.Generation | |||
| 323 | + | }) | |||
| 324 | + | until(desktop, "restored desktop focus", func() bool { return focused == surface.ID() }) | |||
| 325 | + | _, err = io.WriteString(writer, "key c\n") | |||
| 326 | + | check(err) | |||
| 327 | + | until(desktop, "restored desktop input", func() bool { return len(desktopKeys) > desktopCount }) | |||
| 328 | + | if desktopKeys[len(desktopKeys)-1] != 46 { | |||
| 329 | + | t.Fatalf("restored key: %v, want evdev c=46", desktopKeys) | |||
| 330 | + | } | |||
| 331 | + | } | |||
| 332 | + | ||||
| 333 | + | // Real protocol SHM is a client buffer, not a renderer fallback or a double. | |||
| 334 | + | func sessionLockBuffer(t *testing.T, shm *clientcore.Shm, width, height int32, format uint32) *clientcore.Buffer { | |||
| 335 | + | t.Helper() | |||
| 336 | + | size := width * height * 4 | |||
| 337 | + | fd, err := unix.MemfdCreate("session-lock-client", unix.MFD_CLOEXEC) | |||
| 338 | + | if err != nil { | |||
| 339 | + | t.Fatal(err) | |||
| 340 | + | } | |||
| 341 | + | if err := unix.Ftruncate(fd, int64(size)); err != nil { | |||
| 342 | + | unix.Close(fd) | |||
| 343 | + | t.Fatal(err) | |||
| 344 | + | } | |||
| 345 | + | pool, err := shm.CreatePool(fd, size) // generated request consumes fd on success | |||
| 346 | + | if err != nil { | |||
| 347 | + | unix.Close(fd) | |||
| 348 | + | t.Fatal(err) | |||
| 349 | + | } | |||
| 350 | + | buffer, err := pool.CreateBuffer(0, width, height, width*4, format) | |||
| 351 | + | if err != nil { | |||
| 352 | + | t.Fatal(err) | |||
| 353 | + | } | |||
| 354 | + | if err := pool.Destroy(); err != nil { | |||
| 355 | + | t.Fatal(err) | |||
| 356 | + | } | |||
| 357 | + | return buffer | |||
| 358 | + | } | |||
| 359 | + | ||||
| 360 | + | func sessionLockShots(t *testing.T, dir string) map[string][]byte { | |||
| 361 | + | t.Helper() | |||
| 362 | + | files, err := filepath.Glob(filepath.Join(dir, "HEADLESS-*", "*.png")) | |||
| 363 | + | if err != nil { | |||
| 364 | + | t.Fatal(err) | |||
| 365 | + | } | |||
| 366 | + | result := make(map[string][]byte, len(files)) | |||
| 367 | + | for _, path := range files { | |||
| 368 | + | data, err := os.ReadFile(path) | |||
| 369 | + | if err != nil { | |||
| 370 | + | t.Fatal(err) | |||
| 371 | + | } | |||
| 372 | + | result[path] = data | |||
| 373 | + | } | |||
| 374 | + | return result | |||
| 375 | + | } | |||
| 376 | + | ||||
| 377 | + | func equalSessionLockShots(a, b map[string][]byte) bool { | |||
| 378 | + | if len(a) != len(b) { | |||
| 379 | + | return false | |||
| 380 | + | } | |||
| 381 | + | for path, data := range a { | |||
| 382 | + | if !bytes.Equal(data, b[path]) { | |||
| 383 | + | return false | |||
| 384 | + | } | |||
| 385 | + | } | |||
| 386 | + | return true | |||
| 387 | + | } | |||
| 388 | + | ||||
| 389 | + | // captureSupported probes the device the headless outputs will render on. | |||
| 390 | + | func captureSupported(t *testing.T) bool { | |||
| 391 | + | t.Helper() | |||
| 392 | + | r, err := vulkan.New(16, 16) | |||
| 393 | + | if err != nil { | |||
| 394 | + | return false | |||
| 395 | + | } | |||
| 396 | + | defer r.Close() | |||
| 397 | + | return r.CaptureSupported() | |||
| 398 | + | } | |||
M internal/app/textinput_test.go +54 -112
| @@ -12,10 +12,10 @@ import ( | |||||
| 12 | "time" | 12 | "time" | ||
| 13 | 13 | ||||
| 14 | "github.com/bnema/neferwl/internal/adapters/config" | 14 | "github.com/bnema/neferwl/internal/adapters/config" | ||
| 15 | - | "github.com/bnema/purego-libwayland/protocol/textinput" | |||
| 16 | - | "github.com/bnema/purego-libwayland/protocol/wayland" | |||
| 17 | - | "github.com/bnema/purego-libwayland/protocol/xdgshell" | |||
| 18 | "github.com/bnema/wlturbo" | 15 | "github.com/bnema/wlturbo" | ||
| 16 | + | clientcore "github.com/bnema/wlturbo/protocol/core" | |||
| 17 | + | clienttext "github.com/bnema/wlturbo/protocol/textinput" | |||
| 18 | + | clientxdg "github.com/bnema/wlturbo/protocol/xdgshell" | |||
| 19 | "golang.org/x/sys/unix" | 19 | "golang.org/x/sys/unix" | ||
| 20 | ) | 20 | ) | ||
| 21 | 21 | ||||
| @@ -37,53 +37,14 @@ func (b *lockedBuffer) String() string { | |||||
| 37 | return b.buf.String() | 37 | return b.buf.String() | ||
| 38 | } | 38 | } | ||
| 39 | 39 | ||||
| 40 | - | // textClient records what a text input receives. | 40 | + | // textClient retains observations from an ordinary generated text-input proxy. |
| 41 | type textClient struct { | 41 | type textClient struct { | ||
| 42 | - | wlturbo.BaseProxy | 42 | + | *clienttext.TextInputV3 |
| 43 | entered bool | 43 | entered bool | ||
| 44 | preedit []string | 44 | preedit []string | ||
| 45 | commits []string | 45 | commits []string | ||
| 46 | } | 46 | } | ||
| 47 | 47 | ||||
| 48 | - | func (p *textClient) Dispatch(e *wlturbo.Event) { | |||
| 49 | - | switch uint32(e.Opcode) { | |||
| 50 | - | case textinput.ZwpTextInputV3EventEnter: | |||
| 51 | - | p.entered = true | |||
| 52 | - | case textinput.ZwpTextInputV3EventPreeditString: | |||
| 53 | - | p.preedit = append(p.preedit, e.String()) | |||
| 54 | - | case textinput.ZwpTextInputV3EventCommitString: | |||
| 55 | - | p.commits = append(p.commits, e.String()) | |||
| 56 | - | } | |||
| 57 | - | } | |||
| 58 | - | ||||
| 59 | - | // xdgConfigure records the last configure serial. | |||
| 60 | - | type xdgConfigure struct { | |||
| 61 | - | wlturbo.BaseProxy | |||
| 62 | - | serial uint32 | |||
| 63 | - | } | |||
| 64 | - | ||||
| 65 | - | func (p *xdgConfigure) Dispatch(e *wlturbo.Event) { | |||
| 66 | - | if uint32(e.Opcode) == xdgshell.SurfaceEventConfigure { | |||
| 67 | - | p.serial = e.Uint32() | |||
| 68 | - | } | |||
| 69 | - | } | |||
| 70 | - | ||||
| 71 | - | // quiet ignores the events of objects the test does not inspect. | |||
| 72 | - | type quiet struct{ wlturbo.BaseProxy } | |||
| 73 | - | ||||
| 74 | - | func (*quiet) Dispatch(*wlturbo.Event) {} | |||
| 75 | - | ||||
| 76 | - | type wmPing struct { | |||
| 77 | - | wlturbo.BaseProxy | |||
| 78 | - | c *wlturbo.Display | |||
| 79 | - | } | |||
| 80 | - | ||||
| 81 | - | func (p *wmPing) Dispatch(e *wlturbo.Event) { | |||
| 82 | - | if uint32(e.Opcode) == xdgshell.WmBaseEventPing { | |||
| 83 | - | _ = p.c.SendRequest(p.ID(), uint16(xdgshell.WmBaseRequestPong), e.Uint32()) | |||
| 84 | - | } | |||
| 85 | - | } | |||
| 86 | - | ||||
| 87 | // NeferWL headless, the examples/testime input method and a text input | 48 | // NeferWL headless, the examples/testime input method and a text input | ||
| 88 | // client: the input method's preedit and commit reach the client. | 49 | // client: the input method's preedit and commit reach the client. | ||
| 89 | func TestHeadlessTextInputIME(t *testing.T) { | 50 | func TestHeadlessTextInputIME(t *testing.T) { | ||
| @@ -177,16 +138,16 @@ func TestHeadlessTextInputIME(t *testing.T) { | |||||
| 177 | } | 138 | } | ||
| 178 | } | 139 | } | ||
| 179 | poll("no text input enter", func() bool { return text.entered }) | 140 | poll("no text input enter", func() bool { return text.entered }) | ||
| 180 | - | send := func(op uint32, args ...any) { | 141 | + | for i := 1; i <= 2; i++ { |
| 181 | - | t.Helper() | 142 | + | if err := text.Enable(); err != nil { |
| 182 | - | if err := c.SendRequest(text.ID(), uint16(op), args...); err != nil { | 143 | + | t.Fatal(err) |
| 144 | + | } | |||
| 145 | + | if err := text.SetContentType(0, 0); err != nil { | |||
| 146 | + | t.Fatal(err) | |||
| 147 | + | } | |||
| 148 | + | if err := text.Commit(); err != nil { | |||
| 183 | t.Fatal(err) | 149 | t.Fatal(err) | ||
| 184 | } | 150 | } | ||
| 185 | - | } | |||
| 186 | - | for i := 1; i <= 2; i++ { | |||
| 187 | - | send(textinput.ZwpTextInputV3RequestEnable) | |||
| 188 | - | send(textinput.ZwpTextInputV3RequestSetContentType, uint32(0), uint32(0)) | |||
| 189 | - | send(textinput.ZwpTextInputV3RequestCommit) | |||
| 190 | poll("missing commit", func() bool { return len(text.commits) == i }) | 151 | poll("missing commit", func() bool { return len(text.commits) == i }) | ||
| 191 | } | 152 | } | ||
| 192 | if len(text.preedit) != 2 || text.preedit[0] != "nihon" || text.commits[0] != "日本" || text.commits[1] != "日本" { | 153 | if len(text.preedit) != 2 || text.preedit[0] != "nihon" || text.commits[0] != "日本" || text.commits[1] != "日本" { | ||
| @@ -206,80 +167,61 @@ func TestHeadlessTextInputIME(t *testing.T) { | |||||
| 206 | // mapTextClient maps a 1x1 toplevel with a text input on c. | 167 | // mapTextClient maps a 1x1 toplevel with a text input on c. | ||
| 207 | func mapTextClient(t *testing.T, c *wlturbo.Display) *textClient { | 168 | func mapTextClient(t *testing.T, c *wlturbo.Display) *textClient { | ||
| 208 | t.Helper() | 169 | t.Helper() | ||
| 209 | - | if err := c.Roundtrip(); err != nil { | 170 | + | check := func(err error) { |
| 210 | - | t.Fatal(err) | |||
| 211 | - | } | |||
| 212 | - | bind := func(iface string) uint32 { | |||
| 213 | t.Helper() | 171 | t.Helper() | ||
| 214 | - | g, ok := c.Registry().FindGlobal(iface) | |||
| 215 | - | if !ok { | |||
| 216 | - | t.Fatalf("missing %s", iface) | |||
| 217 | - | } | |||
| 218 | - | id, err := c.Registry().BindID(g.Name, g.Interface, 1) | |||
| 219 | if err != nil { | 172 | if err != nil { | ||
| 220 | t.Fatal(err) | 173 | t.Fatal(err) | ||
| 221 | } | 174 | } | ||
| 222 | - | return id | |||
| 223 | - | } | |||
| 224 | - | // New IDs are used in allocation order; every object gets a proxy. | |||
| 225 | - | newID := func() uint32 { | |||
| 226 | - | p := &quiet{} | |||
| 227 | - | p.SetID(c.AllocateID()) | |||
| 228 | - | c.Context().Register(p) | |||
| 229 | - | return p.ID() | |||
| 230 | } | 175 | } | ||
| 231 | - | req := func(id, op uint32, args ...any) { | 176 | + | check(c.Roundtrip()) |
| 232 | - | t.Helper() | 177 | + | comp := clientcore.NewCompositor(c.Context()) |
| 233 | - | if err := c.SendRequest(id, uint16(op), args...); err != nil { | 178 | + | shm := clientcore.NewShm(c.Context()) |
| 234 | - | t.Fatal(err) | 179 | + | seat := clientcore.NewSeat(c.Context()) |
| 235 | - | } | 180 | + | tim := clienttext.NewTextInputManagerV3(c.Context()) |
| 236 | - | } | 181 | + | wm := clientxdg.NewXdgWmBase(c.Context()) |
| 237 | - | comp, shm, seat, tim := bind("wl_compositor"), bind("wl_shm"), bind("wl_seat"), bind("zwp_text_input_manager_v3") | 182 | + | wm.OnPing(func(serial uint32) { check(wm.Pong(serial)) }) |
| 238 | - | for _, id := range []uint32{comp, shm, seat, tim} { | 183 | + | bindTestClient(t, c, clientcore.CompositorInterface, 1, comp) |
| 239 | - | p := &quiet{} | 184 | + | bindTestClient(t, c, clientcore.ShmInterface, 1, shm) |
| 240 | - | p.SetID(id) | 185 | + | bindTestClient(t, c, clientcore.SeatInterface, 1, seat) |
| 241 | - | c.Context().Register(p) | 186 | + | bindTestClient(t, c, clienttext.TextInputManagerV3Interface, 1, tim) |
| 242 | - | } | 187 | + | bindTestClient(t, c, clientxdg.XdgWmBaseInterface, 1, wm) |
| 243 | - | wm := &wmPing{c: c} | |||
| 244 | - | wm.SetID(bind("xdg_wm_base")) | |||
| 245 | - | c.Context().Register(wm) | |||
| 246 | fd, err := unix.MemfdCreate("text-client", 0) | 188 | fd, err := unix.MemfdCreate("text-client", 0) | ||
| 247 | - | if err != nil { | 189 | + | check(err) |
| 248 | - | t.Fatal(err) | |||
| 249 | - | } | |||
| 250 | - | defer unix.Close(fd) | |||
| 251 | if err := unix.Ftruncate(fd, 4); err != nil { | 190 | if err := unix.Ftruncate(fd, 4); err != nil { | ||
| 191 | + | _ = unix.Close(fd) | |||
| 252 | t.Fatal(err) | 192 | t.Fatal(err) | ||
| 253 | } | 193 | } | ||
| 254 | - | pool := newID() | 194 | + | // CreatePool owns the sent descriptor on success; only close it on error. |
| 255 | - | if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(4)); err != nil { | 195 | + | pool, err := shm.CreatePool(fd, 4) |
| 196 | + | if err != nil { | |||
| 197 | + | _ = unix.Close(fd) | |||
| 256 | t.Fatal(err) | 198 | t.Fatal(err) | ||
| 257 | } | 199 | } | ||
| 258 | - | buffer := newID() | 200 | + | buffer, err := pool.CreateBuffer(0, 1, 1, 4, clientcore.FORMAT_ARGB8888) |
| 259 | - | req(pool, wayland.ShmPoolRequestCreateBuffer, buffer, int32(0), int32(1), int32(1), int32(4), uint32(0)) | 201 | + | check(err) |
| 260 | - | surf := newID() | 202 | + | surf, err := comp.CreateSurface() |
| 261 | - | req(comp, wayland.CompositorRequestCreateSurface, surf) | 203 | + | check(err) |
| 262 | - | xdg := &xdgConfigure{} | 204 | + | xdg, err := wm.GetXdgSurface(surf) |
| 263 | - | xdg.SetID(c.AllocateID()) | 205 | + | check(err) |
| 264 | - | c.Context().Register(xdg) | 206 | + | var serial uint32 |
| 265 | - | req(wm.ID(), xdgshell.WmBaseRequestGetXdgSurface, xdg.ID(), surf) | 207 | + | xdg.OnConfigure(func(value uint32) { serial = value }) |
| 266 | - | top := newID() | 208 | + | _, err = xdg.GetToplevel() |
| 267 | - | req(xdg.ID(), xdgshell.SurfaceRequestGetToplevel, top) | 209 | + | check(err) |
| 268 | - | text := &textClient{} | 210 | + | proxy, err := tim.GetTextInput(seat) |
| 269 | - | text.SetID(c.AllocateID()) | 211 | + | check(err) |
| 270 | - | c.Context().Register(text) | 212 | + | text := &textClient{TextInputV3: proxy} |
| 271 | - | req(tim, textinput.ZwpTextInputManagerV3RequestGetTextInput, text.ID(), seat) | 213 | + | proxy.OnEnter(func(uint32) { text.entered = true }) |
| 272 | - | req(surf, wayland.SurfaceRequestCommit) | 214 | + | proxy.OnPreeditString(func(value string, _, _ int32) { text.preedit = append(text.preedit, value) }) |
| 273 | - | for deadline := time.Now().Add(5 * time.Second); xdg.serial == 0; time.Sleep(10 * time.Millisecond) { | 215 | + | proxy.OnCommitString(func(value string) { text.commits = append(text.commits, value) }) |
| 216 | + | check(surf.Commit()) | |||
| 217 | + | for deadline := time.Now().Add(5 * time.Second); serial == 0; time.Sleep(10 * time.Millisecond) { | |||
| 274 | if time.Now().After(deadline) { | 218 | if time.Now().After(deadline) { | ||
| 275 | t.Fatal("no configure") | 219 | t.Fatal("no configure") | ||
| 276 | } | 220 | } | ||
| 277 | - | if err := c.Roundtrip(); err != nil { | 221 | + | check(c.Roundtrip()) |
| 278 | - | t.Fatal(err) | |||
| 279 | - | } | |||
| 280 | } | 222 | } | ||
| 281 | - | req(xdg.ID(), xdgshell.SurfaceRequestAckConfigure, xdg.serial) | 223 | + | check(xdg.AckConfigure(serial)) |
| 282 | - | req(surf, wayland.SurfaceRequestAttach, buffer, int32(0), int32(0)) | 224 | + | check(surf.Attach(buffer, 0, 0)) |
| 283 | - | req(surf, wayland.SurfaceRequestCommit) | 225 | + | check(surf.Commit()) |
| 284 | return text | 226 | return text | ||
| 285 | } | 227 | } | ||
A internal/app/wire_client_test.go +16 -0
| @@ -0,0 +1,16 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/wlturbo" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // bindTestClient uses the generated real client proxy so context, version, | |||
| 10 | + | // event signatures and child lifetimes follow the dependency's contracts. | |||
| 11 | + | func bindTestClient(t *testing.T, c *wlturbo.Display, iface string, version uint32, p wlturbo.Proxy) { | |||
| 12 | + | t.Helper() | |||
| 13 | + | if _, err := c.Registry().BindNegotiated(iface, version, p); err != nil { | |||
| 14 | + | t.Fatal(err) | |||
| 15 | + | } | |||
| 16 | + | } | |||
M internal/core/capture_workspace.go +2 -1
| @@ -154,7 +154,8 @@ func (c *Core) captureScene(seq uint64) *ports.Scene { | |||||
| 154 | f := cw.frame | 154 | f := cw.frame | ||
| 155 | rebase := func(r Rect) Rect { r.X, r.Y = r.X-f.X, r.Y-f.Y; return r } | 155 | rebase := func(r Rect) Rect { r.X, r.Y = r.X-f.X, r.Y-f.Y; return r } | ||
| 156 | s := &ports.Scene{ | 156 | s := &ports.Scene{ | ||
| 157 | - | Output: cw.sc.name(), Seq: seq, OutputWidth: f.W, OutputHeight: f.H, WorkspaceClip: Rect{W: f.W, H: f.H}, | 157 | + | Security: c.security, |
| 158 | + | Output: cw.sc.name(), Seq: seq, OutputWidth: f.W, OutputHeight: f.H, WorkspaceClip: Rect{W: f.W, H: f.H}, | |||
| 158 | Scale: cw.sc.scale, Background: c.cfg.Background.Color, | 159 | Scale: cw.sc.scale, Background: c.cfg.Background.Color, | ||
| 159 | Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, | 160 | Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, | ||
| 160 | Windows: make([]ports.SceneWindow, 0, len(cw.placements)), | 161 | Windows: make([]ports.SceneWindow, 0, len(cw.placements)), | ||
M internal/core/core.go +130 -265
| @@ -15,10 +15,14 @@ import ( | |||||
| 15 | 15 | ||||
| 16 | var ErrQuit = errors.New("quit requested") | 16 | var ErrQuit = errors.New("quit requested") | ||
| 17 | 17 | ||||
| 18 | + | var errSecurityChanged = errors.New("security epoch changed during input") | |||
| 19 | + | ||||
| 18 | // Channels connects the owner to adapters. Scenes must have capacity 1: core is | 20 | // Channels connects the owner to adapters. Scenes must have capacity 1: core is | ||
| 19 | // its only sender and may drain a stale set; consumers only receive. Each | 21 | // its only sender and may drain a stale set; consumers only receive. Each | ||
| 20 | // send holds one scene per output. | 22 | // send holds one scene per output. | ||
| 21 | type Channels struct { | 23 | type Channels struct { | ||
| 24 | + | // Security is the defensive session gate; nil preserves standalone operation. | |||
| 25 | + | Security ports.SessionSecurity | |||
| 22 | Client <-chan ports.ClientEvent | 26 | Client <-chan ports.ClientEvent | ||
| 23 | Input <-chan ports.InputEvent | 27 | Input <-chan ports.InputEvent | ||
| 24 | Output <-chan ports.OutputEvent | 28 | Output <-chan ports.OutputEvent | ||
| @@ -63,6 +67,12 @@ type binding struct { | |||||
| 63 | key string | 67 | key string | ||
| 64 | } | 68 | } | ||
| 65 | type Core struct { | 69 | type Core struct { | ||
| 70 | + | security ports.SecurityState | |||
| 71 | + | lockSurfaces []ports.LockSurfacePlacement | |||
| 72 | + | lockFocus WindowID | |||
| 73 | + | inputKeys map[string]bool | |||
| 74 | + | inputActive bool | |||
| 75 | + | startup [][]string | |||
| 66 | nextWorkspaceID uint64 | 76 | nextWorkspaceID uint64 | ||
| 67 | ch Channels | 77 | ch Channels | ||
| 68 | cfg ports.Config | 78 | cfg ports.Config | ||
| @@ -274,13 +284,20 @@ func (c *Core) publishPending(ctx context.Context) error { | |||||
| 274 | // just switched to another workspace), it also refills the empty slots of | 284 | // just switched to another workspace), it also refills the empty slots of | ||
| 275 | // the workspace now on screen. | 285 | // the workspace now on screen. | ||
| 276 | func (c *Core) spawnSlots(ctx context.Context, shown bool) error { | 286 | func (c *Core) spawnSlots(ctx context.Context, shown bool) error { | ||
| 287 | + | if c.protectionRequested() { | |||
| 288 | + | return nil | |||
| 289 | + | } | |||
| 277 | keys := c.toSpawn | 290 | keys := c.toSpawn | ||
| 278 | c.toSpawn = nil | 291 | c.toSpawn = nil | ||
| 279 | if shown { | 292 | if shown { | ||
| 280 | keys = append(keys, c.refill()...) | 293 | keys = append(keys, c.refill()...) | ||
| 281 | } | 294 | } | ||
| 282 | started := map[slotKey]bool{} | 295 | started := map[slotKey]bool{} | ||
| 283 | - | for _, key := range keys { | 296 | + | for i, key := range keys { |
| 297 | + | if c.protectionRequested() { | |||
| 298 | + | c.toSpawn = append(c.toSpawn, keys[i:]...) | |||
| 299 | + | return nil | |||
| 300 | + | } | |||
| 284 | st, ok := c.slots[key] | 301 | st, ok := c.slots[key] | ||
| 285 | // A key can be queued twice (new slot on the workspace on screen). | 302 | // A key can be queued twice (new slot on the workspace on screen). | ||
| 286 | if !ok || started[key] || st.window != 0 { | 303 | if !ok || started[key] || st.window != 0 { | ||
| @@ -289,13 +306,18 @@ func (c *Core) spawnSlots(ctx context.Context, shown bool) error { | |||||
| 289 | started[key] = true | 306 | started[key] = true | ||
| 290 | req := c.spawnSlot(key) | 307 | req := c.spawnSlot(key) | ||
| 291 | // Wayland must read slot tokens before this window can map. | 308 | // Wayland must read slot tokens before this window can map. | ||
| 292 | - | if err := c.publishPending(ctx); err != nil { | 309 | + | pendingErr := c.publishPending(ctx) |
| 293 | - | return err | 310 | + | if pendingErr != nil || c.protectionRequested() { |
| 311 | + | c.placement.dropSlot(key) | |||
| 312 | + | st.stale = false | |||
| 313 | + | c.toSpawn = append(c.toSpawn, keys[i:]...) | |||
| 314 | + | return pendingErr | |||
| 294 | } | 315 | } | ||
| 295 | - | select { | 316 | + | if !c.trySpawn(ctx, req) { |
| 296 | - | case <-ctx.Done(): | 317 | + | c.placement.dropSlot(key) |
| 318 | + | st.stale = false | |||
| 319 | + | c.toSpawn = append(c.toSpawn, keys[i:]...) | |||
| 297 | return ctx.Err() | 320 | return ctx.Err() | ||
| 298 | - | case c.ch.Spawn <- req: | |||
| 299 | } | 321 | } | ||
| 300 | } | 322 | } | ||
| 301 | return nil | 323 | return nil | ||
| @@ -305,7 +327,7 @@ func New(cfg ports.Config, ch Channels) (*Core, error) { | |||||
| 305 | return nil, fmt.Errorf("scenes, layouts, constraints, state and workspaces must have capacity 1") | 327 | return nil, fmt.Errorf("scenes, layouts, constraints, state and workspaces must have capacity 1") | ||
| 306 | } | 328 | } | ||
| 307 | // A placeholder screen holds windows until the first output arrives. | 329 | // A placeholder screen holds windows until the first output arrives. | ||
| 308 | - | c := &Core{slots: map[slotKey]*slotState{}, placement: newSpawnPlacement(), windows: newWindowRegistry(), popups: map[WindowID]*popupState{}, ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, configures: newConfigures()} | 330 | + | c := &Core{inputKeys: map[string]bool{}, slots: map[slotKey]*slotState{}, placement: newSpawnPlacement(), windows: newWindowRegistry(), popups: map[WindowID]*popupState{}, ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, configures: newConfigures()} |
| 309 | c.screens = []*screen{{mon: newMonitorWithIDs("", "", &c.nextWorkspaceID), scale: 1, cfgScale: 1}} | 331 | c.screens = []*screen{{mon: newMonitorWithIDs("", "", &c.nextWorkspaceID), scale: 1, cfgScale: 1}} | ||
| 310 | if err := c.apply(cfg); err != nil { | 332 | if err := c.apply(cfg); err != nil { | ||
| 311 | return nil, err | 333 | return nil, err | ||
| @@ -320,10 +342,19 @@ func (c *Core) now() time.Time { | |||||
| 320 | } | 342 | } | ||
| 321 | 343 | ||||
| 322 | func (c *Core) command(ctx context.Context, v ports.ClientCommand) error { | 344 | func (c *Core) command(ctx context.Context, v ports.ClientCommand) error { | ||
| 345 | + | if c.inputEpochChanged() { | |||
| 346 | + | return errSecurityChanged | |||
| 347 | + | } | |||
| 348 | + | if c.ch.Security != nil { | |||
| 349 | + | v = ports.SecurityCommand{State: c.security, Command: v} | |||
| 350 | + | } | |||
| 323 | select { | 351 | select { | ||
| 324 | case <-ctx.Done(): | 352 | case <-ctx.Done(): | ||
| 325 | return ctx.Err() | 353 | return ctx.Err() | ||
| 326 | case c.ch.Commands <- v: | 354 | case c.ch.Commands <- v: | ||
| 355 | + | if c.inputEpochChanged() { | |||
| 356 | + | return errSecurityChanged | |||
| 357 | + | } | |||
| 327 | return nil | 358 | return nil | ||
| 328 | } | 359 | } | ||
| 329 | } | 360 | } | ||
| @@ -380,6 +411,9 @@ func (c *Core) windowFocus() map[*screen]WindowID { | |||||
| 380 | 411 | ||||
| 381 | // keyboardFocus is the surface holding the keyboard (see keyboard.focus). | 412 | // keyboardFocus is the surface holding the keyboard (see keyboard.focus). | ||
| 382 | func (c *Core) keyboardFocus() WindowID { | 413 | func (c *Core) keyboardFocus() WindowID { | ||
| 414 | + | if c.security.Protected { | |||
| 415 | + | return c.lockKeyboardFocus() | |||
| 416 | + | } | |||
| 383 | var exclusive WindowID | 417 | var exclusive WindowID | ||
| 384 | for _, sc := range c.screens { | 418 | for _, sc := range c.screens { | ||
| 385 | for _, l := range sc.layers { | 419 | for _, l := range sc.layers { | ||
| @@ -429,6 +463,13 @@ func (c *Core) setLayers(all []ports.LayerSurface) { | |||||
| 429 | } | 463 | } | ||
| 430 | 464 | ||||
| 431 | func (c *Core) publish(ctx context.Context) error { | 465 | func (c *Core) publish(ctx context.Context) error { | ||
| 466 | + | if c.inputEpochChanged() { | |||
| 467 | + | return errSecurityChanged | |||
| 468 | + | } | |||
| 469 | + | c.syncSecurity() | |||
| 470 | + | if c.security.Protected { | |||
| 471 | + | return c.publishProtected(ctx) | |||
| 472 | + | } | |||
| 432 | capture, err := c.captureEvaluate(ctx) | 473 | capture, err := c.captureEvaluate(ctx) | ||
| 433 | if err != nil { | 474 | if err != nil { | ||
| 434 | return err | 475 | return err | ||
| @@ -470,7 +511,7 @@ func (c *Core) publish(ctx context.Context) error { | |||||
| 470 | if frame != (Rect{W: o.W, H: o.H}) { | 511 | if frame != (Rect{W: o.W, H: o.H}) { | ||
| 471 | clip = frame | 512 | clip = frame | ||
| 472 | } | 513 | } | ||
| 473 | - | scene := ports.Scene{Output: sc.name(), Seq: c.seq, OutputWidth: o.W, OutputHeight: o.H, WorkspaceClip: clip, Scale: sc.scale, Off: sc.off, Background: c.cfg.Background.Color, Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0), Layers: shownLayers(sc)} | 514 | + | scene := ports.Scene{Security: c.security, Output: sc.name(), Seq: c.seq, OutputWidth: o.W, OutputHeight: o.H, WorkspaceClip: clip, Scale: sc.scale, Off: sc.off, Background: c.cfg.Background.Color, Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0), Layers: shownLayers(sc)} |
| 474 | layout := sc.mon.Layout() | 515 | layout := sc.mon.Layout() | ||
| 475 | var real map[WindowID]Placement | 516 | var real map[WindowID]Placement | ||
| 476 | if sc.mon.ov.open { | 517 | if sc.mon.ov.open { | ||
| @@ -543,6 +584,12 @@ func (c *Core) publish(ctx context.Context) error { | |||||
| 543 | return err | 584 | return err | ||
| 544 | } | 585 | } | ||
| 545 | c.resolveConstraint() | 586 | c.resolveConstraint() | ||
| 587 | + | if c.inputEpochChanged() { | |||
| 588 | + | return errSecurityChanged | |||
| 589 | + | } | |||
| 590 | + | if c.syncSecurity() { | |||
| 591 | + | return c.publish(ctx) | |||
| 592 | + | } | |||
| 546 | latest(c.ch.Scenes, scenes) | 593 | latest(c.ch.Scenes, scenes) | ||
| 547 | c.publishState() | 594 | c.publishState() | ||
| 548 | c.publishWorkspaces() | 595 | c.publishWorkspaces() | ||
| @@ -600,6 +647,9 @@ func (c *Core) rehit(ctx context.Context) error { | |||||
| 600 | // updateInhibit makes the shortcuts inhibitor of the keyboard focus the | 647 | // updateInhibit makes the shortcuts inhibitor of the keyboard focus the | ||
| 601 | // active one and tells wayland when it changes. | 648 | // active one and tells wayland when it changes. | ||
| 602 | func (c *Core) updateInhibit(ctx context.Context) error { | 649 | func (c *Core) updateInhibit(ctx context.Context) error { | ||
| 650 | + | if c.security.Protected { | |||
| 651 | + | return nil | |||
| 652 | + | } | |||
| 603 | f := c.keyboardFocus() | 653 | f := c.keyboardFocus() | ||
| 604 | release, activate, changed := c.keyboard.inhibit(f, c.windows.lookup(f).inhibitShortcuts) | 654 | release, activate, changed := c.keyboard.inhibit(f, c.windows.lookup(f).inhibitShortcuts) | ||
| 605 | if !changed { | 655 | if !changed { | ||
| @@ -765,6 +815,9 @@ func (c *Core) acceptsInput(id WindowID, x, y float64) bool { | |||||
| 765 | // windows (last visible placement is topmost), then bottom and | 815 | // windows (last visible placement is topmost), then bottom and | ||
| 766 | // background layers. | 816 | // background layers. | ||
| 767 | func (c *Core) hit(x, y float64) (WindowID, float64, float64) { | 817 | func (c *Core) hit(x, y float64) (WindowID, float64, float64) { | ||
| 818 | + | if c.security.Protected { | |||
| 819 | + | return c.lockHit(x, y) | |||
| 820 | + | } | |||
| 768 | o, ok := c.layout().At(x, y) | 821 | o, ok := c.layout().At(x, y) | ||
| 769 | if !ok { | 822 | if !ok { | ||
| 770 | return 0, 0, 0 | 823 | return 0, 0, 0 | ||
| @@ -817,32 +870,50 @@ func (c *Core) hit(x, y float64) (WindowID, float64, float64) { | |||||
| 817 | } | 870 | } | ||
| 818 | 871 | ||||
| 819 | func (c *Core) Run(ctx context.Context) error { | 872 | func (c *Core) Run(ctx context.Context) error { | ||
| 873 | + | c.syncSecurity() | |||
| 820 | defer c.stopFrame() | 874 | defer c.stopFrame() | ||
| 821 | defer c.stopCapture() | 875 | defer c.stopCapture() | ||
| 822 | - | // Startup commands run once per session; a config reload does not | 876 | + | // Startup commands run once per session. Full launcher queues retain a |
| 823 | - | // run them again. | 877 | + | // bounded remainder, selectable alongside owner events without polling. |
| 824 | - | for _, argv := range c.cfg.Startup { | 878 | + | c.startup = make([][]string, len(c.cfg.Startup)) |
| 825 | - | select { | 879 | + | for i, argv := range c.cfg.Startup { |
| 826 | - | case <-ctx.Done(): | 880 | + | c.startup[i] = slices.Clone(argv) |
| 827 | - | return nil | |||
| 828 | - | case c.ch.Spawn <- ports.SpawnRequest{Argv: slices.Clone(argv)}: | |||
| 829 | - | } | |||
| 830 | } | 881 | } | ||
| 882 | + | c.spawnStartup(ctx) | |||
| 831 | if c.spawnSlots(ctx, false) != nil || c.publishPending(ctx) != nil { | 883 | if c.spawnSlots(ctx, false) != nil || c.publishPending(ctx) != nil { | ||
| 832 | return nil | 884 | return nil | ||
| 833 | } | 885 | } | ||
| 834 | c.publishState() | 886 | c.publishState() | ||
| 835 | c.publishWorkspaces() | 887 | c.publishWorkspaces() | ||
| 836 | for { | 888 | for { | ||
| 889 | + | if c.securityCheckpoint(ctx) != nil { | |||
| 890 | + | return nil | |||
| 891 | + | } | |||
| 892 | + | startupC, startupRequest := c.pendingStartup() | |||
| 837 | select { | 893 | select { | ||
| 838 | case <-ctx.Done(): | 894 | case <-ctx.Done(): | ||
| 839 | return nil | 895 | return nil | ||
| 896 | + | case startupC <- startupRequest: | |||
| 897 | + | // Successful transfer consumes startup once. Only the receiver can | |||
| 898 | + | // decide execution admission; a later gate read cannot infer it. | |||
| 899 | + | c.startup = c.startup[1:] | |||
| 900 | + | continue | |||
| 840 | case ev, ok := <-c.ch.Client: | 901 | case ev, ok := <-c.ch.Client: | ||
| 841 | if !ok { | 902 | if !ok { | ||
| 842 | c.ch.Client = nil | 903 | c.ch.Client = nil | ||
| 843 | continue | 904 | continue | ||
| 844 | } | 905 | } | ||
| 906 | + | if c.securityCheckpoint(ctx) != nil { | |||
| 907 | + | return nil | |||
| 908 | + | } | |||
| 909 | + | if c.security.Protected && blockedProtectedEvent(ev) { | |||
| 910 | + | continue | |||
| 911 | + | } | |||
| 845 | switch v := ev.(type) { | 912 | switch v := ev.(type) { | ||
| 913 | + | case ports.SessionLockChanged: | |||
| 914 | + | if !c.applyLockChanged(v) { | |||
| 915 | + | continue | |||
| 916 | + | } | |||
| 846 | case ports.CaptureSessionBegin: | 917 | case ports.CaptureSessionBegin: | ||
| 847 | if c.captureBegin(ctx, v) != nil { | 918 | if c.captureBegin(ctx, v) != nil { | ||
| 848 | return nil | 919 | return nil | ||
| @@ -951,6 +1022,9 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 951 | return nil | 1022 | return nil | ||
| 952 | } | 1023 | } | ||
| 953 | case f, ok := <-c.ch.Frames: | 1024 | case f, ok := <-c.ch.Frames: | ||
| 1025 | + | if c.securityCheckpoint(ctx) != nil { | |||
| 1026 | + | return nil | |||
| 1027 | + | } | |||
| 954 | if !ok { | 1028 | if !ok { | ||
| 955 | c.ch.Frames = nil | 1029 | c.ch.Frames = nil | ||
| 956 | continue | 1030 | continue | ||
| @@ -960,12 +1034,18 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 960 | } | 1034 | } | ||
| 961 | continue | 1035 | continue | ||
| 962 | case <-c.frameC: | 1036 | case <-c.frameC: | ||
| 1037 | + | if c.securityCheckpoint(ctx) != nil { | |||
| 1038 | + | return nil | |||
| 1039 | + | } | |||
| 963 | c.frameC, c.frameStop = nil, nil | 1040 | c.frameC, c.frameStop = nil, nil | ||
| 964 | if c.step(ctx) != nil { | 1041 | if c.step(ctx) != nil { | ||
| 965 | return nil | 1042 | return nil | ||
| 966 | } | 1043 | } | ||
| 967 | continue | 1044 | continue | ||
| 968 | case <-c.captureC: | 1045 | case <-c.captureC: | ||
| 1046 | + | if c.securityCheckpoint(ctx) != nil { | |||
| 1047 | + | return nil | |||
| 1048 | + | } | |||
| 969 | c.captureExpired() | 1049 | c.captureExpired() | ||
| 970 | if c.publish(ctx) != nil { | 1050 | if c.publish(ctx) != nil { | ||
| 971 | return nil | 1051 | return nil | ||
| @@ -976,6 +1056,9 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 976 | c.ch.Output = nil | 1056 | c.ch.Output = nil | ||
| 977 | continue | 1057 | continue | ||
| 978 | } | 1058 | } | ||
| 1059 | + | if c.securityCheckpoint(ctx) != nil { | |||
| 1060 | + | return nil | |||
| 1061 | + | } | |||
| 979 | switch v := ev.(type) { | 1062 | switch v := ev.(type) { | ||
| 980 | case ports.OutputAdded: | 1063 | case ports.OutputAdded: | ||
| 981 | c.addScreen(v.Info) | 1064 | c.addScreen(v.Info) | ||
| @@ -1000,6 +1083,9 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 1000 | c.ch.Config = nil | 1083 | c.ch.Config = nil | ||
| 1001 | continue | 1084 | continue | ||
| 1002 | } | 1085 | } | ||
| 1086 | + | if c.securityCheckpoint(ctx) != nil { | |||
| 1087 | + | return nil | |||
| 1088 | + | } | |||
| 1003 | if err := c.apply(ev.Config); err != nil { | 1089 | if err := c.apply(ev.Config); err != nil { | ||
| 1004 | select { | 1090 | select { | ||
| 1005 | case c.ch.ConfigErrors <- err: | 1091 | case c.ch.ConfigErrors <- err: | ||
| @@ -1018,254 +1104,27 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 1018 | c.ch.Input = nil | 1104 | c.ch.Input = nil | ||
| 1019 | continue | 1105 | continue | ||
| 1020 | } | 1106 | } | ||
| 1021 | - | if err := c.userActivity(ctx); err != nil { | 1107 | + | if c.securityCheckpoint(ctx) != nil { |
| 1022 | return nil | 1108 | return nil | ||
| 1023 | } | 1109 | } | ||
| 1024 | - | switch v := ev.(type) { | 1110 | + | // Even stale input is activity, but never becomes client input. |
| 1025 | - | case ports.PointerMotion: | 1111 | + | if err := c.userActivity(ctx); err != nil { |
| 1026 | - | c.motionMsec = v.TimeMsec | 1112 | + | return nil |
| 1027 | - | // A locked pointer stays still; relative motion still flows. | |||
| 1028 | - | if c.constraint.Mode != ports.ConstraintLock { | |||
| 1029 | - | c.cursorX, c.cursorY = c.constraint.Clamp(c.layout().Clamp(c.cursorX, c.cursorY, v.X, v.Y)) | |||
| 1030 | - | } | |||
| 1031 | - | // The focused screen follows the pointer, so new windows | |||
| 1032 | - | // and launchers open where the user is. | |||
| 1033 | - | // Only a pointer entering another output switches: keyboard | |||
| 1034 | - | // moves to another screen stick until then. Not mid-drag. | |||
| 1035 | - | if o, ok := c.layout().At(c.cursorX, c.cursorY); ok && o.Info.Name != c.pointerOutput && c.grab == 0 { | |||
| 1036 | - | c.pointerOutput = o.Info.Name | |||
| 1037 | - | if o.Info.Name != c.cur().name() { | |||
| 1038 | - | c.focusScreen = c.screenIndex(o.Info.Name) | |||
| 1039 | - | if err := c.publish(ctx); err != nil { | |||
| 1040 | - | return nil | |||
| 1041 | - | } | |||
| 1042 | - | } | |||
| 1043 | - | } | |||
| 1044 | - | id, x, y := c.hit(c.cursorX, c.cursorY) | |||
| 1045 | - | // Layout changes are intentionally re-hit-tested only on motion. | |||
| 1046 | - | if id != c.pointer { | |||
| 1047 | - | c.pointer = id | |||
| 1048 | - | if err := c.command(ctx, ports.PointerFocus{ID: id, X: x, Y: y}); err != nil { | |||
| 1049 | - | return nil | |||
| 1050 | - | } | |||
| 1051 | - | } | |||
| 1052 | - | c.pointerAt = [2]float64{x, y} | |||
| 1053 | - | if id != 0 { | |||
| 1054 | - | if err := c.command(ctx, ports.PointerMotionTo{ID: id, X: x, Y: y, DX: v.DX, DY: v.DY, UnaccelDX: v.UnaccelDX, UnaccelDY: v.UnaccelDY, TimeMsec: v.TimeMsec, TimeUsec: v.TimeUsec}); err != nil { | |||
| 1055 | - | return nil | |||
| 1056 | - | } | |||
| 1057 | - | } | |||
| 1058 | - | continue | |||
| 1059 | - | case ports.PointerButton: | |||
| 1060 | - | if v.Pressed && c.pointer == 0 && c.grab == 0 && len(c.buttons) == 0 { | |||
| 1061 | - | if picked, err := c.overviewClick(ctx); err != nil { | |||
| 1062 | - | return nil | |||
| 1063 | - | } else if picked { | |||
| 1064 | - | continue | |||
| 1065 | - | } | |||
| 1066 | - | } | |||
| 1067 | - | id := c.pointer | |||
| 1068 | - | if c.grab != 0 { | |||
| 1069 | - | id = c.grab | |||
| 1070 | - | } | |||
| 1071 | - | if v.Pressed && len(c.buttons) == 0 { | |||
| 1072 | - | // A click outside an open menu closes it. | |||
| 1073 | - | if err := c.dismissGrabs(ctx, id); err != nil { | |||
| 1074 | - | return nil | |||
| 1075 | - | } | |||
| 1076 | - | } | |||
| 1077 | - | if v.Pressed { | |||
| 1078 | - | if len(c.buttons) == 0 { | |||
| 1079 | - | c.grab = id | |||
| 1080 | - | } | |||
| 1081 | - | c.buttons[v.Button] = true | |||
| 1082 | - | } else { | |||
| 1083 | - | delete(c.buttons, v.Button) | |||
| 1084 | - | } | |||
| 1085 | - | if id != 0 { | |||
| 1086 | - | if err := c.command(ctx, ports.PointerButtonTo{ID: id, Button: v.Button, Pressed: v.Pressed, TimeMsec: v.TimeMsec}); err != nil { | |||
| 1087 | - | return nil | |||
| 1088 | - | } | |||
| 1089 | - | // A click on an on-demand layer gives it the keyboard. | |||
| 1090 | - | if v.Pressed && c.onDemand(id) && c.keyboard.clickLayer(id, c.windowFocus()) { | |||
| 1091 | - | if err := c.publish(ctx); err != nil { | |||
| 1092 | - | return nil | |||
| 1093 | - | } | |||
| 1094 | - | } else if l := c.keyboard.layer; v.Pressed && l != 0 && id != l && c.popupRoot(id) != l { | |||
| 1095 | - | // A click anywhere else takes the keyboard back. | |||
| 1096 | - | c.keyboard.takeBack() | |||
| 1097 | - | if err := c.publish(ctx); err != nil { | |||
| 1098 | - | return nil | |||
| 1099 | - | } | |||
| 1100 | - | } | |||
| 1101 | - | // A click focuses the window and its output. | |||
| 1102 | - | s, w := c.screenOf(id) | |||
| 1103 | - | if v.Pressed && s != nil && w == s.mon.Current() && (c.keyboard.sent != id || s != c.cur()) { | |||
| 1104 | - | w.Click(id) | |||
| 1105 | - | c.focusScreen = c.screenIndex(s.name()) | |||
| 1106 | - | if err := c.publish(ctx); err != nil { | |||
| 1107 | - | return nil | |||
| 1108 | - | } | |||
| 1109 | - | } | |||
| 1110 | - | } | |||
| 1111 | - | if len(c.buttons) == 0 { | |||
| 1112 | - | c.grab = 0 | |||
| 1113 | - | } | |||
| 1114 | - | continue | |||
| 1115 | - | case ports.PointerAxis: | |||
| 1116 | - | // In the overview, scrolling moves the selection. | |||
| 1117 | - | if c.cur().mon.ov.open && !c.overviewKeyboardTaken() { | |||
| 1118 | - | if c.cur().mon.overviewScroll(v) { | |||
| 1119 | - | if c.workspaceVisible(ctx, true) != nil { | |||
| 1120 | - | return nil | |||
| 1121 | - | } | |||
| 1122 | - | if err := c.publish(ctx); err != nil { | |||
| 1123 | - | return nil | |||
| 1124 | - | } | |||
| 1125 | - | } | |||
| 1126 | - | continue | |||
| 1127 | - | } | |||
| 1128 | - | // Scroll goes to the window under the pointer, which has the | |||
| 1129 | - | // pointer focus even mid-drag. | |||
| 1130 | - | if c.pointer != 0 { | |||
| 1131 | - | if err := c.command(ctx, ports.PointerAxisTo{ID: c.pointer, Axis: v}); err != nil { | |||
| 1132 | - | return nil | |||
| 1133 | - | } | |||
| 1134 | - | } | |||
| 1135 | - | continue | |||
| 1136 | - | case ports.SwipeBegin: | |||
| 1137 | - | if c.swipeBegin(v) && c.slid(ctx, false) != nil { | |||
| 1138 | - | return nil | |||
| 1139 | - | } | |||
| 1140 | - | continue | |||
| 1141 | - | case ports.SwipeUpdate: | |||
| 1142 | - | if c.swipeUpdate(v) && c.slid(ctx, false) != nil { | |||
| 1143 | - | return nil | |||
| 1144 | - | } | |||
| 1145 | - | continue | |||
| 1146 | - | case ports.SwipeEnd: | |||
| 1147 | - | if c.slid(ctx, c.swipeEnd(v)) != nil { | |||
| 1148 | - | return nil | |||
| 1149 | - | } | |||
| 1150 | - | continue | |||
| 1151 | - | } | |||
| 1152 | - | key, ok := ev.(ports.KeyEvent) | |||
| 1153 | - | if !ok { | |||
| 1154 | - | continue | |||
| 1155 | } | 1113 | } | ||
| 1156 | - | // The overview takes its keys before any window; others still | 1114 | + | // Commands can backpressure; recheck after activity before admits/binds. |
| 1157 | - | // run binds, and are not forwarded. | 1115 | + | if c.securityCheckpoint(ctx) != nil { |
| 1158 | - | // A launcher or a menu holding the keyboard gets them first. | 1116 | + | return nil |
| 1159 | - | if mon := c.cur().mon; mon.ov.open && !c.overviewKeyboardTaken() { | |||
| 1160 | - | if key.Pressed && mon.overviewKey(key) { | |||
| 1161 | - | c.pressed[heldKey(key)] = true | |||
| 1162 | - | if c.workspaceVisible(ctx, true) != nil { | |||
| 1163 | - | return nil | |||
| 1164 | - | } | |||
| 1165 | - | if err := c.publish(ctx); err != nil { | |||
| 1166 | - | return nil | |||
| 1167 | - | } | |||
| 1168 | - | continue | |||
| 1169 | - | } | |||
| 1170 | } | 1117 | } | ||
| 1171 | - | // A focused window inhibiting shortcuts gets every key: | 1118 | + | var admitted bool |
| 1172 | - | // no bind runs (emergency quit and VT switch are handled by | 1119 | + | ev, admitted = c.admitInput(ev) |
| 1173 | - | // input before core). | 1120 | + | if !admitted { |
| 1174 | - | if c.keyboard.inhibited(c.keyboardFocus()) { | |||
| 1175 | - | if held := heldKey(key); !key.Pressed && c.pressed[held] { | |||
| 1176 | - | // Its press ran a bind before inhibiting began: the | |||
| 1177 | - | // window never saw it. | |||
| 1178 | - | delete(c.pressed, held) | |||
| 1179 | - | continue | |||
| 1180 | - | } | |||
| 1181 | - | if err := c.command(ctx, ports.ForwardKey{ID: c.keyboard.inhibiting, Key: key}); err != nil { | |||
| 1182 | - | return nil | |||
| 1183 | - | } | |||
| 1184 | continue | 1121 | continue | ||
| 1185 | } | 1122 | } | ||
| 1186 | - | name := keyName(key.Keysym) | 1123 | + | if err := c.handleInput(ctx, ev); err != nil { |
| 1187 | - | // A bind on the keysym wins; then the unshifted keysym (Cmd+Shift+1 | 1124 | + | if errors.Is(err, errSecurityChanged) { |
| 1188 | - | // prints exclam on US); then the physical key (code:N). | |||
| 1189 | - | action, bound := c.binds[binding{key: name, mods: key.Mods}] | |||
| 1190 | - | if !bound && key.Base != "" { | |||
| 1191 | - | action, bound = c.binds[binding{key: keyName(key.Base), mods: key.Mods}] | |||
| 1192 | - | } | |||
| 1193 | - | if !bound && key.Keycode != 0 { | |||
| 1194 | - | action, bound = c.binds[binding{key: "code:" + strconv.FormatUint(uint64(key.Keycode), 10), mods: key.Mods}] | |||
| 1195 | - | } | |||
| 1196 | - | // Track presses by physical key: Shift may be released before the key. | |||
| 1197 | - | held := heldKey(key) | |||
| 1198 | - | if !key.Pressed { | |||
| 1199 | - | consumed := c.pressed[held] | |||
| 1200 | - | delete(c.pressed, held) | |||
| 1201 | - | if consumed { | |||
| 1202 | - | continue | |||
| 1203 | - | } | |||
| 1204 | - | } | |||
| 1205 | - | if key.Pressed { | |||
| 1206 | - | if bound { | |||
| 1207 | - | c.pressed[held] = true | |||
| 1208 | - | if action == ActionScaleUp || action == ActionScaleDown { | |||
| 1209 | - | dir := 1 | |||
| 1210 | - | if action == ActionScaleDown { | |||
| 1211 | - | dir = -1 | |||
| 1212 | - | } | |||
| 1213 | - | sc := c.cur() | |||
| 1214 | - | prev := sc.scale | |||
| 1215 | - | sc.setScale(StepScale(sc.info.Width, sc.info.Height, sc.scale, dir)) | |||
| 1216 | - | c.order() | |||
| 1217 | - | if c.ch.Scales != nil && sc.scale != prev && sc.name() != "" { | |||
| 1218 | - | // Never wait on persistence: a stalled save drops the change. | |||
| 1219 | - | select { | |||
| 1220 | - | case c.ch.Scales <- ports.ScaleChanged{Output: sc.name(), Scale: sc.scale}: | |||
| 1221 | - | default: | |||
| 1222 | - | } | |||
| 1223 | - | } | |||
| 1224 | - | if err := c.publish(ctx); err != nil { | |||
| 1225 | - | return nil | |||
| 1226 | - | } | |||
| 1227 | - | continue | |||
| 1228 | - | } | |||
| 1229 | - | before := c.cur().mon.Current() | |||
| 1230 | - | swiped := c.swipedWorkspace() | |||
| 1231 | - | c.keyboard.takeBack() // a bind acts on the windows | |||
| 1232 | - | effect := c.applyAction(action) | |||
| 1233 | - | if effect.Quit { | |||
| 1234 | - | return ErrQuit | |||
| 1235 | - | } | |||
| 1236 | - | if effect.Spawn { | |||
| 1237 | - | argv := effect.Argv | |||
| 1238 | - | if argv == nil { | |||
| 1239 | - | argv = append([]string(nil), c.cfg.Terminal.Command...) | |||
| 1240 | - | } | |||
| 1241 | - | select { | |||
| 1242 | - | case <-ctx.Done(): | |||
| 1243 | - | return nil | |||
| 1244 | - | case c.ch.Spawn <- ports.SpawnRequest{Argv: argv}: | |||
| 1245 | - | } | |||
| 1246 | - | } | |||
| 1247 | - | if c.swipedWorkspace() != swiped { | |||
| 1248 | - | c.dropSwipe() | |||
| 1249 | - | } | |||
| 1250 | - | if c.workspaceVisible(ctx, c.cur().mon.Current() != before) != nil { | |||
| 1251 | - | return nil | |||
| 1252 | - | } | |||
| 1253 | - | if effect.Close != 0 { | |||
| 1254 | - | if err := c.command(ctx, ports.CloseWindow{ID: effect.Close}); err != nil { | |||
| 1255 | - | return nil | |||
| 1256 | - | } | |||
| 1257 | - | } | |||
| 1258 | - | if err := c.publish(ctx); err != nil { | |||
| 1259 | - | return nil | |||
| 1260 | - | } | |||
| 1261 | continue | 1125 | continue | ||
| 1262 | } | 1126 | } | ||
| 1263 | - | c.pressed[held] = false | 1127 | + | return err |
| 1264 | - | } | |||
| 1265 | - | if id := c.keyboardFocus(); id != 0 && (!c.cur().mon.ov.open || c.overviewKeyboardTaken()) { | |||
| 1266 | - | if err := c.command(ctx, ports.ForwardKey{ID: id, Key: key}); err != nil { | |||
| 1267 | - | return nil | |||
| 1268 | - | } | |||
| 1269 | } | 1128 | } | ||
| 1270 | } | 1129 | } | ||
| 1271 | } | 1130 | } | ||
| @@ -1283,18 +1142,24 @@ func (c *Core) workspaceVisible(ctx context.Context, shown bool) error { | |||||
| 1283 | // it after the output layout and before scenes, so a terminal never maps | 1142 | // it after the output layout and before scenes, so a terminal never maps | ||
| 1284 | // before wayland knows its output. | 1143 | // before wayland knows its output. | ||
| 1285 | func (c *Core) spawnEmpty(ctx context.Context) error { | 1144 | func (c *Core) spawnEmpty(ctx context.Context) error { | ||
| 1286 | - | terms := c.fillEmpty() | 1145 | + | if c.protectionRequested() { |
| 1287 | - | if err := c.publishPending(ctx); err != nil { | 1146 | + | return nil |
| 1288 | - | return err | |||
| 1289 | } | 1147 | } | ||
| 1290 | - | for _, req := range terms { | 1148 | + | first := c.firstTerminalResolved |
| 1291 | - | select { | 1149 | + | terms := c.fillEmpty() |
| 1292 | - | case <-ctx.Done(): | 1150 | + | err := c.publishPending(ctx) |
| 1293 | - | return ctx.Err() | 1151 | + | for i, req := range terms { |
| 1294 | - | case c.ch.Spawn <- req: | 1152 | + | if err != nil || !c.trySpawn(ctx, req) { |
| 1153 | + | for _, suppressed := range terms[i:] { | |||
| 1154 | + | c.cancelTerminal(suppressed) | |||
| 1155 | + | } | |||
| 1156 | + | if i == 0 { | |||
| 1157 | + | c.firstTerminalResolved = first | |||
| 1158 | + | } | |||
| 1159 | + | return err | |||
| 1295 | } | 1160 | } | ||
| 1296 | } | 1161 | } | ||
| 1297 | - | return nil | 1162 | + | return err |
| 1298 | } | 1163 | } | ||
| 1299 | 1164 | ||||
| 1300 | // activate brings a window forward for a valid xdg-activation token. The | 1165 | // activate brings a window forward for a valid xdg-activation token. The | ||
A internal/core/input.go +264 -0
| @@ -0,0 +1,264 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "strconv" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error { | |||
| 11 | + | c.inputActive = true | |||
| 12 | + | defer func() { c.inputActive = false }() | |||
| 13 | + | if c.security.Protected { | |||
| 14 | + | if err := c.protectedInput(ctx, ev); err != nil { | |||
| 15 | + | return err | |||
| 16 | + | } | |||
| 17 | + | return nil | |||
| 18 | + | } | |||
| 19 | + | switch v := ev.(type) { | |||
| 20 | + | case ports.PointerMotion: | |||
| 21 | + | c.motionMsec = v.TimeMsec | |||
| 22 | + | // A locked pointer stays still; relative motion still flows. | |||
| 23 | + | if c.constraint.Mode != ports.ConstraintLock { | |||
| 24 | + | c.cursorX, c.cursorY = c.constraint.Clamp(c.layout().Clamp(c.cursorX, c.cursorY, v.X, v.Y)) | |||
| 25 | + | } | |||
| 26 | + | // The focused screen follows the pointer, so new windows | |||
| 27 | + | // and launchers open where the user is. | |||
| 28 | + | // Only a pointer entering another output switches: keyboard | |||
| 29 | + | // moves to another screen stick until then. Not mid-drag. | |||
| 30 | + | if o, ok := c.layout().At(c.cursorX, c.cursorY); ok && o.Info.Name != c.pointerOutput && c.grab == 0 { | |||
| 31 | + | c.pointerOutput = o.Info.Name | |||
| 32 | + | if o.Info.Name != c.cur().name() { | |||
| 33 | + | c.focusScreen = c.screenIndex(o.Info.Name) | |||
| 34 | + | if err := c.publish(ctx); err != nil { | |||
| 35 | + | return err | |||
| 36 | + | } | |||
| 37 | + | } | |||
| 38 | + | } | |||
| 39 | + | id, x, y := c.hit(c.cursorX, c.cursorY) | |||
| 40 | + | // Layout changes are intentionally re-hit-tested only on motion. | |||
| 41 | + | if id != c.pointer { | |||
| 42 | + | c.pointer = id | |||
| 43 | + | if err := c.command(ctx, ports.PointerFocus{ID: id, X: x, Y: y}); err != nil { | |||
| 44 | + | return err | |||
| 45 | + | } | |||
| 46 | + | } | |||
| 47 | + | c.pointerAt = [2]float64{x, y} | |||
| 48 | + | if id != 0 { | |||
| 49 | + | if err := c.command(ctx, ports.PointerMotionTo{ID: id, X: x, Y: y, DX: v.DX, DY: v.DY, UnaccelDX: v.UnaccelDX, UnaccelDY: v.UnaccelDY, TimeMsec: v.TimeMsec, TimeUsec: v.TimeUsec}); err != nil { | |||
| 50 | + | return err | |||
| 51 | + | } | |||
| 52 | + | } | |||
| 53 | + | return nil | |||
| 54 | + | case ports.PointerButton: | |||
| 55 | + | if v.Pressed && c.pointer == 0 && c.grab == 0 && len(c.buttons) == 0 { | |||
| 56 | + | if picked, err := c.overviewClick(ctx); err != nil { | |||
| 57 | + | return err | |||
| 58 | + | } else if picked { | |||
| 59 | + | return nil | |||
| 60 | + | } | |||
| 61 | + | } | |||
| 62 | + | id := c.pointer | |||
| 63 | + | if c.grab != 0 { | |||
| 64 | + | id = c.grab | |||
| 65 | + | } | |||
| 66 | + | if v.Pressed && len(c.buttons) == 0 { | |||
| 67 | + | // A click outside an open menu closes it. | |||
| 68 | + | if err := c.dismissGrabs(ctx, id); err != nil { | |||
| 69 | + | return err | |||
| 70 | + | } | |||
| 71 | + | } | |||
| 72 | + | if v.Pressed { | |||
| 73 | + | if len(c.buttons) == 0 { | |||
| 74 | + | c.grab = id | |||
| 75 | + | } | |||
| 76 | + | c.buttons[v.Button] = true | |||
| 77 | + | } else { | |||
| 78 | + | delete(c.buttons, v.Button) | |||
| 79 | + | } | |||
| 80 | + | if id != 0 { | |||
| 81 | + | if err := c.command(ctx, ports.PointerButtonTo{ID: id, Button: v.Button, Pressed: v.Pressed, TimeMsec: v.TimeMsec}); err != nil { | |||
| 82 | + | return err | |||
| 83 | + | } | |||
| 84 | + | // A click on an on-demand layer gives it the keyboard. | |||
| 85 | + | if v.Pressed && c.onDemand(id) && c.keyboard.clickLayer(id, c.windowFocus()) { | |||
| 86 | + | if err := c.publish(ctx); err != nil { | |||
| 87 | + | return err | |||
| 88 | + | } | |||
| 89 | + | } else if l := c.keyboard.layer; v.Pressed && l != 0 && id != l && c.popupRoot(id) != l { | |||
| 90 | + | // A click anywhere else takes the keyboard back. | |||
| 91 | + | c.keyboard.takeBack() | |||
| 92 | + | if err := c.publish(ctx); err != nil { | |||
| 93 | + | return err | |||
| 94 | + | } | |||
| 95 | + | } | |||
| 96 | + | // A click focuses the window and its output. | |||
| 97 | + | s, w := c.screenOf(id) | |||
| 98 | + | if v.Pressed && s != nil && w == s.mon.Current() && (c.keyboard.sent != id || s != c.cur()) { | |||
| 99 | + | w.Click(id) | |||
| 100 | + | c.focusScreen = c.screenIndex(s.name()) | |||
| 101 | + | if err := c.publish(ctx); err != nil { | |||
| 102 | + | return err | |||
| 103 | + | } | |||
| 104 | + | } | |||
| 105 | + | } | |||
| 106 | + | if len(c.buttons) == 0 { | |||
| 107 | + | c.grab = 0 | |||
| 108 | + | } | |||
| 109 | + | return nil | |||
| 110 | + | case ports.PointerAxis: | |||
| 111 | + | // In the overview, scrolling moves the selection. | |||
| 112 | + | if c.cur().mon.ov.open && !c.overviewKeyboardTaken() { | |||
| 113 | + | if c.cur().mon.overviewScroll(v) { | |||
| 114 | + | if err := c.workspaceVisible(ctx, true); err != nil { | |||
| 115 | + | return err | |||
| 116 | + | } | |||
| 117 | + | if err := c.publish(ctx); err != nil { | |||
| 118 | + | return err | |||
| 119 | + | } | |||
| 120 | + | } | |||
| 121 | + | return nil | |||
| 122 | + | } | |||
| 123 | + | // Scroll goes to the window under the pointer, which has the | |||
| 124 | + | // pointer focus even mid-drag. | |||
| 125 | + | if c.pointer != 0 { | |||
| 126 | + | if err := c.command(ctx, ports.PointerAxisTo{ID: c.pointer, Axis: v}); err != nil { | |||
| 127 | + | return err | |||
| 128 | + | } | |||
| 129 | + | } | |||
| 130 | + | return nil | |||
| 131 | + | case ports.SwipeBegin: | |||
| 132 | + | if c.swipeBegin(v) { | |||
| 133 | + | return c.slid(ctx, false) | |||
| 134 | + | } | |||
| 135 | + | return nil | |||
| 136 | + | case ports.SwipeUpdate: | |||
| 137 | + | if c.swipeUpdate(v) { | |||
| 138 | + | return c.slid(ctx, false) | |||
| 139 | + | } | |||
| 140 | + | return nil | |||
| 141 | + | case ports.SwipeEnd: | |||
| 142 | + | return c.slid(ctx, c.swipeEnd(v)) | |||
| 143 | + | } | |||
| 144 | + | key, ok := ev.(ports.KeyEvent) | |||
| 145 | + | if !ok { | |||
| 146 | + | return nil | |||
| 147 | + | } | |||
| 148 | + | // The overview takes its keys before any window; others still | |||
| 149 | + | // run binds, and are not forwarded. | |||
| 150 | + | // A launcher or a menu holding the keyboard gets them first. | |||
| 151 | + | if mon := c.cur().mon; mon.ov.open && !c.overviewKeyboardTaken() { | |||
| 152 | + | if key.Pressed && mon.overviewKey(key) { | |||
| 153 | + | c.pressed[heldKey(key)] = true | |||
| 154 | + | if err := c.workspaceVisible(ctx, true); err != nil { | |||
| 155 | + | return err | |||
| 156 | + | } | |||
| 157 | + | if err := c.publish(ctx); err != nil { | |||
| 158 | + | return err | |||
| 159 | + | } | |||
| 160 | + | return nil | |||
| 161 | + | } | |||
| 162 | + | } | |||
| 163 | + | // A focused window inhibiting shortcuts gets every key: | |||
| 164 | + | // no bind runs (emergency quit and VT switch are handled by | |||
| 165 | + | // input before core). | |||
| 166 | + | if c.keyboard.inhibited(c.keyboardFocus()) { | |||
| 167 | + | if held := heldKey(key); !key.Pressed && c.pressed[held] { | |||
| 168 | + | // Its press ran a bind before inhibiting began: the | |||
| 169 | + | // window never saw it. | |||
| 170 | + | delete(c.pressed, held) | |||
| 171 | + | return nil | |||
| 172 | + | } | |||
| 173 | + | if err := c.command(ctx, ports.ForwardKey{ID: c.keyboard.inhibiting, Key: key}); err != nil { | |||
| 174 | + | return err | |||
| 175 | + | } | |||
| 176 | + | return nil | |||
| 177 | + | } | |||
| 178 | + | name := keyName(key.Keysym) | |||
| 179 | + | // A bind on the keysym wins; then the unshifted keysym (Cmd+Shift+1 | |||
| 180 | + | // prints exclam on US); then the physical key (code:N). | |||
| 181 | + | action, bound := c.binds[binding{key: name, mods: key.Mods}] | |||
| 182 | + | if !bound && key.Base != "" { | |||
| 183 | + | action, bound = c.binds[binding{key: keyName(key.Base), mods: key.Mods}] | |||
| 184 | + | } | |||
| 185 | + | if !bound && key.Keycode != 0 { | |||
| 186 | + | action, bound = c.binds[binding{key: "code:" + strconv.FormatUint(uint64(key.Keycode), 10), mods: key.Mods}] | |||
| 187 | + | } | |||
| 188 | + | // Track presses by physical key: Shift may be released before the key. | |||
| 189 | + | held := heldKey(key) | |||
| 190 | + | if !key.Pressed { | |||
| 191 | + | consumed := c.pressed[held] | |||
| 192 | + | delete(c.pressed, held) | |||
| 193 | + | if consumed { | |||
| 194 | + | return nil | |||
| 195 | + | } | |||
| 196 | + | } | |||
| 197 | + | if key.Pressed { | |||
| 198 | + | if bound { | |||
| 199 | + | c.pressed[held] = true | |||
| 200 | + | if action == ActionScaleUp || action == ActionScaleDown { | |||
| 201 | + | dir := 1 | |||
| 202 | + | if action == ActionScaleDown { | |||
| 203 | + | dir = -1 | |||
| 204 | + | } | |||
| 205 | + | sc := c.cur() | |||
| 206 | + | prev := sc.scale | |||
| 207 | + | sc.setScale(StepScale(sc.info.Width, sc.info.Height, sc.scale, dir)) | |||
| 208 | + | c.order() | |||
| 209 | + | if c.ch.Scales != nil && sc.scale != prev && sc.name() != "" { | |||
| 210 | + | // Never wait on persistence: a stalled save drops the change. | |||
| 211 | + | select { | |||
| 212 | + | case c.ch.Scales <- ports.ScaleChanged{Output: sc.name(), Scale: sc.scale}: | |||
| 213 | + | default: | |||
| 214 | + | } | |||
| 215 | + | } | |||
| 216 | + | if err := c.publish(ctx); err != nil { | |||
| 217 | + | return err | |||
| 218 | + | } | |||
| 219 | + | return nil | |||
| 220 | + | } | |||
| 221 | + | before := c.cur().mon.Current() | |||
| 222 | + | swiped := c.swipedWorkspace() | |||
| 223 | + | c.keyboard.takeBack() // a bind acts on the windows | |||
| 224 | + | effect := c.applyAction(action) | |||
| 225 | + | if effect.Quit { | |||
| 226 | + | return ErrQuit | |||
| 227 | + | } | |||
| 228 | + | if effect.Spawn { | |||
| 229 | + | argv := effect.Argv | |||
| 230 | + | if argv == nil { | |||
| 231 | + | argv = append([]string(nil), c.cfg.Terminal.Command...) | |||
| 232 | + | } | |||
| 233 | + | // Explicit actions are suppressed on a full launcher queue; | |||
| 234 | + | // never keep the input owner waiting across an epoch change. | |||
| 235 | + | c.trySpawn(ctx, ports.SpawnRequest{Argv: argv}) | |||
| 236 | + | if c.inputEpochChanged() { | |||
| 237 | + | return errSecurityChanged | |||
| 238 | + | } | |||
| 239 | + | } | |||
| 240 | + | if c.swipedWorkspace() != swiped { | |||
| 241 | + | c.dropSwipe() | |||
| 242 | + | } | |||
| 243 | + | if err := c.workspaceVisible(ctx, c.cur().mon.Current() != before); err != nil { | |||
| 244 | + | return err | |||
| 245 | + | } | |||
| 246 | + | if effect.Close != 0 { | |||
| 247 | + | if err := c.command(ctx, ports.CloseWindow{ID: effect.Close}); err != nil { | |||
| 248 | + | return err | |||
| 249 | + | } | |||
| 250 | + | } | |||
| 251 | + | if err := c.publish(ctx); err != nil { | |||
| 252 | + | return err | |||
| 253 | + | } | |||
| 254 | + | return nil | |||
| 255 | + | } | |||
| 256 | + | c.pressed[held] = false | |||
| 257 | + | } | |||
| 258 | + | if id := c.keyboardFocus(); id != 0 && (!c.cur().mon.ov.open || c.overviewKeyboardTaken()) { | |||
| 259 | + | if err := c.command(ctx, ports.ForwardKey{ID: id, Key: key}); err != nil { | |||
| 260 | + | return err | |||
| 261 | + | } | |||
| 262 | + | } | |||
| 263 | + | return nil | |||
| 264 | + | } | |||
M internal/core/screens_test.go +3 -1
| @@ -495,7 +495,9 @@ func TestPointerFocusesOutput(t *testing.T) { | |||||
| 495 | func TestFirstTerminalFollowsSetOutputs(t *testing.T) { | 495 | func TestFirstTerminalFollowsSetOutputs(t *testing.T) { | ||
| 496 | cfg := config.Defaults() | 496 | cfg := config.Defaults() | ||
| 497 | commands := make(chan ports.ClientCommand) | 497 | commands := make(chan ports.ClientCommand) | ||
| 498 | - | spawn := make(chan ports.SpawnRequest) | 498 | + | // The launcher handoff is deliberately nonblocking. A ready buffered |
| 499 | + | // queue tests ordering without requiring core to wait for this reader. | |||
| 500 | + | spawn := make(chan ports.SpawnRequest, 1) | |||
| 499 | output := make(chan ports.OutputEvent, 1) | 501 | output := make(chan ports.OutputEvent, 1) | ||
| 500 | scenes := make(chan []ports.Scene, 1) | 502 | scenes := make(chan []ports.Scene, 1) | ||
| 501 | state := make(chan ports.State, 1) | 503 | state := make(chan ports.State, 1) | ||
A internal/core/security.go +290 -0
| @@ -0,0 +1,290 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "slices" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // syncSecurity runs only on the owner. The gate, not event selection order, | |||
| 11 | + | // establishes the epoch; surface events can only fill in that exact epoch. | |||
| 12 | + | func (c *Core) syncSecurity() bool { | |||
| 13 | + | if c.inputActive { | |||
| 14 | + | return false | |||
| 15 | + | } | |||
| 16 | + | if c.ch.Security == nil { | |||
| 17 | + | return false | |||
| 18 | + | } | |||
| 19 | + | state := c.ch.Security.Snapshot() | |||
| 20 | + | if state == c.security { | |||
| 21 | + | return false | |||
| 22 | + | } | |||
| 23 | + | c.security = state | |||
| 24 | + | c.lockSurfaces, c.lockFocus = nil, 0 | |||
| 25 | + | c.pressed, c.buttons, c.inputKeys = map[string]bool{}, map[uint32]bool{}, map[string]bool{} | |||
| 26 | + | c.pointer, c.grab, c.pointerOutput = 0, 0, "" | |||
| 27 | + | c.pointerAt, c.motionMsec = [2]float64{}, 0 | |||
| 28 | + | c.keyboard = keyboard{} | |||
| 29 | + | c.constrained, c.constraint = ports.PointerConstrained{}, ports.PointerConstraint{} | |||
| 30 | + | if c.ch.Constraints != nil { | |||
| 31 | + | latest(c.ch.Constraints, c.constraint) | |||
| 32 | + | } | |||
| 33 | + | c.dropSwipe() | |||
| 34 | + | c.swipe = nil | |||
| 35 | + | for _, sc := range c.screens { | |||
| 36 | + | sc.mon.stopSwitch() | |||
| 37 | + | for _, w := range sc.mon.all() { | |||
| 38 | + | w.stopSlide() | |||
| 39 | + | } | |||
| 40 | + | } | |||
| 41 | + | c.stopFrame() | |||
| 42 | + | if state.Protected { | |||
| 43 | + | c.dropCapture() | |||
| 44 | + | } | |||
| 45 | + | return true | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | // Check spawns without adopting a new epoch midway through processing an | |||
| 49 | + | // event: commands emitted by that event must retain its original owner epoch. | |||
| 50 | + | func (c *Core) inputEpochChanged() bool { | |||
| 51 | + | return c.inputActive && c.ch.Security != nil && c.ch.Security.Snapshot() != c.security | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func (c *Core) protectionRequested() bool { | |||
| 55 | + | return c.security.Protected || c.ch.Security != nil && c.ch.Security.Snapshot() != c.security | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | func (c *Core) securityCheckpoint(ctx context.Context) error { | |||
| 59 | + | if !c.syncSecurity() { | |||
| 60 | + | return nil | |||
| 61 | + | } | |||
| 62 | + | // Clear display focus even if the new epoch has no mapped lock surfaces. | |||
| 63 | + | if err := c.command(ctx, ports.PointerFocus{}); err != nil { | |||
| 64 | + | return err | |||
| 65 | + | } | |||
| 66 | + | if err := c.command(ctx, ports.FocusWindow{}); err != nil { | |||
| 67 | + | return err | |||
| 68 | + | } | |||
| 69 | + | return c.publish(ctx) | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | func blockedProtectedEvent(ev ports.ClientEvent) bool { | |||
| 73 | + | switch ev.(type) { | |||
| 74 | + | case ports.WindowActivate, ports.WorkspaceActivate, ports.PointerWarp, ports.PointerConstrained, | |||
| 75 | + | ports.PopupRequest, ports.ShortcutsInhibit, ports.WindowFullscreenRequest, | |||
| 76 | + | ports.CaptureSessionBegin, ports.CaptureSessionLayer, ports.CaptureSessionPing: | |||
| 77 | + | return true | |||
| 78 | + | } | |||
| 79 | + | return false | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | func (c *Core) applyLockChanged(v ports.SessionLockChanged) bool { | |||
| 83 | + | if c.ch.Security == nil || v.State != c.security || v.State != c.ch.Security.Snapshot() { | |||
| 84 | + | return false | |||
| 85 | + | } | |||
| 86 | + | if !c.security.Protected { | |||
| 87 | + | return true | |||
| 88 | + | } | |||
| 89 | + | c.lockSurfaces = slices.Clone(v.Surfaces) | |||
| 90 | + | c.lockKeyboardFocus() | |||
| 91 | + | // A destroyed or resized role must not retain a pointer grab. | |||
| 92 | + | if !c.validLockID(c.pointer) { | |||
| 93 | + | c.pointer = 0 | |||
| 94 | + | } | |||
| 95 | + | if !c.validLockID(c.grab) { | |||
| 96 | + | c.grab = 0 | |||
| 97 | + | clear(c.buttons) | |||
| 98 | + | } | |||
| 99 | + | return true | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | func (c *Core) lockSurface(sc *screen) (ports.LockSurfacePlacement, bool) { | |||
| 103 | + | o := sc.mon.Output() | |||
| 104 | + | for _, s := range c.lockSurfaces { | |||
| 105 | + | if s.ID != 0 && sc.name() != "" && s.Output == sc.name() && s.Width == o.W && s.Height == o.H && o.W > 0 && o.H > 0 { | |||
| 106 | + | return s, true | |||
| 107 | + | } | |||
| 108 | + | } | |||
| 109 | + | return ports.LockSurfacePlacement{}, false | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | func (c *Core) validLockID(id WindowID) bool { | |||
| 113 | + | if id == 0 { | |||
| 114 | + | return false | |||
| 115 | + | } | |||
| 116 | + | for _, sc := range c.screens { | |||
| 117 | + | if s, ok := c.lockSurface(sc); ok && s.ID == id { | |||
| 118 | + | return true | |||
| 119 | + | } | |||
| 120 | + | } | |||
| 121 | + | return false | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | func (c *Core) lockKeyboardFocus() WindowID { | |||
| 125 | + | if c.validLockID(c.lockFocus) { | |||
| 126 | + | return c.lockFocus | |||
| 127 | + | } | |||
| 128 | + | c.lockFocus = 0 | |||
| 129 | + | // Prefer the focused output, where the user is looking, then any other. | |||
| 130 | + | if c.focusScreen >= 0 && c.focusScreen < len(c.screens) { | |||
| 131 | + | if s, ok := c.lockSurface(c.screens[c.focusScreen]); ok { | |||
| 132 | + | c.lockFocus = s.ID | |||
| 133 | + | return c.lockFocus | |||
| 134 | + | } | |||
| 135 | + | } | |||
| 136 | + | for _, sc := range c.screens { | |||
| 137 | + | if s, ok := c.lockSurface(sc); ok { | |||
| 138 | + | c.lockFocus = s.ID | |||
| 139 | + | break | |||
| 140 | + | } | |||
| 141 | + | } | |||
| 142 | + | return c.lockFocus | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | func (c *Core) lockHit(x, y float64) (WindowID, float64, float64) { | |||
| 146 | + | o, ok := c.layout().At(x, y) | |||
| 147 | + | if !ok { | |||
| 148 | + | return 0, 0, 0 | |||
| 149 | + | } | |||
| 150 | + | i := c.screenIndex(o.Info.Name) | |||
| 151 | + | if i < 0 { | |||
| 152 | + | return 0, 0, 0 | |||
| 153 | + | } | |||
| 154 | + | if s, ok := c.lockSurface(c.screens[i]); ok { | |||
| 155 | + | return s.ID, x - float64(o.X), y - float64(o.Y) | |||
| 156 | + | } | |||
| 157 | + | return 0, 0, 0 | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | func (c *Core) publishProtected(ctx context.Context) error { | |||
| 161 | + | v := ports.SetOutputs{Outputs: c.layout(), Focused: c.cur().name(), Off: c.offOutputs()} | |||
| 162 | + | if !sameOutputs(v, c.sentOutputs) { | |||
| 163 | + | if err := c.command(ctx, v); err != nil { | |||
| 164 | + | return err | |||
| 165 | + | } | |||
| 166 | + | if c.ch.Layouts != nil { | |||
| 167 | + | latest(c.ch.Layouts, v.Outputs) | |||
| 168 | + | } | |||
| 169 | + | c.sentOutputs = v | |||
| 170 | + | } | |||
| 171 | + | focus := c.lockKeyboardFocus() | |||
| 172 | + | scenes := make([]ports.Scene, 0, len(c.screens)) | |||
| 173 | + | for _, sc := range c.screens { | |||
| 174 | + | c.seq++ | |||
| 175 | + | o := sc.mon.Output() | |||
| 176 | + | scene := ports.Scene{Security: c.security, Output: sc.name(), Seq: c.seq, OutputWidth: o.W, OutputHeight: o.H, Scale: sc.scale, Off: sc.off, Background: "#000000"} | |||
| 177 | + | if s, ok := c.lockSurface(sc); ok { | |||
| 178 | + | scene.Windows = []ports.SceneWindow{{ID: s.ID, Rect: Rect{W: o.W, H: o.H}, Fullscreen: true, Focused: s.ID == focus}} | |||
| 179 | + | } | |||
| 180 | + | scenes = append(scenes, scene) | |||
| 181 | + | } | |||
| 182 | + | if c.keyboard.sent != focus { | |||
| 183 | + | if err := c.command(ctx, ports.FocusWindow{ID: focus}); err != nil { | |||
| 184 | + | return err | |||
| 185 | + | } | |||
| 186 | + | c.keyboard.sent = focus | |||
| 187 | + | } | |||
| 188 | + | // Surface updates may invalidate the role under a stationary pointer. | |||
| 189 | + | if !c.validLockID(c.pointer) { | |||
| 190 | + | c.pointer = 0 | |||
| 191 | + | } | |||
| 192 | + | if err := c.command(ctx, ports.PointerFocus{ID: c.pointer, X: c.pointerAt[0], Y: c.pointerAt[1]}); err != nil { | |||
| 193 | + | return err | |||
| 194 | + | } | |||
| 195 | + | latest(c.ch.Scenes, scenes) | |||
| 196 | + | c.publishState() | |||
| 197 | + | c.publishWorkspaces() | |||
| 198 | + | return nil | |||
| 199 | + | } | |||
| 200 | + | ||||
| 201 | + | func (c *Core) admitInput(ev ports.InputEvent) (ports.InputEvent, bool) { | |||
| 202 | + | if v, ok := ev.(ports.SecurityInput); ok { | |||
| 203 | + | if v.State != c.security || v.Event == nil { | |||
| 204 | + | return nil, false | |||
| 205 | + | } | |||
| 206 | + | ev = v.Event | |||
| 207 | + | } else if c.ch.Security != nil || c.security != (ports.SecurityState{}) { | |||
| 208 | + | return nil, false | |||
| 209 | + | } | |||
| 210 | + | // The producer resets native keymap state and quarantines physically held | |||
| 211 | + | // keys on transitions. Core preserves that keymap-aware modifier state; | |||
| 212 | + | // it must not infer modifier meaning from evdev codes or XKB bit positions. | |||
| 213 | + | // No release without a press in this epoch (password keys must not reach | |||
| 214 | + | // the desktop after unlock). Standalone legacy channel tests remain raw. | |||
| 215 | + | if c.ch.Security != nil { | |||
| 216 | + | if b, ok := ev.(ports.PointerButton); ok && !b.Pressed && !c.buttons[b.Button] { | |||
| 217 | + | return nil, false | |||
| 218 | + | } | |||
| 219 | + | if k, ok := ev.(ports.KeyEvent); ok { | |||
| 220 | + | held := heldKey(k) | |||
| 221 | + | if k.Pressed { | |||
| 222 | + | c.inputKeys[held] = true | |||
| 223 | + | } else { | |||
| 224 | + | if !c.inputKeys[held] { | |||
| 225 | + | return nil, false | |||
| 226 | + | } | |||
| 227 | + | delete(c.inputKeys, held) | |||
| 228 | + | } | |||
| 229 | + | } | |||
| 230 | + | } | |||
| 231 | + | return ev, true | |||
| 232 | + | } | |||
| 233 | + | ||||
| 234 | + | func (c *Core) protectedInput(ctx context.Context, ev ports.InputEvent) error { | |||
| 235 | + | switch v := ev.(type) { | |||
| 236 | + | case ports.KeyEvent: | |||
| 237 | + | if id := c.lockKeyboardFocus(); id != 0 { | |||
| 238 | + | return c.command(ctx, ports.ForwardKey{ID: id, Key: v}) | |||
| 239 | + | } | |||
| 240 | + | case ports.PointerMotion: | |||
| 241 | + | c.motionMsec = v.TimeMsec | |||
| 242 | + | c.cursorX, c.cursorY = c.layout().Clamp(c.cursorX, c.cursorY, v.X, v.Y) | |||
| 243 | + | id, x, y := c.lockHit(c.cursorX, c.cursorY) | |||
| 244 | + | if id != c.pointer { | |||
| 245 | + | c.pointer = id | |||
| 246 | + | if err := c.command(ctx, ports.PointerFocus{ID: id, X: x, Y: y}); err != nil { | |||
| 247 | + | return err | |||
| 248 | + | } | |||
| 249 | + | } | |||
| 250 | + | c.pointerAt = [2]float64{x, y} | |||
| 251 | + | if id != 0 { | |||
| 252 | + | return c.command(ctx, ports.PointerMotionTo{ID: id, X: x, Y: y, DX: v.DX, DY: v.DY, UnaccelDX: v.UnaccelDX, UnaccelDY: v.UnaccelDY, TimeMsec: v.TimeMsec, TimeUsec: v.TimeUsec}) | |||
| 253 | + | } | |||
| 254 | + | case ports.PointerButton: | |||
| 255 | + | id := c.pointer | |||
| 256 | + | if c.grab != 0 { | |||
| 257 | + | id = c.grab | |||
| 258 | + | } | |||
| 259 | + | if !v.Pressed && !c.buttons[v.Button] { | |||
| 260 | + | return nil | |||
| 261 | + | } | |||
| 262 | + | if v.Pressed { | |||
| 263 | + | if len(c.buttons) == 0 { | |||
| 264 | + | c.grab = id | |||
| 265 | + | } | |||
| 266 | + | c.buttons[v.Button] = true | |||
| 267 | + | } else { | |||
| 268 | + | delete(c.buttons, v.Button) | |||
| 269 | + | } | |||
| 270 | + | if c.validLockID(id) { | |||
| 271 | + | if v.Pressed && c.lockFocus != id { | |||
| 272 | + | c.lockFocus = id | |||
| 273 | + | if err := c.publishProtected(ctx); err != nil { | |||
| 274 | + | return err | |||
| 275 | + | } | |||
| 276 | + | } | |||
| 277 | + | if err := c.command(ctx, ports.PointerButtonTo{ID: id, Button: v.Button, Pressed: v.Pressed, TimeMsec: v.TimeMsec}); err != nil { | |||
| 278 | + | return err | |||
| 279 | + | } | |||
| 280 | + | } | |||
| 281 | + | if len(c.buttons) == 0 { | |||
| 282 | + | c.grab = 0 | |||
| 283 | + | } | |||
| 284 | + | case ports.PointerAxis: | |||
| 285 | + | if c.validLockID(c.pointer) { | |||
| 286 | + | return c.command(ctx, ports.PointerAxisTo{ID: c.pointer, Axis: v}) | |||
| 287 | + | } | |||
| 288 | + | } | |||
| 289 | + | return nil | |||
| 290 | + | } | |||
A internal/core/security_internal_test.go +96 -0
| @@ -0,0 +1,96 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |||
| 8 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestProtectedDropsPrivateOffscreenCaptureAndDesktopEffects(t *testing.T) { | |||
| 12 | + | c, ws, commands := hiddenCaptureCommands(t) | |||
| 13 | + | state := ports.SecurityState{Generation: 1, Protected: true} | |||
| 14 | + | gate := portsmocks.NewMockSessionSecurity(t) | |||
| 15 | + | gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return state }) | |||
| 16 | + | c.ch.Security = gate | |||
| 17 | + | c.capture = &captureSession{begin: ports.CaptureSessionBegin{ID: 7, Output: "A", Workspace: ws.ID}} | |||
| 18 | + | c.screens[0].layers = []ports.LayerSurface{{ID: 50, Layer: ports.LayerOverlay, Keyboard: 1}} | |||
| 19 | + | c.pressed["password"] = true | |||
| 20 | + | c.buttons[0x110] = true | |||
| 21 | + | c.pointer, c.grab = 1, 1 | |||
| 22 | + | c.keyboard = keyboard{layer: 50, sent: 1, inhibiting: 1} | |||
| 23 | + | c.constrained = ports.PointerConstrained{ID: 1, Mode: ports.ConstraintLock} | |||
| 24 | + | c.swipe = &swipeGesture{} | |||
| 25 | + | if err := c.publish(context.Background()); err != nil { | |||
| 26 | + | t.Fatal(err) | |||
| 27 | + | } | |||
| 28 | + | scenes := <-c.ch.Scenes | |||
| 29 | + | s := scenes[0] | |||
| 30 | + | if s.Security != state || s.Capture != nil || s.CaptureScene != nil || len(s.Windows) != 0 || len(s.Layers) != 0 || len(s.Separators) != 0 || s.Background != "#000000" { | |||
| 31 | + | t.Fatalf("unsafe protected scene %+v", s) | |||
| 32 | + | } | |||
| 33 | + | if c.capture != nil || c.configures.cw.active() || len(c.pressed) != 0 || len(c.buttons) != 0 || c.pointer != 0 || c.grab != 0 || c.keyboard.layer != 0 || c.constrained.ID != 0 || c.swipe != nil { | |||
| 34 | + | t.Fatal("transition kept desktop input/capture state") | |||
| 35 | + | } | |||
| 36 | + | // Logical dimensions must match exactly, including after output changes. | |||
| 37 | + | c.applyLockChanged(ports.SessionLockChanged{State: state, Surfaces: []ports.LockSurfacePlacement{{ID: 10, Output: "A", Width: 299, Height: 200}}}) | |||
| 38 | + | if c.lockKeyboardFocus() != 0 { | |||
| 39 | + | t.Fatal("wrong dimensions accepted") | |||
| 40 | + | } | |||
| 41 | + | c.applyLockChanged(ports.SessionLockChanged{State: state, Surfaces: []ports.LockSurfacePlacement{{ID: 10, Output: "A", Width: 300, Height: 200}}}) | |||
| 42 | + | if c.lockKeyboardFocus() != 10 { | |||
| 43 | + | t.Fatal("mapped role not focused") | |||
| 44 | + | } | |||
| 45 | + | for _, ev := range []ports.ClientEvent{ports.WindowActivate{ID: 1}, ports.WorkspaceActivate{}, ports.PointerWarp{ID: 1}, ports.PointerConstrained{ID: 1}, ports.PopupRequest{}, ports.CaptureSessionBegin{}} { | |||
| 46 | + | if !blockedProtectedEvent(ev) { | |||
| 47 | + | t.Fatalf("allowed desktop effect %T", ev) | |||
| 48 | + | } | |||
| 49 | + | } | |||
| 50 | + | for len(commands) > 0 { | |||
| 51 | + | if _, ok := (<-commands).(ports.SecurityCommand); !ok { | |||
| 52 | + | t.Fatal("unwrapped protected command") | |||
| 53 | + | } | |||
| 54 | + | } | |||
| 55 | + | // Release is a transition too: password presses, button grabs and | |||
| 56 | + | // keyboard ownership cannot survive it, even with freshly stamped releases. | |||
| 57 | + | c.inputKeys["#30"] = true | |||
| 58 | + | c.buttons[0x110] = true | |||
| 59 | + | c.pointer, c.grab = 10, 10 | |||
| 60 | + | c.keyboard = keyboard{sent: 10} | |||
| 61 | + | state = ports.SecurityState{Generation: 2} | |||
| 62 | + | if !c.syncSecurity() || len(c.inputKeys) != 0 || c.grab != 0 || c.keyboard.sent != 0 || len(c.lockSurfaces) != 0 { | |||
| 63 | + | t.Fatal("release did not reset input ownership") | |||
| 64 | + | } | |||
| 65 | + | for _, ev := range []ports.InputEvent{ports.KeyEvent{Keycode: 30}, ports.PointerButton{Button: 0x110}} { | |||
| 66 | + | if _, ok := c.admitInput(ports.SecurityInput{State: state, Event: ev}); ok { | |||
| 67 | + | t.Fatalf("release crossed epoch: %T", ev) | |||
| 68 | + | } | |||
| 69 | + | } | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | func TestWiredZeroEpochRejectsRawInputAndCommandKeepsOwnerEpoch(t *testing.T) { | |||
| 73 | + | c, _, commands := hiddenCaptureCommands(t) | |||
| 74 | + | gate := portsmocks.NewMockSessionSecurity(t) | |||
| 75 | + | next := ports.SecurityState{Generation: 1, Protected: true} | |||
| 76 | + | gate.EXPECT().Snapshot().Return(next) | |||
| 77 | + | c.ch.Security = gate | |||
| 78 | + | if _, ok := c.admitInput(ports.KeyEvent{Pressed: true}); ok { | |||
| 79 | + | t.Fatal("raw wired input admitted") | |||
| 80 | + | } | |||
| 81 | + | if _, ok := c.admitInput(ports.SecurityInput{Event: ports.KeyEvent{Pressed: true}}); !ok { | |||
| 82 | + | t.Fatal("zero epoch wrapped input rejected") | |||
| 83 | + | } | |||
| 84 | + | // Gate changes after selection must not relabel an old command as new. | |||
| 85 | + | if err := c.command(context.Background(), ports.ForwardKey{ID: 1}); err != nil { | |||
| 86 | + | t.Fatal(err) | |||
| 87 | + | } | |||
| 88 | + | v := (<-commands).(ports.SecurityCommand) | |||
| 89 | + | if v.State != (ports.SecurityState{}) { | |||
| 90 | + | t.Fatalf("relabeled stale command: %+v", v) | |||
| 91 | + | } | |||
| 92 | + | c.syncSecurity() | |||
| 93 | + | if _, ok := c.admitInput(ports.SecurityInput{Event: ports.KeyEvent{Pressed: true}}); ok { | |||
| 94 | + | t.Fatal("stale input admitted") | |||
| 95 | + | } | |||
| 96 | + | } | |||
A internal/core/security_review_test.go +190 -0
| @@ -0,0 +1,190 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "sync/atomic" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |||
| 11 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | func reviewReceive[T any](t *testing.T, ch <-chan T) T { | |||
| 15 | + | t.Helper() | |||
| 16 | + | select { | |||
| 17 | + | case v := <-ch: | |||
| 18 | + | return v | |||
| 19 | + | case <-time.After(time.Second): | |||
| 20 | + | t.Fatal("timed out") | |||
| 21 | + | var z T | |||
| 22 | + | return z | |||
| 23 | + | } | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | func TestInputOutputSwitchBackpressureKeepsAdmittedEpoch(t *testing.T) { | |||
| 27 | + | c, _, _ := hiddenCaptureCommands(t) | |||
| 28 | + | c.addScreen(ports.OutputInfo{Name: "B", Width: 200, Height: 100}) | |||
| 29 | + | var state atomic.Value | |||
| 30 | + | state.Store(ports.SecurityState{}) | |||
| 31 | + | gate := portsmocks.NewMockSessionSecurity(t) | |||
| 32 | + | entered := make(chan struct{}, 1) | |||
| 33 | + | var calls atomic.Int32 | |||
| 34 | + | gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { | |||
| 35 | + | v := state.Load().(ports.SecurityState) | |||
| 36 | + | if calls.Add(1) == 2 { | |||
| 37 | + | entered <- struct{}{} | |||
| 38 | + | } | |||
| 39 | + | return v | |||
| 40 | + | }) | |||
| 41 | + | c.ch.Security = gate | |||
| 42 | + | c.sentOutputs = ports.SetOutputs{Outputs: c.layout(), Focused: c.cur().name(), Off: c.offOutputs()} | |||
| 43 | + | commands := make(chan ports.ClientCommand) | |||
| 44 | + | c.ch.Commands = commands | |||
| 45 | + | done := make(chan error, 1) | |||
| 46 | + | go func() { done <- c.handleInput(context.Background(), ports.PointerMotion{X: 350, Y: 30}) }() | |||
| 47 | + | reviewReceive(t, entered) | |||
| 48 | + | state.Store(ports.SecurityState{Generation: 2}) | |||
| 49 | + | cmd := reviewReceive(t, commands).(ports.SecurityCommand) | |||
| 50 | + | if cmd.State != (ports.SecurityState{}) { | |||
| 51 | + | t.Fatalf("old handler adopted new epoch: %+v", cmd) | |||
| 52 | + | } | |||
| 53 | + | if err := reviewReceive(t, done); !errors.Is(err, errSecurityChanged) { | |||
| 54 | + | t.Fatalf("handler not aborted: %v", err) | |||
| 55 | + | } | |||
| 56 | + | if c.security != (ports.SecurityState{}) { | |||
| 57 | + | t.Fatal("publish adopted generation mid-input") | |||
| 58 | + | } | |||
| 59 | + | select { | |||
| 60 | + | case cmd := <-commands: | |||
| 61 | + | t.Fatalf("remaining old input command: %+v", cmd) | |||
| 62 | + | default: | |||
| 63 | + | } | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | func TestExactEpochAdmissionPreservesProducerModifierState(t *testing.T) { | |||
| 67 | + | c, _, _ := hiddenCaptureCommands(t) | |||
| 68 | + | state := ports.SecurityState{Generation: 2} | |||
| 69 | + | gate := portsmocks.NewMockSessionSecurity(t) | |||
| 70 | + | gate.EXPECT().Snapshot().Return(state) | |||
| 71 | + | c.ch.Security = gate | |||
| 72 | + | c.syncSecurity() | |||
| 73 | + | key := ports.KeyEvent{Keycode: 58, Keysym: "Control_L", Pressed: true, Mods: ports.ModCtrl, State: ports.ModState{Depressed: 1 << 19, Latched: 1 << 21, Locked: 1 << 23, Group: 2}} | |||
| 74 | + | ev, ok := c.admitInput(ports.SecurityInput{State: state, Event: key}) | |||
| 75 | + | if !ok || ev.(ports.KeyEvent) != key { | |||
| 76 | + | t.Fatalf("producer state reinterpreted: %+v", ev) | |||
| 77 | + | } | |||
| 78 | + | if _, ok := c.admitInput(ports.SecurityInput{State: ports.SecurityState{Generation: 1}, Event: key}); ok { | |||
| 79 | + | t.Fatal("stale producer epoch admitted") | |||
| 80 | + | } | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | func TestSlotsPendingBackpressureSuppressesSpawnAndDefersSlots(t *testing.T) { | |||
| 84 | + | c, _, _ := hiddenCaptureCommands(t) | |||
| 85 | + | key := slotKey{workspace: "dev", index: 1} | |||
| 86 | + | c.slots[key] = &slotState{argv: []string{"terminal"}} | |||
| 87 | + | c.toSpawn = []slotKey{key} | |||
| 88 | + | var state atomic.Value | |||
| 89 | + | state.Store(ports.SecurityState{}) | |||
| 90 | + | gate := portsmocks.NewMockSessionSecurity(t) | |||
| 91 | + | checked := make(chan struct{}, 1) | |||
| 92 | + | var calls atomic.Int32 | |||
| 93 | + | gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { | |||
| 94 | + | v := state.Load().(ports.SecurityState) | |||
| 95 | + | if calls.Add(1) == 2 { | |||
| 96 | + | checked <- struct{}{} | |||
| 97 | + | } | |||
| 98 | + | return v | |||
| 99 | + | }) | |||
| 100 | + | c.ch.Security = gate | |||
| 101 | + | commands := make(chan ports.ClientCommand) | |||
| 102 | + | c.ch.Commands = commands | |||
| 103 | + | spawn := make(chan ports.SpawnRequest, 2) | |||
| 104 | + | c.ch.Spawn = spawn | |||
| 105 | + | done := make(chan error, 1) | |||
| 106 | + | go func() { done <- c.spawnSlots(context.Background(), false) }() | |||
| 107 | + | reviewReceive(t, checked) | |||
| 108 | + | state.Store(ports.SecurityState{Generation: 1, Protected: true}) | |||
| 109 | + | cmd := reviewReceive(t, commands).(ports.SecurityCommand) | |||
| 110 | + | if _, ok := cmd.Command.(ports.SlotsPending); !ok { | |||
| 111 | + | t.Fatal(cmd) | |||
| 112 | + | } | |||
| 113 | + | if err := reviewReceive(t, done); err != nil { | |||
| 114 | + | t.Fatal(err) | |||
| 115 | + | } | |||
| 116 | + | if len(spawn) != 0 || len(c.toSpawn) != 1 || c.placement.slotPending(key) { | |||
| 117 | + | t.Fatal("spawn leaked or deferred slot lost") | |||
| 118 | + | } | |||
| 119 | + | // Retry after unlock, with a real buffered command sink. | |||
| 120 | + | c.ch.Commands = make(chan ports.ClientCommand, 8) | |||
| 121 | + | state.Store(ports.SecurityState{Generation: 2}) | |||
| 122 | + | c.syncSecurity() | |||
| 123 | + | if err := c.spawnSlots(context.Background(), false); err != nil { | |||
| 124 | + | t.Fatal(err) | |||
| 125 | + | } | |||
| 126 | + | if len(spawn) != 1 || len(c.toSpawn) != 0 { | |||
| 127 | + | t.Fatal("deferred slot did not retry") | |||
| 128 | + | } | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | func TestTerminalPendingBackpressureSuppressesSpawnAndRestoresRetry(t *testing.T) { | |||
| 132 | + | c, _, _ := hiddenCaptureCommands(t) | |||
| 133 | + | c.cur().mon.RemoveWindow(1) | |||
| 134 | + | c.cur().mon.RemoveWindow(2) | |||
| 135 | + | c.ch.Terminal = true | |||
| 136 | + | c.cfg.Terminal.AutoOpen = "always" | |||
| 137 | + | c.cfg.Terminal.Command = []string{"terminal"} | |||
| 138 | + | var state atomic.Value | |||
| 139 | + | state.Store(ports.SecurityState{}) | |||
| 140 | + | gate := portsmocks.NewMockSessionSecurity(t) | |||
| 141 | + | checked := make(chan struct{}, 1) | |||
| 142 | + | var calls atomic.Int32 | |||
| 143 | + | gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { | |||
| 144 | + | v := state.Load().(ports.SecurityState) | |||
| 145 | + | if calls.Add(1) == 1 { | |||
| 146 | + | checked <- struct{}{} | |||
| 147 | + | } | |||
| 148 | + | return v | |||
| 149 | + | }) | |||
| 150 | + | c.ch.Security = gate | |||
| 151 | + | commands := make(chan ports.ClientCommand) | |||
| 152 | + | c.ch.Commands = commands | |||
| 153 | + | spawn := make(chan ports.SpawnRequest, 1) | |||
| 154 | + | c.ch.Spawn = spawn | |||
| 155 | + | done := make(chan error, 1) | |||
| 156 | + | go func() { done <- c.spawnEmpty(context.Background()) }() | |||
| 157 | + | reviewReceive(t, checked) | |||
| 158 | + | state.Store(ports.SecurityState{Generation: 1, Protected: true}) | |||
| 159 | + | reviewReceive(t, commands) | |||
| 160 | + | if err := reviewReceive(t, done); err != nil { | |||
| 161 | + | t.Fatal(err) | |||
| 162 | + | } | |||
| 163 | + | if len(spawn) != 0 || c.placement.anyPending() { | |||
| 164 | + | t.Fatal("terminal spawn/claim crossed lock") | |||
| 165 | + | } | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | // The pre-existing slot test can observe a startup placeholder snapshot: a | |||
| 169 | + | // scene send happens before the real-output workspace publication. Reproduce | |||
| 170 | + | // the empty snapshot contract without intentionally panicking a test runner. | |||
| 171 | + | func TestStartupWorkspaceSnapshotHasNoOutputBeforeOutputAdded(t *testing.T) { | |||
| 172 | + | var cfg ports.Config | |||
| 173 | + | cfg.Keyboard.CmdKey = "super" | |||
| 174 | + | cfg.Layout.MaxColumns = 2 | |||
| 175 | + | workspaces := make(chan ports.Workspaces, 1) | |||
| 176 | + | c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1), Workspaces: workspaces}) | |||
| 177 | + | if err != nil { | |||
| 178 | + | t.Fatal(err) | |||
| 179 | + | } | |||
| 180 | + | c.publishWorkspaces() | |||
| 181 | + | initial := <-workspaces | |||
| 182 | + | if len(initial.Outputs) != 0 { | |||
| 183 | + | t.Fatal(initial) | |||
| 184 | + | } | |||
| 185 | + | c.addScreen(ports.OutputInfo{Name: "A", Width: 100, Height: 80}) | |||
| 186 | + | c.publishWorkspaces() | |||
| 187 | + | if next := <-workspaces; len(next.Outputs) != 1 { | |||
| 188 | + | t.Fatal(next) | |||
| 189 | + | } | |||
| 190 | + | } | |||
A internal/core/security_test.go +147 -0
| @@ -0,0 +1,147 @@ | |||||
| 1 | + | package core_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "sync/atomic" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/neferwl/internal/adapters/config" | |||
| 9 | + | "github.com/bnema/neferwl/internal/core" | |||
| 10 | + | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |||
| 11 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | func TestSecurityOwnerAdversarialQueues(t *testing.T) { | |||
| 15 | + | var epoch atomic.Value | |||
| 16 | + | epoch.Store(ports.SecurityState{}) | |||
| 17 | + | gate := portsmocks.NewMockSessionSecurity(t) | |||
| 18 | + | gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return epoch.Load().(ports.SecurityState) }) | |||
| 19 | + | client := make(chan ports.ClientEvent, 32) | |||
| 20 | + | input := make(chan ports.InputEvent, 32) | |||
| 21 | + | output := make(chan ports.OutputEvent, 8) | |||
| 22 | + | reload := make(chan ports.ConfigChanged, 8) | |||
| 23 | + | commands := make(chan ports.ClientCommand, 256) | |||
| 24 | + | scenes := make(chan []ports.Scene, 1) | |||
| 25 | + | spawn := make(chan ports.SpawnRequest, 8) | |||
| 26 | + | constraints := make(chan ports.PointerConstraint, 1) | |||
| 27 | + | cfg := config.Defaults() | |||
| 28 | + | cfg.Terminal.Command = []string{"terminal"} | |||
| 29 | + | cfg.Terminal.AutoOpen = "off" | |||
| 30 | + | cfg.Binds = map[string]string{"Super+q": "quit"} | |||
| 31 | + | c, err := core.New(cfg, core.Channels{Security: gate, Client: client, Input: input, Output: output, Config: reload, Commands: commands, Scenes: scenes, Spawn: spawn, Constraints: constraints, Terminal: true}) | |||
| 32 | + | if err != nil { | |||
| 33 | + | t.Fatal(err) | |||
| 34 | + | } | |||
| 35 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 36 | + | done := make(chan error, 1) | |||
| 37 | + | go func() { done <- c.Run(ctx) }() | |||
| 38 | + | t.Cleanup(func() { | |||
| 39 | + | cancel() | |||
| 40 | + | if err := receive(t, done); err != nil { | |||
| 41 | + | t.Error(err) | |||
| 42 | + | } | |||
| 43 | + | }) | |||
| 44 | + | output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "A", Width: 300, Height: 200}} | |||
| 45 | + | scene(t, scenes) | |||
| 46 | + | client <- ports.WindowMapped{ID: 1} | |||
| 47 | + | scene(t, scenes) | |||
| 48 | + | // A pre-lock key is queued BEFORE any SessionLockChanged event. The gate | |||
| 49 | + | // must block it regardless of which channel select chooses next. | |||
| 50 | + | locked := ports.SecurityState{Generation: 1, Protected: true} | |||
| 51 | + | epoch.Store(locked) | |||
| 52 | + | input <- ports.SecurityInput{State: ports.SecurityState{}, Event: ports.KeyEvent{Keysym: "q", Mods: ports.ModSuper, Pressed: true}} | |||
| 53 | + | s := sceneMatch(t, scenes, func(s ports.Scene) bool { return s.Security == locked }) | |||
| 54 | + | assertProtected(t, s) | |||
| 55 | + | if len(s.Windows) != 0 { | |||
| 56 | + | t.Fatal(s) | |||
| 57 | + | } | |||
| 58 | + | receive(t, constraints) | |||
| 59 | + | output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "B", Width: 200, Height: 100}} | |||
| 60 | + | sceneMatch(t, scenes, func(s ports.Scene) bool { return s.Security == locked }) | |||
| 61 | + | surfaces := []ports.LockSurfacePlacement{{ID: 10, Output: "A", Width: 300, Height: 200}, {ID: 20, Output: "B", Width: 200, Height: 100}} | |||
| 62 | + | client <- ports.SessionLockChanged{State: locked, Surfaces: surfaces} | |||
| 63 | + | s = sceneMatch(t, scenes, func(s ports.Scene) bool { return len(s.Windows) == 1 && s.Windows[0].ID == 10 }) | |||
| 64 | + | assertProtected(t, s) | |||
| 65 | + | // Raw input is refused even in the initial unlocked epoch when wired; | |||
| 66 | + | // stale, raw, bind and swipe events must not activate desktop behavior. | |||
| 67 | + | input <- ports.KeyEvent{Keysym: "raw", Pressed: true} | |||
| 68 | + | input <- ports.SecurityInput{State: locked, Event: ports.KeyEvent{Keysym: "q", Mods: ports.ModSuper, Pressed: true}} | |||
| 69 | + | input <- ports.SecurityInput{State: locked, Event: ports.SwipeBegin{Fingers: 3}} | |||
| 70 | + | input <- ports.SecurityInput{State: locked, Event: ports.KeyEvent{Keysym: "password", Keycode: 30, Pressed: true}} | |||
| 71 | + | expectKey(t, commands, locked, 10, "password") | |||
| 72 | + | input <- ports.SecurityInput{State: locked, Event: ports.PointerMotion{X: 350, Y: 30}} | |||
| 73 | + | input <- ports.SecurityInput{State: locked, Event: ports.PointerButton{Button: 0x110, Pressed: true}} | |||
| 74 | + | input <- ports.SecurityInput{State: locked, Event: ports.KeyEvent{Keysym: "clicked", Pressed: true}} | |||
| 75 | + | expectKey(t, commands, locked, 20, "clicked") | |||
| 76 | + | // Stale surfaces cannot replace the current lock roles. Invalid logical | |||
| 77 | + | // dimensions and unknown outputs never become drawn/input targets. | |||
| 78 | + | client <- ports.SessionLockChanged{State: ports.SecurityState{}, Surfaces: []ports.LockSurfacePlacement{{ID: 1, Output: "A", Width: 300, Height: 200}}} | |||
| 79 | + | client <- ports.SessionLockChanged{State: locked, Surfaces: append(surfaces, ports.LockSurfacePlacement{ID: 99, Output: "unknown", Width: 300, Height: 200})} | |||
| 80 | + | s = sceneMatch(t, scenes, func(s ports.Scene) bool { return len(s.Windows) == 1 && s.Windows[0].ID == 10 }) | |||
| 81 | + | assertProtected(t, s) | |||
| 82 | + | cfg.Terminal.AutoOpen = "always" | |||
| 83 | + | cfg.Startup = [][]string{{"must-not-start"}} | |||
| 84 | + | reload <- ports.ConfigChanged{Config: cfg} | |||
| 85 | + | s = scene(t, scenes) | |||
| 86 | + | assertProtected(t, s) | |||
| 87 | + | output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "C", Width: 100, Height: 100}} | |||
| 88 | + | for _, s := range receive(t, scenes) { | |||
| 89 | + | assertProtected(t, s) | |||
| 90 | + | if s.Output == "C" && len(s.Windows) != 0 { | |||
| 91 | + | t.Fatal(s) | |||
| 92 | + | } | |||
| 93 | + | } | |||
| 94 | + | select { | |||
| 95 | + | case req := <-spawn: | |||
| 96 | + | t.Fatalf("protected spawn: %+v", req) | |||
| 97 | + | default: | |||
| 98 | + | } | |||
| 99 | + | // Unlock discards all presses/grabs, including a password press with no | |||
| 100 | + | // corresponding release until AFTER the transition. | |||
| 101 | + | unlocked := ports.SecurityState{Generation: 2} | |||
| 102 | + | epoch.Store(unlocked) | |||
| 103 | + | input <- ports.SecurityInput{State: locked, Event: ports.KeyEvent{Keysym: "stale-password", Pressed: true}} | |||
| 104 | + | input <- ports.SecurityInput{State: unlocked, Event: ports.KeyEvent{Keysym: "password", Keycode: 30, Pressed: false}} | |||
| 105 | + | input <- ports.SecurityInput{State: unlocked, Event: ports.KeyEvent{Keysym: "desktop", Pressed: true}} | |||
| 106 | + | expectKey(t, commands, unlocked, 1, "desktop") | |||
| 107 | + | s = sceneMatch(t, scenes, func(s ports.Scene) bool { return s.Security == unlocked }) | |||
| 108 | + | if len(s.Windows) == 0 || s.Windows[0].ID != 1 { | |||
| 109 | + | t.Fatal(s) | |||
| 110 | + | } | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | func assertProtected(t *testing.T, s ports.Scene) { | |||
| 114 | + | t.Helper() | |||
| 115 | + | if !s.Security.Protected || s.Background != "#000000" || s.Capture != nil || s.CaptureScene != nil || len(s.Layers) != 0 || len(s.Separators) != 0 || s.Border.Width != 0 || s.Dim != 0 || s.WorkspaceClip != (ports.Rect{}) { | |||
| 116 | + | t.Fatalf("unsafe scene: %+v", s) | |||
| 117 | + | } | |||
| 118 | + | for _, w := range s.Windows { | |||
| 119 | + | if !w.Fullscreen || w.Hidden || w.Popup || w.Rect != (ports.Rect{W: s.OutputWidth, H: s.OutputHeight}) { | |||
| 120 | + | t.Fatalf("unsafe role: %+v", w) | |||
| 121 | + | } | |||
| 122 | + | } | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | func expectKey(t *testing.T, ch <-chan ports.ClientCommand, state ports.SecurityState, id ports.WindowID, name string) { | |||
| 126 | + | t.Helper() | |||
| 127 | + | for { | |||
| 128 | + | wrapped, ok := receive(t, ch).(ports.SecurityCommand) | |||
| 129 | + | if !ok { | |||
| 130 | + | t.Fatal("unwrapped command") | |||
| 131 | + | } | |||
| 132 | + | if k, ok := wrapped.Command.(ports.ForwardKey); ok { | |||
| 133 | + | if wrapped.State != state { | |||
| 134 | + | continue | |||
| 135 | + | } // older commands keep their epoch | |||
| 136 | + | if k.Key.Keysym == "raw" || k.Key.Keysym == "stale-password" || !k.Key.Pressed { | |||
| 137 | + | t.Fatalf("leaked input: %+v", k) | |||
| 138 | + | } | |||
| 139 | + | if k.Key.Keysym == name { | |||
| 140 | + | if k.ID != id { | |||
| 141 | + | t.Fatalf("key target %d want %d", k.ID, id) | |||
| 142 | + | } | |||
| 143 | + | return | |||
| 144 | + | } | |||
| 145 | + | } | |||
| 146 | + | } | |||
| 147 | + | } | |||
A internal/core/spawn.go +40 -0
| @@ -0,0 +1,40 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // trySpawn is a bounded handoff, not execution admission. The launcher checks | |||
| 10 | + | // Security again before exec: the gate may change after this owner's check. | |||
| 11 | + | func (c *Core) trySpawn(ctx context.Context, req ports.SpawnRequest) bool { | |||
| 12 | + | if ctx.Err() != nil || c.protectionRequested() { | |||
| 13 | + | return false | |||
| 14 | + | } | |||
| 15 | + | req.Security = c.security | |||
| 16 | + | select { | |||
| 17 | + | case c.ch.Spawn <- req: | |||
| 18 | + | return true | |||
| 19 | + | default: | |||
| 20 | + | return false | |||
| 21 | + | } | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | // Startup has at most the configured number of pending commands. Initial | |||
| 25 | + | // ready sends are bounded; Run selects the remainder alongside owner events. | |||
| 26 | + | func (c *Core) pendingStartup() (chan<- ports.SpawnRequest, ports.SpawnRequest) { | |||
| 27 | + | if len(c.startup) == 0 || c.protectionRequested() { | |||
| 28 | + | return nil, ports.SpawnRequest{} | |||
| 29 | + | } | |||
| 30 | + | return c.ch.Spawn, ports.SpawnRequest{Security: c.security, Argv: c.startup[0]} | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | func (c *Core) spawnStartup(ctx context.Context) { | |||
| 34 | + | for len(c.startup) > 0 { | |||
| 35 | + | if !c.trySpawn(ctx, ports.SpawnRequest{Argv: c.startup[0]}) { | |||
| 36 | + | return | |||
| 37 | + | } | |||
| 38 | + | c.startup = c.startup[1:] | |||
| 39 | + | } | |||
| 40 | + | } | |||
A internal/core/spawn_security_test.go +241 -0
| @@ -0,0 +1,241 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "sync/atomic" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |||
| 9 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func TestFullSpawnQueueSlotsTerminalsExplicitRemainTransitionSafe(t *testing.T) { | |||
| 13 | + | for _, kind := range []string{"slot", "terminal", "explicit", "startup"} { | |||
| 14 | + | t.Run(kind, func(t *testing.T) { | |||
| 15 | + | c, _, commands := hiddenCaptureCommands(t) | |||
| 16 | + | c.configures.cw.reset() | |||
| 17 | + | c.cur().mon.RemoveWindow(1) | |||
| 18 | + | c.cur().mon.RemoveWindow(2) | |||
| 19 | + | var epoch atomic.Value | |||
| 20 | + | epoch.Store(ports.SecurityState{Generation: 2}) | |||
| 21 | + | gate := portsmocks.NewMockSessionSecurity(t) | |||
| 22 | + | gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return epoch.Load().(ports.SecurityState) }) | |||
| 23 | + | c.ch.Security = gate | |||
| 24 | + | c.syncSecurity() | |||
| 25 | + | spawn := make(chan ports.SpawnRequest, 1) | |||
| 26 | + | c.ch.Spawn = spawn | |||
| 27 | + | sentinel := ports.SpawnRequest{Argv: []string{"occupied"}} | |||
| 28 | + | spawn <- sentinel | |||
| 29 | + | key := slotKey{workspace: "dev", index: 1} | |||
| 30 | + | c.cfg.Terminal.Command = []string{"terminal"} | |||
| 31 | + | c.cfg.Terminal.AutoOpen = "always" | |||
| 32 | + | c.ch.Terminal = kind == "terminal" | |||
| 33 | + | if kind == "slot" { | |||
| 34 | + | c.slots[key] = &slotState{argv: []string{"slot"}} | |||
| 35 | + | c.toSpawn = []slotKey{key} | |||
| 36 | + | } | |||
| 37 | + | if kind == "startup" { | |||
| 38 | + | c.startup = [][]string{{"startup"}} | |||
| 39 | + | } | |||
| 40 | + | c.binds = map[binding]Action{{key: "t"}: ActionSpawnTerminal} | |||
| 41 | + | attempt := func() error { | |||
| 42 | + | switch kind { | |||
| 43 | + | case "slot": | |||
| 44 | + | return c.spawnSlots(context.Background(), false) | |||
| 45 | + | case "terminal": | |||
| 46 | + | return c.spawnEmpty(context.Background()) | |||
| 47 | + | case "explicit": | |||
| 48 | + | return c.handleInput(context.Background(), ports.KeyEvent{Keysym: "t", Pressed: true}) | |||
| 49 | + | default: | |||
| 50 | + | c.spawnStartup(context.Background()) | |||
| 51 | + | return nil | |||
| 52 | + | } | |||
| 53 | + | } | |||
| 54 | + | done := make(chan error, 1) | |||
| 55 | + | go func() { done <- attempt() }() | |||
| 56 | + | if err := reviewReceive(t, done); err != nil { | |||
| 57 | + | t.Fatal(err) | |||
| 58 | + | } | |||
| 59 | + | if len(spawn) != 1 { | |||
| 60 | + | t.Fatal("full queue changed") | |||
| 61 | + | } | |||
| 62 | + | if c.placement.anyPending() { | |||
| 63 | + | t.Fatal("unsent spawn kept claim") | |||
| 64 | + | } | |||
| 65 | + | if kind == "slot" && len(c.toSpawn) != 1 { | |||
| 66 | + | t.Fatal("deferred slot lost") | |||
| 67 | + | } | |||
| 68 | + | if kind == "startup" && len(c.startup) != 1 { | |||
| 69 | + | t.Fatal("deferred startup lost") | |||
| 70 | + | } | |||
| 71 | + | // A full queue never prevents the owner from adopting protection. Empty | |||
| 72 | + | // it only after transition; no path may enqueue in the protected epoch. | |||
| 73 | + | epoch.Store(ports.SecurityState{Generation: 3, Protected: true}) | |||
| 74 | + | c.syncSecurity() | |||
| 75 | + | <-spawn | |||
| 76 | + | if err := attempt(); err != nil { | |||
| 77 | + | t.Fatal(err) | |||
| 78 | + | } | |||
| 79 | + | if len(spawn) != 0 { | |||
| 80 | + | t.Fatal("protected spawn enqueued") | |||
| 81 | + | } | |||
| 82 | + | epoch.Store(ports.SecurityState{Generation: 4}) | |||
| 83 | + | c.syncSecurity() | |||
| 84 | + | if err := attempt(); err != nil { | |||
| 85 | + | t.Fatal(err) | |||
| 86 | + | } | |||
| 87 | + | req := reviewReceive(t, spawn) | |||
| 88 | + | if req.Security != (ports.SecurityState{Generation: 4}) { | |||
| 89 | + | t.Fatalf("missing owner stamp: %+v", req) | |||
| 90 | + | } | |||
| 91 | + | // Queued requests retain admission generation, not a later gate value. | |||
| 92 | + | epoch.Store(ports.SecurityState{Generation: 5, Protected: true}) | |||
| 93 | + | if req.Security.Generation != 4 { | |||
| 94 | + | t.Fatal("queued request relabeled") | |||
| 95 | + | } | |||
| 96 | + | for len(commands) > 0 { | |||
| 97 | + | <-commands | |||
| 98 | + | } | |||
| 99 | + | }) | |||
| 100 | + | } | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | func TestRunStartupDrainsWithoutFurtherOwnerEvents(t *testing.T) { | |||
| 104 | + | c, _, _ := hiddenCaptureCommands(t) | |||
| 105 | + | c.cfg.Startup = [][]string{{"one"}, {"two"}, {"three"}} | |||
| 106 | + | spawn := make(chan ports.SpawnRequest, 1) | |||
| 107 | + | spawn <- ports.SpawnRequest{Argv: []string{"occupied"}} | |||
| 108 | + | c.ch.Spawn = spawn | |||
| 109 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 110 | + | done := make(chan error, 1) | |||
| 111 | + | go func() { done <- c.Run(ctx) }() | |||
| 112 | + | if got := reviewReceive(t, spawn); got.Argv[0] != "occupied" { | |||
| 113 | + | t.Fatal(got) | |||
| 114 | + | } | |||
| 115 | + | for _, want := range []string{"one", "two", "three"} { | |||
| 116 | + | if got := reviewReceive(t, spawn); got.Argv[0] != want { | |||
| 117 | + | t.Fatal(got) | |||
| 118 | + | } | |||
| 119 | + | } | |||
| 120 | + | cancel() | |||
| 121 | + | if err := reviewReceive(t, done); err != nil { | |||
| 122 | + | t.Fatal(err) | |||
| 123 | + | } | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | func TestRunStartupPendingDisabledWhileLockedResumesOnUnlockEvent(t *testing.T) { | |||
| 127 | + | c, _, _ := hiddenCaptureCommands(t) | |||
| 128 | + | c.cfg.Startup = [][]string{{"one"}, {"two"}, {"three"}} | |||
| 129 | + | var epoch atomic.Value | |||
| 130 | + | locked := ports.SecurityState{Generation: 1, Protected: true} | |||
| 131 | + | epoch.Store(locked) | |||
| 132 | + | gate := portsmocks.NewMockSessionSecurity(t) | |||
| 133 | + | gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return epoch.Load().(ports.SecurityState) }) | |||
| 134 | + | c.ch.Security = gate | |||
| 135 | + | spawn := make(chan ports.SpawnRequest, 1) | |||
| 136 | + | c.ch.Spawn = spawn | |||
| 137 | + | client := make(chan ports.ClientEvent, 1) | |||
| 138 | + | c.ch.Client = client | |||
| 139 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 140 | + | done := make(chan error, 1) | |||
| 141 | + | go func() { done <- c.Run(ctx) }() | |||
| 142 | + | client <- ports.SessionLockChanged{State: locked} | |||
| 143 | + | reviewReceive(t, c.ch.Scenes) // locked event has completed, no startup sent | |||
| 144 | + | select { | |||
| 145 | + | case req := <-spawn: | |||
| 146 | + | t.Fatalf("locked startup sent: %+v", req) | |||
| 147 | + | default: | |||
| 148 | + | } | |||
| 149 | + | unlocked := ports.SecurityState{Generation: 2} | |||
| 150 | + | epoch.Store(unlocked) | |||
| 151 | + | client <- ports.SessionLockChanged{State: unlocked} | |||
| 152 | + | for _, want := range []string{"one", "two", "three"} { | |||
| 153 | + | got := reviewReceive(t, spawn) | |||
| 154 | + | if got.Argv[0] != want || got.Security != unlocked { | |||
| 155 | + | t.Fatal(got) | |||
| 156 | + | } | |||
| 157 | + | } | |||
| 158 | + | cancel() | |||
| 159 | + | if err := reviewReceive(t, done); err != nil { | |||
| 160 | + | t.Fatal(err) | |||
| 161 | + | } | |||
| 162 | + | } | |||
| 163 | + | ||||
| 164 | + | func TestStartupSuccessfulHandoffConsumesDespiteImmediateTransition(t *testing.T) { | |||
| 165 | + | c, _, _ := hiddenCaptureCommands(t) | |||
| 166 | + | c.startup = [][]string{{"once"}} | |||
| 167 | + | spawn := make(chan ports.SpawnRequest, 1) | |||
| 168 | + | c.ch.Spawn = spawn | |||
| 169 | + | var epoch atomic.Value | |||
| 170 | + | epoch.Store(ports.SecurityState{}) | |||
| 171 | + | gate := portsmocks.NewMockSessionSecurity(t) | |||
| 172 | + | gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { | |||
| 173 | + | if len(spawn) != 0 { | |||
| 174 | + | epoch.Store(ports.SecurityState{Generation: 1, Protected: true}) | |||
| 175 | + | } | |||
| 176 | + | return epoch.Load().(ports.SecurityState) | |||
| 177 | + | }) | |||
| 178 | + | c.ch.Security = gate | |||
| 179 | + | c.spawnStartup(context.Background()) | |||
| 180 | + | if len(c.startup) != 0 { | |||
| 181 | + | t.Fatal("successful handoff kept pending startup") | |||
| 182 | + | } | |||
| 183 | + | if !gate.Snapshot().Protected { | |||
| 184 | + | t.Fatal("test did not engage gate after handoff") | |||
| 185 | + | } | |||
| 186 | + | epoch.Store(ports.SecurityState{Generation: 2}) | |||
| 187 | + | req := <-spawn | |||
| 188 | + | if req.Argv[0] != "once" || req.Security != (ports.SecurityState{}) { | |||
| 189 | + | t.Fatal(req) | |||
| 190 | + | } | |||
| 191 | + | c.syncSecurity() | |||
| 192 | + | c.spawnStartup(context.Background()) | |||
| 193 | + | if len(spawn) != 0 { | |||
| 194 | + | t.Fatal("successful startup duplicated after release") | |||
| 195 | + | } | |||
| 196 | + | } | |||
| 197 | + | ||||
| 198 | + | func TestRunSelectableStartupConsumedOnceAcrossReceiverTransition(t *testing.T) { | |||
| 199 | + | c, _, _ := hiddenCaptureCommands(t) | |||
| 200 | + | c.cfg.Startup = [][]string{{"once"}} | |||
| 201 | + | var epoch atomic.Value | |||
| 202 | + | epoch.Store(ports.SecurityState{}) | |||
| 203 | + | gate := portsmocks.NewMockSessionSecurity(t) | |||
| 204 | + | gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return epoch.Load().(ports.SecurityState) }) | |||
| 205 | + | c.ch.Security = gate | |||
| 206 | + | spawn := make(chan ports.SpawnRequest) | |||
| 207 | + | c.ch.Spawn = spawn | |||
| 208 | + | client := make(chan ports.ClientEvent, 1) | |||
| 209 | + | c.ch.Client = client | |||
| 210 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 211 | + | done := make(chan error, 1) | |||
| 212 | + | go func() { done <- c.Run(ctx) }() | |||
| 213 | + | req := reviewReceive(t, spawn) | |||
| 214 | + | if req.Argv[0] != "once" { | |||
| 215 | + | t.Fatal(req) | |||
| 216 | + | } | |||
| 217 | + | locked := ports.SecurityState{Generation: 1, Protected: true} | |||
| 218 | + | epoch.Store(locked) | |||
| 219 | + | client <- ports.SessionLockChanged{State: locked} | |||
| 220 | + | reviewReceive(t, c.ch.Scenes) | |||
| 221 | + | unlocked := ports.SecurityState{Generation: 2} | |||
| 222 | + | epoch.Store(unlocked) | |||
| 223 | + | client <- ports.SessionLockChanged{State: unlocked} | |||
| 224 | + | for { | |||
| 225 | + | if s := reviewReceive(t, c.ch.Scenes); s[0].Security == unlocked { | |||
| 226 | + | break | |||
| 227 | + | } | |||
| 228 | + | } | |||
| 229 | + | select { | |||
| 230 | + | case duplicate := <-spawn: | |||
| 231 | + | t.Fatalf("startup replayed: %+v", duplicate) | |||
| 232 | + | default: | |||
| 233 | + | } | |||
| 234 | + | cancel() | |||
| 235 | + | if err := reviewReceive(t, done); err != nil { | |||
| 236 | + | t.Fatal(err) | |||
| 237 | + | } | |||
| 238 | + | if len(c.startup) != 0 { | |||
| 239 | + | t.Fatal("startup not consumed") | |||
| 240 | + | } | |||
| 241 | + | } | |||
A internal/core/startup_alloc_test.go +20 -0
| @@ -0,0 +1,20 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 5 | + | "testing" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | // A full launcher must not copy the pending command on every unrelated event. | |||
| 9 | + | func TestPendingStartupReusesImmutableArguments(t *testing.T) { | |||
| 10 | + | c := &Core{startup: [][]string{{"command", "argument"}}} | |||
| 11 | + | c.ch.Spawn = make(chan ports.SpawnRequest, 1) | |||
| 12 | + | c.ch.Spawn <- ports.SpawnRequest{} | |||
| 13 | + | var req ports.SpawnRequest | |||
| 14 | + | if allocations := testing.AllocsPerRun(100, func() { _, req = c.pendingStartup() }); allocations != 0 { | |||
| 15 | + | t.Fatalf("pending startup: %.1f allocations", allocations) | |||
| 16 | + | } | |||
| 17 | + | if len(req.Argv) != 2 || &req.Argv[0] != &c.startup[0][0] { | |||
| 18 | + | t.Fatal("pending arguments copied or changed") | |||
| 19 | + | } | |||
| 20 | + | } | |||
M internal/core/terminal.go +13 -0
| @@ -1,6 +1,7 @@ | |||||
| 1 | package core | 1 | package core | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "strings" | |||
| 4 | "time" | 5 | "time" | ||
| 5 | 6 | ||||
| 6 | "github.com/bnema/neferwl/internal/ports" | 7 | "github.com/bnema/neferwl/internal/ports" | ||
| @@ -48,6 +49,18 @@ func (c *Core) fillEmpty() []ports.SpawnRequest { | |||||
| 48 | return reqs | 49 | return reqs | ||
| 49 | } | 50 | } | ||
| 50 | 51 | ||||
| 52 | + | // cancelTerminal removes a claim minted for a send suppressed by the gate. | |||
| 53 | + | func (c *Core) cancelTerminal(req ports.SpawnRequest) { | |||
| 54 | + | for _, env := range req.Env { | |||
| 55 | + | if token, ok := strings.CutPrefix(env, ports.SlotEnv+"="); ok { | |||
| 56 | + | if target, ok := c.placement.pending[token]; ok && target.terminal != nil { | |||
| 57 | + | target.terminal.termAt = time.Time{} | |||
| 58 | + | delete(c.placement.pending, token) | |||
| 59 | + | } | |||
| 60 | + | } | |||
| 61 | + | } | |||
| 62 | + | } | |||
| 63 | + | ||||
| 51 | func (c *Core) hasSlots(w *Workspace) bool { | 64 | func (c *Core) hasSlots(w *Workspace) bool { | ||
| 52 | for key := range c.slots { | 65 | for key := range c.slots { | ||
| 53 | if w.Name != "" && key.workspace == w.Name { | 66 | if w.Name != "" && key.workspace == w.Name { | ||
A internal/mocks/ports/session_security_mock.go +283 -0
| @@ -0,0 +1,283 @@ | |||||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |||
| 2 | + | // github.com/vektra/mockery | |||
| 3 | + | // template: testify | |||
| 4 | + | ||||
| 5 | + | package portsmocks | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 9 | + | mock "github.com/stretchr/testify/mock" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // NewMockSessionSecurity creates a new instance of MockSessionSecurity. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 13 | + | // The first argument is typically a *testing.T value. | |||
| 14 | + | func NewMockSessionSecurity(t interface { | |||
| 15 | + | mock.TestingT | |||
| 16 | + | Cleanup(func()) | |||
| 17 | + | }) *MockSessionSecurity { | |||
| 18 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 19 | + | helper.Helper() | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | mock := &MockSessionSecurity{} | |||
| 23 | + | mock.Mock.Test(t) | |||
| 24 | + | ||||
| 25 | + | t.Cleanup(func() { | |||
| 26 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 27 | + | helper.Helper() | |||
| 28 | + | } | |||
| 29 | + | mock.AssertExpectations(t) | |||
| 30 | + | }) | |||
| 31 | + | ||||
| 32 | + | return mock | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | // MockSessionSecurity is an autogenerated mock type for the SessionSecurity type | |||
| 36 | + | type MockSessionSecurity struct { | |||
| 37 | + | mock.Mock | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | type MockSessionSecurity_Expecter struct { | |||
| 41 | + | mock *mock.Mock | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | func (_m *MockSessionSecurity) EXPECT() *MockSessionSecurity_Expecter { | |||
| 45 | + | return &MockSessionSecurity_Expecter{mock: &_m.Mock} | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | // Snapshot provides a mock function for the type MockSessionSecurity | |||
| 49 | + | func (_mock *MockSessionSecurity) Snapshot() ports.SecurityState { | |||
| 50 | + | ret := _mock.Called() | |||
| 51 | + | ||||
| 52 | + | if len(ret) == 0 { | |||
| 53 | + | panic("no return value specified for Snapshot") | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | var r0 ports.SecurityState | |||
| 57 | + | if returnFunc, ok := ret.Get(0).(func() ports.SecurityState); ok { | |||
| 58 | + | r0 = returnFunc() | |||
| 59 | + | } else { | |||
| 60 | + | r0 = ret.Get(0).(ports.SecurityState) | |||
| 61 | + | } | |||
| 62 | + | return r0 | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | // MockSessionSecurity_Snapshot_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Snapshot' | |||
| 66 | + | type MockSessionSecurity_Snapshot_Call struct { | |||
| 67 | + | *mock.Call | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | // Snapshot is a helper method to define mock.On call | |||
| 71 | + | func (_e *MockSessionSecurity_Expecter) Snapshot() *MockSessionSecurity_Snapshot_Call { | |||
| 72 | + | return &MockSessionSecurity_Snapshot_Call{Call: _e.mock.On("Snapshot")} | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | func (_c *MockSessionSecurity_Snapshot_Call) Run(run func()) *MockSessionSecurity_Snapshot_Call { | |||
| 76 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 77 | + | run() | |||
| 78 | + | }) | |||
| 79 | + | return _c | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | func (_c *MockSessionSecurity_Snapshot_Call) Return(securityState ports.SecurityState) *MockSessionSecurity_Snapshot_Call { | |||
| 83 | + | _c.Call.Return(securityState) | |||
| 84 | + | return _c | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | func (_c *MockSessionSecurity_Snapshot_Call) RunAndReturn(run func() ports.SecurityState) *MockSessionSecurity_Snapshot_Call { | |||
| 88 | + | _c.Call.Return(run) | |||
| 89 | + | return _c | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | // NewMockSessionSecurityController creates a new instance of MockSessionSecurityController. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 93 | + | // The first argument is typically a *testing.T value. | |||
| 94 | + | func NewMockSessionSecurityController(t interface { | |||
| 95 | + | mock.TestingT | |||
| 96 | + | Cleanup(func()) | |||
| 97 | + | }) *MockSessionSecurityController { | |||
| 98 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 99 | + | helper.Helper() | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | mock := &MockSessionSecurityController{} | |||
| 103 | + | mock.Mock.Test(t) | |||
| 104 | + | ||||
| 105 | + | t.Cleanup(func() { | |||
| 106 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 107 | + | helper.Helper() | |||
| 108 | + | } | |||
| 109 | + | mock.AssertExpectations(t) | |||
| 110 | + | }) | |||
| 111 | + | ||||
| 112 | + | return mock | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | // MockSessionSecurityController is an autogenerated mock type for the SessionSecurityController type | |||
| 116 | + | type MockSessionSecurityController struct { | |||
| 117 | + | mock.Mock | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | type MockSessionSecurityController_Expecter struct { | |||
| 121 | + | mock *mock.Mock | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | func (_m *MockSessionSecurityController) EXPECT() *MockSessionSecurityController_Expecter { | |||
| 125 | + | return &MockSessionSecurityController_Expecter{mock: &_m.Mock} | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | // Engage provides a mock function for the type MockSessionSecurityController | |||
| 129 | + | func (_mock *MockSessionSecurityController) Engage() (ports.SecurityState, error) { | |||
| 130 | + | ret := _mock.Called() | |||
| 131 | + | ||||
| 132 | + | if len(ret) == 0 { | |||
| 133 | + | panic("no return value specified for Engage") | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | var r0 ports.SecurityState | |||
| 137 | + | var r1 error | |||
| 138 | + | if returnFunc, ok := ret.Get(0).(func() (ports.SecurityState, error)); ok { | |||
| 139 | + | return returnFunc() | |||
| 140 | + | } | |||
| 141 | + | if returnFunc, ok := ret.Get(0).(func() ports.SecurityState); ok { | |||
| 142 | + | r0 = returnFunc() | |||
| 143 | + | } else { | |||
| 144 | + | r0 = ret.Get(0).(ports.SecurityState) | |||
| 145 | + | } | |||
| 146 | + | if returnFunc, ok := ret.Get(1).(func() error); ok { | |||
| 147 | + | r1 = returnFunc() | |||
| 148 | + | } else { | |||
| 149 | + | r1 = ret.Error(1) | |||
| 150 | + | } | |||
| 151 | + | return r0, r1 | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | // MockSessionSecurityController_Engage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Engage' | |||
| 155 | + | type MockSessionSecurityController_Engage_Call struct { | |||
| 156 | + | *mock.Call | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | // Engage is a helper method to define mock.On call | |||
| 160 | + | func (_e *MockSessionSecurityController_Expecter) Engage() *MockSessionSecurityController_Engage_Call { | |||
| 161 | + | return &MockSessionSecurityController_Engage_Call{Call: _e.mock.On("Engage")} | |||
| 162 | + | } | |||
| 163 | + | ||||
| 164 | + | func (_c *MockSessionSecurityController_Engage_Call) Run(run func()) *MockSessionSecurityController_Engage_Call { | |||
| 165 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 166 | + | run() | |||
| 167 | + | }) | |||
| 168 | + | return _c | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | func (_c *MockSessionSecurityController_Engage_Call) Return(securityState ports.SecurityState, err error) *MockSessionSecurityController_Engage_Call { | |||
| 172 | + | _c.Call.Return(securityState, err) | |||
| 173 | + | return _c | |||
| 174 | + | } | |||
| 175 | + | ||||
| 176 | + | func (_c *MockSessionSecurityController_Engage_Call) RunAndReturn(run func() (ports.SecurityState, error)) *MockSessionSecurityController_Engage_Call { | |||
| 177 | + | _c.Call.Return(run) | |||
| 178 | + | return _c | |||
| 179 | + | } | |||
| 180 | + | ||||
| 181 | + | // Release provides a mock function for the type MockSessionSecurityController | |||
| 182 | + | func (_mock *MockSessionSecurityController) Release(expected ports.LockGeneration) (ports.SecurityState, error) { | |||
| 183 | + | ret := _mock.Called(expected) | |||
| 184 | + | ||||
| 185 | + | if len(ret) == 0 { | |||
| 186 | + | panic("no return value specified for Release") | |||
| 187 | + | } | |||
| 188 | + | ||||
| 189 | + | var r0 ports.SecurityState | |||
| 190 | + | var r1 error | |||
| 191 | + | if returnFunc, ok := ret.Get(0).(func(ports.LockGeneration) (ports.SecurityState, error)); ok { | |||
| 192 | + | return returnFunc(expected) | |||
| 193 | + | } | |||
| 194 | + | if returnFunc, ok := ret.Get(0).(func(ports.LockGeneration) ports.SecurityState); ok { | |||
| 195 | + | r0 = returnFunc(expected) | |||
| 196 | + | } else { | |||
| 197 | + | r0 = ret.Get(0).(ports.SecurityState) | |||
| 198 | + | } | |||
| 199 | + | if returnFunc, ok := ret.Get(1).(func(ports.LockGeneration) error); ok { | |||
| 200 | + | r1 = returnFunc(expected) | |||
| 201 | + | } else { | |||
| 202 | + | r1 = ret.Error(1) | |||
| 203 | + | } | |||
| 204 | + | return r0, r1 | |||
| 205 | + | } | |||
| 206 | + | ||||
| 207 | + | // MockSessionSecurityController_Release_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Release' | |||
| 208 | + | type MockSessionSecurityController_Release_Call struct { | |||
| 209 | + | *mock.Call | |||
| 210 | + | } | |||
| 211 | + | ||||
| 212 | + | // Release is a helper method to define mock.On call | |||
| 213 | + | // - expected ports.LockGeneration | |||
| 214 | + | func (_e *MockSessionSecurityController_Expecter) Release(expected any) *MockSessionSecurityController_Release_Call { | |||
| 215 | + | return &MockSessionSecurityController_Release_Call{Call: _e.mock.On("Release", expected)} | |||
| 216 | + | } | |||
| 217 | + | ||||
| 218 | + | func (_c *MockSessionSecurityController_Release_Call) Run(run func(expected ports.LockGeneration)) *MockSessionSecurityController_Release_Call { | |||
| 219 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 220 | + | var arg0 ports.LockGeneration | |||
| 221 | + | if args[0] != nil { | |||
| 222 | + | arg0 = args[0].(ports.LockGeneration) | |||
| 223 | + | } | |||
| 224 | + | run( | |||
| 225 | + | arg0, | |||
| 226 | + | ) | |||
| 227 | + | }) | |||
| 228 | + | return _c | |||
| 229 | + | } | |||
| 230 | + | ||||
| 231 | + | func (_c *MockSessionSecurityController_Release_Call) Return(securityState ports.SecurityState, err error) *MockSessionSecurityController_Release_Call { | |||
| 232 | + | _c.Call.Return(securityState, err) | |||
| 233 | + | return _c | |||
| 234 | + | } | |||
| 235 | + | ||||
| 236 | + | func (_c *MockSessionSecurityController_Release_Call) RunAndReturn(run func(expected ports.LockGeneration) (ports.SecurityState, error)) *MockSessionSecurityController_Release_Call { | |||
| 237 | + | _c.Call.Return(run) | |||
| 238 | + | return _c | |||
| 239 | + | } | |||
| 240 | + | ||||
| 241 | + | // Snapshot provides a mock function for the type MockSessionSecurityController | |||
| 242 | + | func (_mock *MockSessionSecurityController) Snapshot() ports.SecurityState { | |||
| 243 | + | ret := _mock.Called() | |||
| 244 | + | ||||
| 245 | + | if len(ret) == 0 { | |||
| 246 | + | panic("no return value specified for Snapshot") | |||
| 247 | + | } | |||
| 248 | + | ||||
| 249 | + | var r0 ports.SecurityState | |||
| 250 | + | if returnFunc, ok := ret.Get(0).(func() ports.SecurityState); ok { | |||
| 251 | + | r0 = returnFunc() | |||
| 252 | + | } else { | |||
| 253 | + | r0 = ret.Get(0).(ports.SecurityState) | |||
| 254 | + | } | |||
| 255 | + | return r0 | |||
| 256 | + | } | |||
| 257 | + | ||||
| 258 | + | // MockSessionSecurityController_Snapshot_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Snapshot' | |||
| 259 | + | type MockSessionSecurityController_Snapshot_Call struct { | |||
| 260 | + | *mock.Call | |||
| 261 | + | } | |||
| 262 | + | ||||
| 263 | + | // Snapshot is a helper method to define mock.On call | |||
| 264 | + | func (_e *MockSessionSecurityController_Expecter) Snapshot() *MockSessionSecurityController_Snapshot_Call { | |||
| 265 | + | return &MockSessionSecurityController_Snapshot_Call{Call: _e.mock.On("Snapshot")} | |||
| 266 | + | } | |||
| 267 | + | ||||
| 268 | + | func (_c *MockSessionSecurityController_Snapshot_Call) Run(run func()) *MockSessionSecurityController_Snapshot_Call { | |||
| 269 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 270 | + | run() | |||
| 271 | + | }) | |||
| 272 | + | return _c | |||
| 273 | + | } | |||
| 274 | + | ||||
| 275 | + | func (_c *MockSessionSecurityController_Snapshot_Call) Return(securityState ports.SecurityState) *MockSessionSecurityController_Snapshot_Call { | |||
| 276 | + | _c.Call.Return(securityState) | |||
| 277 | + | return _c | |||
| 278 | + | } | |||
| 279 | + | ||||
| 280 | + | func (_c *MockSessionSecurityController_Snapshot_Call) RunAndReturn(run func() ports.SecurityState) *MockSessionSecurityController_Snapshot_Call { | |||
| 281 | + | _c.Call.Return(run) | |||
| 282 | + | return _c | |||
| 283 | + | } | |||
M internal/ports/messages.go +8 -1
| @@ -775,7 +775,10 @@ type CursorChange struct { | |||||
| 775 | 775 | ||||
| 776 | // SpawnRequest carries core → launcher process arguments. | 776 | // SpawnRequest carries core → launcher process arguments. | ||
| 777 | type SpawnRequest struct { | 777 | type SpawnRequest struct { | ||
| 778 | - | Argv []string | 778 | + | // Security retains the admitting owner epoch through launcher backpressure. |
| 779 | + | // A wired launcher rejects stale or protected requests before execution. | |||
| 780 | + | Security SecurityState | |||
| 781 | + | Argv []string | |||
| 779 | // Env adds KEY=value entries to the child environment. | 782 | // Env adds KEY=value entries to the child environment. | ||
| 780 | Env []string | 783 | Env []string | ||
| 781 | } | 784 | } | ||
| @@ -784,6 +787,10 @@ type SpawnRequest struct { | |||||
| 784 | // one per output. Rects and the output size are logical and local to the | 787 | // one per output. Rects and the output size are logical and local to the | ||
| 785 | // output; the renderer multiplies by Scale. | 788 | // output; the renderer multiplies by Scale. | ||
| 786 | type Scene struct { | 789 | type Scene struct { | ||
| 790 | + | // Security is the owner epoch captured when this scene was published. | |||
| 791 | + | // Output owners reject stale scenes rather than showing a queued desktop | |||
| 792 | + | // or accepting old locker buffers across acquisition/release. | |||
| 793 | + | Security SecurityState | |||
| 787 | Output string | 794 | Output string | ||
| 788 | Seq uint64 | 795 | Seq uint64 | ||
| 789 | OutputWidth, OutputHeight int | 796 | OutputWidth, OutputHeight int | ||
A internal/ports/session_backend.go +42 -0
| @@ -0,0 +1,42 @@ | |||||
| 1 | + | package ports | |||
| 2 | + | ||||
| 3 | + | // SecurityBackendEvent carries authoritative backend inventory and affirmative | |||
| 4 | + | // evidence to the display owner. Unlike OutputPresented, it is never merged or | |||
| 5 | + | // dropped. Output lifetimes register before starting a renderer. | |||
| 6 | + | type SecurityBackendEvent interface{ securityBackendEvent() } | |||
| 7 | + | ||||
| 8 | + | type SecurityOutputAdded struct { | |||
| 9 | + | Instance OutputInstance | |||
| 10 | + | Output string | |||
| 11 | + | } | |||
| 12 | + | ||||
| 13 | + | func (SecurityOutputAdded) securityBackendEvent() {} | |||
| 14 | + | ||||
| 15 | + | // SecurityOutputRemoved means the connector is confirmed disconnected, or an | |||
| 16 | + | // output lifetime was shut down with a successful inactive KMS transaction. | |||
| 17 | + | // Stopping a renderer, failed KMS, timeout or DRM master loss do not qualify. | |||
| 18 | + | type SecurityOutputRemoved struct{ Instance OutputInstance } | |||
| 19 | + | ||||
| 20 | + | func (SecurityOutputRemoved) securityBackendEvent() {} | |||
| 21 | + | ||||
| 22 | + | // SecurityOutputInvalidated revokes prior physical proof on master loss, | |||
| 23 | + | // resume or recovery, without treating an output error as disconnection. | |||
| 24 | + | type SecurityOutputInvalidated struct { | |||
| 25 | + | Generation LockGeneration | |||
| 26 | + | Instance OutputInstance | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | func (SecurityOutputInvalidated) securityBackendEvent() {} | |||
| 30 | + | ||||
| 31 | + | type SecurityOutputProof struct{ Proof OutputProtection } | |||
| 32 | + | ||||
| 33 | + | func (SecurityOutputProof) securityBackendEvent() {} | |||
| 34 | + | ||||
| 35 | + | // SecurityBackendBarrier acknowledges ordered inventory and actual lease | |||
| 36 | + | // revocation for this round. Errors are diagnostics, never readiness evidence. | |||
| 37 | + | type SecurityBackendBarrier struct { | |||
| 38 | + | Generation LockGeneration | |||
| 39 | + | Err error | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | func (SecurityBackendBarrier) securityBackendEvent() {} | |||
A internal/ports/session_lock.go +29 -0
| @@ -0,0 +1,29 @@ | |||||
| 1 | + | package ports | |||
| 2 | + | ||||
| 3 | + | // OutputInstance identifies one output lifetime, not its connector or name. | |||
| 4 | + | // Instances are nonzero and unique for the compositor's lifetime; reconnecting | |||
| 5 | + | // or replacing an output requires a new instance even if its name is unchanged. | |||
| 6 | + | type OutputInstance uint64 | |||
| 7 | + | ||||
| 8 | + | // ProtectionKind describes affirmative evidence of output protection. | |||
| 9 | + | // The zero value and all values other than the named kinds are invalid. | |||
| 10 | + | type ProtectionKind string | |||
| 11 | + | ||||
| 12 | + | const ( | |||
| 13 | + | // ProtectionProtectedFrame confirms a protected frame is presented, not | |||
| 14 | + | // merely queued or submitted for rendering. | |||
| 15 | + | ProtectionProtectedFrame ProtectionKind = "protected-frame" | |||
| 16 | + | // ProtectionInactiveOutput confirms the output is actually inactive, not | |||
| 17 | + | // that rendering failed or that its disappearance was inferred from an error. | |||
| 18 | + | ProtectionInactiveOutput ProtectionKind = "inactive-output" | |||
| 19 | + | ) | |||
| 20 | + | ||||
| 21 | + | // OutputProtection is affirmative protection evidence for one output lifetime | |||
| 22 | + | // in exactly one security generation. It does not acknowledge backend lease | |||
| 23 | + | // drainage/security handoff or completion of admitted prelock capture copies; | |||
| 24 | + | // those are separate barriers. | |||
| 25 | + | type OutputProtection struct { | |||
| 26 | + | Generation LockGeneration | |||
| 27 | + | Instance OutputInstance | |||
| 28 | + | Kind ProtectionKind | |||
| 29 | + | } | |||
A internal/ports/session_lock_messages.go +36 -0
| @@ -0,0 +1,36 @@ | |||||
| 1 | + | package ports | |||
| 2 | + | ||||
| 3 | + | // SessionLockChanged carries display-owned protection changes to core. The | |||
| 4 | + | // defensive gate is engaged before this event is queued. Unlock also changes | |||
| 5 | + | // the generation. A dead owner leaves Protected true and no surfaces. | |||
| 6 | + | type SessionLockChanged struct { | |||
| 7 | + | State SecurityState | |||
| 8 | + | Surfaces []LockSurfacePlacement | |||
| 9 | + | } | |||
| 10 | + | ||||
| 11 | + | // LockSurfacePlacement is a validated mapped lock role, never a desktop window. | |||
| 12 | + | type LockSurfacePlacement struct { | |||
| 13 | + | ID WindowID | |||
| 14 | + | Output string | |||
| 15 | + | Width, Height int | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | func (SessionLockChanged) clientEvent() {} | |||
| 19 | + | ||||
| 20 | + | // SecurityInput snapshots the gate when an input event is produced, before it | |||
| 21 | + | // enters a forwarding queue. Stale events cannot become password/desktop input. | |||
| 22 | + | type SecurityInput struct { | |||
| 23 | + | State SecurityState | |||
| 24 | + | Event InputEvent | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | func (SecurityInput) inputEvent() {} | |||
| 28 | + | ||||
| 29 | + | // SecurityCommand preserves the epoch of core input/focus commands through the | |||
| 30 | + | // display queue. Unwrapped commands are for standalone unlocked protocol tests. | |||
| 31 | + | type SecurityCommand struct { | |||
| 32 | + | State SecurityState | |||
| 33 | + | Command ClientCommand | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | func (SecurityCommand) clientCommand() {} | |||
A internal/ports/session_security.go +25 -0
| @@ -0,0 +1,25 @@ | |||||
| 1 | + | package ports | |||
| 2 | + | ||||
| 3 | + | // LockGeneration identifies a session transition. Zero is the initial unlocked epoch. | |||
| 4 | + | type LockGeneration uint64 | |||
| 5 | + | ||||
| 6 | + | // SecurityState is one coherent admission epoch. Generation changes on both | |||
| 7 | + | // acquisition and release so queued work cannot cross a lock transition. | |||
| 8 | + | type SecurityState struct { | |||
| 9 | + | Generation LockGeneration | |||
| 10 | + | Protected bool | |||
| 11 | + | } | |||
| 12 | + | ||||
| 13 | + | // SessionSecurity exposes the defensive gate to off-display boundaries. | |||
| 14 | + | // The display goroutine remains the sole authority for protocol admission. | |||
| 15 | + | type SessionSecurity interface { | |||
| 16 | + | Snapshot() SecurityState | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | // SessionSecurityController is owned by the display goroutine. Releasing | |||
| 20 | + | // protection requires its exact acquisition generation; crashes do not release. | |||
| 21 | + | type SessionSecurityController interface { | |||
| 22 | + | SessionSecurity | |||
| 23 | + | Engage() (SecurityState, error) | |||
| 24 | + | Release(expected LockGeneration) (SecurityState, error) | |||
| 25 | + | } | |||
A internal/sessionlock/readiness.go +164 -0
| @@ -0,0 +1,164 @@ | |||||
| 1 | + | package sessionlock | |||
| 2 | + | ||||
| 3 | + | import "github.com/bnema/neferwl/internal/ports" | |||
| 4 | + | ||||
| 5 | + | // Readiness is pure readiness bookkeeping owned by one goroutine. Its zero | |||
| 6 | + | // value is an empty, disengaged ledger. It must not be copied after first use; | |||
| 7 | + | // it owns its inventory and never exposes mutable state. | |||
| 8 | + | // | |||
| 9 | + | // Readiness is not authentication or protocol policy. All acknowledgements | |||
| 10 | + | // must be affirmative evidence from the respective owner, never an inference | |||
| 11 | + | // from a failed operation. | |||
| 12 | + | type Readiness struct { | |||
| 13 | + | generation ports.LockGeneration // high-water mark, retained after End | |||
| 14 | + | active bool | |||
| 15 | + | backend bool | |||
| 16 | + | capture bool | |||
| 17 | + | outputs map[ports.OutputInstance]lockOutputReadiness | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | type lockOutputReadiness struct { | |||
| 21 | + | registered bool | |||
| 22 | + | proof ports.ProtectionKind | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | // Add registers a new output lifetime before or during a lock. Zero, duplicate | |||
| 26 | + | // and retired IDs are rejected without changing state. A new output has no | |||
| 27 | + | // proof, so adding it while engaged revokes Ready until it is protected. IDs | |||
| 28 | + | // remain as tombstones after Remove to prevent accepting a replaced lifetime. | |||
| 29 | + | func (r *Readiness) Add(instance ports.OutputInstance) bool { | |||
| 30 | + | if instance == 0 { | |||
| 31 | + | return false | |||
| 32 | + | } | |||
| 33 | + | if _, seen := r.outputs[instance]; seen { | |||
| 34 | + | return false | |||
| 35 | + | } | |||
| 36 | + | if r.outputs == nil { | |||
| 37 | + | r.outputs = make(map[ports.OutputInstance]lockOutputReadiness) | |||
| 38 | + | } | |||
| 39 | + | r.outputs[instance] = lockOutputReadiness{registered: true} | |||
| 40 | + | return true | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | // Remove retires a lifetime only when the caller has confirmed it is actually | |||
| 44 | + | // disconnected or inactive. An error (including "gone") is not confirmation. | |||
| 45 | + | // An inactive lifetime that may resume should instead remain registered with | |||
| 46 | + | // an inactive-output proof. Unknown or already retired instances are rejected. | |||
| 47 | + | func (r *Readiness) Remove(instance ports.OutputInstance) bool { | |||
| 48 | + | output, ok := r.outputs[instance] | |||
| 49 | + | if !ok || !output.registered { | |||
| 50 | + | return false | |||
| 51 | + | } | |||
| 52 | + | r.outputs[instance] = lockOutputReadiness{} | |||
| 53 | + | return true | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | // Begin snapshots the registered inventory into a new readiness round by | |||
| 57 | + | // clearing every proof and both barriers. Registrations may subsequently | |||
| 58 | + | // change. Generations must increase strictly, even across End; an invalid | |||
| 59 | + | // Begin leaves the current round untouched. Exhaustion must not wrap to zero. | |||
| 60 | + | func (r *Readiness) Begin(generation ports.LockGeneration) bool { | |||
| 61 | + | if generation == 0 || generation <= r.generation { | |||
| 62 | + | return false | |||
| 63 | + | } | |||
| 64 | + | r.generation, r.active = generation, true | |||
| 65 | + | r.backend, r.capture = false, false | |||
| 66 | + | for instance, output := range r.outputs { | |||
| 67 | + | output.proof = "" | |||
| 68 | + | r.outputs[instance] = output | |||
| 69 | + | } | |||
| 70 | + | return true | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | // End clears only the exact active round, retaining inventory and the | |||
| 74 | + | // generation high-water mark. Late End calls cannot cancel a newer round, and | |||
| 75 | + | // late proofs or barriers cannot reactivate an ended round. | |||
| 76 | + | func (r *Readiness) End(generation ports.LockGeneration) bool { | |||
| 77 | + | if !r.accepts(generation) { | |||
| 78 | + | return false | |||
| 79 | + | } | |||
| 80 | + | r.active, r.backend, r.capture = false, false, false | |||
| 81 | + | for instance, output := range r.outputs { | |||
| 82 | + | output.proof = "" | |||
| 83 | + | r.outputs[instance] = output | |||
| 84 | + | } | |||
| 85 | + | return true | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | // Record accepts only affirmative protection evidence for a registered | |||
| 89 | + | // lifetime in the exact active generation. Repeated valid proofs are harmless; | |||
| 90 | + | // invalid evidence never overwrites an existing proof. | |||
| 91 | + | func (r *Readiness) Record(proof ports.OutputProtection) bool { | |||
| 92 | + | if !r.accepts(proof.Generation) { | |||
| 93 | + | return false | |||
| 94 | + | } | |||
| 95 | + | switch proof.Kind { | |||
| 96 | + | case ports.ProtectionProtectedFrame, ports.ProtectionInactiveOutput: | |||
| 97 | + | default: | |||
| 98 | + | return false | |||
| 99 | + | } | |||
| 100 | + | output, ok := r.outputs[proof.Instance] | |||
| 101 | + | if !ok || !output.registered { | |||
| 102 | + | return false | |||
| 103 | + | } | |||
| 104 | + | output.proof = proof.Kind | |||
| 105 | + | r.outputs[proof.Instance] = output | |||
| 106 | + | return true | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | // Invalidate revokes a prior proof within the active epoch. A recovered or | |||
| 110 | + | // resumed lifetime must provide fresh affirmative protection, never reuse its | |||
| 111 | + | // earlier presentation. Unknown, retired and stale epochs have no effect. | |||
| 112 | + | func (r *Readiness) Invalidate(generation ports.LockGeneration, instance ports.OutputInstance) bool { | |||
| 113 | + | if !r.accepts(generation) { | |||
| 114 | + | return false | |||
| 115 | + | } | |||
| 116 | + | output, ok := r.outputs[instance] | |||
| 117 | + | if !ok || !output.registered { | |||
| 118 | + | return false | |||
| 119 | + | } | |||
| 120 | + | output.proof = "" | |||
| 121 | + | r.outputs[instance] = output | |||
| 122 | + | return true | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | // BackendBarrier acknowledges authoritative output inventory, drainage of all | |||
| 126 | + | // backend leases, and completed security handoff for the active generation. | |||
| 127 | + | // This is required even with zero outputs; an empty local inventory is not an | |||
| 128 | + | // acknowledgement. It does not acknowledge capture memory-copy completion. | |||
| 129 | + | func (r *Readiness) BackendBarrier(generation ports.LockGeneration) bool { | |||
| 130 | + | if !r.accepts(generation) { | |||
| 131 | + | return false | |||
| 132 | + | } | |||
| 133 | + | r.backend = true | |||
| 134 | + | return true | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | // CaptureBarrier acknowledges that all admitted prelock memory-copy work, | |||
| 138 | + | // including writes to capture FDs, has completed for the active generation. | |||
| 139 | + | // Output protection and backend drainage cannot substitute for this barrier. | |||
| 140 | + | func (r *Readiness) CaptureBarrier(generation ports.LockGeneration) bool { | |||
| 141 | + | if !r.accepts(generation) { | |||
| 142 | + | return false | |||
| 143 | + | } | |||
| 144 | + | r.capture = true | |||
| 145 | + | return true | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | // Ready requires an active round, both independent barriers, and affirmative | |||
| 149 | + | // protection evidence for every currently registered output lifetime. | |||
| 150 | + | func (r *Readiness) Ready() bool { | |||
| 151 | + | if !r.active || !r.backend || !r.capture { | |||
| 152 | + | return false | |||
| 153 | + | } | |||
| 154 | + | for _, output := range r.outputs { | |||
| 155 | + | if output.registered && output.proof == "" { | |||
| 156 | + | return false | |||
| 157 | + | } | |||
| 158 | + | } | |||
| 159 | + | return true | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | func (r *Readiness) accepts(generation ports.LockGeneration) bool { | |||
| 163 | + | return r.active && generation != 0 && generation == r.generation | |||
| 164 | + | } | |||
A internal/sessionlock/readiness_test.go +296 -0
| @@ -0,0 +1,296 @@ | |||||
| 1 | + | package sessionlock | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func lockProof(generation ports.LockGeneration, instance ports.OutputInstance) ports.OutputProtection { | |||
| 11 | + | return ports.OutputProtection{Generation: generation, Instance: instance, Kind: ports.ProtectionProtectedFrame} | |||
| 12 | + | } | |||
| 13 | + | ||||
| 14 | + | func requireLockAccepted(t *testing.T, accepted bool) { | |||
| 15 | + | t.Helper() | |||
| 16 | + | if !accepted { | |||
| 17 | + | t.Fatal("valid operation rejected") | |||
| 18 | + | } | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | func requireLockReady(t *testing.T, r *Readiness, want bool) { | |||
| 22 | + | t.Helper() | |||
| 23 | + | if got := r.Ready(); got != want { | |||
| 24 | + | t.Fatalf("Ready() = %v, want %v; ledger %+v", got, want, r) | |||
| 25 | + | } | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func TestLockReadinessZeroValue(t *testing.T) { | |||
| 29 | + | var r Readiness | |||
| 30 | + | requireLockReady(t, &r, false) | |||
| 31 | + | if r.Begin(0) || r.End(0) || r.Add(0) || r.Remove(0) || r.Remove(99) || | |||
| 32 | + | r.Record(ports.OutputProtection{}) || r.Record(lockProof(1, 1)) || | |||
| 33 | + | r.BackendBarrier(0) || r.BackendBarrier(1) || r.CaptureBarrier(0) || r.CaptureBarrier(1) { | |||
| 34 | + | t.Fatal("zero/disengaged ledger accepted invalid input") | |||
| 35 | + | } | |||
| 36 | + | requireLockReady(t, &r, false) | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | func TestLockReadinessEmptyInventoryNeedsBothBarriers(t *testing.T) { | |||
| 40 | + | for _, backendFirst := range []bool{false, true} { | |||
| 41 | + | t.Run(fmt.Sprintf("backend-first=%v", backendFirst), func(t *testing.T) { | |||
| 42 | + | var r Readiness | |||
| 43 | + | requireLockAccepted(t, r.Begin(7)) | |||
| 44 | + | requireLockReady(t, &r, false) | |||
| 45 | + | if backendFirst { | |||
| 46 | + | requireLockAccepted(t, r.BackendBarrier(7)) | |||
| 47 | + | } else { | |||
| 48 | + | requireLockAccepted(t, r.CaptureBarrier(7)) | |||
| 49 | + | } | |||
| 50 | + | requireLockReady(t, &r, false) | |||
| 51 | + | if backendFirst { | |||
| 52 | + | requireLockAccepted(t, r.CaptureBarrier(7)) | |||
| 53 | + | } else { | |||
| 54 | + | requireLockAccepted(t, r.BackendBarrier(7)) | |||
| 55 | + | } | |||
| 56 | + | requireLockReady(t, &r, true) | |||
| 57 | + | }) | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func TestLockReadinessEveryPrerequisiteInAnyOrder(t *testing.T) { | |||
| 62 | + | // Two output proofs and two independent barriers: all 24 arrival orders. | |||
| 63 | + | for a := range 4 { | |||
| 64 | + | for b := range 4 { | |||
| 65 | + | for c := range 4 { | |||
| 66 | + | for d := range 4 { | |||
| 67 | + | order := [4]int{a, b, c, d} | |||
| 68 | + | if a == b || a == c || a == d || b == c || b == d || c == d { | |||
| 69 | + | continue | |||
| 70 | + | } | |||
| 71 | + | t.Run(fmt.Sprint(order), func(t *testing.T) { | |||
| 72 | + | var r Readiness | |||
| 73 | + | requireLockAccepted(t, r.Add(11)) | |||
| 74 | + | requireLockAccepted(t, r.Add(12)) | |||
| 75 | + | requireLockAccepted(t, r.Begin(5)) | |||
| 76 | + | requireLockReady(t, &r, false) | |||
| 77 | + | for i, step := range order { | |||
| 78 | + | var accepted bool | |||
| 79 | + | switch step { | |||
| 80 | + | case 0: | |||
| 81 | + | accepted = r.Record(lockProof(5, 11)) | |||
| 82 | + | case 1: | |||
| 83 | + | accepted = r.Record(ports.OutputProtection{Generation: 5, Instance: 12, Kind: ports.ProtectionInactiveOutput}) | |||
| 84 | + | case 2: | |||
| 85 | + | accepted = r.BackendBarrier(5) | |||
| 86 | + | case 3: | |||
| 87 | + | accepted = r.CaptureBarrier(5) | |||
| 88 | + | } | |||
| 89 | + | requireLockAccepted(t, accepted) | |||
| 90 | + | requireLockReady(t, &r, i == 3) | |||
| 91 | + | } | |||
| 92 | + | // Duplicate evidence is idempotent. | |||
| 93 | + | requireLockAccepted(t, r.Record(lockProof(5, 11))) | |||
| 94 | + | requireLockAccepted(t, r.BackendBarrier(5)) | |||
| 95 | + | requireLockAccepted(t, r.CaptureBarrier(5)) | |||
| 96 | + | requireLockReady(t, &r, true) | |||
| 97 | + | }) | |||
| 98 | + | } | |||
| 99 | + | } | |||
| 100 | + | } | |||
| 101 | + | } | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | func TestLockReadinessGenerationFencing(t *testing.T) { | |||
| 105 | + | var r Readiness | |||
| 106 | + | requireLockAccepted(t, r.Add(1)) | |||
| 107 | + | requireLockAccepted(t, r.Begin(10)) | |||
| 108 | + | requireLockAccepted(t, r.Record(lockProof(10, 1))) | |||
| 109 | + | requireLockAccepted(t, r.BackendBarrier(10)) | |||
| 110 | + | requireLockAccepted(t, r.CaptureBarrier(10)) | |||
| 111 | + | requireLockReady(t, &r, true) | |||
| 112 | + | for _, generation := range []ports.LockGeneration{0, 9, 10} { | |||
| 113 | + | if r.Begin(generation) { | |||
| 114 | + | t.Fatalf("accepted non-increasing generation %d", generation) | |||
| 115 | + | } | |||
| 116 | + | requireLockReady(t, &r, true) | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | // A newer Begin while engaged clears both barriers and every proof. | |||
| 120 | + | requireLockAccepted(t, r.Begin(11)) | |||
| 121 | + | requireLockReady(t, &r, false) | |||
| 122 | + | for _, generation := range []ports.LockGeneration{0, 9, 10, 12} { | |||
| 123 | + | if r.Record(lockProof(generation, 1)) || r.BackendBarrier(generation) || r.CaptureBarrier(generation) || r.End(generation) { | |||
| 124 | + | t.Fatalf("accepted wrong-generation evidence/End for %d", generation) | |||
| 125 | + | } | |||
| 126 | + | } | |||
| 127 | + | requireLockAccepted(t, r.BackendBarrier(11)) | |||
| 128 | + | requireLockAccepted(t, r.CaptureBarrier(11)) | |||
| 129 | + | requireLockReady(t, &r, false) // old proof was cleared | |||
| 130 | + | requireLockAccepted(t, r.Record(lockProof(11, 1))) | |||
| 131 | + | requireLockReady(t, &r, true) | |||
| 132 | + | requireLockAccepted(t, r.End(11)) | |||
| 133 | + | requireLockReady(t, &r, false) | |||
| 134 | + | if r.Record(lockProof(11, 1)) || r.BackendBarrier(11) || r.CaptureBarrier(11) || r.End(11) || r.Begin(11) || r.Begin(10) { | |||
| 135 | + | t.Fatal("ended round accepted late evidence or reused generation") | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | requireLockAccepted(t, r.Begin(13)) // intervening gate release may consume 12 | |||
| 139 | + | if r.End(11) || r.Record(lockProof(11, 1)) || r.BackendBarrier(11) || r.CaptureBarrier(11) { | |||
| 140 | + | t.Fatal("late old round affected newer round") | |||
| 141 | + | } | |||
| 142 | + | requireLockAccepted(t, r.Record(lockProof(13, 1))) | |||
| 143 | + | requireLockReady(t, &r, false) // neither old barrier survived End/Begin | |||
| 144 | + | requireLockAccepted(t, r.BackendBarrier(13)) | |||
| 145 | + | requireLockReady(t, &r, false) | |||
| 146 | + | requireLockAccepted(t, r.CaptureBarrier(13)) | |||
| 147 | + | requireLockReady(t, &r, true) | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | func TestLockReadinessGenerationExhaustionDoesNotWrap(t *testing.T) { | |||
| 151 | + | var r Readiness | |||
| 152 | + | maxGeneration := ports.LockGeneration(^uint64(0)) | |||
| 153 | + | requireLockAccepted(t, r.Begin(maxGeneration)) | |||
| 154 | + | requireLockAccepted(t, r.End(maxGeneration)) | |||
| 155 | + | for _, generation := range []ports.LockGeneration{0, 1, maxGeneration} { | |||
| 156 | + | if r.Begin(generation) { | |||
| 157 | + | t.Fatalf("accepted wrapped/reused generation %d", generation) | |||
| 158 | + | } | |||
| 159 | + | } | |||
| 160 | + | requireLockReady(t, &r, false) | |||
| 161 | + | } | |||
| 162 | + | ||||
| 163 | + | func TestLockReadinessInvalidProofsDoNotCountOrEraseEvidence(t *testing.T) { | |||
| 164 | + | invalid := []ports.OutputProtection{ | |||
| 165 | + | {}, | |||
| 166 | + | {Generation: 1, Instance: 1}, | |||
| 167 | + | {Generation: 1, Instance: 1, Kind: "submitted-frame"}, | |||
| 168 | + | {Generation: 1, Instance: 1, Kind: "error-gone"}, | |||
| 169 | + | {Generation: 1, Instance: 0, Kind: ports.ProtectionProtectedFrame}, | |||
| 170 | + | {Generation: 1, Instance: 99, Kind: ports.ProtectionProtectedFrame}, | |||
| 171 | + | {Generation: 0, Instance: 1, Kind: ports.ProtectionProtectedFrame}, | |||
| 172 | + | {Generation: 2, Instance: 1, Kind: ports.ProtectionProtectedFrame}, | |||
| 173 | + | } | |||
| 174 | + | var r Readiness | |||
| 175 | + | requireLockAccepted(t, r.Add(1)) | |||
| 176 | + | requireLockAccepted(t, r.Begin(1)) | |||
| 177 | + | requireLockAccepted(t, r.BackendBarrier(1)) | |||
| 178 | + | requireLockAccepted(t, r.CaptureBarrier(1)) | |||
| 179 | + | for _, proof := range invalid { | |||
| 180 | + | if r.Record(proof) { | |||
| 181 | + | t.Fatalf("accepted invalid proof %+v", proof) | |||
| 182 | + | } | |||
| 183 | + | requireLockReady(t, &r, false) | |||
| 184 | + | } | |||
| 185 | + | requireLockAccepted(t, r.Record(lockProof(1, 1))) | |||
| 186 | + | for _, proof := range invalid { | |||
| 187 | + | if r.Record(proof) { | |||
| 188 | + | t.Fatalf("accepted invalid proof %+v", proof) | |||
| 189 | + | } | |||
| 190 | + | requireLockReady(t, &r, true) | |||
| 191 | + | } | |||
| 192 | + | } | |||
| 193 | + | ||||
| 194 | + | func TestLockReadinessOutputInventoryChanges(t *testing.T) { | |||
| 195 | + | var r Readiness | |||
| 196 | + | requireLockAccepted(t, r.Add(1)) | |||
| 197 | + | requireLockAccepted(t, r.Add(2)) | |||
| 198 | + | requireLockAccepted(t, r.Remove(2)) // registration and retirement before Begin | |||
| 199 | + | if r.Add(2) || r.Remove(2) || r.Add(1) { | |||
| 200 | + | t.Fatal("accepted duplicate or retired lifetime") | |||
| 201 | + | } | |||
| 202 | + | requireLockAccepted(t, r.Begin(1)) | |||
| 203 | + | requireLockAccepted(t, r.Add(3)) // new output before Ready | |||
| 204 | + | requireLockAccepted(t, r.Record(lockProof(1, 1))) | |||
| 205 | + | requireLockAccepted(t, r.BackendBarrier(1)) | |||
| 206 | + | requireLockAccepted(t, r.CaptureBarrier(1)) | |||
| 207 | + | requireLockReady(t, &r, false) | |||
| 208 | + | requireLockAccepted(t, r.Record(lockProof(1, 3))) | |||
| 209 | + | requireLockReady(t, &r, true) | |||
| 210 | + | if r.Add(0) || r.Add(3) || r.Remove(99) { | |||
| 211 | + | t.Fatal("accepted invalid inventory operation") | |||
| 212 | + | } | |||
| 213 | + | requireLockReady(t, &r, true) // duplicate registration did not erase proof | |||
| 214 | + | requireLockAccepted(t, r.Add(4)) // hotplug after Ready must revoke it | |||
| 215 | + | requireLockReady(t, &r, false) | |||
| 216 | + | requireLockAccepted(t, r.Remove(4)) // caller-confirmed retired lifetime | |||
| 217 | + | requireLockReady(t, &r, true) | |||
| 218 | + | ||||
| 219 | + | // The same output name can return, but the new lifetime is instance 5. | |||
| 220 | + | requireLockAccepted(t, r.Remove(3)) | |||
| 221 | + | requireLockAccepted(t, r.Add(5)) | |||
| 222 | + | if r.Record(lockProof(1, 3)) || r.Add(3) { | |||
| 223 | + | t.Fatal("replacement accepted old-lifetime evidence") | |||
| 224 | + | } | |||
| 225 | + | requireLockReady(t, &r, false) | |||
| 226 | + | requireLockAccepted(t, r.Record(lockProof(1, 5))) | |||
| 227 | + | requireLockReady(t, &r, true) | |||
| 228 | + | ||||
| 229 | + | requireLockAccepted(t, r.End(1)) | |||
| 230 | + | requireLockAccepted(t, r.Add(6)) // changes after End join next snapshot | |||
| 231 | + | requireLockAccepted(t, r.Remove(1)) | |||
| 232 | + | requireLockAccepted(t, r.Begin(2)) | |||
| 233 | + | requireLockAccepted(t, r.BackendBarrier(2)) | |||
| 234 | + | requireLockAccepted(t, r.CaptureBarrier(2)) | |||
| 235 | + | requireLockAccepted(t, r.Record(lockProof(2, 5))) | |||
| 236 | + | requireLockReady(t, &r, false) | |||
| 237 | + | requireLockAccepted(t, r.Record(lockProof(2, 6))) | |||
| 238 | + | requireLockReady(t, &r, true) | |||
| 239 | + | if r.Add(1) || r.Add(2) || r.Add(3) || r.Add(4) { | |||
| 240 | + | t.Fatal("retired lifetime reused across lock rounds") | |||
| 241 | + | } | |||
| 242 | + | } | |||
| 243 | + | ||||
| 244 | + | func TestLockReadinessInvalidationRequiresFreshProof(t *testing.T) { | |||
| 245 | + | var r Readiness | |||
| 246 | + | requireLockAccepted(t, r.Add(1)) | |||
| 247 | + | requireLockAccepted(t, r.Begin(7)) | |||
| 248 | + | requireLockAccepted(t, r.Record(lockProof(7, 1))) | |||
| 249 | + | requireLockAccepted(t, r.BackendBarrier(7)) | |||
| 250 | + | requireLockAccepted(t, r.CaptureBarrier(7)) | |||
| 251 | + | requireLockReady(t, &r, true) | |||
| 252 | + | if r.Invalidate(6, 1) || r.Invalidate(7, 2) { | |||
| 253 | + | t.Fatal("invalid invalidation accepted") | |||
| 254 | + | } | |||
| 255 | + | requireLockReady(t, &r, true) | |||
| 256 | + | requireLockAccepted(t, r.Invalidate(7, 1)) | |||
| 257 | + | requireLockReady(t, &r, false) | |||
| 258 | + | requireLockAccepted(t, r.Record(lockProof(7, 1))) | |||
| 259 | + | requireLockReady(t, &r, true) | |||
| 260 | + | requireLockAccepted(t, r.End(7)) | |||
| 261 | + | if r.Invalidate(7, 1) { | |||
| 262 | + | t.Fatal("ended invalidation accepted") | |||
| 263 | + | } | |||
| 264 | + | } | |||
| 265 | + | ||||
| 266 | + | func TestLockReadinessInvalidationIsolatedAndRetired(t *testing.T) { | |||
| 267 | + | var r Readiness | |||
| 268 | + | requireLockAccepted(t, r.Add(1)) | |||
| 269 | + | requireLockAccepted(t, r.Add(2)) | |||
| 270 | + | requireLockAccepted(t, r.Add(3)) | |||
| 271 | + | requireLockAccepted(t, r.Remove(3)) | |||
| 272 | + | requireLockAccepted(t, r.Begin(7)) | |||
| 273 | + | requireLockAccepted(t, r.Record(lockProof(7, 1))) | |||
| 274 | + | requireLockAccepted(t, r.Record(lockProof(7, 2))) | |||
| 275 | + | requireLockAccepted(t, r.BackendBarrier(7)) | |||
| 276 | + | requireLockAccepted(t, r.CaptureBarrier(7)) | |||
| 277 | + | if r.Invalidate(8, 1) || r.Invalidate(7, 3) { | |||
| 278 | + | t.Fatal("future or retired invalidation accepted") | |||
| 279 | + | } | |||
| 280 | + | requireLockReady(t, &r, true) | |||
| 281 | + | requireLockAccepted(t, r.Invalidate(7, 1)) | |||
| 282 | + | requireLockReady(t, &r, false) | |||
| 283 | + | requireLockAccepted(t, r.Record(lockProof(7, 1))) | |||
| 284 | + | requireLockReady(t, &r, true) // output 2 and both barriers retained | |||
| 285 | + | } | |||
| 286 | + | ||||
| 287 | + | func TestLockReadinessRemovingLastOutputCannotReplaceBackendACK(t *testing.T) { | |||
| 288 | + | var r Readiness | |||
| 289 | + | requireLockAccepted(t, r.Add(1)) | |||
| 290 | + | requireLockAccepted(t, r.Begin(1)) | |||
| 291 | + | requireLockAccepted(t, r.CaptureBarrier(1)) | |||
| 292 | + | requireLockAccepted(t, r.Remove(1)) | |||
| 293 | + | requireLockReady(t, &r, false) | |||
| 294 | + | requireLockAccepted(t, r.BackendBarrier(1)) | |||
| 295 | + | requireLockReady(t, &r, true) | |||
| 296 | + | } | |||