53 Commits

Author SHA1 Message Date
7ad8f15030 internal/pkg: implement caching for files
All checks were successful
Test / Create distribution (push) Successful in 46s
Test / Sandbox (push) Successful in 2m30s
Test / ShareFS (push) Successful in 3m34s
Test / Sandbox (race detector) (push) Successful in 4m42s
Test / Hpkg (push) Successful in 4m22s
Test / Hakurei (race detector) (push) Successful in 3m15s
Test / Hakurei (push) Successful in 2m28s
Test / Flake checks (push) Successful in 1m39s
This change contains primitives for validating and caching single-file artifacts.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-01-02 00:59:37 +09:00
993afde840 dist: install sharefs
All checks were successful
Test / Create distribution (push) Successful in 1m3s
Test / Sandbox (push) Successful in 2m48s
Test / Hakurei (push) Successful in 3m58s
Test / ShareFS (push) Successful in 3m53s
Test / Hpkg (push) Successful in 4m39s
Test / Sandbox (race detector) (push) Successful in 5m0s
Test / Flake checks (push) Successful in 1m49s
Test / Hakurei (race detector) (push) Successful in 6m1s
This also removes the deprecated hpkg program.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-01-02 00:57:51 +09:00
c9cd16fd2a cmd/sharefs: prepare directory early
All checks were successful
Test / Create distribution (push) Successful in 38s
Test / ShareFS (push) Successful in 43s
Test / Sandbox (race detector) (push) Successful in 47s
Test / Sandbox (push) Successful in 49s
Test / Hpkg (push) Successful in 50s
Test / Hakurei (race detector) (push) Successful in 55s
Test / Hakurei (push) Successful in 58s
Test / Flake checks (push) Successful in 1m41s
This change also checks against filesystem daemon running as root early.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-12-27 23:17:02 +09:00
e42ea32dbe nix: configure sharefs via fileSystems
All checks were successful
Test / ShareFS (push) Successful in 41s
Test / Sandbox (race detector) (push) Successful in 45s
Test / Create distribution (push) Successful in 42s
Test / Sandbox (push) Successful in 47s
Test / Hpkg (push) Successful in 50s
Test / Hakurei (push) Successful in 56s
Test / Hakurei (race detector) (push) Successful in 56s
Test / Flake checks (push) Successful in 1m35s
Turns out this did not work because in the vm test harness, virtualisation.fileSystems completely and silently overrides fileSystems, causing its contents to not even be evaluated anymore. This is not documented as far as I can tell, and is not obvious by any stretch of the imagination. The current hack is cargo culted from nix-community/impermanence and hopefully lasts until this project fully replaces nix.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-12-27 23:14:08 +09:00
e7982b4ee9 cmd/sharefs: create directory as root
All checks were successful
Test / Create distribution (push) Successful in 42s
Test / Sandbox (push) Successful in 2m20s
Test / Hakurei (push) Successful in 3m30s
Test / Sandbox (race detector) (push) Successful in 4m42s
Test / Flake checks (push) Successful in 1m36s
Test / ShareFS (push) Successful in 3m26s
Test / Hpkg (push) Successful in 4m19s
Test / Hakurei (race detector) (push) Successful in 5m30s
This optional behaviour is required on NixOS as it is otherwise impossible to set this up: systemd.mounts breaks startup order somehow even though my unit looks identical to generated ones, fileSystems does not support any kind of initialisation or ordering other than against other mount points.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-12-27 22:14:33 +09:00
ef1ebf12d9 cmd/sharefs: handle mount -t fuse.sharefs
All checks were successful
Test / Create distribution (push) Successful in 33s
Test / ShareFS (push) Successful in 39s
Test / Sandbox (push) Successful in 46s
Test / Sandbox (race detector) (push) Successful in 45s
Test / Hpkg (push) Successful in 49s
Test / Hakurei (push) Successful in 54s
Test / Hakurei (race detector) (push) Successful in 55s
Test / Flake checks (push) Successful in 1m35s
This should have been handled in a custom option parsing function, but that much extra complexity is unnecessary for this edge case. Honestly I do not know why libfuse does not handle this itself.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-12-27 20:49:27 +09:00
775a9f57c9 cmd/sharefs: check option parsing behaviour
All checks were successful
Test / Create distribution (push) Successful in 44s
Test / ShareFS (push) Successful in 39s
Test / Sandbox (push) Successful in 47s
Test / Sandbox (race detector) (push) Successful in 46s
Test / Hakurei (race detector) (push) Successful in 54s
Test / Hpkg (push) Successful in 50s
Test / Hakurei (push) Successful in 55s
Test / Flake checks (push) Successful in 1m35s
This change makes it possible to check parseOpts behaviour as part of Go tests.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-12-27 17:33:12 +09:00
2f8ca83376 cmd/sharefs: containerise filesystem daemon
All checks were successful
Test / Create distribution (push) Successful in 44s
Test / Sandbox (push) Successful in 2m30s
Test / Hakurei (push) Successful in 3m26s
Test / ShareFS (push) Successful in 3m26s
Test / Hpkg (push) Successful in 4m20s
Test / Sandbox (race detector) (push) Successful in 4m41s
Test / Hakurei (race detector) (push) Successful in 5m31s
Test / Flake checks (push) Successful in 1m36s
This replaces the forking daemonise libfuse function which prevents Go callbacks from calling into the runtime. This also enforces least privilege on the daemon process.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-12-27 10:16:35 +09:00
3d720ada92 container: optionally allow orphan
All checks were successful
Test / Create distribution (push) Successful in 43s
Test / Sandbox (push) Successful in 2m21s
Test / ShareFS (push) Successful in 3m25s
Test / Hakurei (push) Successful in 3m31s
Test / Sandbox (race detector) (push) Successful in 4m37s
Test / Hpkg (push) Successful in 4m26s
Test / Hakurei (race detector) (push) Successful in 3m16s
Test / Flake checks (push) Successful in 1m45s
This is required for the typical daemonise use case.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-12-27 09:12:02 +09:00
2e5362e536 cmd/sharefs: opaque setup state
All checks were successful
Test / Sandbox (push) Successful in 2m26s
Test / Hakurei (push) Successful in 3m28s
Test / ShareFS (push) Successful in 3m31s
Test / Hpkg (push) Successful in 4m26s
Test / Sandbox (race detector) (push) Successful in 4m38s
Test / Hakurei (race detector) (push) Successful in 5m34s
Test / Flake checks (push) Successful in 1m42s
Test / Create distribution (push) Successful in 43s
This allows unrestricted use of the type system and prepares setup code for cross-process initialisation.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-12-27 04:14:00 +09:00
6d3bd27220 cmd/sharefs: expand fuse_main
All checks were successful
Test / Create distribution (push) Successful in 42s
Test / Sandbox (push) Successful in 2m20s
Test / ShareFS (push) Successful in 3m22s
Test / Hpkg (push) Successful in 4m20s
Test / Sandbox (race detector) (push) Successful in 4m34s
Test / Hakurei (race detector) (push) Successful in 5m28s
Test / Hakurei (push) Successful in 2m41s
Test / Flake checks (push) Successful in 1m55s
This change should not change behaviour other than making output more consistent.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-12-27 02:30:28 +09:00
a27305cb4a cmd/sharefs: improve help message
All checks were successful
Test / Create distribution (push) Successful in 42s
Test / Sandbox (push) Successful in 2m20s
Test / Hakurei (push) Successful in 3m24s
Test / ShareFS (push) Successful in 3m23s
Test / Hpkg (push) Successful in 4m19s
Test / Sandbox (race detector) (push) Successful in 4m36s
Test / Hakurei (race detector) (push) Successful in 5m32s
Test / Flake checks (push) Successful in 1m52s
This improves consistency with the fuse_main help message.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-12-27 02:20:41 +09:00
0e476c5e5b cmd/sharefs: allocate sharefs_private early
All checks were successful
Test / Create distribution (push) Successful in 43s
Test / Sandbox (push) Successful in 2m21s
Test / ShareFS (push) Successful in 3m26s
Test / Hpkg (push) Successful in 4m14s
Test / Sandbox (race detector) (push) Successful in 4m31s
Test / Hakurei (race detector) (push) Successful in 5m31s
Test / Hakurei (push) Successful in 2m34s
Test / Flake checks (push) Successful in 1m39s
This also removes global state used by sharefs_init.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-12-26 08:08:41 +09:00
54712e0426 nix: set noatime on sharefs
All checks were successful
Test / Create distribution (push) Successful in 42s
Test / Sandbox (push) Successful in 47s
Test / Sandbox (race detector) (push) Successful in 46s
Test / Hakurei (push) Successful in 55s
Test / Hpkg (push) Successful in 50s
Test / Hakurei (race detector) (push) Successful in 55s
Test / ShareFS (push) Successful in 2m27s
Test / Flake checks (push) Successful in 1m38s
Could improve performance, atime is not useful for this filesystem anyway.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-12-26 05:34:05 +09:00
b77c1ecfdb cmd/sharefs/test: check option handling
All checks were successful
Test / Sandbox (push) Successful in 46s
Test / Create distribution (push) Successful in 42s
Test / Sandbox (race detector) (push) Successful in 46s
Test / Hpkg (push) Successful in 50s
Test / Hakurei (push) Successful in 55s
Test / Hakurei (race detector) (push) Successful in 56s
Test / ShareFS (push) Successful in 2m27s
Test / Flake checks (push) Successful in 1m43s
This verifies behaviour related to setuid/setgid when starting as root.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-12-26 05:28:45 +09:00
dce5839a79 nix: do not restart sharefs
All checks were successful
Test / Create distribution (push) Successful in 43s
Test / Sandbox (push) Successful in 47s
Test / Sandbox (race detector) (push) Successful in 47s
Test / Hpkg (push) Successful in 50s
Test / Hakurei (race detector) (push) Successful in 54s
Test / Hakurei (push) Successful in 56s
Test / ShareFS (push) Successful in 2m29s
Test / Flake checks (push) Successful in 1m44s
This avoids disrupting running containers.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-12-26 04:12:14 +09:00
d597592e1f cmd/sharefs: rename fuse-helper to fuse-operations
All checks were successful
Test / Create distribution (push) Successful in 43s
Test / Sandbox (push) Successful in 2m34s
Test / Hakurei (push) Successful in 3m26s
Test / ShareFS (push) Successful in 3m23s
Test / Hpkg (push) Successful in 4m23s
Test / Sandbox (race detector) (push) Successful in 4m38s
Test / Hakurei (race detector) (push) Successful in 5m33s
Test / Flake checks (push) Successful in 1m45s
This is not really just library wrapper functions, but instead implements the callbacks, so fuse-operations makes more sense.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-12-26 03:19:32 +09:00
056f5b12d4 cmd/sharefs: move translate_pathname body to macro wrapper
All checks were successful
Test / Create distribution (push) Successful in 45s
Test / Sandbox (push) Successful in 2m26s
Test / Hakurei (push) Successful in 3m29s
Test / ShareFS (push) Successful in 3m26s
Test / Hpkg (push) Successful in 4m20s
Test / Sandbox (race detector) (push) Successful in 4m50s
Test / Hakurei (race detector) (push) Successful in 5m39s
Test / Flake checks (push) Successful in 1m45s
This is never called directly anywhere and it is simple enough to be included in the macro. This avoids passing the pointer around and dereferencing errno location, resulting in over 5% increase in throughput on the clang build. No change in the gcc build though.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-12-26 02:10:59 +09:00
da2bb546ba cmd/sharefs: remove readlink
All checks were successful
Test / Create distribution (push) Successful in 44s
Test / Sandbox (push) Successful in 2m31s
Test / ShareFS (push) Successful in 3m23s
Test / Hakurei (push) Successful in 3m27s
Test / Hpkg (push) Successful in 4m20s
Test / Sandbox (race detector) (push) Successful in 4m40s
Test / Hakurei (race detector) (push) Successful in 5m33s
Test / Flake checks (push) Successful in 1m44s
This filesystem does not support symbolic links, so readlink is not useful, and unreachable in this case because of the check in getattr.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-12-25 06:00:58 +09:00
7bfbd59810 cmd/sharefs: implement shared filesystem
All checks were successful
Test / Create distribution (push) Successful in 46s
Test / Sandbox (push) Successful in 2m40s
Test / Hakurei (push) Successful in 3m41s
Test / Hpkg (push) Successful in 4m42s
Test / Sandbox (race detector) (push) Successful in 4m53s
Test / Hakurei (race detector) (push) Successful in 5m53s
Test / ShareFS (push) Successful in 38m10s
Test / Flake checks (push) Successful in 1m46s
This is for passing files between applications, similar to android /sdcard.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-12-25 05:13:02 +09:00
ea815a59e8 nix: disable source fortification in devShell
All checks were successful
Test / Create distribution (push) Successful in 35s
Test / Sandbox (push) Successful in 43s
Test / Sandbox (race detector) (push) Successful in 42s
Test / Hakurei (race detector) (push) Successful in 46s
Test / Hpkg (push) Successful in 44s
Test / Hakurei (push) Successful in 49s
Test / Flake checks (push) Successful in 1m36s
This generates warnings when compiling without optimisation.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-12-21 02:22:28 +09:00
28a8dc67d2 internal/pipewire: raise Core::Sync timeout
All checks were successful
Test / Create distribution (push) Successful in 38s
Test / Sandbox (push) Successful in 2m27s
Test / Hakurei (push) Successful in 3m24s
Test / Hpkg (push) Successful in 4m12s
Test / Sandbox (race detector) (push) Successful in 4m40s
Test / Hakurei (race detector) (push) Successful in 5m26s
Test / Flake checks (push) Successful in 1m35s
Hopefully relieves spurious failures on a very overloaded system.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-12-19 00:49:33 +09:00
ec49c63c5f internal/pipewire: EPOLL_CTL_ADD instead of EPOLL_CTL_MOD
All checks were successful
Test / Create distribution (push) Successful in 37s
Test / Sandbox (push) Successful in 2m29s
Test / Hakurei (push) Successful in 3m24s
Test / Hpkg (push) Successful in 4m15s
Test / Sandbox (race detector) (push) Successful in 4m30s
Test / Hakurei (race detector) (push) Successful in 5m26s
Test / Flake checks (push) Successful in 1m45s
Implementation is no longer tied down by the limitations of SyscallConn.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-12-19 00:43:44 +09:00
5a50bf80ee internal/pipewire: hold socket fd directly
All checks were successful
Test / Create distribution (push) Successful in 38s
Test / Sandbox (push) Successful in 2m39s
Test / Hakurei (push) Successful in 3m31s
Test / Sandbox (race detector) (push) Successful in 4m30s
Test / Hpkg (push) Successful in 4m34s
Test / Hakurei (race detector) (push) Successful in 5m29s
Test / Flake checks (push) Successful in 1m40s
The interface provided by net is not used here and is a leftover from a previous implementation. This change removes it.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-12-19 00:28:24 +09:00
ce06b7b663 internal/pipewire: inform conn of blocking intent
All checks were successful
Test / Create distribution (push) Successful in 30s
Test / Sandbox (push) Successful in 2m31s
Test / Hakurei (push) Successful in 3m29s
Test / Hpkg (push) Successful in 4m24s
Test / Sandbox (race detector) (push) Successful in 4m30s
Test / Hakurei (race detector) (push) Successful in 5m27s
Test / Flake checks (push) Successful in 1m40s
The interface does not expose underlying kernel notification mechanisms. This change removes the need to poll in situations were the next call might block.

This is made cumbersome by the SyscallConn interface left over from a previous implementation, it will be replaced in a later commit as the current implementation does not make use of any net.Conn methods other than Close.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-12-19 00:00:33 +09:00
08bdc68f3a internal/pipewire: sendmsg/recvmsg errors are fatal
All checks were successful
Test / Create distribution (push) Successful in 40s
Test / Sandbox (push) Successful in 2m35s
Test / Hakurei (push) Successful in 3m31s
Test / Hpkg (push) Successful in 4m20s
Test / Sandbox (race detector) (push) Successful in 4m42s
Test / Hakurei (race detector) (push) Successful in 5m28s
Test / Flake checks (push) Successful in 1m35s
When returned wrapped as a syscall error, these are impossible to recover from, so wrap them as a fatal error.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-12-18 23:33:12 +09:00
8cb0b433b2 release: 0.3.3
All checks were successful
Release / Create release (push) Successful in 42s
Test / Sandbox (push) Successful in 43s
Test / Hakurei (push) Successful in 3m2s
Test / Create distribution (push) Successful in 27s
Test / Hpkg (push) Successful in 3m57s
Test / Sandbox (race detector) (push) Successful in 4m41s
Test / Hakurei (race detector) (push) Successful in 5m0s
Test / Flake checks (push) Successful in 1m43s
Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-12-15 20:34:45 +09:00
767f1844d2 test: check shim private dir cleanup
All checks were successful
Test / Create distribution (push) Successful in 38s
Test / Hpkg (push) Successful in 45s
Test / Sandbox (push) Successful in 1m35s
Test / Sandbox (race detector) (push) Successful in 2m28s
Test / Hakurei (push) Successful in 2m32s
Test / Hakurei (race detector) (push) Successful in 3m17s
Test / Flake checks (push) Successful in 1m31s
This asserts that no shim private dir was left behind after all containers terminate.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-12-15 20:30:19 +09:00
54610aaddc internal/outcome: expose pipewire via pipewire-pulse
All checks were successful
Test / Create distribution (push) Successful in 28s
Test / Sandbox (push) Successful in 42s
Test / Hakurei (push) Successful in 3m20s
Test / Hpkg (push) Successful in 2m13s
Test / Sandbox (race detector) (push) Successful in 4m25s
Test / Hakurei (race detector) (push) Successful in 3m21s
Test / Flake checks (push) Successful in 1m30s
This no longer exposes the pipewire socket to the container, and instead mediates access via pipewire-pulse. This makes insecure parts of the protocol inaccessible as explained in the doc comment in hst.

Closes #29.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-12-15 12:57:06 +09:00
2e80660169 internal/outcome: look up pipewire-pulse path
All checks were successful
Test / Create distribution (push) Successful in 36s
Test / Sandbox (push) Successful in 2m27s
Test / Hakurei (push) Successful in 3m19s
Test / Hpkg (push) Successful in 4m9s
Test / Sandbox (race detector) (push) Successful in 4m20s
Test / Hakurei (race detector) (push) Successful in 5m16s
Test / Flake checks (push) Successful in 1m29s
This is for setting up the pipewire-pulse container in shim, for #29.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-12-15 12:38:39 +09:00
d0a3c6a2f3 internal/outcome: optional shim private dir
All checks were successful
Test / Create distribution (push) Successful in 34s
Test / Sandbox (push) Successful in 2m20s
Test / Hakurei (push) Successful in 3m24s
Test / Hpkg (push) Successful in 4m1s
Test / Sandbox (race detector) (push) Successful in 4m32s
Test / Hakurei (race detector) (push) Successful in 5m18s
Test / Flake checks (push) Successful in 1m40s
This is a private work directory owned by the specific shim. Useful for sockets owned by this instance of the shim and requires no direct assistance from the priv-side process.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-12-15 12:32:46 +09:00
0c0e3d6fc2 hst: add direct hardware option
All checks were successful
Test / Create distribution (push) Successful in 34s
Test / Sandbox (push) Successful in 2m19s
Test / Hakurei (push) Successful in 47s
Test / Sandbox (race detector) (push) Successful in 2m17s
Test / Hakurei (race detector) (push) Successful in 3m12s
Test / Hpkg (push) Successful in 3m26s
Test / Flake checks (push) Successful in 1m37s
This is unfortunately the only possible setup to securely expose PipeWire to the container. Further explanation explained in the doc comment and #29.

This will be implemented in a future commit.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-12-15 12:29:32 +09:00
fae910a1ad container: sync stubbed wait4 loop after notify
All checks were successful
Test / Create distribution (push) Successful in 35s
Test / Sandbox (push) Successful in 2m22s
Test / Hakurei (push) Successful in 3m29s
Test / Hpkg (push) Successful in 4m19s
Test / Sandbox (race detector) (push) Successful in 4m27s
Test / Hakurei (race detector) (push) Successful in 5m28s
Test / Flake checks (push) Successful in 1m30s
This ensures consistent state observed by wait4 loop when running against stub.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-12-14 10:22:48 +09:00
178c8bc28b internal/pipewire: handle SecurityContext::Create error
All checks were successful
Test / Create distribution (push) Successful in 35s
Test / Sandbox (push) Successful in 2m24s
Test / Hpkg (push) Successful in 4m14s
Test / Sandbox (race detector) (push) Successful in 4m22s
Test / Hakurei (race detector) (push) Successful in 5m21s
Test / Hakurei (push) Successful in 2m24s
Test / Flake checks (push) Successful in 1m31s
This method can result in an error targeting it, so it is handled here. This change also causes a call to Create to also Core::Sync, as it should have done.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-12-14 09:41:28 +09:00
30dcab0734 internal/pipewire: SecurityContext as destructible
All checks were successful
Test / Create distribution (push) Successful in 35s
Test / Sandbox (push) Successful in 2m24s
Test / Hakurei (push) Successful in 3m28s
Test / Sandbox (race detector) (push) Successful in 4m23s
Test / Hpkg (push) Successful in 4m25s
Test / Hakurei (race detector) (push) Successful in 5m23s
Test / Flake checks (push) Successful in 1m33s
This proxy can be destroyed by sending a Core::Destroy targeting it. This change implements the Destroy method by embedding destructible.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-12-14 09:31:50 +09:00
0ea051062b internal/pipewire: reorder context struct
All checks were successful
Test / Create distribution (push) Successful in 36s
Test / Sandbox (push) Successful in 2m25s
Test / Hakurei (push) Successful in 3m25s
Test / Hpkg (push) Successful in 4m15s
Test / Sandbox (race detector) (push) Successful in 4m28s
Test / Hakurei (race detector) (push) Successful in 3m12s
Test / Flake checks (push) Successful in 1m39s
This change reorders and groups struct elements. This improves readability since this struct holds a lot of state loosely related to each other.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-12-14 09:26:30 +09:00
b0f2ab6fff internal/pipewire: implement Core::Destroy
All checks were successful
Test / Create distribution (push) Successful in 35s
Test / Sandbox (push) Successful in 2m28s
Test / Hakurei (push) Successful in 3m25s
Test / Hpkg (push) Successful in 4m19s
Test / Sandbox (race detector) (push) Successful in 4m26s
Test / Hakurei (race detector) (push) Successful in 5m21s
Test / Flake checks (push) Successful in 1m43s
This change also implements pending destructible check on Sync. Destruction method should always be implemented as a wrapper of destructible.destroy.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-12-14 09:20:58 +09:00
00a5bdf006 internal/pipewire: do not emit None for spa_dict
All checks were successful
Test / Create distribution (push) Successful in 35s
Test / Sandbox (push) Successful in 2m23s
Test / Hakurei (push) Successful in 3m26s
Test / Hpkg (push) Successful in 4m9s
Test / Sandbox (race detector) (push) Successful in 4m30s
Test / Hakurei (race detector) (push) Successful in 3m18s
Test / Flake checks (push) Successful in 1m41s
Turns out the PipeWire server does not expect a value of type None here at all.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-12-14 09:06:44 +09:00
a27dfdc058 internal/pipewire: implement Core::CreateObject
All checks were successful
Test / Create distribution (push) Successful in 42s
Test / Sandbox (push) Successful in 3m23s
Test / Hakurei (push) Successful in 4m49s
Test / Sandbox (race detector) (push) Successful in 5m28s
Test / Hpkg (push) Successful in 6m12s
Test / Hakurei (race detector) (push) Successful in 6m37s
Test / Flake checks (push) Successful in 1m34s
Nothing uses this right now, this would have to be called by wrapper methods on Registry that would search the objects

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-12-14 08:10:57 +09:00
6d0d9cecd1 internal/pipewire: handle nil spa_dict correctly
All checks were successful
Test / Create distribution (push) Successful in 34s
Test / Sandbox (push) Successful in 2m25s
Test / Hakurei (push) Successful in 3m27s
Test / Hpkg (push) Successful in 4m15s
Test / Sandbox (race detector) (push) Successful in 4m28s
Test / Hakurei (race detector) (push) Successful in 5m21s
Test / Flake checks (push) Successful in 1m37s
This now marshals into a value of type None when the slice is nil, and correctly unmarshals from type None.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-12-14 07:12:00 +09:00
17248d7d61 internal/pipewire: unmarshal nil pointer correctly
All checks were successful
Test / Create distribution (push) Successful in 35s
Test / Sandbox (push) Successful in 2m21s
Test / Hakurei (push) Successful in 3m27s
Test / Sandbox (race detector) (push) Successful in 4m21s
Test / Hpkg (push) Successful in 4m28s
Test / Hakurei (race detector) (push) Successful in 5m21s
Test / Flake checks (push) Successful in 1m41s
This now calls unmarshalCheckTypeBounds to advance to the next message. Additionally, handling for None value is relocated to a function for reuse by other types.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-12-14 06:58:53 +09:00
41e5628c67 internal/pipewire: return correct size for nil spa_dict
All checks were successful
Test / Create distribution (push) Successful in 34s
Test / Sandbox (push) Successful in 2m30s
Test / Hakurei (push) Successful in 3m30s
Test / Hpkg (push) Successful in 4m15s
Test / Sandbox (race detector) (push) Successful in 4m22s
Test / Hakurei (race detector) (push) Successful in 5m25s
Test / Flake checks (push) Successful in 1m39s
A nil spa_dict results in a None type value being sent over the wire.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-12-14 06:07:46 +09:00
ffbec828e1 internal/pipewire: move Core wrapper methods under Core
All checks were successful
Test / Create distribution (push) Successful in 37s
Test / Sandbox (push) Successful in 2m35s
Test / Hpkg (push) Successful in 4m31s
Test / Sandbox (race detector) (push) Successful in 4m37s
Test / Hakurei (race detector) (push) Successful in 5m31s
Test / Hakurei (push) Successful in 2m30s
Test / Flake checks (push) Successful in 1m42s
These do not belong under Context, and is an early implementation limitation that carried over.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-12-14 05:37:21 +09:00
de0467a65e internal/pipewire: treat noAck violation as fatal
All checks were successful
Test / Create distribution (push) Successful in 52s
Test / Sandbox (push) Successful in 2m52s
Test / Sandbox (race detector) (push) Successful in 4m56s
Test / Hpkg (push) Successful in 5m0s
Test / Hakurei (race detector) (push) Successful in 5m51s
Test / Hakurei (push) Successful in 2m50s
Test / Flake checks (push) Successful in 2m3s
Receiving this event indicates something has gone terribly wrong somehow, and ignoring Core::BoundProps causes inconsistent state anyway.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-12-13 22:30:06 +09:00
b5999b8814 internal/pipewire: implement Core::RemoveId
All checks were successful
Test / Create distribution (push) Successful in 1m15s
Test / Sandbox (push) Successful in 3m15s
Test / Hakurei (push) Successful in 4m19s
Test / Hakurei (race detector) (push) Successful in 3m24s
Test / Sandbox (race detector) (push) Successful in 2m34s
Test / Hpkg (push) Successful in 3m34s
Test / Flake checks (push) Successful in 1m50s
This is emitted by the server when a proxy id is removed for any reason. Currently, the only path for this to be emitted is when a global object is destroyed while some proxy is still bound to it.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-12-13 21:50:32 +09:00
ebc67bb8ad nix: update flake lock
All checks were successful
Test / Create distribution (push) Successful in 1m1s
Test / Sandbox (push) Successful in 4m13s
Test / Hakurei (push) Successful in 5m11s
Test / Sandbox (race detector) (push) Successful in 5m46s
Test / Hakurei (race detector) (push) Successful in 6m50s
Test / Hpkg (push) Successful in 13m44s
Test / Flake checks (push) Successful in 2m14s
NixOS 25.11 introduces a crash in cage and an intermittent crash in foot.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-12-12 08:39:55 +09:00
e60ff660f6 internal/pipewire: treat unknown opcode as fatal
All checks were successful
Test / Create distribution (push) Successful in 58s
Test / Hakurei (push) Successful in 8m27s
Test / Hakurei (race detector) (push) Successful in 10m24s
Test / Sandbox (push) Successful in 1m43s
Test / Sandbox (race detector) (push) Successful in 2m13s
Test / Hpkg (push) Successful in 3m21s
Test / Flake checks (push) Successful in 1m30s
Skipping events can cause local state to diverge from the server.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-12-11 04:22:03 +09:00
47db461546 internal/pipewire: generic Core::Error handling
All checks were successful
Test / Create distribution (push) Successful in 58s
Test / Hakurei (push) Successful in 8m1s
Test / Hakurei (race detector) (push) Successful in 10m19s
Test / Sandbox (push) Successful in 1m30s
Test / Sandbox (race detector) (push) Successful in 2m18s
Test / Hpkg (push) Successful in 3m21s
Test / Flake checks (push) Successful in 1m31s
This flushes message buffer before queueing the event expecting the error. Since this is quite useful and relatively complex, it is relocated to a method of Context.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-12-11 04:07:55 +09:00
0a3fe5f907 internal/pipewire: export Registry::Destroy
All checks were successful
Test / Create distribution (push) Successful in 1m32s
Test / Hakurei (push) Successful in 8m23s
Test / Sandbox (push) Successful in 1m35s
Test / Sandbox (race detector) (push) Successful in 2m18s
Test / Hakurei (race detector) (push) Successful in 3m9s
Test / Hpkg (push) Successful in 3m19s
Test / Flake checks (push) Successful in 1m31s
This handles the error returned by Sync.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-12-11 03:34:33 +09:00
b72d502f1c internal/outcome: populate instance metadata for PipeWire
All checks were successful
Test / Create distribution (push) Successful in 1m10s
Test / Hakurei (push) Successful in 12m25s
Test / Sandbox (push) Successful in 1m40s
Test / Sandbox (race detector) (push) Successful in 2m29s
Test / Hakurei (race detector) (push) Successful in 4m54s
Test / Hpkg (push) Successful in 3m56s
Test / Flake checks (push) Successful in 2m42s
These have similar semantics to equivalent Wayland security-context-v1 fields.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-12-10 03:01:30 +09:00
f8b3db3f66 internal/pipewire: cleaner error message for unsupported type
All checks were successful
Test / Create distribution (push) Successful in 43s
Test / Sandbox (push) Successful in 2m55s
Test / Sandbox (race detector) (push) Successful in 5m10s
Test / Hpkg (push) Successful in 5m24s
Test / Hakurei (race detector) (push) Successful in 6m53s
Test / Hakurei (push) Successful in 7m30s
Test / Flake checks (push) Successful in 2m49s
The error string itself is descriptive enough, so use it as the error message directly.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-12-10 01:51:06 +09:00
0e2fb1788f internal/pipewire: implement Registry::Destroy
All checks were successful
Test / Create distribution (push) Successful in 37s
Test / Sandbox (push) Successful in 2m44s
Test / Sandbox (race detector) (push) Successful in 4m56s
Test / Hakurei (push) Successful in 5m7s
Test / Hpkg (push) Successful in 5m7s
Test / Hakurei (race detector) (push) Successful in 7m0s
Test / Flake checks (push) Successful in 2m0s
This requires error handling infrastructure in Core that does not yet exist, so it is not exported for now. It has been manually tested via linkname against PipeWire.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-12-10 01:25:30 +09:00
d8417e2927 internal/pipewire: implement Registry::GlobalRemove
All checks were successful
Test / Create distribution (push) Successful in 41s
Test / Sandbox (push) Successful in 2m47s
Test / Sandbox (race detector) (push) Successful in 5m6s
Test / Hakurei (push) Successful in 5m30s
Test / Hpkg (push) Successful in 5m39s
Test / Hakurei (race detector) (push) Successful in 7m18s
Test / Flake checks (push) Successful in 1m41s
This is emitted by PipeWire when a global object disappears, because PipeWire insists that all clients that had called Core::GetRegistry must constantly sync its local registry state with the remote.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-12-10 01:00:03 +09:00
55 changed files with 3394 additions and 335 deletions

View File

@@ -72,6 +72,23 @@ jobs:
path: result/*
retention-days: 1
sharefs:
name: ShareFS
runs-on: nix
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Run NixOS test
run: nix build --out-link "result" --print-out-paths --print-build-logs .#checks.x86_64-linux.sharefs
- name: Upload test output
uses: actions/upload-artifact@v3
with:
name: "sharefs-vm-output"
path: result/*
retention-days: 1
hpkg:
name: Hpkg
runs-on: nix
@@ -96,6 +113,7 @@ jobs:
- race
- sandbox
- sandbox-race
- sharefs
- hpkg
runs-on: nix
steps:

View File

@@ -14,7 +14,6 @@ import (
_ "unsafe" // for go:linkname
"hakurei.app/command"
"hakurei.app/container"
"hakurei.app/container/check"
"hakurei.app/container/fhs"
"hakurei.app/hst"
@@ -187,14 +186,6 @@ func buildCommand(ctx context.Context, msg message.Msg, early *earlyHardeningErr
}})
}
// start pipewire-pulse: this most likely exists on host if PipeWire is available
if flagPulse {
config.Container.Filesystem = append(config.Container.Filesystem, hst.FilesystemConfigJSON{FilesystemConfig: &hst.FSDaemon{
Target: fhs.AbsRunUser.Append(strconv.Itoa(container.OverflowUid(msg)), "pulse/native"),
Exec: shell, Args: []string{"-lc", "exec pipewire-pulse"},
}})
}
config.Container.Filesystem = append(config.Container.Filesystem,
// opportunistically bind kvm
hst.FilesystemConfigJSON{FilesystemConfig: &hst.FSBind{

View File

@@ -1,5 +1,5 @@
{
nixosTest,
testers,
callPackage,
system,
@@ -8,7 +8,7 @@
let
buildPackage = self.buildPackage.${system};
in
nixosTest {
testers.nixosTest {
name = "hpkg";
nodes.machine = {
environment.etc = {

View File

@@ -0,0 +1,282 @@
#ifndef _GNU_SOURCE
#define _GNU_SOURCE /* O_DIRECT */
#endif
#include <dirent.h>
#include <errno.h>
#include <unistd.h>
/* TODO(ophestra): remove after 05ce67fea99ca09cd4b6625cff7aec9cc222dd5a reaches a release */
#include <sys/syscall.h>
#include "fuse-operations.h"
/* MUST_TRANSLATE_PATHNAME translates a userspace pathname to a relative pathname;
* the resulting address points to a constant string or part of pathname, it is never heap allocated. */
#define MUST_TRANSLATE_PATHNAME(pathname) \
do { \
if (pathname == NULL) \
return -EINVAL; \
while (*pathname == '/') \
pathname++; \
if (*pathname == '\0') \
pathname = "."; \
} while (0)
/* GET_CONTEXT_PRIV obtains fuse context and private data for the calling thread. */
#define GET_CONTEXT_PRIV(ctx, priv) \
do { \
ctx = fuse_get_context(); \
priv = ctx->private_data; \
} while (0)
/* impl_getattr modifies a struct stat from the kernel to present to userspace;
* impl_getattr returns a negative errno style error code. */
static int impl_getattr(struct fuse_context *ctx, struct stat *statbuf) {
/* allowlist of permitted types */
if (!S_ISDIR(statbuf->st_mode) && !S_ISREG(statbuf->st_mode) && !S_ISLNK(statbuf->st_mode)) {
return -ENOTRECOVERABLE; /* returning an errno causes all operations on the file to return EIO */
}
#define OVERRIDE_PERM(v) (statbuf->st_mode & ~0777) | (v & 0777)
if (S_ISDIR(statbuf->st_mode))
statbuf->st_mode = OVERRIDE_PERM(SHAREFS_PERM_DIR);
else if (S_ISREG(statbuf->st_mode))
statbuf->st_mode = OVERRIDE_PERM(SHAREFS_PERM_REG);
else
statbuf->st_mode = 0; /* should always be symlink in this case */
statbuf->st_uid = ctx->uid;
statbuf->st_gid = SHAREFS_MEDIA_RW_ID;
statbuf->st_ctim = statbuf->st_mtim;
statbuf->st_nlink = 1;
return 0;
}
/* fuse_operations implementation */
int sharefs_getattr(const char *pathname, struct stat *statbuf, struct fuse_file_info *fi) {
struct fuse_context *ctx;
struct sharefs_private *priv;
GET_CONTEXT_PRIV(ctx, priv);
MUST_TRANSLATE_PATHNAME(pathname);
(void)fi;
if (fstatat(priv->dirfd, pathname, statbuf, AT_SYMLINK_NOFOLLOW) == -1)
return -errno;
return impl_getattr(ctx, statbuf);
}
int sharefs_readdir(const char *pathname, void *buf, fuse_fill_dir_t filler, off_t offset, struct fuse_file_info *fi, enum fuse_readdir_flags flags) {
int fd;
DIR *dp;
struct stat st;
int ret = 0;
struct dirent *de;
struct fuse_context *ctx;
struct sharefs_private *priv;
GET_CONTEXT_PRIV(ctx, priv);
MUST_TRANSLATE_PATHNAME(pathname);
(void)offset;
(void)fi;
if ((fd = openat(priv->dirfd, pathname, O_RDONLY | O_DIRECTORY | O_CLOEXEC)) == -1)
return -errno;
if ((dp = fdopendir(fd)) == NULL) {
close(fd);
return -errno;
}
errno = 0; /* for the next readdir call */
while ((de = readdir(dp)) != NULL) {
if (flags & FUSE_READDIR_PLUS) {
if (fstatat(dirfd(dp), de->d_name, &st, AT_SYMLINK_NOFOLLOW) == -1) {
ret = -errno;
break;
}
if ((ret = impl_getattr(ctx, &st)) < 0)
break;
errno = 0;
ret = filler(buf, de->d_name, &st, 0, FUSE_FILL_DIR_PLUS);
} else
ret = filler(buf, de->d_name, NULL, 0, 0);
if (ret != 0) {
ret = errno != 0 ? -errno : -EIO; /* filler */
break;
}
errno = 0; /* for the next readdir call */
}
if (ret == 0 && errno != 0)
ret = -errno; /* readdir */
closedir(dp);
return ret;
}
int sharefs_mkdir(const char *pathname, mode_t mode) {
struct fuse_context *ctx;
struct sharefs_private *priv;
GET_CONTEXT_PRIV(ctx, priv);
MUST_TRANSLATE_PATHNAME(pathname);
(void)mode;
if (mkdirat(priv->dirfd, pathname, SHAREFS_PERM_DIR) == -1)
return -errno;
return 0;
}
int sharefs_unlink(const char *pathname) {
struct fuse_context *ctx;
struct sharefs_private *priv;
GET_CONTEXT_PRIV(ctx, priv);
MUST_TRANSLATE_PATHNAME(pathname);
if (unlinkat(priv->dirfd, pathname, 0) == -1)
return -errno;
return 0;
}
int sharefs_rmdir(const char *pathname) {
struct fuse_context *ctx;
struct sharefs_private *priv;
GET_CONTEXT_PRIV(ctx, priv);
MUST_TRANSLATE_PATHNAME(pathname);
if (unlinkat(priv->dirfd, pathname, AT_REMOVEDIR) == -1)
return -errno;
return 0;
}
int sharefs_rename(const char *oldpath, const char *newpath, unsigned int flags) {
struct fuse_context *ctx;
struct sharefs_private *priv;
GET_CONTEXT_PRIV(ctx, priv);
MUST_TRANSLATE_PATHNAME(oldpath);
MUST_TRANSLATE_PATHNAME(newpath);
/* TODO(ophestra): replace with wrapper after 05ce67fea99ca09cd4b6625cff7aec9cc222dd5a reaches a release */
if (syscall(__NR_renameat2, priv->dirfd, oldpath, priv->dirfd, newpath, flags) == -1)
return -errno;
return 0;
}
int sharefs_truncate(const char *pathname, off_t length, struct fuse_file_info *fi) {
int fd;
int ret;
struct fuse_context *ctx;
struct sharefs_private *priv;
GET_CONTEXT_PRIV(ctx, priv);
MUST_TRANSLATE_PATHNAME(pathname);
(void)fi;
if ((fd = openat(priv->dirfd, pathname, O_WRONLY | O_CLOEXEC)) == -1)
return -errno;
if ((ret = ftruncate(fd, length)) == -1)
ret = -errno;
close(fd);
return ret;
}
int sharefs_utimens(const char *pathname, const struct timespec times[2], struct fuse_file_info *fi) {
struct fuse_context *ctx;
struct sharefs_private *priv;
GET_CONTEXT_PRIV(ctx, priv);
MUST_TRANSLATE_PATHNAME(pathname);
(void)fi;
if (utimensat(priv->dirfd, pathname, times, AT_SYMLINK_NOFOLLOW) == -1)
return -errno;
return 0;
}
int sharefs_create(const char *pathname, mode_t mode, struct fuse_file_info *fi) {
int fd;
struct fuse_context *ctx;
struct sharefs_private *priv;
GET_CONTEXT_PRIV(ctx, priv);
MUST_TRANSLATE_PATHNAME(pathname);
(void)mode;
if ((fd = openat(priv->dirfd, pathname, fi->flags & ~SHAREFS_FORBIDDEN_FLAGS, SHAREFS_PERM_REG)) == -1)
return -errno;
fi->fh = fd;
return 0;
}
int sharefs_open(const char *pathname, struct fuse_file_info *fi) {
int fd;
struct fuse_context *ctx;
struct sharefs_private *priv;
GET_CONTEXT_PRIV(ctx, priv);
MUST_TRANSLATE_PATHNAME(pathname);
if ((fd = openat(priv->dirfd, pathname, fi->flags & ~SHAREFS_FORBIDDEN_FLAGS)) == -1)
return -errno;
fi->fh = fd;
return 0;
}
int sharefs_read(const char *pathname, char *buf, size_t count, off_t offset, struct fuse_file_info *fi) {
int ret;
(void)pathname;
if ((ret = pread(fi->fh, buf, count, offset)) == -1)
return -errno;
return ret;
}
int sharefs_write(const char *pathname, const char *buf, size_t count, off_t offset, struct fuse_file_info *fi) {
int ret;
(void)pathname;
if ((ret = pwrite(fi->fh, buf, count, offset)) == -1)
return -errno;
return ret;
}
int sharefs_statfs(const char *pathname, struct statvfs *statbuf) {
int fd;
int ret;
struct fuse_context *ctx;
struct sharefs_private *priv;
GET_CONTEXT_PRIV(ctx, priv);
MUST_TRANSLATE_PATHNAME(pathname);
if ((fd = openat(priv->dirfd, pathname, O_RDONLY | O_CLOEXEC)) == -1)
return -errno;
if ((ret = fstatvfs(fd, statbuf)) == -1)
ret = -errno;
close(fd);
return ret;
}
int sharefs_release(const char *pathname, struct fuse_file_info *fi) {
(void)pathname;
return close(fi->fh);
}
int sharefs_fsync(const char *pathname, int datasync, struct fuse_file_info *fi) {
(void)pathname;
if (datasync ? fdatasync(fi->fh) : fsync(fi->fh) == -1)
return -errno;
return 0;
}

View File

@@ -0,0 +1,34 @@
#define FUSE_USE_VERSION FUSE_MAKE_VERSION(3, 12)
#include <fuse.h>
#include <fuse_lowlevel.h> /* for fuse_cmdline_help */
#if (FUSE_VERSION < FUSE_MAKE_VERSION(3, 12))
#error This package requires libfuse >= v3.12
#endif
#define SHAREFS_MEDIA_RW_ID (1 << 10) - 1 /* owning gid presented to userspace */
#define SHAREFS_PERM_DIR 0700 /* permission bits for directories presented to userspace */
#define SHAREFS_PERM_REG 0600 /* permission bits for regular files presented to userspace */
#define SHAREFS_FORBIDDEN_FLAGS O_DIRECT /* these open flags are cleared unconditionally */
/* sharefs_private is populated by sharefs_init and contains process-wide context */
struct sharefs_private {
int dirfd; /* source dirfd opened during sharefs_init */
uintptr_t setup; /* cgo handle of opaque setup state */
};
int sharefs_getattr(const char *pathname, struct stat *statbuf, struct fuse_file_info *fi);
int sharefs_readdir(const char *pathname, void *buf, fuse_fill_dir_t filler, off_t offset, struct fuse_file_info *fi, enum fuse_readdir_flags flags);
int sharefs_mkdir(const char *pathname, mode_t mode);
int sharefs_unlink(const char *pathname);
int sharefs_rmdir(const char *pathname);
int sharefs_rename(const char *oldpath, const char *newpath, unsigned int flags);
int sharefs_truncate(const char *pathname, off_t length, struct fuse_file_info *fi);
int sharefs_utimens(const char *pathname, const struct timespec times[2], struct fuse_file_info *fi);
int sharefs_create(const char *pathname, mode_t mode, struct fuse_file_info *fi);
int sharefs_open(const char *pathname, struct fuse_file_info *fi);
int sharefs_read(const char *pathname, char *buf, size_t count, off_t offset, struct fuse_file_info *fi);
int sharefs_write(const char *pathname, const char *buf, size_t count, off_t offset, struct fuse_file_info *fi);
int sharefs_statfs(const char *pathname, struct statvfs *statbuf);
int sharefs_release(const char *pathname, struct fuse_file_info *fi);
int sharefs_fsync(const char *pathname, int datasync, struct fuse_file_info *fi);

556
cmd/sharefs/fuse.go Normal file
View File

@@ -0,0 +1,556 @@
package main
/*
#cgo pkg-config: --static fuse3
#include "fuse-operations.h"
#include <stdlib.h>
#include <string.h>
extern void *sharefs_init(struct fuse_conn_info *conn, struct fuse_config *cfg);
extern void sharefs_destroy(void *private_data);
typedef void (*closure)();
static inline struct fuse_opt _FUSE_OPT_END() { return (struct fuse_opt)FUSE_OPT_END; };
*/
import "C"
import (
"context"
"encoding/gob"
"errors"
"fmt"
"io"
"log"
"os"
"os/exec"
"os/signal"
"path"
"runtime"
"runtime/cgo"
"strconv"
"syscall"
"unsafe"
"hakurei.app/container"
"hakurei.app/container/check"
"hakurei.app/container/std"
"hakurei.app/hst"
"hakurei.app/internal/helper/proc"
"hakurei.app/internal/info"
"hakurei.app/message"
)
type (
// closure represents a C function pointer.
closure = C.closure
// fuseArgs represents the fuse_args structure.
fuseArgs = C.struct_fuse_args
// setupState holds state used for setup. Its cgo handle is included in
// sharefs_private and considered opaque to non-setup callbacks.
setupState struct {
// Whether sharefs_init failed.
initFailed bool
// Whether to create source directory as root.
mkdir bool
// Open file descriptor to fuse.
Fuse int
// Pathname to open for dirfd.
Source *check.Absolute
// New uid and gid to set by sharefs_init when starting as root.
Setuid, Setgid int
}
)
func init() { gob.Register(new(setupState)) }
// destroySetup invalidates the setup [cgo.Handle] in a sharefs_private structure.
func destroySetup(private_data unsafe.Pointer) (ok bool) {
if private_data == nil {
return false
}
priv := (*C.struct_sharefs_private)(private_data)
if h := cgo.Handle(priv.setup); h != 0 {
priv.setup = 0
h.Delete()
ok = true
}
return
}
//export sharefs_init
func sharefs_init(_ *C.struct_fuse_conn_info, cfg *C.struct_fuse_config) unsafe.Pointer {
ctx := C.fuse_get_context()
priv := (*C.struct_sharefs_private)(ctx.private_data)
setup := cgo.Handle(priv.setup).Value().(*setupState)
if os.Geteuid() == 0 {
log.Println("filesystem daemon must not run as root")
goto fail
}
cfg.use_ino = C.true
cfg.direct_io = C.false
// getattr is context-dependent
cfg.attr_timeout = 0
cfg.entry_timeout = 0
cfg.negative_timeout = 0
// all future filesystem operations happen through this dirfd
if fd, err := syscall.Open(setup.Source.String(), syscall.O_DIRECTORY|syscall.O_RDONLY|syscall.O_CLOEXEC, 0); err != nil {
log.Printf("cannot open %q: %v", setup.Source, err)
goto fail
} else if err = syscall.Fchdir(fd); err != nil {
_ = syscall.Close(fd)
log.Printf("cannot enter %q: %s", setup.Source, err)
goto fail
} else {
priv.dirfd = C.int(fd)
}
return ctx.private_data
fail:
setup.initFailed = true
C.fuse_exit(ctx.fuse)
return nil
}
//export sharefs_destroy
func sharefs_destroy(private_data unsafe.Pointer) {
if private_data != nil {
destroySetup(private_data)
priv := (*C.struct_sharefs_private)(private_data)
if err := syscall.Close(int(priv.dirfd)); err != nil {
log.Printf("cannot close source directory: %v", err)
}
}
}
// showHelp prints the help message.
func showHelp(args *fuseArgs) {
executableName := sharefsName
if args.argc > 0 {
executableName = path.Base(C.GoString(*args.argv))
} else if name, err := os.Executable(); err == nil {
executableName = path.Base(name)
}
fmt.Printf("usage: %s [options] <mountpoint>\n\n", executableName)
fmt.Println("Filesystem options:")
fmt.Println(" -o source=/data/media source directory to be mounted")
fmt.Println(" -o setuid=1023 uid to run as when starting as root")
fmt.Println(" -o setgid=1023 gid to run as when starting as root")
fmt.Println("\nFUSE options:")
C.fuse_cmdline_help()
C.fuse_lib_help(args)
}
// parseOpts parses fuse options via fuse_opt_parse.
func parseOpts(args *fuseArgs, setup *setupState, log *log.Logger) (ok bool) {
var unsafeOpts struct {
// Pathname to writable source directory.
source *C.char
// Whether to create source directory as root.
mkdir C.int
// Decimal string representation of uid to set when running as root.
setuid *C.char
// Decimal string representation of gid to set when running as root.
setgid *C.char
// Decimal string representation of open file descriptor to read setupState from.
// This is an internal detail for containerisation and must not be specified directly.
setup *C.char
}
if C.fuse_opt_parse(args, unsafe.Pointer(&unsafeOpts), &[]C.struct_fuse_opt{
{templ: C.CString("source=%s"), offset: C.ulong(unsafe.Offsetof(unsafeOpts.source)), value: 0},
{templ: C.CString("mkdir"), offset: C.ulong(unsafe.Offsetof(unsafeOpts.mkdir)), value: 1},
{templ: C.CString("setuid=%s"), offset: C.ulong(unsafe.Offsetof(unsafeOpts.setuid)), value: 0},
{templ: C.CString("setgid=%s"), offset: C.ulong(unsafe.Offsetof(unsafeOpts.setgid)), value: 0},
{templ: C.CString("setup=%s"), offset: C.ulong(unsafe.Offsetof(unsafeOpts.setup)), value: 0},
C._FUSE_OPT_END(),
}[0], nil) == -1 {
return false
}
if unsafeOpts.source != nil {
defer C.free(unsafe.Pointer(unsafeOpts.source))
}
if unsafeOpts.setuid != nil {
defer C.free(unsafe.Pointer(unsafeOpts.setuid))
}
if unsafeOpts.setgid != nil {
defer C.free(unsafe.Pointer(unsafeOpts.setgid))
}
if unsafeOpts.setup != nil {
defer C.free(unsafe.Pointer(unsafeOpts.setup))
if v, err := strconv.Atoi(C.GoString(unsafeOpts.setup)); err != nil || v < 3 {
log.Println("invalid value for option setup")
return false
} else {
r := os.NewFile(uintptr(v), "setup")
defer func() {
if err = r.Close(); err != nil {
log.Println(err)
}
}()
if err = gob.NewDecoder(r).Decode(setup); err != nil {
log.Println(err)
return false
}
}
if setup.Fuse < 3 {
log.Println("invalid file descriptor", setup.Fuse)
return false
}
return true
}
if unsafeOpts.source == nil {
showHelp(args)
return false
} else if a, err := check.NewAbs(C.GoString(unsafeOpts.source)); err != nil {
log.Println(err)
return false
} else {
setup.Source = a
}
setup.mkdir = unsafeOpts.mkdir != 0
if unsafeOpts.setuid == nil {
setup.Setuid = -1
} else if v, err := strconv.Atoi(C.GoString(unsafeOpts.setuid)); err != nil || v <= 0 {
log.Println("invalid value for option setuid")
return false
} else {
setup.Setuid = v
}
if unsafeOpts.setgid == nil {
setup.Setgid = -1
} else if v, err := strconv.Atoi(C.GoString(unsafeOpts.setgid)); err != nil || v <= 0 {
log.Println("invalid value for option setgid")
return false
} else {
setup.Setgid = v
}
return true
}
// copyArgs returns a heap allocated copy of an argument slice in fuse_args representation.
func copyArgs(s ...string) fuseArgs {
if len(s) == 0 {
return fuseArgs{argc: 0, argv: nil, allocated: 0}
}
args := unsafe.Slice((**C.char)(C.malloc(C.size_t(uintptr(len(s))*unsafe.Sizeof(s[0])))), len(s))
for i, arg := range s {
args[i] = C.CString(arg)
}
return fuseArgs{argc: C.int(len(s)), argv: &args[0], allocated: 1}
}
// freeArgs frees the contents of argument list.
func freeArgs(args *fuseArgs) { C.fuse_opt_free_args(args) }
// unsafeAddArgument adds an argument to fuseArgs via fuse_opt_add_arg.
// The last byte of arg must be 0.
func unsafeAddArgument(args *fuseArgs, arg string) {
C.fuse_opt_add_arg(args, (*C.char)(unsafe.Pointer(unsafe.StringData(arg))))
}
func _main(s ...string) (exitCode int) {
msg := message.New(log.Default())
container.TryArgv0(msg)
runtime.LockOSThread()
// don't mask creation mode, kernel already did that
syscall.Umask(0)
var pinner runtime.Pinner
defer pinner.Unpin()
args := copyArgs(s...)
defer freeArgs(&args)
// this causes the kernel to enforce access control based on
// struct stat populated by sharefs_getattr
unsafeAddArgument(&args, "-odefault_permissions\x00")
var priv C.struct_sharefs_private
pinner.Pin(&priv)
var setup setupState
priv.setup = C.uintptr_t(cgo.NewHandle(&setup))
defer destroySetup(unsafe.Pointer(&priv))
var opts C.struct_fuse_cmdline_opts
if C.fuse_parse_cmdline(&args, &opts) != 0 {
return 1
}
if opts.mountpoint != nil {
defer C.free(unsafe.Pointer(opts.mountpoint))
}
if opts.show_version != 0 {
fmt.Println("hakurei version", info.Version())
fmt.Println("FUSE library version", C.GoString(C.fuse_pkgversion()))
C.fuse_lowlevel_version()
return 0
}
if opts.show_help != 0 {
showHelp(&args)
return 0
} else if opts.mountpoint == nil {
log.Println("no mountpoint specified")
return 2
} else {
// hack to keep fuse_parse_cmdline happy in the container
mountpoint := C.GoString(opts.mountpoint)
pathnameArg := -1
for i, arg := range s {
if arg == mountpoint {
pathnameArg = i
break
}
}
if pathnameArg < 0 {
log.Println("mountpoint must be absolute")
return 2
}
s[pathnameArg] = container.Nonexistent
}
if !parseOpts(&args, &setup, msg.GetLogger()) {
return 1
}
asRoot := os.Geteuid() == 0
if asRoot {
if setup.Setuid <= 0 || setup.Setgid <= 0 {
log.Println("setuid and setgid must not be 0")
return 1
}
if setup.Fuse >= 3 {
log.Println("filesystem daemon must not run as root")
return 1
}
if setup.mkdir {
if err := os.MkdirAll(setup.Source.String(), 0700); err != nil {
if !errors.Is(err, os.ErrExist) {
log.Println(err)
return 1
}
// skip setup for existing source directory
} else if err = os.Chown(setup.Source.String(), setup.Setuid, setup.Setgid); err != nil {
log.Println(err)
return 1
}
}
} else if setup.Fuse < 3 && (setup.Setuid > 0 || setup.Setgid > 0) {
log.Println("setuid and setgid has no effect when not starting as root")
return 1
} else if setup.mkdir {
log.Println("mkdir has no effect when not starting as root")
return 1
}
op := C.struct_fuse_operations{
init: closure(C.sharefs_init),
destroy: closure(C.sharefs_destroy),
// implemented in fuse-helper.c
getattr: closure(C.sharefs_getattr),
readdir: closure(C.sharefs_readdir),
mkdir: closure(C.sharefs_mkdir),
unlink: closure(C.sharefs_unlink),
rmdir: closure(C.sharefs_rmdir),
rename: closure(C.sharefs_rename),
truncate: closure(C.sharefs_truncate),
utimens: closure(C.sharefs_utimens),
create: closure(C.sharefs_create),
open: closure(C.sharefs_open),
read: closure(C.sharefs_read),
write: closure(C.sharefs_write),
statfs: closure(C.sharefs_statfs),
release: closure(C.sharefs_release),
fsync: closure(C.sharefs_fsync),
}
fuse := C.fuse_new_fn(&args, &op, C.size_t(unsafe.Sizeof(op)), unsafe.Pointer(&priv))
if fuse == nil {
return 3
}
defer C.fuse_destroy(fuse)
se := C.fuse_get_session(fuse)
if setup.Fuse < 3 {
// unconfined, set up mount point and container
if C.fuse_mount(fuse, opts.mountpoint) != 0 {
return 4
}
// unmounted by initial process
defer func() {
if exitCode == 5 {
C.fuse_unmount(fuse)
}
}()
if asRoot {
if err := syscall.Setresgid(setup.Setgid, setup.Setgid, setup.Setgid); err != nil {
log.Printf("cannot set gid: %v", err)
return 5
}
if err := syscall.Setgroups(nil); err != nil {
log.Printf("cannot set supplementary groups: %v", err)
return 5
}
if err := syscall.Setresuid(setup.Setuid, setup.Setuid, setup.Setuid); err != nil {
log.Printf("cannot set uid: %v", err)
return 5
}
}
msg.SwapVerbose(opts.debug != 0)
ctx := context.Background()
if opts.foreground != 0 {
c, cancel := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM)
defer cancel()
ctx = c
}
z := container.New(ctx, msg)
z.AllowOrphan = opts.foreground == 0
z.Env = os.Environ()
// keep fuse_parse_cmdline happy in the container
z.Tmpfs(check.MustAbs(container.Nonexistent), 1<<10, 0755)
if a, err := check.NewAbs(container.MustExecutable(msg)); err != nil {
log.Println(err)
return 5
} else {
z.Path = a
}
z.Args = s
z.ForwardCancel = true
z.SeccompPresets |= std.PresetStrict
z.ParentPerm = 0700
z.Bind(setup.Source, setup.Source, std.BindWritable)
if !z.AllowOrphan {
z.WaitDelay = hst.WaitDelayMax
z.Stdin, z.Stdout, z.Stderr = os.Stdin, os.Stdout, os.Stderr
}
z.Bind(z.Path, z.Path, 0)
setup.Fuse = int(proc.ExtraFileSlice(&z.ExtraFiles, os.NewFile(uintptr(C.fuse_session_fd(se)), "fuse")))
var setupWriter io.WriteCloser
if fd, w, err := container.Setup(&z.ExtraFiles); err != nil {
log.Println(err)
return 5
} else {
z.Args = append(z.Args, "-osetup="+strconv.Itoa(fd))
setupWriter = w
}
if err := z.Start(); err != nil {
if m, ok := message.GetMessage(err); ok {
log.Println(m)
} else {
log.Println(err)
}
return 5
}
if err := z.Serve(); err != nil {
if m, ok := message.GetMessage(err); ok {
log.Println(m)
} else {
log.Println(err)
}
return 5
}
if err := gob.NewEncoder(setupWriter).Encode(&setup); err != nil {
log.Println(err)
return 5
} else if err = setupWriter.Close(); err != nil {
log.Println(err)
}
if !z.AllowOrphan {
if err := z.Wait(); err != nil {
var exitError *exec.ExitError
if !errors.As(err, &exitError) || exitError == nil {
log.Println(err)
return 5
}
switch code := exitError.ExitCode(); syscall.Signal(code & 0x7f) {
case syscall.SIGINT:
case syscall.SIGTERM:
default:
return code
}
}
}
return 0
} else { // confined
C.free(unsafe.Pointer(opts.mountpoint))
// must be heap allocated
opts.mountpoint = C.CString("/dev/fd/" + strconv.Itoa(setup.Fuse))
if err := os.Chdir("/"); err != nil {
log.Println(err)
}
}
if C.fuse_mount(fuse, opts.mountpoint) != 0 {
return 4
}
defer C.fuse_unmount(fuse)
if C.fuse_set_signal_handlers(se) != 0 {
return 6
}
defer C.fuse_remove_signal_handlers(se)
if opts.singlethread != 0 {
if C.fuse_loop(fuse) != 0 {
return 8
}
} else {
loopConfig := C.fuse_loop_cfg_create()
if loopConfig == nil {
return 7
}
defer C.fuse_loop_cfg_destroy(loopConfig)
C.fuse_loop_cfg_set_clone_fd(loopConfig, C.uint(opts.clone_fd))
C.fuse_loop_cfg_set_idle_threads(loopConfig, opts.max_idle_threads)
C.fuse_loop_cfg_set_max_threads(loopConfig, opts.max_threads)
if C.fuse_loop_mt(fuse, loopConfig) != 0 {
return 8
}
}
if setup.initFailed {
return 1
}
return 0
}

113
cmd/sharefs/fuse_test.go Normal file
View File

@@ -0,0 +1,113 @@
package main
import (
"bytes"
"log"
"reflect"
"testing"
"hakurei.app/container/check"
)
func TestParseOpts(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
args []string
want setupState
wantLog string
wantOk bool
}{
{"zero length", []string{}, setupState{}, "", false},
{"not absolute", []string{"sharefs",
"-o", "source=nonexistent",
"-o", "setuid=1023",
"-o", "setgid=1023",
}, setupState{}, "sharefs: path \"nonexistent\" is not absolute\n", false},
{"not specified", []string{"sharefs",
"-o", "setuid=1023",
"-o", "setgid=1023",
}, setupState{}, "", false},
{"invalid setuid", []string{"sharefs",
"-o", "source=/proc/nonexistent",
"-o", "setuid=ff",
"-o", "setgid=1023",
}, setupState{
Source: check.MustAbs("/proc/nonexistent"),
}, "sharefs: invalid value for option setuid\n", false},
{"invalid setgid", []string{"sharefs",
"-o", "source=/proc/nonexistent",
"-o", "setuid=1023",
"-o", "setgid=ff",
}, setupState{
Source: check.MustAbs("/proc/nonexistent"),
Setuid: 1023,
}, "sharefs: invalid value for option setgid\n", false},
{"simple", []string{"sharefs",
"-o", "source=/proc/nonexistent",
}, setupState{
Source: check.MustAbs("/proc/nonexistent"),
Setuid: -1,
Setgid: -1,
}, "", true},
{"root", []string{"sharefs",
"-o", "source=/proc/nonexistent",
"-o", "setuid=1023",
"-o", "setgid=1023",
}, setupState{
Source: check.MustAbs("/proc/nonexistent"),
Setuid: 1023,
Setgid: 1023,
}, "", true},
{"setuid", []string{"sharefs",
"-o", "source=/proc/nonexistent",
"-o", "setuid=1023",
}, setupState{
Source: check.MustAbs("/proc/nonexistent"),
Setuid: 1023,
Setgid: -1,
}, "", true},
{"setgid", []string{"sharefs",
"-o", "source=/proc/nonexistent",
"-o", "setgid=1023",
}, setupState{
Source: check.MustAbs("/proc/nonexistent"),
Setuid: -1,
Setgid: 1023,
}, "", true},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var (
got setupState
buf bytes.Buffer
)
args := copyArgs(tc.args...)
defer freeArgs(&args)
unsafeAddArgument(&args, "-odefault_permissions\x00")
if ok := parseOpts(&args, &got, log.New(&buf, "sharefs: ", 0)); ok != tc.wantOk {
t.Errorf("parseOpts: ok = %v, want %v", ok, tc.wantOk)
}
if !reflect.DeepEqual(&got, &tc.want) {
t.Errorf("parseOpts: setup = %#v, want %#v", got, tc.want)
}
if buf.String() != tc.wantLog {
t.Errorf("parseOpts: log =\n%s\nwant\n%s", buf.String(), tc.wantLog)
}
})
}
}

31
cmd/sharefs/main.go Normal file
View File

@@ -0,0 +1,31 @@
package main
import (
"log"
"os"
"slices"
)
// sharefsName is the prefix used by log.std in the sharefs process.
const sharefsName = "sharefs"
// handleMountArgs returns an alternative, libfuse-compatible args slice for
// args passed by mount -t fuse.sharefs [options] sharefs <mountpoint>.
//
// In this case, args always has a length of 5 with index 0 being what comes
// after "fuse." in the filesystem type, 1 is the uninterpreted string passed
// to mount (sharefsName is used as the magic string to enable this hack),
// 2 is passed through to libfuse as mountpoint, and 3 is always "-o".
func handleMountArgs(args []string) []string {
if len(args) == 5 && args[1] == sharefsName && args[3] == "-o" {
return []string{sharefsName, args[2], "-o", args[4]}
}
return slices.Clone(args)
}
func main() {
log.SetFlags(0)
log.SetPrefix(sharefsName + ": ")
os.Exit(_main(handleMountArgs(os.Args)...))
}

29
cmd/sharefs/main_test.go Normal file
View File

@@ -0,0 +1,29 @@
package main
import (
"slices"
"testing"
)
func TestHandleMountArgs(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
args []string
want []string
}{
{"nil", nil, nil},
{"passthrough", []string{"sharefs", "-V"}, []string{"sharefs", "-V"}},
{"replace", []string{"/sbin/sharefs", "sharefs", "/sdcard", "-o", "rw"}, []string{"sharefs", "/sdcard", "-o", "rw"}},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := handleMountArgs(tc.args); !slices.Equal(got, tc.want) {
t.Errorf("handleMountArgs: %q, want %q", got, tc.want)
}
})
}
}

View File

@@ -0,0 +1,41 @@
{ pkgs, ... }:
{
users.users = {
alice = {
isNormalUser = true;
description = "Alice Foobar";
password = "foobar";
uid = 1000;
};
};
home-manager.users.alice.home.stateVersion = "24.11";
# Automatically login on tty1 as a normal user:
services.getty.autologinUser = "alice";
environment = {
# For benchmarking sharefs:
systemPackages = [ pkgs.fsmark ];
};
virtualisation = {
diskSize = 6 * 1024;
qemu.options = [
# Increase test performance:
"-smp 8"
];
};
environment.hakurei = rec {
enable = true;
stateDir = "/var/lib/hakurei";
sharefs.source = "${stateDir}/sdcard";
users.alice = 0;
extraHomeConfig = {
home.stateVersion = "23.05";
};
};
}

View File

@@ -0,0 +1,44 @@
{
testers,
system,
self,
}:
testers.nixosTest {
name = "sharefs";
nodes.machine =
{ options, pkgs, ... }:
let
fhs =
let
hakurei = options.environment.hakurei.package.default;
in
pkgs.buildFHSEnv {
pname = "hakurei-fhs";
inherit (hakurei) version;
targetPkgs = _: hakurei.targetPkgs;
extraOutputsToInstall = [ "dev" ];
profile = ''
export PKG_CONFIG_PATH="/usr/share/pkgconfig:$PKG_CONFIG_PATH"
'';
};
in
{
environment.systemPackages = [
# For go tests:
(pkgs.writeShellScriptBin "sharefs-workload-hakurei-tests" ''
cp -r "${self.packages.${system}.hakurei.src}" "/sdcard/hakurei" && cd "/sdcard/hakurei"
${fhs}/bin/hakurei-fhs -c 'CC="clang -O3 -Werror" go test ./...'
'')
];
imports = [
./configuration.nix
self.nixosModules.hakurei
self.inputs.home-manager.nixosModules.home-manager
];
};
testScript = builtins.readFile ./test.py;
}

60
cmd/sharefs/test/test.py Normal file
View File

@@ -0,0 +1,60 @@
start_all()
machine.wait_for_unit("multi-user.target")
# To check sharefs version:
print(machine.succeed("sharefs -V"))
# Make sure sharefs started:
machine.wait_for_unit("sdcard.mount")
machine.succeed("mkdir /mnt")
def check_bad_opts_output(opts, want, source="/etc", privileged=False):
output = machine.fail(("" if privileged else "sudo -u alice -i ") + f"sharefs -f -o source={source},{opts} /mnt 2>&1")
if output != want:
raise Exception(f"unexpected output: {output}")
# Malformed setuid/setgid representation:
check_bad_opts_output("setuid=ff", "sharefs: invalid value for option setuid\n")
check_bad_opts_output("setgid=ff", "sharefs: invalid value for option setgid\n")
# Bounds check for setuid/setgid:
check_bad_opts_output("setuid=0", "sharefs: invalid value for option setuid\n")
check_bad_opts_output("setgid=0", "sharefs: invalid value for option setgid\n")
check_bad_opts_output("setuid=-1", "sharefs: invalid value for option setuid\n")
check_bad_opts_output("setgid=-1", "sharefs: invalid value for option setgid\n")
# Non-root setuid/setgid:
check_bad_opts_output("setuid=1023", "sharefs: setuid and setgid has no effect when not starting as root\n")
check_bad_opts_output("setgid=1023", "sharefs: setuid and setgid has no effect when not starting as root\n")
check_bad_opts_output("setuid=1023,setgid=1023", "sharefs: setuid and setgid has no effect when not starting as root\n")
check_bad_opts_output("mkdir", "sharefs: mkdir has no effect when not starting as root\n")
# Starting as root without setuid/setgid:
check_bad_opts_output("allow_other", "sharefs: setuid and setgid must not be 0\n", privileged=True)
check_bad_opts_output("setuid=1023", "sharefs: setuid and setgid must not be 0\n", privileged=True)
check_bad_opts_output("setgid=1023", "sharefs: setuid and setgid must not be 0\n", privileged=True)
# Make sure nothing actually got mounted:
machine.fail("umount /mnt")
machine.succeed("rmdir /mnt")
# Unprivileged mount/unmount:
machine.succeed("sudo -u alice -i mkdir /home/alice/{sdcard,persistent}")
machine.succeed("sudo -u alice -i sharefs -o source=/home/alice/persistent /home/alice/sdcard")
machine.succeed("sudo -u alice -i touch /home/alice/sdcard/check")
machine.succeed("sudo -u alice -i umount /home/alice/sdcard")
machine.succeed("sudo -u alice -i rm /home/alice/persistent/check")
machine.succeed("sudo -u alice -i rmdir /home/alice/{sdcard,persistent}")
# Benchmark sharefs:
machine.succeed("fs_mark -v -d /sdcard/fs_mark -l /tmp/fs_log.txt")
machine.copy_from_vm("/tmp/fs_log.txt", "")
# Check permissions:
machine.succeed("sudo -u sharefs touch /var/lib/hakurei/sdcard/fs_mark/.check")
machine.succeed("sudo -u sharefs rm /var/lib/hakurei/sdcard/fs_mark/.check")
machine.succeed("sudo -u alice rm -rf /sdcard/fs_mark")
machine.fail("ls /var/lib/hakurei/sdcard/fs_mark")
# Run hakurei tests on sharefs:
machine.succeed("sudo -u alice -i sharefs-workload-hakurei-tests")

View File

@@ -202,7 +202,7 @@ func TestIsAutoRootBindable(t *testing.T) {
t.Parallel()
var msg message.Msg
if tc.log {
msg = &kstub{nil, stub.New(t, func(s *stub.Stub[syscallDispatcher]) syscallDispatcher { panic("unreachable") }, stub.Expect{Calls: []stub.Call{
msg = &kstub{nil, nil, stub.New(t, func(s *stub.Stub[syscallDispatcher]) syscallDispatcher { panic("unreachable") }, stub.Expect{Calls: []stub.Call{
call("verbose", stub.ExpectArgs{[]any{"got unexpected root entry"}}, nil, nil),
}})}
}

View File

@@ -35,6 +35,8 @@ type (
// Container represents a container environment being prepared or run.
// None of [Container] methods are safe for concurrent use.
Container struct {
// Whether the container init should stay alive after its parent terminates.
AllowOrphan bool
// Cgroup fd, nil to disable.
Cgroup *int
// ExtraFiles passed through to initial process in the container,
@@ -252,8 +254,7 @@ func (p *Container) Start() error {
}
p.cmd.Dir = fhs.Root
p.cmd.SysProcAttr = &SysProcAttr{
Setsid: !p.RetainSession,
Pdeathsig: SIGKILL,
Setsid: !p.RetainSession,
Cloneflags: CLONE_NEWUSER | CLONE_NEWPID | CLONE_NEWNS |
CLONE_NEWIPC | CLONE_NEWUTS | CLONE_NEWCGROUP,
@@ -268,6 +269,9 @@ func (p *Container) Start() error {
UseCgroupFD: p.Cgroup != nil,
}
if !p.AllowOrphan {
p.cmd.SysProcAttr.Pdeathsig = SIGKILL
}
if p.cmd.SysProcAttr.UseCgroupFD {
p.cmd.SysProcAttr.CgroupFD = *p.Cgroup
}

View File

@@ -162,7 +162,8 @@ func checkSimple(t *testing.T, fname string, testCases []simpleTestCase) {
t.Parallel()
wait4signal := make(chan struct{})
k := &kstub{wait4signal, stub.New(t, func(s *stub.Stub[syscallDispatcher]) syscallDispatcher { return &kstub{wait4signal, s} }, tc.want)}
lockNotify := make(chan struct{})
k := &kstub{wait4signal, lockNotify, stub.New(t, func(s *stub.Stub[syscallDispatcher]) syscallDispatcher { return &kstub{wait4signal, lockNotify, s} }, tc.want)}
defer stub.HandleExit(t)
if err := tc.f(k); !reflect.DeepEqual(err, tc.wantErr) {
t.Errorf("%s: error = %v, want %v", fname, err, tc.wantErr)
@@ -200,8 +201,8 @@ func checkOpBehaviour(t *testing.T, testCases []opBehaviourTestCase) {
t.Helper()
t.Parallel()
k := &kstub{nil, stub.New(t,
func(s *stub.Stub[syscallDispatcher]) syscallDispatcher { return &kstub{nil, s} },
k := &kstub{nil, nil, stub.New(t,
func(s *stub.Stub[syscallDispatcher]) syscallDispatcher { return &kstub{nil, nil, s} },
stub.Expect{Calls: slices.Concat(tc.early, []stub.Call{{Name: stub.CallSeparator}}, tc.apply)},
)}
state := &setupState{Params: tc.params, Msg: k}
@@ -322,12 +323,19 @@ const (
type kstub struct {
wait4signal chan struct{}
lockNotify chan struct{}
*stub.Stub[syscallDispatcher]
}
func (k *kstub) new(f func(k syscallDispatcher)) { k.Helper(); k.New(f) }
func (k *kstub) lockOSThread() { k.Helper(); k.Expects("lockOSThread") }
func (k *kstub) lockOSThread() {
k.Helper()
expect := k.Expects("lockOSThread")
if k.lockNotify != nil && expect.Ret == magicWait4Signal {
<-k.lockNotify
}
}
func (k *kstub) setPtracer(pid uintptr) error {
k.Helper()
@@ -472,6 +480,10 @@ func (k *kstub) notify(c chan<- os.Signal, sig ...os.Signal) {
k.FailNow()
}
if k.lockNotify != nil && expect.Ret == magicWait4Signal {
defer close(k.lockNotify)
}
// export channel for external instrumentation
if chanf, ok := expect.Args[0].(func(c chan<- os.Signal)); ok && chanf != nil {
chanf(c)

View File

@@ -1992,7 +1992,7 @@ func TestInitEntrypoint(t *testing.T) {
/* wait4 */
Tracks: []stub.Expect{{Calls: []stub.Call{
call("lockOSThread", stub.ExpectArgs{}, nil, nil),
call("lockOSThread", stub.ExpectArgs{}, magicWait4Signal, nil),
// this terminates the goroutine at the call, preventing it from leaking while preserving behaviour
call("wait4", stub.ExpectArgs{-1, nil, 0, nil, stub.PanicExit}, 0, syscall.ECHILD),
@@ -2075,7 +2075,7 @@ func TestInitEntrypoint(t *testing.T) {
call("fatalf", stub.ExpectArgs{"cannot close setup pipe: %v", []any{stub.UniqueError(10)}}, nil, nil),
call("verbosef", stub.ExpectArgs{"starting initial program %s", []any{check.MustAbs("/run/current-system/sw/bin/bash")}}, nil, nil),
call("start", stub.ExpectArgs{"/run/current-system/sw/bin/bash", []string{"bash", "-c", "false"}, ([]string)(nil), "/.hakurei/nonexistent"}, &os.Process{Pid: 0xbad}, nil),
call("notify", stub.ExpectArgs{func(c chan<- os.Signal) { c <- CancelSignal }, []os.Signal{CancelSignal, os.Interrupt, syscall.SIGTERM, syscall.SIGQUIT}}, nil, nil),
call("notify", stub.ExpectArgs{func(c chan<- os.Signal) { c <- CancelSignal }, []os.Signal{CancelSignal, os.Interrupt, syscall.SIGTERM, syscall.SIGQUIT}}, magicWait4Signal, nil),
call("verbose", stub.ExpectArgs{[]any{"forwarding context cancellation"}}, nil, nil),
// magicWait4Signal as ret causes wait4 stub to unblock
call("signal", stub.ExpectArgs{"/run/current-system/sw/bin/bash", []string{"bash", "-c", "false"}, ([]string)(nil), "/.hakurei/nonexistent", os.Interrupt}, magicWait4Signal, stub.UniqueError(9)),
@@ -2090,7 +2090,7 @@ func TestInitEntrypoint(t *testing.T) {
/* wait4 */
Tracks: []stub.Expect{{Calls: []stub.Call{
call("lockOSThread", stub.ExpectArgs{}, nil, nil),
call("lockOSThread", stub.ExpectArgs{}, magicWait4Signal, nil),
// magicWait4Signal as args[4] causes this to block until simulated signal is delivered
call("wait4", stub.ExpectArgs{-1, syscall.WaitStatus(0xfade01ce), 0, nil, magicWait4Signal}, 0xbad, nil),
@@ -2175,7 +2175,7 @@ func TestInitEntrypoint(t *testing.T) {
call("fatalf", stub.ExpectArgs{"cannot close setup pipe: %v", []any{stub.UniqueError(7)}}, nil, nil),
call("verbosef", stub.ExpectArgs{"starting initial program %s", []any{check.MustAbs("/run/current-system/sw/bin/bash")}}, nil, nil),
call("start", stub.ExpectArgs{"/run/current-system/sw/bin/bash", []string{"bash", "-c", "false"}, ([]string)(nil), "/.hakurei/nonexistent"}, &os.Process{Pid: 0xbad}, nil),
call("notify", stub.ExpectArgs{func(c chan<- os.Signal) { c <- syscall.SIGQUIT }, []os.Signal{CancelSignal, os.Interrupt, syscall.SIGTERM, syscall.SIGQUIT}}, nil, nil),
call("notify", stub.ExpectArgs{func(c chan<- os.Signal) { c <- syscall.SIGQUIT }, []os.Signal{CancelSignal, os.Interrupt, syscall.SIGTERM, syscall.SIGQUIT}}, magicWait4Signal, nil),
call("verbosef", stub.ExpectArgs{"got %s, forwarding to initial process", []any{"quit"}}, nil, nil),
// magicWait4Signal as ret causes wait4 stub to unblock
call("signal", stub.ExpectArgs{"/run/current-system/sw/bin/bash", []string{"bash", "-c", "false"}, ([]string)(nil), "/.hakurei/nonexistent", syscall.SIGQUIT}, magicWait4Signal, stub.UniqueError(0xfe)),
@@ -2190,7 +2190,7 @@ func TestInitEntrypoint(t *testing.T) {
/* wait4 */
Tracks: []stub.Expect{{Calls: []stub.Call{
call("lockOSThread", stub.ExpectArgs{}, nil, nil),
call("lockOSThread", stub.ExpectArgs{}, magicWait4Signal, nil),
// magicWait4Signal as args[4] causes this to block until simulated signal is delivered
call("wait4", stub.ExpectArgs{-1, syscall.WaitStatus(0xfade01ce), 0, nil, magicWait4Signal}, 0xbad, nil),
@@ -2275,7 +2275,7 @@ func TestInitEntrypoint(t *testing.T) {
call("fatalf", stub.ExpectArgs{"cannot close setup pipe: %v", []any{stub.UniqueError(7)}}, nil, nil),
call("verbosef", stub.ExpectArgs{"starting initial program %s", []any{check.MustAbs("/run/current-system/sw/bin/bash")}}, nil, nil),
call("start", stub.ExpectArgs{"/run/current-system/sw/bin/bash", []string{"bash", "-c", "false"}, ([]string)(nil), "/.hakurei/nonexistent"}, &os.Process{Pid: 0xbad}, nil),
call("notify", stub.ExpectArgs{func(c chan<- os.Signal) { c <- os.Interrupt }, []os.Signal{CancelSignal, os.Interrupt, syscall.SIGTERM, syscall.SIGQUIT}}, nil, nil),
call("notify", stub.ExpectArgs{func(c chan<- os.Signal) { c <- os.Interrupt }, []os.Signal{CancelSignal, os.Interrupt, syscall.SIGTERM, syscall.SIGQUIT}}, magicWait4Signal, nil),
call("verbosef", stub.ExpectArgs{"got %s", []any{"interrupt"}}, nil, nil),
call("beforeExit", stub.ExpectArgs{}, nil, nil),
call("exit", stub.ExpectArgs{0}, nil, nil),
@@ -2283,7 +2283,7 @@ func TestInitEntrypoint(t *testing.T) {
/* wait4 */
Tracks: []stub.Expect{{Calls: []stub.Call{
call("lockOSThread", stub.ExpectArgs{}, nil, nil),
call("lockOSThread", stub.ExpectArgs{}, magicWait4Signal, nil),
// this terminates the goroutine at the call, preventing it from leaking while preserving behaviour
call("wait4", stub.ExpectArgs{-1, nil, 0, nil, stub.PanicExit}, 0, syscall.ECHILD),
@@ -2366,7 +2366,7 @@ func TestInitEntrypoint(t *testing.T) {
call("fatalf", stub.ExpectArgs{"cannot close setup pipe: %v", []any{stub.UniqueError(5)}}, nil, nil),
call("verbosef", stub.ExpectArgs{"starting initial program %s", []any{check.MustAbs("/run/current-system/sw/bin/bash")}}, nil, nil),
call("start", stub.ExpectArgs{"/run/current-system/sw/bin/bash", []string{"bash", "-c", "false"}, ([]string)(nil), "/.hakurei/nonexistent"}, &os.Process{Pid: 0xbad}, nil),
call("notify", stub.ExpectArgs{nil, []os.Signal{CancelSignal, os.Interrupt, syscall.SIGTERM, syscall.SIGQUIT}}, nil, nil),
call("notify", stub.ExpectArgs{nil, []os.Signal{CancelSignal, os.Interrupt, syscall.SIGTERM, syscall.SIGQUIT}}, magicWait4Signal, nil),
call("verbose", stub.ExpectArgs{[]any{os.ErrInvalid.Error()}}, nil, nil),
call("verbose", stub.ExpectArgs{[]any{os.ErrInvalid.Error()}}, nil, nil),
call("verbosef", stub.ExpectArgs{"initial process exited with signal %s", []any{syscall.Signal(0x4e)}}, nil, nil),
@@ -2377,7 +2377,7 @@ func TestInitEntrypoint(t *testing.T) {
/* wait4 */
Tracks: []stub.Expect{{Calls: []stub.Call{
call("lockOSThread", stub.ExpectArgs{}, nil, nil),
call("lockOSThread", stub.ExpectArgs{}, magicWait4Signal, nil),
call("wait4", stub.ExpectArgs{-1, syscall.WaitStatus(0xfade01ce), 0, nil}, 0xbad, nil),
// this terminates the goroutine at the call, preventing it from leaking while preserving behaviour
@@ -2461,7 +2461,7 @@ func TestInitEntrypoint(t *testing.T) {
call("fatalf", stub.ExpectArgs{"cannot close setup pipe: %v", []any{stub.UniqueError(3)}}, nil, nil),
call("verbosef", stub.ExpectArgs{"starting initial program %s", []any{check.MustAbs("/run/current-system/sw/bin/bash")}}, nil, nil),
call("start", stub.ExpectArgs{"/run/current-system/sw/bin/bash", []string{"bash", "-c", "false"}, ([]string)(nil), "/.hakurei/nonexistent"}, &os.Process{Pid: 0xbad}, nil),
call("notify", stub.ExpectArgs{nil, []os.Signal{CancelSignal, os.Interrupt, syscall.SIGTERM, syscall.SIGQUIT}}, nil, nil),
call("notify", stub.ExpectArgs{nil, []os.Signal{CancelSignal, os.Interrupt, syscall.SIGTERM, syscall.SIGQUIT}}, magicWait4Signal, nil),
call("verbose", stub.ExpectArgs{[]any{os.ErrInvalid.Error()}}, nil, nil),
call("verbose", stub.ExpectArgs{[]any{os.ErrInvalid.Error()}}, nil, nil),
call("verbosef", stub.ExpectArgs{"initial process exited with signal %s", []any{syscall.Signal(0x4e)}}, nil, nil),
@@ -2471,7 +2471,7 @@ func TestInitEntrypoint(t *testing.T) {
/* wait4 */
Tracks: []stub.Expect{{Calls: []stub.Call{
call("lockOSThread", stub.ExpectArgs{}, nil, nil),
call("lockOSThread", stub.ExpectArgs{}, magicWait4Signal, nil),
call("wait4", stub.ExpectArgs{-1, nil, 0, nil}, 0, syscall.EINTR),
call("wait4", stub.ExpectArgs{-1, nil, 0, nil}, 0, syscall.EINTR),
@@ -2599,7 +2599,7 @@ func TestInitEntrypoint(t *testing.T) {
call("fatalf", stub.ExpectArgs{"cannot close setup pipe: %v", []any{stub.UniqueError(1)}}, nil, nil),
call("verbosef", stub.ExpectArgs{"starting initial program %s", []any{check.MustAbs("/run/current-system/sw/bin/bash")}}, nil, nil),
call("start", stub.ExpectArgs{"/run/current-system/sw/bin/bash", []string{"bash", "-c", "false"}, ([]string)(nil), "/.hakurei/nonexistent"}, &os.Process{Pid: 0xbad}, nil),
call("notify", stub.ExpectArgs{nil, []os.Signal{CancelSignal, os.Interrupt, syscall.SIGTERM, syscall.SIGQUIT}}, nil, nil),
call("notify", stub.ExpectArgs{nil, []os.Signal{CancelSignal, os.Interrupt, syscall.SIGTERM, syscall.SIGQUIT}}, magicWait4Signal, nil),
call("verbose", stub.ExpectArgs{[]any{os.ErrInvalid.Error()}}, nil, nil),
call("verbose", stub.ExpectArgs{[]any{os.ErrInvalid.Error()}}, nil, nil),
call("verbosef", stub.ExpectArgs{"initial process exited with code %d", []any{1}}, nil, nil),
@@ -2609,7 +2609,7 @@ func TestInitEntrypoint(t *testing.T) {
/* wait4 */
Tracks: []stub.Expect{{Calls: []stub.Call{
call("lockOSThread", stub.ExpectArgs{}, nil, nil),
call("lockOSThread", stub.ExpectArgs{}, magicWait4Signal, nil),
call("wait4", stub.ExpectArgs{-1, nil, 0, nil}, 0, syscall.EINTR),
call("wait4", stub.ExpectArgs{-1, nil, 0, nil}, 0, syscall.EINTR),
@@ -2741,7 +2741,7 @@ func TestInitEntrypoint(t *testing.T) {
call("fatalf", stub.ExpectArgs{"cannot close setup pipe: %v", []any{stub.UniqueError(0)}}, nil, nil),
call("verbosef", stub.ExpectArgs{"starting initial program %s", []any{check.MustAbs("/bin/zsh")}}, nil, nil),
call("start", stub.ExpectArgs{"/bin/zsh", []string{"zsh", "-c", "exec vim"}, []string{"DISPLAY=:0"}, "/.hakurei"}, &os.Process{Pid: 0xcafe}, nil),
call("notify", stub.ExpectArgs{nil, []os.Signal{CancelSignal, os.Interrupt, syscall.SIGTERM, syscall.SIGQUIT}}, nil, nil),
call("notify", stub.ExpectArgs{nil, []os.Signal{CancelSignal, os.Interrupt, syscall.SIGTERM, syscall.SIGQUIT}}, magicWait4Signal, nil),
call("verbose", stub.ExpectArgs{[]any{os.ErrInvalid.Error()}}, nil, nil),
call("verbose", stub.ExpectArgs{[]any{os.ErrInvalid.Error()}}, nil, nil),
call("verbose", stub.ExpectArgs{[]any{os.ErrInvalid.Error()}}, nil, nil),
@@ -2752,7 +2752,7 @@ func TestInitEntrypoint(t *testing.T) {
/* wait4 */
Tracks: []stub.Expect{{Calls: []stub.Call{
call("lockOSThread", stub.ExpectArgs{}, nil, nil),
call("lockOSThread", stub.ExpectArgs{}, magicWait4Signal, nil),
call("wait4", stub.ExpectArgs{-1, nil, 0, nil}, 0, syscall.EINTR),
call("wait4", stub.ExpectArgs{-1, nil, 0, nil}, 0, syscall.EINTR),

2
dist/install.sh vendored
View File

@@ -2,7 +2,7 @@
cd "$(dirname -- "$0")" || exit 1
install -vDm0755 "bin/hakurei" "${HAKUREI_INSTALL_PREFIX}/usr/bin/hakurei"
install -vDm0755 "bin/hpkg" "${HAKUREI_INSTALL_PREFIX}/usr/bin/hpkg"
install -vDm0755 "bin/sharefs" "${HAKUREI_INSTALL_PREFIX}/usr/bin/sharefs"
install -vDm4511 "bin/hsu" "${HAKUREI_INSTALL_PREFIX}/usr/bin/hsu"
if [ ! -f "${HAKUREI_INSTALL_PREFIX}/etc/hsurc" ]; then

16
flake.lock generated
View File

@@ -7,32 +7,32 @@
]
},
"locked": {
"lastModified": 1756679287,
"narHash": "sha256-Xd1vOeY9ccDf5VtVK12yM0FS6qqvfUop8UQlxEB+gTQ=",
"lastModified": 1765384171,
"narHash": "sha256-FuFtkJrW1Z7u+3lhzPRau69E0CNjADku1mLQQflUORo=",
"owner": "nix-community",
"repo": "home-manager",
"rev": "07fc025fe10487dd80f2ec694f1cd790e752d0e8",
"rev": "44777152652bc9eacf8876976fa72cc77ca8b9d8",
"type": "github"
},
"original": {
"owner": "nix-community",
"ref": "release-25.05",
"ref": "release-25.11",
"repo": "home-manager",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1757020766,
"narHash": "sha256-PLoSjHRa2bUbi1x9HoXgTx2AiuzNXs54c8omhadyvp0=",
"lastModified": 1765311797,
"narHash": "sha256-mSD5Ob7a+T2RNjvPvOA1dkJHGVrNVl8ZOrAwBjKBDQo=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "fe83bbdde2ccdc2cb9573aa846abe8363f79a97a",
"rev": "09eb77e94fa25202af8f3e81ddc7353d9970ac1b",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-25.05",
"ref": "nixos-25.11",
"repo": "nixpkgs",
"type": "github"
}

View File

@@ -2,10 +2,10 @@
description = "hakurei container tool and nixos module";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05";
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11";
home-manager = {
url = "github:nix-community/home-manager/release-25.05";
url = "github:nix-community/home-manager/release-25.11";
inputs.nixpkgs.follows = "nixpkgs";
};
};
@@ -69,6 +69,8 @@
withRace = true;
};
sharefs = callPackage ./cmd/sharefs/test { inherit system self; };
hpkg = callPackage ./cmd/hpkg/test { inherit system self; };
formatting = runCommandLocal "check-formatting" { nativeBuildInputs = [ nixfmt-rfc-style ]; } ''
@@ -136,6 +138,10 @@
;
};
hsu = pkgs.callPackage ./cmd/hsu/package.nix { inherit (self.packages.${system}) hakurei; };
sharefs = pkgs.linkFarm "sharefs" {
"bin/sharefs" = "${hakurei}/libexec/sharefs";
"bin/mount.fuse.sharefs" = "${hakurei}/libexec/sharefs";
};
dist = pkgs.runCommand "${hakurei.name}-dist" { buildInputs = hakurei.targetPkgs ++ [ pkgs.pkgsStatic.musl ]; } ''
# go requires XDG_CACHE_HOME for the build cache
@@ -160,7 +166,10 @@
pkgs = nixpkgsFor.${system};
in
{
default = pkgs.mkShell { buildInputs = hakurei.targetPkgs; };
default = pkgs.mkShell {
buildInputs = hakurei.targetPkgs;
hardeningDisable = [ "fortify" ];
};
withPackage = pkgs.mkShell { buildInputs = [ hakurei ] ++ hakurei.targetPkgs; };
vm =
@@ -185,13 +194,13 @@
hakurei =
let
# this is used for interactive vm testing during development, where tests might be broken
package = self.packages.${pkgs.system}.hakurei.override {
package = self.packages.${pkgs.stdenv.hostPlatform.system}.hakurei.override {
buildGoModule = previousArgs: pkgs.pkgsStatic.buildGoModule (previousArgs // { doCheck = false; });
};
in
{
inherit package;
hsuPackage = self.packages.${pkgs.system}.hsu.override { hakurei = package; };
hsuPackage = self.packages.${pkgs.stdenv.hostPlatform.system}.hsu.override { hakurei = package; };
};
};
}

View File

@@ -30,12 +30,46 @@ type Config struct {
// This option is unsupported and most likely enables full control over the Wayland
// session. Do not set this to true unless you are sure you know what you are doing.
DirectWayland bool `json:"direct_wayland,omitempty"`
// Direct access to the PipeWire socket established via SecurityContext::Create, no
// attempt is made to start the pipewire-pulse server.
//
// The SecurityContext machinery is fatally flawed, it blindly sets read and execute
// bits on all objects for clients with the lowest achievable privilege level (by
// setting PW_KEY_ACCESS to "restricted"). This enables them to call any method
// targeting any object, and since Registry::Destroy checks for the read and execute bit,
// allows the destruction of any object other than PW_ID_CORE as well. This behaviour
// is implemented separately in media-session and wireplumber, with the wireplumber
// implementation in Lua via an embedded Lua vm. In all known setups, wireplumber is
// in use, and there is no known way to change its behaviour and set permissions
// differently without replacing the Lua script. Also, since PipeWire relies on these
// permissions to work, reducing them is not possible.
//
// Currently, the only other sandboxed use case is flatpak, which is not aware of
// PipeWire and blindly exposes the bare PulseAudio socket to the container (behaves
// like DirectPulse). This socket is backed by the pipewire-pulse compatibility daemon,
// which obtains client pid via the SO_PEERCRED option. The PipeWire daemon, pipewire-pulse
// daemon and the session manager daemon then separately performs the /.flatpak-info hack
// described in https://git.gensokyo.uk/security/hakurei/issues/21. Under such use case,
// since the client has no direct access to PipeWire, insecure parts of the protocol are
// obscured by pipewire-pulse simply not implementing them, and thus hiding the flaws
// described above.
//
// Hakurei does not rely on the /.flatpak-info hack. Instead, a socket is sets up via
// SecurityContext. A pipewire-pulse server connected through it achieves the same
// permissions as flatpak does via the /.flatpak-info hack and is maintained for the
// life of the container.
//
// This option is unsupported and enables a denial-of-service attack as the sandboxed
// client is able to destroy any client object and thus disconnecting them from PipeWire,
// or destroy the SecurityContext object preventing any further container creation.
// Do not set this to true, it is insecure under any configuration.
DirectPipeWire bool `json:"direct_pipewire,omitempty"`
// Direct access to PulseAudio socket, no attempt is made to establish pipewire-pulse
// server via a PipeWire socket with a SecurityContext attached and the bare socket
// is made available to the container.
//
// This option is unsupported and enables arbitrary code execution as the PulseAudio
// server. Do not set this to true, this is insecure under any configuration.
// server. Do not set this to true, it is insecure under any configuration.
DirectPulse bool `json:"direct_pulse,omitempty"`
// Extra acl updates to perform before setuid.

View File

@@ -53,6 +53,10 @@ type syscallDispatcher interface {
readdir(name string) ([]os.DirEntry, error)
// tempdir provides [os.TempDir].
tempdir() string
// mkdir provides [os.Mkdir].
mkdir(name string, perm os.FileMode) error
// removeAll provides [os.RemoveAll].
removeAll(path string) error
// exit provides [os.Exit].
exit(code int)
@@ -62,6 +66,8 @@ type syscallDispatcher interface {
// lookupGroupId calls [user.LookupGroup] and returns the Gid field of the resulting [user.Group] struct.
lookupGroupId(name string) (string, error)
// lookPath provides exec.LookPath.
lookPath(file string) (string, error)
// cmdOutput provides the Output method of [exec.Cmd].
cmdOutput(cmd *exec.Cmd) ([]byte, error)
@@ -121,6 +127,8 @@ func (direct) stat(name string) (os.FileInfo, error) { return os.Stat(name)
func (direct) open(name string) (osFile, error) { return os.Open(name) }
func (direct) readdir(name string) ([]os.DirEntry, error) { return os.ReadDir(name) }
func (direct) tempdir() string { return os.TempDir() }
func (direct) mkdir(name string, perm os.FileMode) error { return os.Mkdir(name, perm) }
func (direct) removeAll(path string) error { return os.RemoveAll(path) }
func (direct) exit(code int) { os.Exit(code) }
func (direct) evalSymlinks(path string) (string, error) { return filepath.EvalSymlinks(path) }
@@ -134,6 +142,7 @@ func (direct) lookupGroupId(name string) (gid string, err error) {
return
}
func (direct) lookPath(file string) (string, error) { return exec.LookPath(file) }
func (direct) cmdOutput(cmd *exec.Cmd) ([]byte, error) { return cmd.Output() }
func (direct) notifyContext(parent context.Context, signals ...os.Signal) (ctx context.Context, stop context.CancelFunc) {

View File

@@ -701,10 +701,13 @@ func (panicDispatcher) stat(string) (os.FileInfo, error) { pa
func (panicDispatcher) open(string) (osFile, error) { panic("unreachable") }
func (panicDispatcher) readdir(string) ([]os.DirEntry, error) { panic("unreachable") }
func (panicDispatcher) tempdir() string { panic("unreachable") }
func (panicDispatcher) mkdir(string, os.FileMode) error { panic("unreachable") }
func (panicDispatcher) removeAll(string) error { panic("unreachable") }
func (panicDispatcher) exit(int) { panic("unreachable") }
func (panicDispatcher) evalSymlinks(string) (string, error) { panic("unreachable") }
func (panicDispatcher) prctl(uintptr, uintptr, uintptr) error { panic("unreachable") }
func (panicDispatcher) lookupGroupId(string) (string, error) { panic("unreachable") }
func (panicDispatcher) lookPath(string) (string, error) { panic("unreachable") }
func (panicDispatcher) cmdOutput(*exec.Cmd) ([]byte, error) { panic("unreachable") }
func (panicDispatcher) overflowUid(message.Msg) int { panic("unreachable") }
func (panicDispatcher) overflowGid(message.Msg) int { panic("unreachable") }

View File

@@ -70,7 +70,7 @@ type outcomeState struct {
// Copied from their respective exported values.
mapuid, mapgid *stringPair[int]
// Copied from [EnvPaths] per-process.
// Copied from [env.Paths] per-process.
sc hst.Paths
*env.Paths
@@ -172,6 +172,8 @@ type outcomeStateSys struct {
// Copied from [hst.Config]. Safe for read by spWaylandOp.toSystem only.
directWayland bool
// Copied from [hst.Config]. Safe for read by spPipeWireOp.toSystem only.
directPipeWire bool
// Copied from [hst.Config]. Safe for read by spPulseOp.toSystem only.
directPulse bool
// Copied header from [hst.Config]. Safe for read by spFilesystemOp.toSystem only.
@@ -187,9 +189,8 @@ type outcomeStateSys struct {
func (s *outcomeState) newSys(config *hst.Config, sys *system.I) *outcomeStateSys {
return &outcomeStateSys{
appId: config.ID, et: config.Enablements.Unwrap(),
directWayland: config.DirectWayland, directPulse: config.DirectPulse,
extraPerms: config.ExtraPerms,
sessionBus: config.SessionBus, systemBus: config.SystemBus,
directWayland: config.DirectWayland, directPipeWire: config.DirectPipeWire, directPulse: config.DirectPulse,
extraPerms: config.ExtraPerms, sessionBus: config.SessionBus, systemBus: config.SystemBus,
sys: sys, outcomeState: s,
}
}
@@ -256,6 +257,10 @@ type outcomeStateParams struct {
// Populated by spRuntimeOp.
runtimeDir *check.Absolute
// Path to pipewire-pulse server.
// Populated by spPipeWireOp if DirectPipeWire is false.
pipewirePulsePath *check.Absolute
as hst.ApplyState
*outcomeState
}
@@ -295,7 +300,7 @@ func (state *outcomeStateSys) toSystem() error {
// optional via enablements
&spWaylandOp{},
&spX11Op{},
spPipeWireOp{},
&spPipeWireOp{},
&spPulseOp{},
&spDBusOp{},

View File

@@ -68,7 +68,11 @@ func TestOutcomeRun(t *testing.T) {
).
// spPipeWireOp
PipeWire(m("/tmp/hakurei.0/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/pipewire")).
PipeWire(
m("/tmp/hakurei.0/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/pipewire"),
"org.chromium.Chromium",
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
).
// spDBusOp
MustProxyDBus(
@@ -96,7 +100,6 @@ func TestOutcomeRun(t *testing.T) {
"GOOGLE_DEFAULT_CLIENT_ID=77185425430.apps.googleusercontent.com",
"GOOGLE_DEFAULT_CLIENT_SECRET=OTJgUOQcT7lO7GsGZq2G4IlT",
"HOME=/data/data/org.chromium.Chromium",
"PIPEWIRE_REMOTE=/run/user/1971/pipewire-0",
"SHELL=/run/current-system/sw/bin/zsh",
"TERM=xterm-256color",
"USER=chronos",
@@ -146,9 +149,6 @@ func TestOutcomeRun(t *testing.T) {
// spWaylandOp
Bind(m("/tmp/hakurei.0/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/wayland"), m("/run/user/1971/wayland-0"), 0).
// spPipeWireOp
Bind(m("/tmp/hakurei.0/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/pipewire"), m("/run/user/1971/pipewire-0"), 0).
// spDBusOp
Bind(m("/tmp/hakurei.0/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/bus"), m("/run/user/1971/bus"), 0).
Bind(m("/tmp/hakurei.0/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/system_bus_socket"), m("/var/run/dbus/system_bus_socket"), 0).
@@ -170,7 +170,7 @@ func TestOutcomeRun(t *testing.T) {
Remount(fhs.AbsRoot, syscall.MS_RDONLY),
}},
{"nixos permissive defaults no enablements", new(stubNixOS), &hst.Config{Container: &hst.ContainerConfig{
{"nixos permissive defaults no enablements", new(stubNixOS), &hst.Config{DirectPipeWire: true, Container: &hst.ContainerConfig{
Filesystem: []hst.FilesystemConfigJSON{
{FilesystemConfig: &hst.FSBind{
Target: fhs.AbsRoot,
@@ -252,6 +252,8 @@ func TestOutcomeRun(t *testing.T) {
}},
{"nixos permissive defaults chromium", new(stubNixOS), &hst.Config{
DirectPipeWire: true,
ID: "org.chromium.Chromium",
Identity: 9,
Groups: []string{"video"},
@@ -335,7 +337,7 @@ func TestOutcomeRun(t *testing.T) {
Ensure(m("/tmp/hakurei.0/tmpdir/9"), 01700).UpdatePermType(system.User, m("/tmp/hakurei.0/tmpdir/9"), acl.Read, acl.Write, acl.Execute).
Ephemeral(system.Process, m("/tmp/hakurei.0/ebf083d1b175911782d413369b64ce7c"), 0711).
Wayland(m("/tmp/hakurei.0/ebf083d1b175911782d413369b64ce7c/wayland"), m("/run/user/1971/wayland-0"), "org.chromium.Chromium", "ebf083d1b175911782d413369b64ce7c").
PipeWire(m("/tmp/hakurei.0/ebf083d1b175911782d413369b64ce7c/pipewire")).
PipeWire(m("/tmp/hakurei.0/ebf083d1b175911782d413369b64ce7c/pipewire"), "org.chromium.Chromium", "ebf083d1b175911782d413369b64ce7c").
MustProxyDBus(&hst.BusConfig{
Talk: []string{
"org.freedesktop.Notifications",
@@ -422,6 +424,8 @@ func TestOutcomeRun(t *testing.T) {
}},
{"nixos chromium direct wayland", new(stubNixOS), &hst.Config{
DirectPipeWire: true,
ID: "org.chromium.Chromium",
Enablements: hst.NewEnablements(hst.EWayland | hst.EDBus | hst.EPipeWire | hst.EPulse),
Container: &hst.ContainerConfig{
@@ -486,7 +490,7 @@ func TestOutcomeRun(t *testing.T) {
Ensure(m("/run/user/1971/hakurei"), 0700).UpdatePermType(system.User, m("/run/user/1971/hakurei"), acl.Execute).
UpdatePermType(hst.EWayland, m("/run/user/1971/wayland-0"), acl.Read, acl.Write, acl.Execute).
Ephemeral(system.Process, m("/tmp/hakurei.0/8e2c76b066dabe574cf073bdb46eb5c1"), 0711).
PipeWire(m("/tmp/hakurei.0/8e2c76b066dabe574cf073bdb46eb5c1/pipewire")).
PipeWire(m("/tmp/hakurei.0/8e2c76b066dabe574cf073bdb46eb5c1/pipewire"), "org.chromium.Chromium", "8e2c76b066dabe574cf073bdb46eb5c1").
MustProxyDBus(&hst.BusConfig{
Talk: []string{
"org.freedesktop.FileManager1", "org.freedesktop.Notifications",
@@ -879,6 +883,16 @@ func (k *stubNixOS) lookupGroupId(name string) (string, error) {
}
}
func (k *stubNixOS) lookPath(file string) (string, error) {
switch file {
case "pipewire-pulse":
return "/run/current-system/sw/bin/pipewire-pulse", nil
default:
panic(fmt.Sprintf("unexpected file %q", file))
}
}
func (k *stubNixOS) cmdOutput(cmd *exec.Cmd) ([]byte, error) {
switch cmd.Path {
case "/proc/nonexistent/hsu":

View File

@@ -14,9 +14,12 @@ import (
"time"
"hakurei.app/container"
"hakurei.app/container/check"
"hakurei.app/container/fhs"
"hakurei.app/container/seccomp"
"hakurei.app/container/std"
"hakurei.app/hst"
"hakurei.app/internal/pipewire"
"hakurei.app/message"
)
@@ -83,6 +86,55 @@ func Shim(msg message.Msg) {
shimEntrypoint(direct{msg})
}
// A shimPrivate holds state of the private work directory owned by shim.
type shimPrivate struct {
// Path to directory if created.
pathname *check.Absolute
k syscallDispatcher
id *stringPair[hst.ID]
}
// unwrap returns the underlying pathname.
func (sp *shimPrivate) unwrap() *check.Absolute {
if sp.pathname == nil {
if a, err := check.NewAbs(sp.k.tempdir()); err != nil {
sp.k.fatal(err)
panic("unreachable")
} else {
pathname := a.Append(".hakurei-shim-" + sp.id.String())
sp.k.getMsg().Verbosef("creating private work directory %q", pathname)
if err = sp.k.mkdir(pathname.String(), 0700); err != nil {
sp.k.fatal(err)
panic("unreachable")
}
sp.pathname = pathname
return sp.unwrap()
}
} else {
return sp.pathname
}
}
// String returns the absolute pathname to the directory held by shimPrivate.
func (sp *shimPrivate) String() string { return sp.unwrap().String() }
// destroy removes the directory held by shimPrivate.
func (sp *shimPrivate) destroy() {
defer func() { sp.pathname = nil }()
if sp.pathname != nil {
sp.k.getMsg().Verbosef("destroying private work directory %q", sp.pathname)
if err := sp.k.removeAll(sp.pathname.String()); err != nil {
sp.k.getMsg().GetLogger().Println(err)
}
}
}
const (
// shimPipeWireTimeout is the duration pipewire-pulse is allowed to run before its socket becomes available.
shimPipeWireTimeout = 5 * time.Second
)
func shimEntrypoint(k syscallDispatcher) {
msg := k.getMsg()
if msg == nil {
@@ -208,6 +260,7 @@ func shimEntrypoint(k syscallDispatcher) {
ctx, stop := k.notifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
cancelContainer.Store(&stop)
sp := shimPrivate{k: k, id: state.id}
z := container.New(ctx, msg)
z.Params = *stateParams.params
z.Stdin, z.Stdout, z.Stderr = os.Stdin, os.Stdout, os.Stderr
@@ -215,6 +268,79 @@ func shimEntrypoint(k syscallDispatcher) {
// bounds and default enforced in finalise.go
z.WaitDelay = state.Shim.WaitDelay
if stateParams.pipewirePulsePath != nil {
zpw := container.NewCommand(ctx, msg, stateParams.pipewirePulsePath, pipewirePulseName)
zpw.Hostname = "hakurei-" + pipewirePulseName
zpw.SeccompFlags |= seccomp.AllowMultiarch
zpw.SeccompPresets |= std.PresetStrict
zpw.Env = []string{
// pipewire SecurityContext socket path
pipewire.Remote + "=" + stateParams.instancePath().Append("pipewire").String(),
// pipewire-pulse socket directory path
envXDGRuntimeDir + "=" + sp.String(),
}
if msg.IsVerbose() {
zpw.Stdin, zpw.Stdout, zpw.Stderr = os.Stdin, os.Stdout, os.Stderr
}
zpw.
Bind(fhs.AbsRoot, fhs.AbsRoot, 0).
Bind(sp.unwrap(), sp.unwrap(), std.BindWritable).
Proc(fhs.AbsProc).Dev(fhs.AbsDev, true)
socketPath := sp.unwrap().Append("pulse", "native")
innerSocketPath := stateParams.runtimeDir.Append("pulse", "native")
if err := k.containerStart(zpw); err != nil {
sp.destroy()
printMessageError(func(v ...any) { k.fatal(fmt.Sprintln(v...)) },
"cannot start "+pipewirePulseName+" container:", err)
}
if err := k.containerServe(zpw); err != nil {
sp.destroy()
printMessageError(func(v ...any) { k.fatal(fmt.Sprintln(v...)) },
"cannot configure "+pipewirePulseName+" container:", err)
}
done := make(chan error, 1)
k.new(func(k syscallDispatcher, msg message.Msg) { done <- k.containerWait(zpw) })
socketTimer := time.NewTimer(shimPipeWireTimeout)
for {
select {
case <-socketTimer.C:
sp.destroy()
k.fatal(pipewirePulseName + " exceeded deadline before socket appeared")
break
case err := <-done:
var exitError *exec.ExitError
if !errors.As(err, &exitError) {
msg.Verbosef("cannot wait: %v", err)
k.exit(127)
}
sp.destroy()
k.fatal(pipewirePulseName + " " + exitError.ProcessState.String())
break
default:
if _, err := k.stat(socketPath.String()); err != nil {
if !errors.Is(err, os.ErrNotExist) {
sp.destroy()
k.fatal(err)
break
}
time.Sleep(500 * time.Microsecond)
continue
}
}
break
}
z.Bind(socketPath, innerSocketPath, 0)
z.Env = append(z.Env, "PULSE_SERVER=unix:"+innerSocketPath.String())
}
if err := k.containerStart(z); err != nil {
var f func(v ...any)
if logger := msg.GetLogger(); logger != nil {
@@ -225,9 +351,11 @@ func shimEntrypoint(k syscallDispatcher) {
}
}
printMessageError(f, "cannot start container:", err)
sp.destroy()
k.exit(hst.ExitFailure)
}
if err := k.containerServe(z); err != nil {
sp.destroy()
printMessageError(func(v ...any) { k.fatal(fmt.Sprintln(v...)) },
"cannot configure container:", err)
}
@@ -236,10 +364,13 @@ func shimEntrypoint(k syscallDispatcher) {
seccomp.Preset(std.PresetStrict, seccomp.AllowMultiarch),
seccomp.AllowMultiarch,
); err != nil {
sp.destroy()
k.fatalf("cannot load syscall filter: %v", err)
}
if err := k.containerWait(z); err != nil {
sp.destroy()
var exitError *exec.ExitError
if !errors.As(err, &exitError) {
if errors.Is(err, context.Canceled) {
@@ -250,4 +381,5 @@ func shimEntrypoint(k syscallDispatcher) {
}
k.exit(exitError.ExitCode())
}
sp.destroy()
}

View File

@@ -3,29 +3,51 @@ package outcome
import (
"encoding/gob"
"hakurei.app/container/check"
"hakurei.app/hst"
"hakurei.app/internal/pipewire"
)
func init() { gob.Register(spPipeWireOp{}) }
const pipewirePulseName = "pipewire-pulse"
func init() { gob.Register(new(spPipeWireOp)) }
// spPipeWireOp exports the PipeWire server to the container via SecurityContext.
// Runs after spRuntimeOp.
type spPipeWireOp struct{}
type spPipeWireOp struct {
// Path to pipewire-pulse server. Populated during toSystem if DirectPipeWire is false.
CompatServerPath *check.Absolute
}
func (s spPipeWireOp) toSystem(state *outcomeStateSys) error {
func (s *spPipeWireOp) toSystem(state *outcomeStateSys) error {
if state.et&hst.EPipeWire == 0 {
return errNotEnabled
}
if !state.directPipeWire {
if n, err := state.k.lookPath(pipewirePulseName); err != nil {
return &hst.AppError{Step: "look up " + pipewirePulseName, Err: err}
} else if s.CompatServerPath, err = check.NewAbs(n); err != nil {
return err
}
}
state.sys.PipeWire(state.instance().Append("pipewire"))
appId := state.appId
if appId == "" {
// use instance ID in case app id is not set
appId = "app.hakurei." + state.id.String()
}
state.sys.PipeWire(state.instance().Append("pipewire"), appId, state.id.String())
return nil
}
func (s spPipeWireOp) toContainer(state *outcomeStateParams) error {
innerPath := state.runtimeDir.Append(pipewire.PW_DEFAULT_REMOTE)
state.env[pipewire.Remote] = innerPath.String()
state.params.Bind(state.instancePath().Append("pipewire"), innerPath, 0)
func (s *spPipeWireOp) toContainer(state *outcomeStateParams) error {
if s.CompatServerPath == nil {
innerPath := state.runtimeDir.Append(pipewire.PW_DEFAULT_REMOTE)
state.env[pipewire.Remote] = innerPath.String()
state.params.Bind(state.instancePath().Append("pipewire"), innerPath, 0)
}
// pipewire-pulse behaviour implemented in shim.go
state.pipewirePulsePath = s.CompatServerPath
return nil
}

View File

@@ -16,7 +16,7 @@ func TestSpPipeWireOp(t *testing.T) {
checkOpBehaviour(t, []opBehaviourTestCase{
{"not enabled", func(bool, bool) outcomeOp {
return spPipeWireOp{}
return new(spPipeWireOp)
}, func() *hst.Config {
c := hst.Template()
*c.Enablements = 0
@@ -24,13 +24,19 @@ func TestSpPipeWireOp(t *testing.T) {
}, nil, nil, nil, nil, errNotEnabled, nil, nil, nil, nil, nil},
{"success", func(bool, bool) outcomeOp {
return spPipeWireOp{}
}, hst.Template, nil, []stub.Call{}, newI().
return new(spPipeWireOp)
}, func() *hst.Config {
c := hst.Template()
c.DirectPipeWire = true
return c
}, nil, []stub.Call{}, newI().
// state.instance
Ephemeral(system.Process, m(wantInstancePrefix), 0711).
// toSystem
PipeWire(
m(wantInstancePrefix + "/pipewire"),
m(wantInstancePrefix+"/pipewire"),
"org.chromium.Chromium",
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
), sysUsesInstance(nil), nil, insertsOps(afterSpRuntimeOp(nil)), []stub.Call{
// this op configures the container state and does not make calls during toContainer
}, &container.Params{

View File

@@ -103,6 +103,8 @@ type Client struct {
// Populated by [CoreBoundProps] events targeting [Client].
Properties SPADict `json:"props"`
noRemove
}
func (client *Client) consume(opcode byte, files []int, unmarshal func(v any)) error {
@@ -113,7 +115,7 @@ func (client *Client) consume(opcode byte, files []int, unmarshal func(v any)) e
return nil
default:
return &UnsupportedOpcodeError{opcode, client.String()}
panic(&UnsupportedOpcodeError{opcode, client.String()})
}
}

View File

@@ -40,13 +40,14 @@ const (
PW_CORE_EVENT_ADD_MEM
PW_CORE_EVENT_REMOVE_MEM
PW_CORE_EVENT_BOUND_PROPS
PW_CORE_EVENT_NUM
PW_CORE_EVENT_NUM
PW_VERSION_CORE_EVENTS = 1
)
const (
PW_CORE_METHOD_ADD_LISTENER = iota
PW_CORE_METHOD_HELLO
PW_CORE_METHOD_SYNC
PW_CORE_METHOD_PONG
@@ -54,25 +55,26 @@ const (
PW_CORE_METHOD_GET_REGISTRY
PW_CORE_METHOD_CREATE_OBJECT
PW_CORE_METHOD_DESTROY
PW_CORE_METHOD_NUM
PW_CORE_METHOD_NUM
PW_VERSION_CORE_METHODS = 0
)
const (
PW_REGISTRY_EVENT_GLOBAL = iota
PW_REGISTRY_EVENT_GLOBAL_REMOVE
PW_REGISTRY_EVENT_NUM
PW_REGISTRY_EVENT_NUM
PW_VERSION_REGISTRY_EVENTS = 0
)
const (
PW_REGISTRY_METHOD_ADD_LISTENER = iota
PW_REGISTRY_METHOD_BIND
PW_REGISTRY_METHOD_DESTROY
PW_REGISTRY_METHOD_NUM
PW_REGISTRY_METHOD_NUM
PW_VERSION_REGISTRY_METHODS = 0
)
@@ -266,6 +268,31 @@ type CoreErrorEvent struct{ CoreError }
// Opcode satisfies [Message] with a constant value.
func (c *CoreErrorEvent) Opcode() byte { return PW_CORE_EVENT_ERROR }
// The CoreRemoveId event is used internally by the object ID management logic.
//
// When a client deletes an object, the server will send this event to acknowledge
// that it has seen the delete request. When the client receives this event, it
// will know that it can safely reuse the object ID.
type CoreRemoveId struct {
// A proxy id that was removed.
ID Int `json:"id"`
}
// Opcode satisfies [Message] with a constant value.
func (c *CoreRemoveId) Opcode() byte { return PW_CORE_EVENT_REMOVE_ID }
// FileCount satisfies [Message] with a constant value.
func (c *CoreRemoveId) FileCount() Int { return 0 }
// Size satisfies [KnownSize] with a constant value.
func (c *CoreRemoveId) Size() Word { return SizePrefix + Size(SizeInt) }
// MarshalBinary satisfies [encoding.BinaryMarshaler] via [Marshal].
func (c *CoreRemoveId) MarshalBinary() ([]byte, error) { return Marshal(c) }
// UnmarshalBinary satisfies [encoding.BinaryUnmarshaler] via [Unmarshal].
func (c *CoreRemoveId) UnmarshalBinary(data []byte) error { return Unmarshal(data, c) }
// The CoreBoundProps event is emitted when a local object ID is bound to a global ID.
// It is emitted before the global becomes visible in the registry.
type CoreBoundProps struct {
@@ -299,13 +326,96 @@ func (c *CoreBoundProps) UnmarshalBinary(data []byte) error { return Unmarshal(d
// ErrBadBoundProps is returned when a [CoreBoundProps] event targeting a proxy
// that should never be targeted is received and processed.
var ErrBadBoundProps = errors.New("attempted to store bound props on proxy that should never be targeted")
var ErrBadBoundProps = errors.New("attempting to store bound props on a proxy that should never be targeted")
// noAck is embedded by proxies that are never targeted by [CoreBoundProps].
type noAck struct{}
// setBoundProps should never be called as this proxy should never be targeted by [CoreBoundProps].
func (noAck) setBoundProps(*CoreBoundProps) error { return ErrBadBoundProps }
func (noAck) setBoundProps(*CoreBoundProps) error { panic(ErrBadBoundProps) }
// ErrBadRemove is returned when a [CoreRemoveId] event targeting a proxy
// that should never be targeted is received and processed.
var ErrBadRemove = errors.New("attempting to remove a proxy that should never be targeted")
// noRemove is embedded by proxies that are never targeted by [CoreRemoveId].
type noRemove struct{}
// remove should never be called as this proxy should never be targeted by [CoreRemoveId].
func (noRemove) remove() error { panic(ErrBadRemove) }
// ErrInvalidRemove is returned when a proxy is somehow removed twice. This is only reached for
// an implementation error as the proxy struct should no longer be reachable after the first call.
var ErrInvalidRemove = errors.New("attempting to remove an already freed proxy")
// removable is embedded by proxies that can be targeted by [CoreRemoveId] and requires no cleanup.
type removable bool
// remove checks against removal of a freed proxy and marks the proxy as removed.
func (s *removable) remove() error {
if *s {
panic(ErrInvalidRemove)
}
*s = true
return nil
}
// ErrProxyDestroyed is returned when attempting to use a proxy method when the underlying
// proxy has already been targeted by a [CoreRemoveId] event.
var ErrProxyDestroyed = errors.New("underlying proxy has been removed")
// checkDestroy returns [ErrProxyDestroyed] if the current proxy has been destroyed.
// Must be called at the beginning of any exported method of a proxy embedding removable.
func (s *removable) checkDestroy() error {
if *s {
// not fatal: the caller is allowed to recover from this and allocate a new proxy
return ErrProxyDestroyed
}
return nil
}
// mustCheckDestroy calls checkDestroy and panics if a non-nil error is returned.
// This is useful for non-exported methods as they should become unreachable.
func (s *removable) mustCheckDestroy() {
if err := s.checkDestroy(); err != nil {
panic(err)
}
}
// destructible is embedded by proxies that can be targeted by the [CoreRemoveId] event and the
// [CoreDestroy] method and requires no cleanup. destructible purposefully does not override
// removable.mustCheckDestroy because it is used by unexported methods called during event handling
// and are exempt from the destruction check.
type destructible struct {
destroyed bool
removable
}
// checkDestroy overrides removable.checkDestroy to also check the destroyed field.
func (s *destructible) checkDestroy() error {
if s.destroyed {
return ErrProxyDestroyed
}
if err := s.removable.checkDestroy(); err != nil {
return err
}
return nil
}
// destroy calls removable.checkDestroy then queues a [CoreDestroy] event if it succeeds.
func (s *destructible) destroy(ctx *Context, id Int) error {
if err := s.checkDestroy(); err != nil {
return err
}
l := len(ctx.pendingDestruction)
ctx.pendingDestruction[id] = struct{}{}
if len(ctx.pendingDestruction) != l+1 {
return ErrProxyDestroyed
}
s.destroyed = true
return ctx.GetCore().destroy(id)
}
// An InconsistentIdError describes an inconsistent state where the server claims an impossible
// proxy or global id. This is only generated by the [CoreBoundProps] event.
@@ -349,10 +459,10 @@ func (c *CoreHello) MarshalBinary() ([]byte, error) { return Marshal(c) }
// UnmarshalBinary satisfies [encoding.BinaryUnmarshaler] via [Unmarshal].
func (c *CoreHello) UnmarshalBinary(data []byte) error { return Unmarshal(data, c) }
// coreHello queues a [CoreHello] message for the PipeWire server.
// This method should not be called directly, the New function queues this message.
func (ctx *Context) coreHello() error {
return ctx.writeMessage(
// hello queues a [CoreHello] message for the PipeWire server.
// This method should not be called directly, the [New] function queues this message.
func (core *Core) hello() error {
return core.ctx.writeMessage(
PW_ID_CORE,
&CoreHello{PW_VERSION_CORE},
)
@@ -388,12 +498,12 @@ func (c *CoreSync) MarshalBinary() ([]byte, error) { return Marshal(c) }
// UnmarshalBinary satisfies [encoding.BinaryUnmarshaler] via [Unmarshal].
func (c *CoreSync) UnmarshalBinary(data []byte) error { return Unmarshal(data, c) }
// coreSync queues a [CoreSync] message for the PipeWire server.
// sync queues a [CoreSync] message for the PipeWire server.
// This is not safe to use directly, callers should use Sync instead.
func (ctx *Context) coreSync(id Int) error {
return ctx.writeMessage(
func (core *Core) sync(id Int) error {
return core.ctx.writeMessage(
PW_ID_CORE,
&CoreSync{id, CoreSyncSequenceOffset + Int(ctx.sequence)},
&CoreSync{id, CoreSyncSequenceOffset + Int(core.ctx.sequence)},
)
}
@@ -404,13 +514,13 @@ var ErrNotDone = errors.New("did not receive a Core::Done event targeting previo
const (
// syncTimeout is the maximum duration [Core.Sync] is allowed to take before
// receiving [CoreDone] or failing.
syncTimeout = 5 * time.Second
syncTimeout = 10 * time.Second
)
// Sync queues a [CoreSync] message for the PipeWire server and initiates a Roundtrip.
func (core *Core) Sync() error {
core.done = false
if err := core.ctx.coreSync(roundtripSyncID); err != nil {
if err := core.sync(roundtripSyncID); err != nil {
return err
}
deadline := time.Now().Add(syncTimeout)
@@ -429,6 +539,10 @@ func (core *Core) Sync() error {
core.ctx.closeReceivedFiles()
return &ProxyFatalError{Err: UnacknowledgedProxyError(slices.Collect(maps.Keys(core.ctx.pendingIds))), ProxyErrs: core.ctx.cloneAsProxyErrors()}
}
if len(core.ctx.pendingDestruction) != 0 {
core.ctx.closeReceivedFiles()
return &ProxyFatalError{Err: UnacknowledgedProxyDestructionError(slices.Collect(maps.Keys(core.ctx.pendingDestruction))), ProxyErrs: core.ctx.cloneAsProxyErrors()}
}
return core.ctx.doSyncComplete()
}
@@ -497,6 +611,89 @@ func (ctx *Context) GetRegistry() (*Registry, error) {
)
}
// CoreCreateObject is sent when the client requests to create a
// new object from a factory of a certain type.
//
// The client allocates a new_id for the proxy. The server will
// allocate a new resource with the same new_id and from then on,
// Methods and Events will be exchanged between the new object of
// the given type.
type CoreCreateObject struct {
// The name of a server factory object to use.
FactoryName String `json:"factory_name"`
// The type of the object to create, this is also the type of
// the interface of the new_id proxy.
Type String `json:"type"`
// Undocumented, assumed to be the local version of the proxy.
Version Int `json:"version"`
// Extra properties to create the object.
Properties *SPADict `json:"props"`
// The proxy id of the new object.
NewID Int `json:"new_id"`
}
// Opcode satisfies [Message] with a constant value.
func (c *CoreCreateObject) Opcode() byte { return PW_CORE_METHOD_CREATE_OBJECT }
// FileCount satisfies [Message] with a constant value.
func (c *CoreCreateObject) FileCount() Int { return 0 }
// Size satisfies [KnownSize] with a value computed at runtime.
func (c *CoreCreateObject) Size() Word {
return SizePrefix +
SizeString[Word](c.FactoryName) +
SizeString[Word](c.Type) +
Size(SizeInt) +
c.Properties.Size() +
Size(SizeInt)
}
// MarshalBinary satisfies [encoding.BinaryMarshaler] via [Marshal].
func (c *CoreCreateObject) MarshalBinary() ([]byte, error) { return Marshal(c) }
// UnmarshalBinary satisfies [encoding.BinaryUnmarshaler] via [Unmarshal].
func (c *CoreCreateObject) UnmarshalBinary(data []byte) error { return Unmarshal(data, c) }
// createObject queues a [CoreCreateObject] message for the PipeWire server.
// This is not safe to use directly, callers should use typed wrapper methods on [Registry] instead.
func (core *Core) createObject(factoryName, typeName String, version Int, props SPADict, newId Int) error {
return core.ctx.writeMessage(
PW_ID_CORE,
&CoreCreateObject{factoryName, typeName, version, &props, newId},
)
}
// CoreDestroy is sent when the client requests to destroy an object.
type CoreDestroy struct {
// The proxy id of the object to destroy.
ID Int `json:"id"`
}
// Opcode satisfies [Message] with a constant value.
func (c *CoreDestroy) Opcode() byte { return PW_CORE_METHOD_DESTROY }
// FileCount satisfies [Message] with a constant value.
func (c *CoreDestroy) FileCount() Int { return 0 }
// Size satisfies [KnownSize] with a constant value.
func (c *CoreDestroy) Size() Word { return SizePrefix + Size(SizeInt) }
// MarshalBinary satisfies [encoding.BinaryMarshaler] via [Marshal].
func (c *CoreDestroy) MarshalBinary() ([]byte, error) { return Marshal(c) }
// UnmarshalBinary satisfies [encoding.BinaryUnmarshaler] via [Unmarshal].
func (c *CoreDestroy) UnmarshalBinary(data []byte) error { return Unmarshal(data, c) }
// destroy queues a [CoreDestroy] message for the PipeWire server.
// This is not safe to use directly, callers should use the exported method
// on the proxy implementation instead.
func (core *Core) destroy(id Int) error {
return core.ctx.writeMessage(
PW_ID_CORE,
&CoreDestroy{id},
)
}
// A RegistryGlobal event is emitted to notify a client about a new global object.
type RegistryGlobal struct {
// The global id.
@@ -533,6 +730,30 @@ func (c *RegistryGlobal) MarshalBinary() ([]byte, error) { return Marshal(c) }
// UnmarshalBinary satisfies [encoding.BinaryUnmarshaler] via [Unmarshal].
func (c *RegistryGlobal) UnmarshalBinary(data []byte) error { return Unmarshal(data, c) }
// A RegistryGlobalRemove event is emitted when a global with id was removed.
type RegistryGlobalRemove struct {
// The global id that was removed.
ID Int `json:"id"`
}
// Opcode satisfies [Message] with a constant value.
func (c *RegistryGlobalRemove) Opcode() byte { return PW_REGISTRY_EVENT_GLOBAL_REMOVE }
// FileCount satisfies [Message] with a constant value.
func (c *RegistryGlobalRemove) FileCount() Int { return 0 }
// Size satisfies [KnownSize] with a constant value.
func (c *RegistryGlobalRemove) Size() Word {
return SizePrefix +
Size(SizeInt)
}
// MarshalBinary satisfies [encoding.BinaryMarshaler] via [Marshal].
func (c *RegistryGlobalRemove) MarshalBinary() ([]byte, error) { return Marshal(c) }
// UnmarshalBinary satisfies [encoding.BinaryUnmarshaler] via [Unmarshal].
func (c *RegistryGlobalRemove) UnmarshalBinary(data []byte) error { return Unmarshal(data, c) }
// RegistryBind is sent when the client requests to bind to the
// global object with id and use the client proxy with new_id as
// the proxy. After this call, methods can be sent to the remote
@@ -584,10 +805,70 @@ func (registry *Registry) bind(proxy eventProxy, id, version Int) (Int, error) {
)
}
// RegistryDestroy is sent to try to destroy the global object with id.
// This might fail when the client does not have permission.
type RegistryDestroy struct {
// The global id to destroy.
ID Int `json:"id"`
}
// Opcode satisfies [Message] with a constant value.
func (c *RegistryDestroy) Opcode() byte { return PW_REGISTRY_METHOD_DESTROY }
// FileCount satisfies [Message] with a constant value.
func (c *RegistryDestroy) FileCount() Int { return 0 }
// Size satisfies [KnownSize] with a constant value.
func (c *RegistryDestroy) Size() Word {
return SizePrefix +
Size(SizeInt)
}
// MarshalBinary satisfies [encoding.BinaryMarshaler] via [Marshal].
func (c *RegistryDestroy) MarshalBinary() ([]byte, error) { return Marshal(c) }
// UnmarshalBinary satisfies [encoding.BinaryUnmarshaler] via [Unmarshal].
func (c *RegistryDestroy) UnmarshalBinary(data []byte) error { return Unmarshal(data, c) }
// destroy queues a [RegistryDestroy] message for the PipeWire server.
func (registry *Registry) destroy(id Int) error {
return registry.ctx.writeMessage(
registry.ID,
&RegistryDestroy{id},
)
}
// Destroy tries to destroy the global object with id.
func (registry *Registry) Destroy(id Int) (err error) {
asCoreError := registry.ctx.expectsCoreError(registry.ID, &err)
if err != nil {
return
}
if err = registry.destroy(id); err != nil {
return err
}
if err = registry.ctx.GetCore().Sync(); err == nil {
return nil
}
if coreError := asCoreError(); coreError == nil {
return
} else {
switch syscall.Errno(-coreError.Result) {
case syscall.EPERM:
return &PermissionError{registry.ID, coreError.Message}
default:
return coreError
}
}
}
// An UnsupportedObjectTypeError is the name of a type not known by the server [Registry].
type UnsupportedObjectTypeError string
func (e UnsupportedObjectTypeError) Error() string { return "unsupported object type " + string(e) }
func (e UnsupportedObjectTypeError) Error() string { return "unsupported object type " + string(e) }
func (e UnsupportedObjectTypeError) Message() string { return e.Error() }
// Core holds state of [PW_TYPE_INTERFACE_Core].
type Core struct {
@@ -598,22 +879,24 @@ type Core struct {
done bool
ctx *Context
noAck
noRemove
}
// ErrUnexpectedDone is a [CoreDone] event with unexpected values.
var ErrUnexpectedDone = errors.New("multiple Core::Done events targeting Core::Sync")
// An UnknownBoundIdError describes the server claiming to have bound a proxy id that was never allocated.
type UnknownBoundIdError[E any] struct {
// An UnknownProxyIdError describes an event targeting a proxy id that was never allocated.
type UnknownProxyIdError[E any] struct {
// Offending id decoded from Data.
Id Int
// Event received from the server.
Event E
}
func (e *UnknownBoundIdError[E]) Error() string {
return "unknown bound proxy id " + strconv.Itoa(int(e.Id))
func (e *UnknownProxyIdError[E]) Error() string {
return "unknown proxy id " + strconv.Itoa(int(e.Id))
}
// An InvalidPingError is a [CorePing] event targeting a proxy id that was never allocated.
@@ -668,6 +951,19 @@ func (core *Core) consume(opcode byte, files []int, unmarshal func(v any)) error
unmarshal(&coreError)
return &coreError
case PW_CORE_EVENT_REMOVE_ID:
var coreRemoveId CoreRemoveId
unmarshal(&coreRemoveId)
if proxy, ok := core.ctx.proxy[coreRemoveId.ID]; !ok {
// this should never happen so is non-recoverable if it does
panic(&UnknownProxyIdError[*CoreRemoveId]{Id: coreRemoveId.ID, Event: &coreRemoveId})
} else {
delete(core.ctx.proxy, coreRemoveId.ID)
// not always populated so this is not checked
delete(core.ctx.pendingDestruction, coreRemoveId.ID)
return proxy.remove()
}
case PW_CORE_EVENT_BOUND_PROPS:
var boundProps CoreBoundProps
unmarshal(&boundProps)
@@ -675,12 +971,12 @@ func (core *Core) consume(opcode byte, files []int, unmarshal func(v any)) error
delete(core.ctx.pendingIds, boundProps.ID)
proxy, ok := core.ctx.proxy[boundProps.ID]
if !ok {
return &UnknownBoundIdError[*CoreBoundProps]{Id: boundProps.ID, Event: &boundProps}
return &UnknownProxyIdError[*CoreBoundProps]{Id: boundProps.ID, Event: &boundProps}
}
return proxy.setBoundProps(&boundProps)
default:
return &UnsupportedOpcodeError{opcode, core.String()}
panic(&UnsupportedOpcodeError{opcode, core.String()})
}
}
@@ -698,7 +994,9 @@ type Registry struct {
Objects map[Int]RegistryGlobal `json:"objects"`
ctx *Context
noAck
noRemove
}
// A GlobalIDCollisionError describes a [RegistryGlobal] event stepping on a previous instance of itself.
@@ -714,6 +1012,14 @@ func (e *GlobalIDCollisionError) Error() string {
" stepping on previous id " + strconv.Itoa(int(e.ID)) + " for " + e.Previous.Type
}
// An UnknownGlobalIDRemoveError describes a [RegistryGlobalRemove] event announcing the removal of
// a global id that is not yet known to [Registry] or was already deleted.
type UnknownGlobalIDRemoveError Int
func (e UnknownGlobalIDRemoveError) Error() string {
return "Registry::GlobalRemove event targets unknown id " + strconv.Itoa(int(e))
}
func (registry *Registry) consume(opcode byte, files []int, unmarshal func(v any)) error {
closeReceivedFiles(files...)
switch opcode {
@@ -727,8 +1033,21 @@ func (registry *Registry) consume(opcode byte, files []int, unmarshal func(v any
registry.Objects[global.ID] = global
return nil
case PW_REGISTRY_EVENT_GLOBAL_REMOVE:
var globalRemove RegistryGlobalRemove
unmarshal(&globalRemove)
// server emits PW_CORE_EVENT_REMOVE_ID events targeting
// affected proxies so they do not need to be handled here
l := len(registry.Objects)
delete(registry.Objects, globalRemove.ID)
if len(registry.Objects) != l-1 {
// this should never happen so is non-recoverable if it does
panic(UnknownGlobalIDRemoveError(globalRemove.ID))
}
return nil
default:
return &UnsupportedOpcodeError{opcode, registry.String()}
panic(&UnsupportedOpcodeError{opcode, registry.String()})
}
}

View File

@@ -171,7 +171,7 @@ func TestCoreError(t *testing.T) {
/* padding */ 0, 0, 0, 0,
/* size: 0x1b bytes */ 0x1b, 0, 0, 0,
/*type: String*/ 8, 0, 0, 0,
/* type: String */ 8, 0, 0, 0,
// value: "no permission to destroy 0\x00"
0x6e, 0x6f, 0x20, 0x70,
@@ -192,6 +192,24 @@ func TestCoreError(t *testing.T) {
}.run(t)
}
func TestCoreRemoveId(t *testing.T) {
t.Parallel()
encodingTestCases[pipewire.CoreRemoveId, *pipewire.CoreRemoveId]{
{"sample", []byte{
/* size: rest of data */ 0x10, 0, 0, 0,
/* type: Struct */ 0xe, 0, 0, 0,
/* size: 4 bytes */ 4, 0, 0, 0,
/* type: Int */ 4, 0, 0, 0,
/* value: 3 */ 3, 0, 0, 0,
/* padding */ 0, 0, 0, 0,
}, pipewire.CoreRemoveId{
ID: 3,
}, nil},
}.run(t)
}
func TestCoreBoundProps(t *testing.T) {
t.Parallel()
@@ -288,6 +306,83 @@ func TestCoreGetRegistry(t *testing.T) {
}.run(t)
}
func TestCoreCreateObject(t *testing.T) {
t.Parallel()
encodingTestCases[pipewire.CoreCreateObject, *pipewire.CoreCreateObject]{
{"sample", []byte{
/* size: rest of data */ 0x80, 0, 0, 0,
/* type: Struct */ 0xe, 0, 0, 0,
/* size: 0x13 bytes */ 0x13, 0, 0, 0,
/* type: String */ 8, 0, 0, 0,
// value: "spa-device-factory\x00"
0x73, 0x70, 0x61, 0x2d,
0x64, 0x65, 0x76, 0x69,
0x63, 0x65, 0x2d, 0x66,
0x61, 0x63, 0x74, 0x6f,
0x72, 0x79, 0, 0,
0, 0, 0, 0,
/* size: 0x1a bytes */ 0x1a, 0, 0, 0,
/* type: String */ 8, 0, 0, 0,
// value: "PipeWire:Interface:Device\x00"
0x50, 0x69, 0x70, 0x65,
0x57, 0x69, 0x72, 0x65,
0x3a, 0x49, 0x6e, 0x74,
0x65, 0x72, 0x66, 0x61,
0x63, 0x65, 0x3a, 0x44,
0x65, 0x76, 0x69, 0x63,
0x65, 0, 0, 0,
0, 0, 0, 0,
/* size: 4 bytes */ 4, 0, 0, 0,
/* type: Int */ 4, 0, 0, 0,
/* value: 3 */ 3, 0, 0, 0,
/* padding */ 0, 0, 0, 0,
/* size */ 0x10, 0, 0, 0,
/* type: Struct */ 0xe, 0, 0, 0,
/* size: 4 bytes */ 4, 0, 0, 0,
/* type: Int */ 4, 0, 0, 0,
/* value: 0 */ 0, 0, 0, 0,
/* padding */ 0, 0, 0, 0,
/* size: 4 bytes */ 4, 0, 0, 0,
/* type: Int */ 4, 0, 0, 0,
/* value: 0xbad */ 0xad, 0xb, 0, 0,
/* padding */ 0, 0, 0, 0,
}, pipewire.CoreCreateObject{
FactoryName: "spa-device-factory",
Type: pipewire.PW_TYPE_INTERFACE_Device,
Version: pipewire.PW_VERSION_FACTORY,
Properties: &pipewire.SPADict{},
NewID: 0xbad,
}, nil},
}.run(t)
}
func TestCoreDestroy(t *testing.T) {
t.Parallel()
encodingTestCases[pipewire.CoreDestroy, *pipewire.CoreDestroy]{
{"sample", []byte{
/* size: rest of data */ 0x10, 0, 0, 0,
/* type: Struct */ 0xe, 0, 0, 0,
/* size: 4 bytes */ 4, 0, 0, 0,
/* type: Int */ 4, 0, 0, 0,
/* value: 3 */ 3, 0, 0, 0,
/* padding */ 0, 0, 0, 0,
}, pipewire.CoreDestroy{
ID: 3,
}, nil},
}.run(t)
}
func TestRegistryGlobal(t *testing.T) {
t.Parallel()
@@ -745,6 +840,23 @@ func TestRegistryGlobal(t *testing.T) {
}.run(t)
}
func TestRegistryGlobalRemove(t *testing.T) {
t.Parallel()
encodingTestCases[pipewire.RegistryGlobalRemove, *pipewire.RegistryGlobalRemove]{
{"sample", []byte{
/* size: rest of data*/ 0x10, 0, 0, 0,
/* type: Struct */ 0xe, 0, 0, 0,
/* size: 4 bytes */ 4, 0, 0, 0,
/* type: Int */ 4, 0, 0, 0,
/* value: 0xbad */ 0xad, 0xb, 0, 0,
/* padding */ 0, 0, 0, 0,
}, pipewire.RegistryGlobalRemove{
ID: 0xbad,
}, nil},
}.run(t)
}
func TestRegistryBind(t *testing.T) {
t.Parallel()
@@ -757,3 +869,20 @@ func TestRegistryBind(t *testing.T) {
}, nil},
}.run(t)
}
func TestRegistryDestroy(t *testing.T) {
t.Parallel()
encodingTestCases[pipewire.RegistryDestroy, *pipewire.RegistryDestroy]{
{"sample", []byte{
/* size: rest of data*/ 0x10, 0, 0, 0,
/* type: Struct */ 0xe, 0, 0, 0,
/* size: 4 bytes */ 4, 0, 0, 0,
/* type: Int */ 4, 0, 0, 0,
/* value: 0xbad */ 0xad, 0xb, 0, 0,
/* padding */ 0, 0, 0, 0,
}, pipewire.RegistryDestroy{
ID: 0xbad,
}, nil},
}.run(t)
}

View File

@@ -16,9 +16,9 @@ package pipewire
import (
"encoding/binary"
"errors"
"fmt"
"io"
"net"
"os"
"path"
"runtime"
@@ -26,10 +26,16 @@ import (
"strconv"
"strings"
"syscall"
"time"
)
// Conn is a low level unix socket interface used by [Context].
type Conn interface {
// MightBlock informs the implementation that the next call to
// Recvmsg or Sendmsg might block. A zero or negative timeout
// cancels this behaviour.
MightBlock(timeout time.Duration)
// Recvmsg calls syscall.Recvmsg on the underlying socket.
Recvmsg(p, oob []byte, flags int) (n, oobn, recvflags int, err error)
@@ -51,20 +57,27 @@ type Context struct {
buf []byte
// Current [Header.Sequence] value, incremented every write.
sequence Int
// Current server-side [Header.Sequence] value, incremented on every event processed.
remoteSequence Int
// Pending file descriptors to be sent with the next message.
pendingFiles []int
// File count already kept track of in [Header].
headerFiles int
// Proxy id associations.
proxy map[Int]eventProxy
// Newly allocated proxies pending acknowledgement from the server.
pendingIds map[Int]struct{}
// Smallest available Id for the next proxy.
nextId Int
// Server side registry generation number.
generation Long
// Pending file descriptors to be sent with the next message.
pendingFiles []int
// File count already kept track of in [Header].
headerFiles int
// Proxies targeted by the [CoreDestroy] event pending until next [CoreSync].
pendingDestruction map[Int]struct{}
// Proxy for built-in core events.
core Core
// Proxy for built-in client events.
client Client
// Current server-side [Header.Sequence] value, incremented on every event processed.
remoteSequence Int
// Files from the server. This is discarded on every Roundtrip so eventProxy
// implementations must make sure to close them to avoid leaking fds.
//
@@ -80,13 +93,11 @@ type Context struct {
// Pending footer value deferred to the next round trip,
// sent if pendingFooter is nil. This is for emulating upstream behaviour
deferredPendingFooter KnownSize
// Server side registry generation number.
generation Long
// Deferred operations ran after a [Core.Sync] completes or Close is called. Errors
//are reported as part of [ProxyConsumeError] and is not considered fatal unless panicked.
syncComplete []func() error
// Proxy for built-in core events.
core Core
// Proxy for built-in client events.
client Client
// Passed to [Conn.Recvmsg]. Not copied if sufficient for all received messages.
iovecBuf [1 << 15]byte
@@ -120,8 +131,9 @@ func New(conn Conn, props SPADict) (*Context, error) {
PW_ID_CLIENT: {},
}
ctx.nextId = Int(len(ctx.proxy))
ctx.pendingDestruction = make(map[Int]struct{})
if err := ctx.coreHello(); err != nil {
if err := ctx.core.hello(); err != nil {
return nil, err
}
if err := ctx.clientUpdateProperties(props); err != nil {
@@ -131,45 +143,142 @@ func New(conn Conn, props SPADict) (*Context, error) {
return &ctx, nil
}
// A SyscallConnCloser is a [syscall.Conn] that implements [io.Closer].
type SyscallConnCloser interface {
syscall.Conn
io.Closer
// unixConn is an implementation of the [Conn] interface for connections
// to Unix domain sockets.
type unixConn struct {
fd int
// Whether creation of a new epoll instance was attempted.
epoll bool
// File descriptor referring to the new epoll instance.
// Valid if epoll is true and epollErr is nil.
epollFd int
// Error returned by syscall.EpollCreate1.
epollErr error
// Stores epoll events from the kernel.
epollBuf [32]syscall.EpollEvent
// If non-zero, next call is treated as a blocking call.
timeout time.Duration
}
// A SyscallConn is a [Conn] adapter for [syscall.Conn].
type SyscallConn struct{ SyscallConnCloser }
// Dial connects to a Unix domain socket described by name.
func Dial(name string) (Conn, error) {
if fd, err := syscall.Socket(syscall.AF_UNIX, syscall.SOCK_STREAM|syscall.SOCK_CLOEXEC|syscall.SOCK_NONBLOCK, 0); err != nil {
return nil, os.NewSyscallError("socket", err)
} else if err = syscall.Connect(fd, &syscall.SockaddrUnix{Name: name}); err != nil {
_ = syscall.Close(fd)
return nil, os.NewSyscallError("connect", err)
} else {
return &unixConn{fd: fd}, nil
}
}
// Recvmsg implements [Conn.Recvmsg] via [syscall.Conn.SyscallConn].
func (conn SyscallConn) Recvmsg(p, oob []byte, flags int) (n, oobn, recvflags int, err error) {
var rc syscall.RawConn
if rc, err = conn.SyscallConn(); err != nil {
// MightBlock informs the implementation that the next call
// might block for a non-zero timeout.
func (conn *unixConn) MightBlock(timeout time.Duration) {
if timeout < 0 {
timeout = 0
}
conn.timeout = timeout
}
// wantsEpoll is called at the beginning of any method that might use epoll.
func (conn *unixConn) wantsEpoll() error {
if !conn.epoll {
conn.epoll = true
conn.epollFd, conn.epollErr = syscall.EpollCreate1(syscall.EPOLL_CLOEXEC)
if conn.epollErr == nil {
if conn.epollErr = syscall.EpollCtl(conn.epollFd, syscall.EPOLL_CTL_ADD, conn.fd, &syscall.EpollEvent{
Events: syscall.EPOLLERR | syscall.EPOLLHUP,
Fd: int32(conn.fd),
}); conn.epollErr != nil {
_ = syscall.Close(conn.epollFd)
}
}
}
return conn.epollErr
}
// wait waits for a specific I/O event on fd. Caller must arrange for wantsEpoll
// to be called somewhere before wait is called.
func (conn *unixConn) wait(event uint32) (err error) {
if conn.timeout == 0 {
return nil
}
deadline := time.Now().Add(conn.timeout)
conn.timeout = 0
if err = syscall.EpollCtl(conn.epollFd, syscall.EPOLL_CTL_MOD, conn.fd, &syscall.EpollEvent{
Events: event | syscall.EPOLLERR | syscall.EPOLLHUP,
Fd: int32(conn.fd),
}); err != nil {
return
}
if controlErr := rc.Control(func(fd uintptr) {
n, oobn, recvflags, _, err = syscall.Recvmsg(int(fd), p, oob, flags)
}); controlErr != nil && err == nil {
err = controlErr
for timeout := deadline.Sub(time.Now()); timeout > 0; timeout = deadline.Sub(time.Now()) {
var n int
if n, err = syscall.EpollWait(conn.epollFd, conn.epollBuf[:], int(timeout/time.Millisecond)); err != nil {
return
}
switch n {
case 1: // only the socket fd is ever added
if conn.epollBuf[0].Fd != int32(conn.fd) { // unreachable
return syscall.ENOTRECOVERABLE
}
if conn.epollBuf[0].Events&event == event ||
conn.epollBuf[0].Events&syscall.EPOLLERR|syscall.EPOLLHUP != 0 {
return nil
}
err = syscall.ETIME
continue
case 0: // timeout
return syscall.ETIMEDOUT
default: // unreachable
return syscall.ENOTRECOVERABLE
}
}
return
}
// Sendmsg implements [Conn.Sendmsg] via [syscall.Conn.SyscallConn].
func (conn SyscallConn) Sendmsg(p, oob []byte, flags int) (n int, err error) {
var rc syscall.RawConn
if rc, err = conn.SyscallConn(); err != nil {
// Recvmsg calls syscall.Recvmsg on the underlying socket.
func (conn *unixConn) Recvmsg(p, oob []byte, flags int) (n, oobn, recvflags int, err error) {
if err = conn.wantsEpoll(); err != nil {
return
} else if err = conn.wait(syscall.EPOLLIN); err != nil {
return
}
if controlErr := rc.Control(func(fd uintptr) {
n, err = syscall.SendmsgN(int(fd), p, oob, nil, flags)
}); controlErr != nil && err == nil {
err = controlErr
}
n, oobn, recvflags, _, err = syscall.Recvmsg(conn.fd, p, oob, flags)
return
}
// Sendmsg calls syscall.Sendmsg on the underlying socket.
func (conn *unixConn) Sendmsg(p, oob []byte, flags int) (n int, err error) {
if err = conn.wantsEpoll(); err != nil {
return
} else if err = conn.wait(syscall.EPOLLOUT); err != nil {
return
}
n, err = syscall.SendmsgN(conn.fd, p, oob, nil, flags)
return
}
// Close closes the underlying socket and the epoll fd if populated.
func (conn *unixConn) Close() (err error) {
if conn.epoll && conn.epollErr == nil {
conn.epollErr = syscall.Close(conn.epollFd)
}
if err = syscall.Close(conn.fd); err != nil {
return
}
return conn.epollErr
}
// MustNew calls [New](conn, props) and panics on error.
// It is intended for use in tests with hard-coded strings.
func MustNew(conn Conn, props SPADict) *Context {
@@ -303,7 +412,7 @@ func (ctx *Context) recvmsg(remaining []byte) (payload []byte, err error) {
}
if err != syscall.EAGAIN && err != syscall.EWOULDBLOCK {
ctx.closeReceivedFiles()
return nil, os.NewSyscallError("recvmsg", err)
return nil, &ProxyFatalError{Err: os.NewSyscallError("recvmsg", err), ProxyErrs: ctx.cloneAsProxyErrors()}
}
}
@@ -340,7 +449,7 @@ func (ctx *Context) sendmsg(p []byte, fds ...int) error {
}
if err != nil && err != syscall.EAGAIN && err != syscall.EWOULDBLOCK {
return os.NewSyscallError("sendmsg", err)
return &ProxyFatalError{Err: os.NewSyscallError("sendmsg", err), ProxyErrs: ctx.cloneAsProxyErrors()}
}
return err
}
@@ -419,6 +528,9 @@ type eventProxy interface {
consume(opcode byte, files []int, unmarshal func(v any)) error
// setBoundProps stores a [CoreBoundProps] event received from the server.
setBoundProps(event *CoreBoundProps) error
// remove is called when the proxy is removed for any reason, usually from
// being targeted by a [PW_CORE_EVENT_REMOVE_ID] event.
remove() error
// Stringer returns the PipeWire interface name.
fmt.Stringer
@@ -499,13 +611,21 @@ func (e DanglingFilesError) Error() string {
}
// An UnacknowledgedProxyError holds newly allocated proxy ids that the server failed
// to acknowledge after an otherwise successful [Context.Roundtrip].
// to acknowledge after an otherwise successful [Core.Sync].
type UnacknowledgedProxyError []Int
func (e UnacknowledgedProxyError) Error() string {
return "server did not acknowledge " + strconv.Itoa(len(e)) + " proxies"
}
// An UnacknowledgedProxyDestructionError holds destroyed proxy ids that the server failed
// to acknowledge after an otherwise successful [Core.Sync].
type UnacknowledgedProxyDestructionError []Int
func (e UnacknowledgedProxyDestructionError) Error() string {
return "server did not acknowledge " + strconv.Itoa(len(e)) + " proxy destructions"
}
// A ProxyFatalError describes an error that terminates event handling during a
// [Context.Roundtrip] and makes further event processing no longer possible.
type ProxyFatalError struct {
@@ -580,8 +700,15 @@ func (ctx *Context) Roundtrip() (err error) {
return
}
const (
// roundtripTimeout is the maximum duration socket operations during
// Context.roundtrip is allowed to block for.
roundtripTimeout = 5 * time.Second
)
// roundtrip implements the Roundtrip method without checking proxyErrors.
func (ctx *Context) roundtrip() (err error) {
ctx.conn.MightBlock(roundtripTimeout)
if err = ctx.sendmsg(ctx.buf, ctx.pendingFiles...); err != nil {
return
}
@@ -615,6 +742,7 @@ func (ctx *Context) roundtrip() (err error) {
}()
var remaining []byte
ctx.conn.MightBlock(roundtripTimeout)
for {
remaining, err = ctx.consume(remaining)
if err == nil {
@@ -636,7 +764,7 @@ func (ctx *Context) roundtrip() (err error) {
}
// currentSeq returns the current sequence number.
// This must only be called from eventProxy.consume.
// This must only be called immediately after queueing a message.
func (ctx *Context) currentSeq() Int { return ctx.sequence - 1 }
// currentRemoteSeq returns the current remote sequence number.
@@ -786,6 +914,52 @@ func (ctx *Context) Close() (err error) {
}
}
// expectsCoreError returns a function that inspects an error value and
// returns the address of a [CoreError] if it is the only error present
// and targets the specified proxy and sequence.
//
// The behaviour of expectsCoreError is only correct for an empty buf
// prior to calling. If buf is not empty, [Core.Sync] is called, with
// its return value stored to the value pointed to by errP if not nil,
// and the function is not populated.
//
// The caller must queue a message and call [Core.Sync] immediately
// after calling expectsCoreError.
func (ctx *Context) expectsCoreError(id Int, errP *error) (asCoreError func() (coreError *CoreError)) {
if len(ctx.buf) > 0 {
if err := ctx.GetCore().Sync(); err != nil {
*errP = err
return nil
}
}
sequence := ctx.sequence
return func() (coreError *CoreError) {
if proxyErrors, ok := (*errP).(ProxyConsumeError); !ok ||
len(proxyErrors) != 1 ||
!errors.As(proxyErrors[0], &coreError) ||
coreError == nil ||
coreError.ID != id ||
coreError.Sequence != sequence {
// do not return a non-matching CoreError
coreError = nil
}
return
}
}
// A PermissionError describes an error emitted by the server when trying to
// perform an operation that the client has no permission for.
type PermissionError struct {
// The id of the resource (proxy if emitted by the client) that is in error.
ID Int `json:"id"`
// An error message.
Message string `json:"message"`
}
func (*PermissionError) Unwrap() error { return syscall.EPERM }
func (e *PermissionError) Error() string { return e.Message }
// Remote is the environment (sic) with the remote name.
const Remote = "PIPEWIRE_REMOTE"
@@ -793,14 +967,14 @@ const Remote = "PIPEWIRE_REMOTE"
const DEFAULT_SYSTEM_RUNTIME_DIR = "/run/pipewire"
// connectName connects to a PipeWire remote by name and returns the [net.UnixConn].
func connectName(name string, manager bool) (conn *net.UnixConn, err error) {
// connectName connects to a PipeWire remote by name and returns the resulting [Conn].
func connectName(name string, manager bool) (conn Conn, err error) {
if manager && !strings.HasSuffix(name, "-manager") {
return connectName(name+"-manager", false)
}
if path.IsAbs(name) || (len(name) > 0 && name[0] == '@') {
return net.DialUnix("unix", nil, &net.UnixAddr{Name: name, Net: "unix"})
return Dial(name)
} else {
runtimeDir, ok := os.LookupEnv("PIPEWIRE_RUNTIME_DIR")
if !ok || !path.IsAbs(runtimeDir) {
@@ -815,7 +989,7 @@ func connectName(name string, manager bool) (conn *net.UnixConn, err error) {
if !ok || !path.IsAbs(runtimeDir) {
runtimeDir = DEFAULT_SYSTEM_RUNTIME_DIR
}
return net.DialUnix("unix", nil, &net.UnixAddr{Name: path.Join(runtimeDir, name), Net: "unix"})
return Dial(path.Join(runtimeDir, name))
}
}
@@ -833,12 +1007,11 @@ func ConnectName(name string, manager bool, props SPADict) (ctx *Context, err er
}
}
var conn *net.UnixConn
var conn Conn
if conn, err = connectName(name, manager); err != nil {
return
}
if ctx, err = New(SyscallConn{conn}, props); err != nil {
if ctx, err = New(conn, props); err != nil {
ctx = nil
_ = conn.Close()
}

View File

@@ -6,6 +6,7 @@ import (
"strconv"
. "syscall"
"testing"
"time"
"hakurei.app/container/stub"
"hakurei.app/internal/pipewire"
@@ -680,9 +681,6 @@ func TestContext(t *testing.T) {
}); err != nil {
t.Fatalf("SecurityContext.Create: error = %v", err)
}
if err := ctx.GetCore().Sync(); err != nil {
t.Fatalf("Sync: error = %v", err)
}
// none of these should change
if coreInfo := ctx.GetCore().Info; !reflect.DeepEqual(coreInfo, &wantCoreInfo0) {
@@ -718,6 +716,18 @@ type stubUnixConn struct {
current int
}
func (conn *stubUnixConn) MightBlock(timeout time.Duration) {
if timeout != 5*time.Second {
panic("unexpected timeout " + timeout.String())
}
if conn.current == 0 ||
(conn.samples[conn.current-1].nr == SYS_RECVMSG && conn.samples[conn.current-1].errno == EAGAIN && conn.samples[conn.current].nr == SYS_SENDMSG) ||
(conn.samples[conn.current-1].nr == SYS_SENDMSG && conn.samples[conn.current].nr == SYS_RECVMSG) {
return
}
panic("unexpected blocking hint before sample " + strconv.Itoa(conn.current))
}
// nextSample returns the current sample and increments the counter.
func (conn *stubUnixConn) nextSample(nr uintptr) (sample *stubUnixConnSample, wantOOB []byte, err error) {
sample = &conn.samples[conn.current]
@@ -836,6 +846,7 @@ func TestContextErrors(t *testing.T) {
{"UnexpectedFileCountError", &pipewire.UnexpectedFileCountError{0, -1}, "received -1 files instead of the expected 0"},
{"UnacknowledgedProxyError", make(pipewire.UnacknowledgedProxyError, 1<<4), "server did not acknowledge 16 proxies"},
{"UnacknowledgedProxyDestructionError", make(pipewire.UnacknowledgedProxyDestructionError, 1<<4), "server did not acknowledge 16 proxy destructions"},
{"DanglingFilesError", make(pipewire.DanglingFilesError, 1<<4), "received 16 dangling files"},
{"UnexpectedFilesError", pipewire.UnexpectedFilesError(1 << 4), "server message headers claim to have sent more files than actually received"},
{"UnexpectedSequenceError", pipewire.UnexpectedSequenceError(1 << 4), "unexpected seq 16"},
@@ -862,6 +873,19 @@ func TestContextErrors(t *testing.T) {
ID: 0xbad,
Sequence: 0xcafe,
}, "received Core::Ping seq 51966 targeting unknown proxy id 2989"},
{"GlobalIDCollisionError", &pipewire.GlobalIDCollisionError{
ID: 0xbad,
Previous: &pipewire.RegistryGlobal{Type: "PipeWire:Interface:Invalid"},
Current: &pipewire.RegistryGlobal{Type: "PipeWire:Interface:NewInvalid"},
}, "new Registry::Global event for PipeWire:Interface:NewInvalid stepping on previous id 2989 for PipeWire:Interface:Invalid"},
{"UnknownGlobalIDRemoveError", pipewire.UnknownGlobalIDRemoveError(0xbad), "Registry::GlobalRemove event targets unknown id 2989"},
{"PermissionError", &pipewire.PermissionError{
ID: 2,
Message: "no permission to destroy 0",
}, "no permission to destroy 0"},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {

View File

@@ -384,19 +384,16 @@ func unmarshalValue(data []byte, v reflect.Value, wireSizeP *Word) error {
return nil
case reflect.Pointer:
if len(data) < SizePrefix {
return ErrEOFPrefix
}
switch SPAKind(binary.NativeEndian.Uint32(data[SizeSPrefix:])) {
case SPA_TYPE_None:
if ok, err := unmarshalHandleNone(&data, wireSizeP); err != nil {
return err
} else if ok {
v.SetZero()
return nil
default:
v.Set(reflect.New(v.Type().Elem()))
return unmarshalValue(data, v.Elem(), wireSizeP)
}
v.Set(reflect.New(v.Type().Elem()))
return unmarshalValue(data, v.Elem(), wireSizeP)
case reflect.String:
*wireSizeP = 0
if err := unmarshalCheckTypeBounds(&data, SPA_TYPE_String, wireSizeP); err != nil {
@@ -422,6 +419,29 @@ func unmarshalValue(data []byte, v reflect.Value, wireSizeP *Word) error {
}
}
// unmarshalHandleNone establishes prefix bounds and, for a value of type [SPA_TYPE_None],
// validates its size and skips the header. This is for unmarshalling values that can be nil.
func unmarshalHandleNone(data *[]byte, wireSizeP *Word) (bool, error) {
if len(*data) < SizePrefix {
return false, ErrEOFPrefix
}
if SPAKind(binary.NativeEndian.Uint32((*data)[SizeSPrefix:])) != SPA_TYPE_None {
return false, nil
}
*wireSizeP = 0
if err := unmarshalCheckTypeBounds(data, SPA_TYPE_None, wireSizeP); err != nil {
return true, err
}
if len(*data) != 0 {
return true, TrailingGarbageError(*data)
}
return true, nil
}
// An InconsistentSizeError describes an inconsistent size prefix encountered
// in data passed to [Unmarshal].
type InconsistentSizeError struct{ Prefix, Expect Word }

View File

@@ -26,9 +26,10 @@ const (
const (
PW_SECURITY_CONTEXT_METHOD_ADD_LISTENER = iota
PW_SECURITY_CONTEXT_METHOD_CREATE
PW_SECURITY_CONTEXT_METHOD_NUM
PW_SECURITY_CONTEXT_METHOD_CREATE
PW_SECURITY_CONTEXT_METHOD_NUM
PW_VERSION_SECURITY_CONTEXT_METHODS = 0
)
@@ -90,6 +91,8 @@ type SecurityContext struct {
GlobalID Int `json:"id"`
ctx *Context
destructible
}
// GetSecurityContext queues a [RegistryBind] message for the PipeWire server
@@ -108,13 +111,39 @@ func (registry *Registry) GetSecurityContext() (securityContext *SecurityContext
}
// Create queues a [SecurityContextCreate] message for the PipeWire server.
func (securityContext *SecurityContext) Create(listenFd, closeFd int, props SPADict) error {
func (securityContext *SecurityContext) Create(listenFd, closeFd int, props SPADict) (err error) {
if err = securityContext.checkDestroy(); err != nil {
return
}
asCoreError := securityContext.ctx.expectsCoreError(securityContext.ID, &err)
if err != nil {
return
}
// queued in reverse based on upstream behaviour, unsure why
offset := securityContext.ctx.queueFiles(closeFd, listenFd)
return securityContext.ctx.writeMessage(
if err = securityContext.ctx.writeMessage(
securityContext.ID,
&SecurityContextCreate{ListenFd: offset + 1, CloseFd: offset + 0, Properties: &props},
)
); err != nil {
return
}
if err = securityContext.ctx.GetCore().Sync(); err == nil {
return nil
}
if coreError := asCoreError(); coreError == nil {
return
} else {
switch syscall.Errno(-coreError.Result) {
case syscall.EPERM:
return &PermissionError{securityContext.ID, coreError.Message}
default:
return coreError
}
}
}
// securityContextCloser holds onto resources associated to the security context.
@@ -144,6 +173,9 @@ func (scc *securityContextCloser) Close() (err error) {
// BindAndCreate binds a new socket to the specified pathname and pass it to Create.
// It returns an [io.Closer] corresponding to [SecurityContextCreate.CloseFd].
func (securityContext *SecurityContext) BindAndCreate(pathname string, props SPADict) (io.Closer, error) {
if err := securityContext.checkDestroy(); err != nil {
return nil, err
}
var scc securityContextCloser
// ensure pathname is available
@@ -185,17 +217,19 @@ func (securityContext *SecurityContext) BindAndCreate(pathname string, props SPA
}
func (securityContext *SecurityContext) consume(opcode byte, files []int, _ func(v any)) error {
securityContext.mustCheckDestroy()
closeReceivedFiles(files...)
switch opcode {
// SecurityContext does not receive any events
default:
return &UnsupportedOpcodeError{opcode, securityContext.String()}
panic(&UnsupportedOpcodeError{opcode, securityContext.String()})
}
}
func (securityContext *SecurityContext) setBoundProps(event *CoreBoundProps) error {
securityContext.mustCheckDestroy()
if securityContext.ID != event.ID {
return &InconsistentIdError{Proxy: securityContext, ID: securityContext.ID, ServerID: event.ID}
}
@@ -205,4 +239,9 @@ func (securityContext *SecurityContext) setBoundProps(event *CoreBoundProps) err
return nil
}
// Destroy destroys this [SecurityContext] proxy.
func (securityContext *SecurityContext) Destroy() error {
return securityContext.destroy(securityContext.ctx, securityContext.ID)
}
func (securityContext *SecurityContext) String() string { return PW_TYPE_INTERFACE_SecurityContext }

307
internal/pkg/pkg.go Normal file
View File

@@ -0,0 +1,307 @@
// Package pkg provides utilities for packaging software.
package pkg
import (
"crypto/sha512"
"encoding/base64"
"encoding/gob"
"errors"
"io"
"io/fs"
"os"
"sync"
"hakurei.app/container/check"
)
type (
// A Checksum is a SHA-384 checksum computed for a cured [Artifact].
Checksum = [sha512.Size384]byte
// An ID is a unique identifier returned by [Artifact.ID]. This value must
// be deterministically determined ahead of time.
ID Checksum
)
// MustDecode decodes a string representation of [Checksum] and panics if there
// is a decoding error or the resulting data is too short.
func MustDecode(s string) (checksum Checksum) {
if n, err := base64.URLEncoding.Decode(
checksum[:],
[]byte(s),
); err != nil {
panic(err)
} else if n != len(Checksum{}) {
panic(io.ErrUnexpectedEOF)
}
return
}
// An Artifact is a read-only reference to a piece of data that may be created
// deterministically but might not currently be available in memory or on the
// filesystem.
type Artifact interface {
// ID returns a globally unique identifier referring to the current
// [Artifact]. This value must be known ahead of time and guaranteed to be
// unique without having obtained the full contents of the [Artifact].
ID() ID
// Hash returns the [Checksum] created from the full contents of a cured
// [Artifact]. This can be stored for future lookup in a [Cache].
//
// A call to Hash implicitly cures [Artifact].
Hash() (Checksum, error)
// Pathname returns an absolute pathname to a file or directory populated
// with the full contents of [Artifact]. This is the most expensive
// operation possible on any [Artifact] and should be avoided if possible.
//
// A call to Pathname implicitly cures [Artifact].
//
// Callers must only open files read-only. If [Artifact] is a directory,
// files must not be created or removed under this directory.
Pathname() (*check.Absolute, error)
}
// A File refers to an [Artifact] backed by a single file.
type File interface {
// Data returns the full contents of [Artifact].
//
// Callers must not modify the returned byte slice.
Data() ([]byte, error)
Artifact
}
// FlatEntry is the representation of a directory entry via [Flatten].
type FlatEntry struct {
Name string // base name of the file
Mode fs.FileMode // file mode bits
Data []byte // file content or symlink destination
}
// Flatten writes a deterministic representation of the contents of fsys to w.
// The resulting data can be hashed to produce a deterministic checksum for the
// directory.
func Flatten(fsys fs.FS, root string, w io.Writer) error {
e := gob.NewEncoder(w)
return fs.WalkDir(fsys, root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
var fi fs.FileInfo
fi, err = d.Info()
if err != nil {
return err
}
ent := FlatEntry{
Name: fi.Name(),
Mode: fi.Mode(),
}
if ent.Mode.IsRegular() {
if ent.Data, err = fs.ReadFile(fsys, path); err != nil {
return err
}
} else if ent.Mode&fs.ModeSymlink != 0 {
var newpath string
if newpath, err = fs.ReadLink(fsys, path); err != nil {
return err
}
ent.Data = []byte(newpath)
}
return e.Encode(&ent)
})
}
// HashFS returns a checksum produced by hashing the result of [Flatten].
func HashFS(fsys fs.FS, root string) (Checksum, error) {
h := sha512.New384()
if err := Flatten(fsys, root, h); err != nil {
return Checksum{}, err
}
return (Checksum)(h.Sum(nil)), nil
}
// HashDir returns a checksum produced by hashing the result of [Flatten].
func HashDir(pathname *check.Absolute) (Checksum, error) {
return HashFS(os.DirFS(pathname.String()), ".")
}
const (
// dirIdentifier is the directory name appended to Cache.base for storing
// artifacts named after their [ID].
dirIdentifier = "identifier"
// dirChecksum is the directory name appended to Cache.base for storing
// artifacts named after their [Checksum].
dirChecksum = "checksum"
)
// Cache is a support layer that implementations of [Artifact] can use to store
// cured [Artifact] data in a content addressed fashion.
type Cache struct {
// Directory where all [Cache] related files are placed.
base *check.Absolute
// Synchronises access to public methods.
mu sync.RWMutex
}
// LoadFile loads the contents of a [File] by its identifier.
func (c *Cache) LoadFile(id ID) (
pathname *check.Absolute,
data []byte,
err error,
) {
pathname = c.base.Append(
dirIdentifier,
base64.URLEncoding.EncodeToString(id[:]),
)
c.mu.RLock()
data, err = os.ReadFile(pathname.String())
c.mu.RUnlock()
return
}
// A ChecksumMismatchError describes an [Artifact] with unexpected content.
type ChecksumMismatchError struct {
// Actual and expected checksums.
Got, Want Checksum
}
func (e *ChecksumMismatchError) Error() string {
return "got " + base64.URLEncoding.EncodeToString(e.Got[:]) +
" instead of " + base64.URLEncoding.EncodeToString(e.Want[:])
}
// pathname returns the content-addressed pathname for a [Checksum].
func (c *Cache) pathname(checksum *Checksum) *check.Absolute {
return c.base.Append(
dirChecksum,
base64.URLEncoding.EncodeToString(checksum[:]),
)
}
// pathnameIdent returns the identifier-based pathname for an [ID].
func (c *Cache) pathnameIdent(id *ID) *check.Absolute {
return c.base.Append(
dirIdentifier,
base64.URLEncoding.EncodeToString(id[:]),
)
}
// storeFile stores the contents of a [File]. An optional checksum can be
// passed via the result buffer which is used to validate the submitted data.
//
// If locking is disabled, the caller is responsible for acquiring a write lock
// and releasing it after this method returns. This makes LoadOrStoreFile
// possible without holding the lock while computing hash for store only.
func (c *Cache) storeFile(
identifierPathname *check.Absolute,
data []byte,
buf *Checksum,
validate, lock bool,
) error {
h := sha512.New384()
h.Write(data)
if validate {
if got := (Checksum)(h.Sum(nil)); got != *buf {
return &ChecksumMismatchError{got, *buf}
}
} else {
h.Sum(buf[:0])
}
checksumPathname := c.pathname(buf)
if lock {
c.mu.Lock()
defer c.mu.Unlock()
}
if f, err := os.OpenFile(
checksumPathname.String(),
os.O_WRONLY|os.O_CREATE|os.O_EXCL,
0400,
); err != nil {
// two artifacts may be backed by the same file
if !errors.Is(err, os.ErrExist) {
return err
}
} else if _, err = f.Write(data); err != nil {
// do not attempt cleanup: this is content-addressed and a partial
// write is caught during integrity check
return err
}
return os.Link(
checksumPathname.String(),
identifierPathname.String(),
)
}
// StoreFile stores the contents of a [File]. An optional checksum can be
// passed via the result buffer which is used to validate the submitted data.
func (c *Cache) StoreFile(
id ID,
data []byte,
buf *Checksum,
validate bool,
) (pathname *check.Absolute, err error) {
pathname = c.pathnameIdent(&id)
err = c.storeFile(pathname, data, buf, validate, true)
return
}
// LoadOrStoreFile attempts to load the contents of a [File] by its identifier,
// and if that file is not present, calls makeData and stores its result
// instead. Hash validation behaviour is identical to StoreFile.
func (c *Cache) LoadOrStoreFile(
id ID,
makeData func() ([]byte, error),
buf *Checksum,
validate bool,
) (
pathname *check.Absolute,
data []byte,
store bool,
err error,
) {
pathname = c.pathnameIdent(&id)
c.mu.Lock()
defer c.mu.Unlock()
data, err = os.ReadFile(pathname.String())
if err == nil || !errors.Is(err, os.ErrNotExist) {
return
}
store = true
data, err = makeData()
if err != nil {
return
}
err = c.storeFile(pathname, data, buf, validate, false)
return
}
// New returns the address to a new instance of [Cache].
func New(base *check.Absolute) (*Cache, error) {
for _, name := range []string{
dirIdentifier,
dirChecksum,
} {
if err := os.MkdirAll(base.Append(name).String(), 0700); err != nil &&
!errors.Is(err, os.ErrExist) {
return nil, err
}
}
return &Cache{
base: base,
}, nil
}

366
internal/pkg/pkg_test.go Normal file
View File

@@ -0,0 +1,366 @@
package pkg_test
import (
"bytes"
"crypto/sha512"
"encoding/base64"
"io/fs"
"os"
"path/filepath"
"reflect"
"syscall"
"testing"
"testing/fstest"
"hakurei.app/container"
"hakurei.app/container/check"
"hakurei.app/container/stub"
"hakurei.app/internal/pkg"
)
func TestCache(t *testing.T) {
t.Parallel()
const testdata = "" +
"\x00\x00\x00\x00" +
"\xad\x0b\x00" +
"\x04" +
"\xfe\xfe\x00\x00" +
"\xfe\xca\x00\x00"
testdataChecksum := func() pkg.Checksum {
h := sha512.New384()
h.Write([]byte(testdata))
return (pkg.Checksum)(h.Sum(nil))
}()
testdataChecksumString := base64.URLEncoding.EncodeToString(testdataChecksum[:])
testCases := []struct {
name string
early func(t *testing.T, base *check.Absolute)
f func(t *testing.T, base *check.Absolute, c *pkg.Cache)
check func(t *testing.T, base *check.Absolute)
}{
{"file", nil, func(t *testing.T, base *check.Absolute, c *pkg.Cache) {
wantErrNonexistent := &os.PathError{
Op: "open",
Path: base.Append(
"identifier",
testdataChecksumString,
).String(),
Err: syscall.ENOENT,
}
if _, _, err := c.LoadFile(testdataChecksum); !reflect.DeepEqual(err, wantErrNonexistent) {
t.Fatalf("LoadFile: error = %#v, want %#v", err, wantErrNonexistent)
}
identifier := (pkg.ID)(bytes.Repeat([]byte{
0x75, 0xe6, 0x9d, 0x6d, 0xe7, 0x9f,
}, 8))
wantPathname := base.Append(
"identifier",
"deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
)
identifier0 := (pkg.ID)(bytes.Repeat([]byte{
0x71, 0xa7, 0xde, 0x6d, 0xa6, 0xde,
}, 8))
wantPathname0 := base.Append(
"identifier",
"cafebabecafebabecafebabecafebabecafebabecafebabecafebabecafebabe",
)
// initial store
if pathname, err := c.StoreFile(
identifier,
[]byte(testdata),
&testdataChecksum,
true,
); err != nil {
t.Fatalf("StoreFile: error = %v", err)
} else if !pathname.Is(wantPathname) {
t.Fatalf("StoreFile: pathname = %q, want %q", pathname, wantPathname)
}
// load or store, identical content
if pathname, data, store, err := c.LoadOrStoreFile(identifier0, func() ([]byte, error) {
return []byte(testdata), nil
}, &testdataChecksum, true); err != nil {
t.Fatalf("LoadOrStoreFile: error = %v", err)
} else if !pathname.Is(wantPathname0) {
t.Fatalf("LoadOrStoreFile: pathname = %q, want %q", pathname, wantPathname0)
} else if string(data) != testdata {
t.Fatalf("LoadOrStoreFile: data = %x, want %x", data, testdata)
} else if !store {
t.Fatal("LoadOrStoreFile did not store nonpresent entry")
}
// load or store, existing entry
if pathname, data, store, err := c.LoadOrStoreFile(identifier, func() ([]byte, error) {
return []byte(testdata), nil
}, &testdataChecksum, true); err != nil {
t.Fatalf("LoadOrStoreFile: error = %v", err)
} else if !pathname.Is(wantPathname) {
t.Fatalf("LoadOrStoreFile: pathname = %q, want %q", pathname, wantPathname)
} else if string(data) != testdata {
t.Fatalf("LoadOrStoreFile: data = %x, want %x", data, testdata)
} else if store {
t.Fatal("LoadOrStoreFile stored over present entry")
}
// load, existing entry
if pathname, data, err := c.LoadFile(identifier0); err != nil {
t.Fatalf("LoadFile: error = %v", err)
} else if !pathname.Is(wantPathname0) {
t.Fatalf("LoadFile: pathname = %q, want %q", pathname, wantPathname0)
} else if string(data) != testdata {
t.Fatalf("LoadFile: data = %x, want %x", data, testdata)
}
// checksum mismatch
wantErrChecksum := &pkg.ChecksumMismatchError{
Got: testdataChecksum,
}
if _, err := c.StoreFile(
testdataChecksum,
[]byte(testdata),
new(pkg.Checksum),
true,
); !reflect.DeepEqual(err, wantErrChecksum) {
t.Fatalf("StoreFile: error = %#v, want %#v", err, wantErrChecksum)
}
// verify failed store
if _, _, err := c.LoadFile(testdataChecksum); !reflect.DeepEqual(err, wantErrNonexistent) {
t.Fatalf("LoadFile: error = %#v, want %#v", err, wantErrNonexistent)
}
// store, same identifier
wantPathnameF := base.Append(
"identifier",
testdataChecksumString,
)
if pathname, err := c.StoreFile(
testdataChecksum,
[]byte(testdata),
&testdataChecksum,
true,
); err != nil {
t.Fatalf("StoreFile: error = %v", err)
} else if !pathname.Is(wantPathnameF) {
t.Fatalf("StoreFile: pathname = %q, want %q", pathname, wantPathnameF)
}
// load, same identifier
if pathname, data, err := c.LoadFile(testdataChecksum); err != nil {
t.Fatalf("LoadFile: error = %v", err)
} else if !pathname.Is(wantPathnameF) {
t.Fatalf("LoadFile: pathname = %q, want %q", pathname, wantPathnameF)
} else if string(data) != testdata {
t.Fatalf("LoadFile: data = %x, want %x", data, testdata)
}
// store without validation
wantChecksum := pkg.Checksum{
0xbe, 0xc0, 0x21, 0xb4, 0xf3, 0x68,
0xe3, 0x06, 0x91, 0x34, 0xe0, 0x12,
0xc2, 0xb4, 0x30, 0x70, 0x83, 0xd3,
0xa9, 0xbd, 0xd2, 0x06, 0xe2, 0x4e,
0x5f, 0x0d, 0x86, 0xe1, 0x3d, 0x66,
0x36, 0x65, 0x59, 0x33, 0xec, 0x2b,
0x41, 0x34, 0x65, 0x96, 0x68, 0x17,
0xa9, 0xc2, 0x08, 0xa1, 0x17, 0x17,
}
var gotChecksum pkg.Checksum
wantPathnameG := base.Append(
"identifier",
base64.URLEncoding.EncodeToString(wantChecksum[:]),
)
if pathname, err := c.StoreFile(
wantChecksum,
[]byte{0},
&gotChecksum,
false,
); err != nil {
t.Fatalf("StoreFile: error = %#v", err)
} else if !pathname.Is(wantPathnameG) {
t.Fatalf("StoreFile: pathname = %q, want %q", pathname, wantPathnameG)
} else if gotChecksum != wantChecksum {
t.Fatalf("StoreFile: buf = %x, want %x", gotChecksum, wantChecksum)
}
// makeData passthrough
var zeroIdent pkg.ID
wantErrPassthrough := stub.UniqueError(0xcafe)
if _, _, _, err := c.LoadOrStoreFile(zeroIdent, func() ([]byte, error) {
return nil, wantErrPassthrough
}, new(pkg.Checksum), true); !reflect.DeepEqual(err, wantErrPassthrough) {
t.Fatalf("LoadOrStoreFile: error = %#v, want %#v", err, wantErrPassthrough)
}
// verify failed store
wantErrNonexistentZero := &os.PathError{
Op: "open",
Path: base.Append(
"identifier",
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
).String(),
Err: syscall.ENOENT,
}
if _, _, err := c.LoadFile(zeroIdent); !reflect.DeepEqual(err, wantErrNonexistentZero) {
t.Fatalf("LoadFile: error = %#v, want %#v", err, wantErrNonexistentZero)
}
}, func(t *testing.T, base *check.Absolute) {
wantChecksum := pkg.MustDecode(
"lvK4lY9bQUFscHpxqHmiPvptjUwOgn3BFhzCXZMeupkY1n22WUPSuh7pswEvVZrx",
)
if checksum, err := pkg.HashDir(base); err != nil {
t.Fatalf("HashDir: error = %v", err)
} else if checksum != wantChecksum {
t.Fatalf("HashDir: %v", &pkg.ChecksumMismatchError{
Got: checksum,
Want: wantChecksum,
})
}
}},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
base := check.MustAbs(t.TempDir())
if err := os.Chmod(base.String(), 0700); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := filepath.WalkDir(base.String(), func(path string, d fs.DirEntry, err error) error {
if err != nil {
t.Error(err)
return nil
}
if !d.IsDir() {
return nil
}
return os.Chmod(path, 0700)
}); err != nil {
t.Fatal(err)
}
})
if c, err := pkg.New(base); err != nil {
t.Fatalf("New: error = %v", err)
} else {
if tc.early != nil {
tc.early(t, base)
}
tc.f(t, base, c)
tc.check(t, base)
}
})
}
}
func TestFlatten(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
fsys fs.FS
want pkg.Checksum
}{
{"sample cache file", fstest.MapFS{
".": {Mode: 020000000700},
"checksum": {Mode: 020000000700},
"checksum/vsAhtPNo4waRNOASwrQwcIPTqb3SBuJOXw2G4T1mNmVZM-wrQTRllmgXqcIIoRcX": {Mode: 0400, Data: []byte{0x0}},
"checksum/0bSFPu5Tnd-2Jj0Mv6co23PW2t3BmHc7eLFj9TgY3eIBg8zislo7xZYNBqovVLcq": {Mode: 0400, Data: []byte{0x0, 0x0, 0x0, 0x0, 0xad, 0xb, 0x0, 0x4, 0xfe, 0xfe, 0x0, 0x0, 0xfe, 0xca, 0x0, 0x0}},
"identifier": {Mode: 020000000700},
"identifier/vsAhtPNo4waRNOASwrQwcIPTqb3SBuJOXw2G4T1mNmVZM-wrQTRllmgXqcIIoRcX": {Mode: 0400, Data: []byte{0x0}},
"identifier/0bSFPu5Tnd-2Jj0Mv6co23PW2t3BmHc7eLFj9TgY3eIBg8zislo7xZYNBqovVLcq": {Mode: 0400, Data: []byte{0x0, 0x0, 0x0, 0x0, 0xad, 0xb, 0x0, 0x4, 0xfe, 0xfe, 0x0, 0x0, 0xfe, 0xca, 0x0, 0x0}},
"identifier/cafebabecafebabecafebabecafebabecafebabecafebabecafebabecafebabe": {Mode: 0400, Data: []byte{0x0, 0x0, 0x0, 0x0, 0xad, 0xb, 0x0, 0x4, 0xfe, 0xfe, 0x0, 0x0, 0xfe, 0xca, 0x0, 0x0}},
"identifier/deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef": {Mode: 0400, Data: []byte{0x0, 0x0, 0x0, 0x0, 0xad, 0xb, 0x0, 0x4, 0xfe, 0xfe, 0x0, 0x0, 0xfe, 0xca, 0x0, 0x0}},
}, pkg.MustDecode("lvK4lY9bQUFscHpxqHmiPvptjUwOgn3BFhzCXZMeupkY1n22WUPSuh7pswEvVZrx")},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got, err := pkg.HashFS(tc.fsys, "."); err != nil {
t.Fatalf("HashFS: error = %v", err)
} else if got != tc.want {
t.Fatalf("HashFS: %v", &pkg.ChecksumMismatchError{
Got: got,
Want: tc.want,
})
}
})
}
}
func TestErrors(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
err error
want string
}{
{"ChecksumMismatchError", &pkg.ChecksumMismatchError{
Want: (pkg.Checksum)(bytes.Repeat([]byte{
0x75, 0xe6, 0x9d, 0x6d, 0xe7, 0x9f,
}, 8)),
}, "got AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" +
" instead of deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := tc.err.Error(); got != tc.want {
t.Errorf("Error: %q, want %q", got, tc.want)
}
})
}
}
func TestNew(t *testing.T) {
t.Parallel()
t.Run("nonexistent", func(t *testing.T) {
t.Parallel()
wantErr := &os.PathError{
Op: "mkdir",
Path: container.Nonexistent,
Err: syscall.ENOENT,
}
if _, err := pkg.New(check.MustAbs(container.Nonexistent)); !reflect.DeepEqual(err, wantErr) {
t.Errorf("New: error = %#v, want %#v", err, wantErr)
}
})
t.Run("permission", func(t *testing.T) {
t.Parallel()
tempDir := check.MustAbs(t.TempDir())
if err := os.Chmod(tempDir.String(), 0); err != nil {
t.Fatal(err)
} else {
t.Cleanup(func() {
if err = os.Chmod(tempDir.String(), 0700); err != nil {
t.Fatal(err)
}
})
}
wantErr := &os.PathError{
Op: "mkdir",
Path: tempDir.Append("cache").String(),
Err: syscall.EACCES,
}
if _, err := pkg.New(tempDir.Append("cache")); !reflect.DeepEqual(err, wantErr) {
t.Errorf("New: error = %#v, want %#v", err, wantErr)
}
})
}

View File

@@ -14,8 +14,8 @@ import (
// PipeWire maintains a pipewire socket with SecurityContext attached via [pipewire].
// The socket stops accepting connections once the pipe referred to by sync is closed.
// The socket is pathname only and is destroyed on revert.
func (sys *I) PipeWire(dst *check.Absolute) *I {
sys.ops = append(sys.ops, &pipewireOp{nil, dst})
func (sys *I) PipeWire(dst *check.Absolute, appID, instanceID string) *I {
sys.ops = append(sys.ops, &pipewireOp{nil, dst, appID, instanceID})
return sys
}
@@ -23,6 +23,8 @@ func (sys *I) PipeWire(dst *check.Absolute) *I {
type pipewireOp struct {
scc io.Closer
dst *check.Absolute
appID, instanceID string
}
func (p *pipewireOp) Type() hst.Enablement { return Process }
@@ -56,12 +58,11 @@ func (p *pipewireOp) apply(sys *I) (err error) {
if p.scc, err = securityContext.BindAndCreate(p.dst.String(), pipewire.SPADict{
{Key: pipewire.PW_KEY_SEC_ENGINE, Value: "app.hakurei"},
{Key: pipewire.PW_KEY_SEC_APP_ID, Value: p.appID},
{Key: pipewire.PW_KEY_SEC_INSTANCE_ID, Value: p.instanceID},
{Key: pipewire.PW_KEY_ACCESS, Value: "restricted"},
}); err != nil {
return newOpError("pipewire", err, false)
} else if err = ctx.GetCore().Sync(); err != nil {
_ = p.scc.Close()
return newOpError("pipewire", err, false)
}
if err = sys.chmod(p.dst.String(), 0); err != nil {
@@ -92,7 +93,9 @@ func (p *pipewireOp) revert(sys *I, _ *Criteria) error {
func (p *pipewireOp) Is(o Op) bool {
target, ok := o.(*pipewireOp)
return ok && p != nil && target != nil &&
p.dst.Is(target.dst)
p.dst.Is(target.dst) &&
p.appID == target.appID &&
p.instanceID == target.instanceID
}
func (p *pipewireOp) Path() string { return p.dst.String() }

View File

@@ -6,6 +6,7 @@ import (
"path"
"syscall"
"testing"
"time"
"hakurei.app/container/stub"
"hakurei.app/internal/acl"
@@ -18,6 +19,8 @@ func TestPipeWireOp(t *testing.T) {
checkOpBehaviour(t, checkNoParallel, []opBehaviourTestCase{
{"success", 0xbeef, 0xff, &pipewireOp{nil,
m(path.Join(t.TempDir(), "pipewire")),
"org.chromium.Chromium",
"ebf083d1b175911782d413369b64ce7c",
}, []stub.Call{
call("pipewireConnect", stub.ExpectArgs{}, func() *pipewire.Context {
if ctx, err := pipewire.New(&stubPipeWireConn{sendmsg: []string{
@@ -154,11 +157,11 @@ func TestPipeWireOp(t *testing.T) {
string([]byte{
// header: SecurityContext::Create
3, 0, 0, 0,
0xa8, 0, 0, 1,
0x40, 1, 0, 1,
5, 0, 0, 0,
2, 0, 0, 0,
// Struct
0xa0, 0, 0, 0,
0x38, 1, 0, 0,
0xe, 0, 0, 0,
// Fd: listen_fd = 1
8, 0, 0, 0,
@@ -171,12 +174,12 @@ func TestPipeWireOp(t *testing.T) {
0, 0, 0, 0,
0, 0, 0, 0,
// Struct: spa_dict
0x78, 0, 0, 0,
0x10, 1, 0, 0,
0xe, 0, 0, 0,
// Int: n_items = 2
// Int: n_items = 4
4, 0, 0, 0,
4, 0, 0, 0,
4, 0, 0, 0,
2, 0, 0, 0,
0, 0, 0, 0,
// String: key = "pipewire.sec.engine"
0x14, 0, 0, 0,
@@ -194,6 +197,48 @@ func TestPipeWireOp(t *testing.T) {
0x68, 0x61, 0x6b, 0x75,
0x72, 0x65, 0x69, 0,
0, 0, 0, 0,
// String: key = "pipewire.sec.app-id"
0x14, 0, 0, 0,
8, 0, 0, 0,
0x70, 0x69, 0x70, 0x65,
0x77, 0x69, 0x72, 0x65,
0x2e, 0x73, 0x65, 0x63,
0x2e, 0x61, 0x70, 0x70,
0x2d, 0x69, 0x64, 0,
0, 0, 0, 0,
// String: value = "org.chromium.Chromium"
0x16, 0, 0, 0,
8, 0, 0, 0,
0x6f, 0x72, 0x67, 0x2e,
0x63, 0x68, 0x72, 0x6f,
0x6d, 0x69, 0x75, 0x6d,
0x2e, 0x43, 0x68, 0x72,
0x6f, 0x6d, 0x69, 0x75,
// String: key = "pipewire.sec.instance-id"
0x6d, 0, 0, 0,
0x19, 0, 0, 0,
8, 0, 0, 0,
0x70, 0x69, 0x70, 0x65,
0x77, 0x69, 0x72, 0x65,
0x2e, 0x73, 0x65, 0x63,
0x2e, 0x69, 0x6e, 0x73,
0x74, 0x61, 0x6e, 0x63,
0x65, 0x2d, 0x69, 0x64,
0, 0, 0, 0,
0, 0, 0, 0,
// String: value = "ebf083d1b175911782d413369b64ce7c"
0x21, 0, 0, 0,
8, 0, 0, 0,
0x65, 0x62, 0x66, 0x30,
0x38, 0x33, 0x64, 0x31,
0x62, 0x31, 0x37, 0x35,
0x39, 0x31, 0x31, 0x37,
0x38, 0x32, 0x64, 0x34,
0x31, 0x33, 0x33, 0x36,
0x39, 0x62, 0x36, 0x34,
0x63, 0x65, 0x37, 0x63,
0, 0, 0, 0,
0, 0, 0, 0,
// String: key = "pipewire.access"
0x10, 0, 0, 0,
8, 0, 0, 0,
@@ -386,29 +431,43 @@ func TestPipeWireOp(t *testing.T) {
checkOpsBuilder(t, "PipeWire", []opsBuilderTestCase{
{"sample", 0xcafe, func(_ *testing.T, sys *I) {
sys.PipeWire(m("/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/pipewire"))
sys.PipeWire(m("/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/pipewire"),
"org.chromium.Chromium",
"ebf083d1b175911782d413369b64ce7c")
}, []Op{&pipewireOp{nil,
m("/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/pipewire"),
"org.chromium.Chromium",
"ebf083d1b175911782d413369b64ce7c",
}}, stub.Expect{}},
})
checkOpIs(t, []opIsTestCase{
{"dst differs", &pipewireOp{nil,
m("/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7d/pipewire"),
"org.chromium.Chromium",
"ebf083d1b175911782d413369b64ce7c",
}, &pipewireOp{nil,
m("/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/pipewire"),
"org.chromium.Chromium",
"ebf083d1b175911782d413369b64ce7c",
}, false},
{"equals", &pipewireOp{nil,
m("/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/pipewire"),
"org.chromium.Chromium",
"ebf083d1b175911782d413369b64ce7c",
}, &pipewireOp{nil,
m("/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/pipewire"),
"org.chromium.Chromium",
"ebf083d1b175911782d413369b64ce7c",
}, true},
})
checkOpMeta(t, []opMetaTestCase{
{"sample", &pipewireOp{nil,
m("/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/pipewire"),
"org.chromium.Chromium",
"ebf083d1b175911782d413369b64ce7c",
}, Process, "/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/pipewire",
`pipewire socket at "/tmp/hakurei.1971/ebf083d1b175911782d413369b64ce7c/pipewire"`},
})
@@ -439,6 +498,12 @@ type stubPipeWireConn struct {
curSendmsg int
}
func (conn *stubPipeWireConn) MightBlock(timeout time.Duration) {
if timeout != 5*time.Second {
panic("unexpected timeout " + timeout.String())
}
}
// Recvmsg marshals and copies a stubMessage prepared ahead of time.
func (conn *stubPipeWireConn) Recvmsg(p, _ []byte, _ int) (n, _, recvflags int, err error) {
defer func() { conn.curRecvmsg++ }()

106
nixos.nix
View File

@@ -24,11 +24,38 @@ let
getsubuid = userid: appid: userid * 100000 + 10000 + appid;
getsubname = userid: appid: "u${toString userid}_a${toString appid}";
getsubhome = userid: appid: "${cfg.stateDir}/u${toString userid}/a${toString appid}";
mountpoints = {
${cfg.sharefs.name} = mkIf (cfg.sharefs.source != null) {
depends = [ cfg.sharefs.source ];
device = "sharefs";
fsType = "fuse.sharefs";
noCheck = true;
options = [
"rw"
"noexec"
"nosuid"
"nodev"
"noatime"
"allow_other"
"mkdir"
"source=${cfg.sharefs.source}"
"setuid=${toString config.users.users.${cfg.sharefs.user}.uid}"
"setgid=${toString config.users.groups.${cfg.sharefs.group}.gid}"
];
};
};
in
{
imports = [ (import ./options.nix packages) ];
options = {
# Forward declare a dummy option for VM filesystems since the real one won't exist
# unless the VM module is actually imported.
virtualisation.fileSystems = lib.mkOption { };
};
config = mkIf cfg.enable {
assertions = [
(
@@ -66,9 +93,13 @@ in
) "" cfg.users;
};
environment.systemPackages = optional (cfg.sharefs.source != null) cfg.sharefs.package;
fileSystems = mountpoints;
virtualisation.fileSystems = mountpoints;
home-manager =
let
privPackages = mapAttrs (username: userid: {
privPackages = mapAttrs (_: userid: {
home.packages = foldlAttrs (
acc: id: app:
[
@@ -196,15 +227,6 @@ in
}
]
)
++ optional (app.enablements.pipewire && app.pulse) {
type = "daemon";
dst = if app.mapRealUid then "/run/user/${toString config.users.users.${username}.uid}/pulse/native" else "/run/user/65534/pulse/native";
path = cfg.shell;
args = [
"-lc"
"exec pipewire-pulse"
];
}
++ [
{
type = "bind";
@@ -331,25 +353,57 @@ in
in
{
users = mkMerge (
foldlAttrs (
acc: _: fid:
acc
++ foldlAttrs (
acc': _: app:
acc' ++ [ { ${getsubname fid app.identity} = getuser fid app.identity; } ]
) [ { ${getsubname fid 0} = getuser fid 0; } ] cfg.apps
) [ ] cfg.users
foldlAttrs
(
acc: _: fid:
acc
++ foldlAttrs (
acc': _: app:
acc' ++ [ { ${getsubname fid app.identity} = getuser fid app.identity; } ]
) [ { ${getsubname fid 0} = getuser fid 0; } ] cfg.apps
)
(
if (cfg.sharefs.source != null) then
[
{
${cfg.sharefs.user} = {
uid = lib.mkDefault 1023;
inherit (cfg.sharefs) group;
isSystemUser = true;
home = cfg.sharefs.source;
};
}
]
else
[ ]
)
cfg.users
);
groups = mkMerge (
foldlAttrs (
acc: _: fid:
acc
++ foldlAttrs (
acc': _: app:
acc' ++ [ { ${getsubname fid app.identity} = getgroup fid app.identity; } ]
) [ { ${getsubname fid 0} = getgroup fid 0; } ] cfg.apps
) [ ] cfg.users
foldlAttrs
(
acc: _: fid:
acc
++ foldlAttrs (
acc': _: app:
acc' ++ [ { ${getsubname fid app.identity} = getgroup fid app.identity; } ]
) [ { ${getsubname fid 0} = getgroup fid 0; } ] cfg.apps
)
(
if (cfg.sharefs.source != null) then
[
{
${cfg.sharefs.group} = {
gid = lib.mkDefault 1023;
};
}
]
else
[ ]
)
cfg.users
);
};
};

View File

@@ -35,7 +35,7 @@ package
*Default:*
` <derivation hakurei-static-x86_64-unknown-linux-musl-0.3.1> `
` <derivation hakurei-static-x86_64-unknown-linux-musl-0.3.3> `
@@ -73,11 +73,11 @@ null or boolean
## environment\.hakurei\.apps\.\<name>\.enablements\.pulse
## environment\.hakurei\.apps\.\<name>\.enablements\.pipewire
Whether to share the PulseAudio socket and cookie\.
Whether to share the PipeWire server via pipewire-pulse on a SecurityContext socket\.
@@ -95,7 +95,7 @@ null or boolean
Whether to share the Wayland socket\.
Whether to share the Wayland server via security-context-v1\.
@@ -805,7 +805,97 @@ package
*Default:*
` <derivation hakurei-hsu-0.3.1> `
` <derivation hakurei-hsu-0.3.3> `
## environment\.hakurei\.sharefs\.package
The sharefs package to use\.
*Type:*
package
*Default:*
` <derivation sharefs> `
## environment\.hakurei\.sharefs\.group
Name of the group to run the sharefs daemon as\.
*Type:*
string
*Default:*
` "sharefs" `
## environment\.hakurei\.sharefs\.name
Host path to mount sharefs on\.
*Type:*
string
*Default:*
` "/sdcard" `
## environment\.hakurei\.sharefs\.source
Writable backing directory\. Setting this to null disables sharefs\.
*Type:*
null or string
*Default:*
` null `
## environment\.hakurei\.sharefs\.user
Name of the user to run the sharefs daemon as\.
*Type:*
string
*Default:*
` "sharefs" `

View File

@@ -12,13 +12,13 @@ in
package = mkOption {
type = types.package;
default = packages.${pkgs.system}.hakurei;
default = packages.${pkgs.stdenv.hostPlatform.system}.hakurei;
description = "The hakurei package to use.";
};
hsuPackage = mkOption {
type = types.package;
default = packages.${pkgs.system}.hsu;
default = packages.${pkgs.stdenv.hostPlatform.system}.hsu;
description = "The hsu package to use.";
};
@@ -40,6 +40,46 @@ in
'';
};
sharefs = {
package = mkOption {
type = types.package;
default = packages.${pkgs.stdenv.hostPlatform.system}.sharefs;
description = "The sharefs package to use.";
};
user = mkOption {
type = types.str;
default = "sharefs";
description = ''
Name of the user to run the sharefs daemon as.
'';
};
group = mkOption {
type = types.str;
default = "sharefs";
description = ''
Name of the group to run the sharefs daemon as.
'';
};
name = mkOption {
type = types.str;
default = "/sdcard";
description = ''
Host path to mount sharefs on.
'';
};
source = mkOption {
type = types.nullOr types.str;
default = null;
description = ''
Writable backing directory. Setting this to null disables sharefs.
'';
};
};
apps = mkOption {
type =
let
@@ -242,19 +282,11 @@ in
type = nullOr bool;
default = true;
description = ''
Whether to share the PipeWire server via SecurityContext.
Whether to share the PipeWire server via pipewire-pulse on a SecurityContext socket.
'';
};
};
pulse = mkOption {
type = nullOr bool;
default = true;
description = ''
Whether to run the PulseAudio compatibility daemon.
'';
};
share = mkOption {
type = nullOr package;
default = null;

View File

@@ -13,6 +13,9 @@
wayland-scanner,
xorg,
# for sharefs
fuse3,
# for hpkg
zstd,
gnutar,
@@ -32,7 +35,7 @@
buildGoModule rec {
pname = "hakurei";
version = "0.3.2";
version = "0.3.3";
srcFiltered = builtins.path {
name = "${pname}-src";
@@ -92,6 +95,7 @@ buildGoModule rec {
buildInputs = [
libffi
libseccomp
fuse3
acl
wayland
]

View File

@@ -84,7 +84,7 @@
virtualisation = {
# Hopefully reduces spurious test failures:
memorySize = if pkgs.hostPlatform.is32bit then 2046 else 8192;
memorySize = if pkgs.stdenv.hostPlatform.is32bit then 2046 else 8192;
qemu.options = [
# Need to switch to a different GPU driver than the default one (-vga std) so that Sway can launch:

View File

@@ -1,6 +1,6 @@
{
lib,
nixosTest,
testers,
buildFHSEnv,
writeShellScriptBin,
@@ -9,7 +9,7 @@
withRace ? false,
}:
nixosTest {
testers.nixosTest {
name = "hakurei" + (if withRace then "-race" else "");
nodes.machine =
{ options, pkgs, ... }:

View File

@@ -1,8 +1,9 @@
{ pkgs, ... }:
{
environment.hakurei = {
environment.hakurei = rec {
enable = true;
stateDir = "/var/lib/hakurei";
sharefs.source = "${stateDir}/sdcard";
users.alice = 0;
apps = {
"cat.gensokyo.extern.foot.noEnablements" = {

View File

@@ -41,7 +41,6 @@ in
"DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/65534/bus"
"DISPLAY=unix:/tmp/.X11-unix/X0"
"HOME=/var/lib/hakurei/u0/a4"
"PIPEWIRE_REMOTE=/run/user/65534/pipewire-0"
"SHELL=/run/current-system/sw/bin/bash"
"TERM=linux"
"USER=u0_a4"
@@ -49,6 +48,7 @@ in
"XDG_RUNTIME_DIR=/run/user/65534"
"XDG_SESSION_CLASS=user"
"XDG_SESSION_TYPE=wayland"
"PULSE_SERVER=unix:/run/user/65534/pulse/native"
];
fs = fs "dead" {
@@ -75,6 +75,7 @@ in
"fstab" = fs "80001ff" null null;
"hsurc" = fs "80001ff" null null;
"fuse.conf" = fs "80001ff" null null;
"gai.conf" = fs "80001ff" null null;
"group" = fs "180" null "hakurei:x:65534:\n";
"host.conf" = fs "80001ff" null null;
"hostname" = fs "80001ff" null null;
@@ -137,12 +138,8 @@ in
user = fs "800001ed" {
"65534" = fs "800001c0" {
bus = fs "10001fd" null null;
pulse = fs "800001c0" {
native = fs "10001ff" null null;
pid = fs "1a4" null null;
} null;
pulse = fs "800001c0" { native = fs "10001ff" null null; } null;
wayland-0 = fs "1000038" null null;
pipewire-0 = fs "1000038" null null;
} null;
} null;
} null;
@@ -193,8 +190,6 @@ in
home-manager = fs "800001ed" { gcroots = fs "800001ed" { current-home = fs "80001ff" null null; } null; } null;
nix = fs "800001ed" {
profiles = fs "800001ed" {
home-manager = fs "80001ff" null null;
home-manager-1-link = fs "80001ff" null null;
profile = fs "80001ff" null null;
profile-1-link = fs "80001ff" null null;
} null;
@@ -225,15 +220,14 @@ in
(ent "/" ignore ignore ignore ignore ignore)
(ent "/" "/dev/shm" "rw,nosuid,nodev,relatime" "tmpfs" "ephemeral" "rw,uid=10004,gid=10004")
(ent "/" "/run/user" "rw,nosuid,nodev,relatime" "tmpfs" "ephemeral" "rw,size=16384k,mode=755,uid=10004,gid=10004")
(ent "/tmp/hakurei.0/tmpdir/4" "/tmp" "rw,nosuid,nodev,relatime" "ext4" "/dev/disk/by-label/nixos" "rw")
(ent "/tmp/hakurei.0/tmpdir/4" "/tmp" "rw,nosuid,nodev,relatime" "ext4" "/dev/vda" "rw")
(ent ignore "/etc/passwd" "ro,nosuid,nodev,relatime" "tmpfs" "rootfs" "rw,uid=10004,gid=10004")
(ent ignore "/etc/group" "ro,nosuid,nodev,relatime" "tmpfs" "rootfs" "rw,uid=10004,gid=10004")
(ent ignore "/run/user/65534/wayland-0" "ro,nosuid,nodev,relatime" "ext4" "/dev/disk/by-label/nixos" "rw")
(ent "/tmp/.X11-unix" "/tmp/.X11-unix" "ro,nosuid,nodev,relatime" "ext4" "/dev/disk/by-label/nixos" "rw")
(ent ignore "/run/user/65534/pipewire-0" "ro,nosuid,nodev,relatime" "ext4" "/dev/disk/by-label/nixos" "rw")
(ent ignore "/run/user/65534/bus" "ro,nosuid,nodev,relatime" "ext4" "/dev/disk/by-label/nixos" "rw")
(ent "/bin" "/bin" "ro,nosuid,nodev,relatime" "ext4" "/dev/disk/by-label/nixos" "rw")
(ent "/usr/bin" "/usr/bin" "ro,nosuid,nodev,relatime" "ext4" "/dev/disk/by-label/nixos" "rw")
(ent ignore "/run/user/65534/wayland-0" "ro,nosuid,nodev,relatime" "ext4" "/dev/vda" "rw")
(ent "/tmp/.X11-unix" "/tmp/.X11-unix" "ro,nosuid,nodev,relatime" "ext4" "/dev/vda" "rw")
(ent ignore "/run/user/65534/bus" "ro,nosuid,nodev,relatime" "ext4" "/dev/vda" "rw")
(ent "/bin" "/bin" "ro,nosuid,nodev,relatime" "ext4" "/dev/vda" "rw")
(ent "/usr/bin" "/usr/bin" "ro,nosuid,nodev,relatime" "ext4" "/dev/vda" "rw")
(ent "/" "/nix/store" "ro,nosuid,nodev,relatime" "overlay" "overlay" "rw,lowerdir=/mnt-root/nix/.ro-store,upperdir=/mnt-root/nix/.rw-store/upper,workdir=/mnt-root/nix/.rw-store/work,uuid=on")
(ent "/block" "/sys/block" "ro,nosuid,nodev,noexec,relatime" "sysfs" "sysfs" "rw")
(ent "/bus" "/sys/bus" "ro,nosuid,nodev,noexec,relatime" "sysfs" "sysfs" "rw")
@@ -241,12 +235,13 @@ in
(ent "/dev" "/sys/dev" "ro,nosuid,nodev,noexec,relatime" "sysfs" "sysfs" "rw")
(ent "/devices" "/sys/devices" "ro,nosuid,nodev,noexec,relatime" "sysfs" "sysfs" "rw")
(ent "/dri" "/dev/dri" "rw,nosuid" "devtmpfs" "devtmpfs" ignore)
(ent "/var/tmp" "/var/tmp" "rw,nosuid,nodev,relatime" "ext4" "/dev/disk/by-label/nixos" "rw")
(ent "/var/cache" "/var/cache" "rw,nosuid,nodev,relatime" "ext4" "/dev/disk/by-label/nixos" "rw")
(ent "/var/tmp" "/var/tmp" "rw,nosuid,nodev,relatime" "ext4" "/dev/vda" "rw")
(ent "/var/cache" "/var/cache" "rw,nosuid,nodev,relatime" "ext4" "/dev/vda" "rw")
(ent "/" "/.hakurei/.ro-store" "rw,relatime" "overlay" "overlay" "ro,lowerdir=/host/nix/.ro-store:/host/nix/.rw-store/upper,redirect_dir=nofollow,userxattr")
(ent "/" "/.hakurei/store" "rw,relatime" "overlay" "overlay" "rw,lowerdir=/host/nix/.ro-store:/host/nix/.rw-store/upper,upperdir=/host/tmp/.hakurei-store-rw/upper,workdir=/host/tmp/.hakurei-store-rw/work,redirect_dir=nofollow,userxattr")
(ent "/etc" ignore "ro,nosuid,nodev,relatime" "ext4" "/dev/disk/by-label/nixos" "rw")
(ent "/var/lib/hakurei/u0/a4" "/var/lib/hakurei/u0/a4" "rw,nosuid,nodev,relatime" "ext4" "/dev/disk/by-label/nixos" "rw")
(ent "/etc" ignore "ro,nosuid,nodev,relatime" "ext4" "/dev/vda" "rw")
(ent "/var/lib/hakurei/u0/a4" "/var/lib/hakurei/u0/a4" "rw,nosuid,nodev,relatime" "ext4" "/dev/vda" "rw")
(ent ignore "/run/user/65534/pulse/native" "ro,nosuid,nodev,relatime" "ext4" "/dev/vda" "rw")
];
seccomp = true;

View File

@@ -49,7 +49,6 @@ in
env = [
"DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus"
"HOME=/var/lib/hakurei/u0/a3"
"PIPEWIRE_REMOTE=/run/user/1000/pipewire-0"
"SHELL=/run/current-system/sw/bin/bash"
"TERM=linux"
"USER=u0_a3"
@@ -57,6 +56,7 @@ in
"XDG_RUNTIME_DIR=/run/user/1000"
"XDG_SESSION_CLASS=user"
"XDG_SESSION_TYPE=wayland"
"PULSE_SERVER=unix:/run/user/1000/pulse/native"
];
fs = fs "dead" {
@@ -100,6 +100,7 @@ in
"fstab" = fs "80001ff" null null;
"hsurc" = fs "80001ff" null null;
"fuse.conf" = fs "80001ff" null null;
"gai.conf" = fs "80001ff" null null;
"group" = fs "180" null "hakurei:x:100:\n";
"host.conf" = fs "80001ff" null null;
"hostname" = fs "80001ff" null null;
@@ -162,12 +163,8 @@ in
user = fs "800001ed" {
"1000" = fs "800001f8" {
bus = fs "10001fd" null null;
pulse = fs "800001c0" {
native = fs "10001ff" null null;
pid = fs "1a4" null null;
} null;
pulse = fs "800001c0" { native = fs "10001ff" null null; } null;
wayland-0 = fs "1000038" null null;
pipewire-0 = fs "1000038" null null;
} null;
} null;
} null;
@@ -216,8 +213,6 @@ in
home-manager = fs "800001ed" { gcroots = fs "800001ed" { current-home = fs "80001ff" null null; } null; } null;
nix = fs "800001ed" {
profiles = fs "800001ed" {
home-manager = fs "80001ff" null null;
home-manager-1-link = fs "80001ff" null null;
profile = fs "80001ff" null null;
profile-1-link = fs "80001ff" null null;
} null;
@@ -252,15 +247,14 @@ in
(ent "/" "/dev/mqueue" "rw,nosuid,nodev,noexec,relatime" "mqueue" "mqueue" "rw")
(ent "/" "/dev/shm" "rw,nosuid,nodev,relatime" "tmpfs" "ephemeral" "rw,uid=10003,gid=10003")
(ent "/" "/run/user" "rw,nosuid,nodev,relatime" "tmpfs" "ephemeral" "rw,size=16384k,mode=755,uid=10003,gid=10003")
(ent "/tmp/hakurei.0/runtime/3" "/run/user/1000" "rw,nosuid,nodev,relatime" "ext4" "/dev/disk/by-label/nixos" "rw")
(ent "/tmp/hakurei.0/tmpdir/3" "/tmp" "rw,nosuid,nodev,relatime" "ext4" "/dev/disk/by-label/nixos" "rw")
(ent "/tmp/hakurei.0/runtime/3" "/run/user/1000" "rw,nosuid,nodev,relatime" "ext4" "/dev/vda" "rw")
(ent "/tmp/hakurei.0/tmpdir/3" "/tmp" "rw,nosuid,nodev,relatime" "ext4" "/dev/vda" "rw")
(ent ignore "/etc/passwd" "ro,nosuid,nodev,relatime" "tmpfs" "rootfs" "rw,uid=10003,gid=10003")
(ent ignore "/etc/group" "ro,nosuid,nodev,relatime" "tmpfs" "rootfs" "rw,uid=10003,gid=10003")
(ent ignore "/run/user/1000/wayland-0" "ro,nosuid,nodev,relatime" "ext4" "/dev/disk/by-label/nixos" "rw")
(ent ignore "/run/user/1000/pipewire-0" "ro,nosuid,nodev,relatime" "ext4" "/dev/disk/by-label/nixos" "rw")
(ent ignore "/run/user/1000/bus" "ro,nosuid,nodev,relatime" "ext4" "/dev/disk/by-label/nixos" "rw")
(ent "/bin" "/bin" "ro,nosuid,nodev,relatime" "ext4" "/dev/disk/by-label/nixos" "rw")
(ent "/usr/bin" "/usr/bin" "ro,nosuid,nodev,relatime" "ext4" "/dev/disk/by-label/nixos" "rw")
(ent ignore "/run/user/1000/wayland-0" "ro,nosuid,nodev,relatime" "ext4" "/dev/vda" "rw")
(ent ignore "/run/user/1000/bus" "ro,nosuid,nodev,relatime" "ext4" "/dev/vda" "rw")
(ent "/bin" "/bin" "ro,nosuid,nodev,relatime" "ext4" "/dev/vda" "rw")
(ent "/usr/bin" "/usr/bin" "ro,nosuid,nodev,relatime" "ext4" "/dev/vda" "rw")
(ent "/" "/nix/store" "ro,nosuid,nodev,relatime" "overlay" "overlay" "rw,lowerdir=/mnt-root/nix/.ro-store,upperdir=/mnt-root/nix/.rw-store/upper,workdir=/mnt-root/nix/.rw-store/work,uuid=on")
(ent "/block" "/sys/block" "ro,nosuid,nodev,noexec,relatime" "sysfs" "sysfs" "rw")
(ent "/bus" "/sys/bus" "ro,nosuid,nodev,noexec,relatime" "sysfs" "sysfs" "rw")
@@ -268,12 +262,13 @@ in
(ent "/dev" "/sys/dev" "ro,nosuid,nodev,noexec,relatime" "sysfs" "sysfs" "rw")
(ent "/devices" "/sys/devices" "ro,nosuid,nodev,noexec,relatime" "sysfs" "sysfs" "rw")
(ent "/dri" "/dev/dri" "rw,nosuid" "devtmpfs" "devtmpfs" ignore)
(ent "/var/tmp" "/var/tmp" "rw,nosuid,nodev,relatime" "ext4" "/dev/disk/by-label/nixos" "rw")
(ent "/var/cache" "/var/cache" "rw,nosuid,nodev,relatime" "ext4" "/dev/disk/by-label/nixos" "rw")
(ent "/var/tmp" "/var/tmp" "rw,nosuid,nodev,relatime" "ext4" "/dev/vda" "rw")
(ent "/var/cache" "/var/cache" "rw,nosuid,nodev,relatime" "ext4" "/dev/vda" "rw")
(ent "/" "/.hakurei/.ro-store" "rw,relatime" "overlay" "overlay" "ro,lowerdir=/host/nix/.ro-store:/host/nix/.rw-store/upper,redirect_dir=nofollow,userxattr")
(ent "/" "/.hakurei/store" "rw,relatime" "overlay" "overlay" "rw,lowerdir=/host/nix/.ro-store:/host/nix/.rw-store/upper,upperdir=/host/tmp/.hakurei-store-rw/upper,workdir=/host/tmp/.hakurei-store-rw/work,redirect_dir=nofollow,userxattr")
(ent "/etc" ignore "ro,nosuid,nodev,relatime" "ext4" "/dev/disk/by-label/nixos" "rw")
(ent "/var/lib/hakurei/u0/a3" "/var/lib/hakurei/u0/a3" "rw,nosuid,nodev,relatime" "ext4" "/dev/disk/by-label/nixos" "rw")
(ent "/etc" ignore "ro,nosuid,nodev,relatime" "ext4" "/dev/vda" "rw")
(ent "/var/lib/hakurei/u0/a3" "/var/lib/hakurei/u0/a3" "rw,nosuid,nodev,relatime" "ext4" "/dev/vda" "rw")
(ent ignore "/run/user/1000/pulse/native" "ro,nosuid,nodev,relatime" "ext4" "/dev/vda" "rw")
];
seccomp = true;

View File

@@ -61,6 +61,7 @@
"fstab" = fs "80001ff" null null;
"hsurc" = fs "80001ff" null null;
"fuse.conf" = fs "80001ff" null null;
"gai.conf" = fs "80001ff" null null;
"group" = fs "180" null "hakurei:x:65534:\n";
"host.conf" = fs "80001ff" null null;
"hostname" = fs "80001ff" null null;
@@ -139,23 +140,23 @@
mount = [
(ent "/sysroot" "/" "ro,nosuid,nodev,relatime" "tmpfs" "rootfs" "rw,uid=10000,gid=10000")
(ent "/bin" "/bin" "rw,nosuid,nodev,relatime" "ext4" "/dev/disk/by-label/nixos" "rw")
(ent "/home" "/home" "rw,nosuid,nodev,relatime" "ext4" "/dev/disk/by-label/nixos" "rw")
(ent "/lib64" "/lib64" "rw,nosuid,nodev,relatime" "ext4" "/dev/disk/by-label/nixos" "rw")
(ent "/lost+found" "/lost+found" "rw,nosuid,nodev,relatime" "ext4" "/dev/disk/by-label/nixos" "rw")
(ent "/nix" "/nix" "rw,nosuid,nodev,relatime" "ext4" "/dev/disk/by-label/nixos" "rw")
(ent "/bin" "/bin" "rw,nosuid,nodev,relatime" "ext4" "/dev/vda" "rw")
(ent "/home" "/home" "rw,nosuid,nodev,relatime" "ext4" "/dev/vda" "rw")
(ent "/lib64" "/lib64" "rw,nosuid,nodev,relatime" "ext4" "/dev/vda" "rw")
(ent "/lost+found" "/lost+found" "rw,nosuid,nodev,relatime" "ext4" "/dev/vda" "rw")
(ent "/nix" "/nix" "rw,nosuid,nodev,relatime" "ext4" "/dev/vda" "rw")
(ent "/" "/nix/.ro-store" "rw,nosuid,nodev,relatime" "9p" "nix-store" ignore)
(ent "/" "/nix/.rw-store" "rw,nosuid,nodev,relatime" "tmpfs" "tmpfs" "rw,mode=755")
(ent "/" "/nix/store" "rw,relatime" "overlay" "overlay" "rw,lowerdir=/mnt-root/nix/.ro-store,upperdir=/mnt-root/nix/.rw-store/upper,workdir=/mnt-root/nix/.rw-store/work,uuid=on")
(ent "/" "/nix/store" "ro,nosuid,nodev,relatime" "overlay" "overlay" "rw,lowerdir=/mnt-root/nix/.ro-store,upperdir=/mnt-root/nix/.rw-store/upper,workdir=/mnt-root/nix/.rw-store/work,uuid=on")
(ent "/root" "/root" "rw,nosuid,nodev,relatime" "ext4" "/dev/disk/by-label/nixos" "rw")
(ent "/root" "/root" "rw,nosuid,nodev,relatime" "ext4" "/dev/vda" "rw")
(ent "/" "/run" "rw,nosuid,nodev" "tmpfs" "tmpfs" ignore)
(ent "/" "/run/keys" "rw,nosuid,nodev,relatime" "ramfs" "ramfs" "rw,mode=750")
(ent "/" "/run/credentials/systemd-journald.service" "ro,nosuid,nodev,noexec,relatime,nosymfollow" "tmpfs" "tmpfs" "rw,size=1024k,nr_inodes=1024,mode=700,noswap")
(ent "/" "/run/wrappers" "rw,nosuid,nodev,relatime" "tmpfs" "tmpfs" ignore)
(ent "/" "/run/credentials/getty@tty1.service" "ro,nosuid,nodev,noexec,relatime,nosymfollow" "tmpfs" "tmpfs" "rw,size=1024k,nr_inodes=1024,mode=700,noswap")
(ent "/" "/run/user/1000" "rw,nosuid,nodev,relatime" "tmpfs" "tmpfs" ignore)
(ent "/srv" "/srv" "rw,nosuid,nodev,relatime" "ext4" "/dev/disk/by-label/nixos" "rw")
(ent "/srv" "/srv" "rw,nosuid,nodev,relatime" "ext4" "/dev/vda" "rw")
(ent "/" "/sys" "rw,nosuid,nodev,noexec,relatime" "sysfs" "sysfs" "rw")
(ent "/" "/sys/kernel/security" "rw,nosuid,nodev,noexec,relatime" "securityfs" "securityfs" "rw")
(ent "/../../.." "/sys/fs/cgroup" "rw,nosuid,nodev,noexec,relatime" "cgroup2" "cgroup2" "rw,nsdelegate,memory_recursiveprot")
@@ -166,8 +167,8 @@
(ent "/" ignore "rw,nosuid,nodev,noexec,relatime" ignore ignore "rw")
(ent "/" ignore "rw,nosuid,nodev,noexec,relatime" ignore ignore "rw")
(ent "/" ignore "rw,nosuid,nodev,noexec,relatime" ignore ignore "rw")
(ent "/usr" "/usr" "rw,nosuid,nodev,relatime" "ext4" "/dev/disk/by-label/nixos" "rw")
(ent "/var" "/var" "rw,nosuid,nodev,relatime" "ext4" "/dev/disk/by-label/nixos" "rw")
(ent "/usr" "/usr" "rw,nosuid,nodev,relatime" "ext4" "/dev/vda" "rw")
(ent "/var" "/var" "rw,nosuid,nodev,relatime" "ext4" "/dev/vda" "rw")
(ent "/" "/proc" "rw,nosuid,nodev,noexec,relatime" "proc" "proc" "rw")
(ent "/" "/.hakurei" "rw,nosuid,nodev,relatime" "tmpfs" "ephemeral" "rw,size=4k,mode=755,uid=10000,gid=10000")
(ent "/" "/dev" "ro,nosuid,nodev,relatime" "tmpfs" "devtmpfs" "rw,mode=755,uid=10000,gid=10000")
@@ -182,12 +183,12 @@
(ent "/" "/dev/mqueue" "rw,nosuid,nodev,noexec,relatime" "mqueue" "mqueue" "rw")
(ent "/" "/dev/shm" "rw,nosuid,nodev,relatime" "tmpfs" "ephemeral" "rw,uid=10000,gid=10000")
(ent "/" "/run/user" "rw,nosuid,nodev,relatime" "tmpfs" "ephemeral" "rw,size=16384k,mode=755,uid=10000,gid=10000")
(ent "/tmp/hakurei.0/runtime/0" "/run/user/65534" "rw,nosuid,nodev,relatime" "ext4" "/dev/disk/by-label/nixos" "rw")
(ent "/tmp/hakurei.0/tmpdir/0" "/tmp" "rw,nosuid,nodev,relatime" "ext4" "/dev/disk/by-label/nixos" "rw")
(ent "/tmp/hakurei.0/runtime/0" "/run/user/65534" "rw,nosuid,nodev,relatime" "ext4" "/dev/vda" "rw")
(ent "/tmp/hakurei.0/tmpdir/0" "/tmp" "rw,nosuid,nodev,relatime" "ext4" "/dev/vda" "rw")
(ent ignore "/etc/passwd" "ro,nosuid,nodev,relatime" "tmpfs" "rootfs" "rw,uid=10000,gid=10000")
(ent ignore "/etc/group" "ro,nosuid,nodev,relatime" "tmpfs" "rootfs" "rw,uid=10000,gid=10000")
(ent "/kvm" "/dev/kvm" "rw,nosuid" "devtmpfs" "devtmpfs" ignore)
(ent "/etc" ignore "ro,nosuid,nodev,relatime" "ext4" "/dev/disk/by-label/nixos" "rw")
(ent "/etc" ignore "ro,nosuid,nodev,relatime" "ext4" "/dev/vda" "rw")
(ent "/" "/run/user/1000" "rw,nosuid,nodev,relatime" "tmpfs" "ephemeral" "rw,size=8k,mode=755,uid=10000,gid=10000")
(ent "/" "/run/nscd" "rw,nosuid,nodev,relatime" "tmpfs" "ephemeral" "rw,size=8k,mode=755,uid=10000,gid=10000")
(ent "/" "/run/dbus" "rw,nosuid,nodev,relatime" "tmpfs" "ephemeral" "rw,size=8k,mode=755,uid=10000,gid=10000")

View File

@@ -49,7 +49,6 @@ in
env = [
"DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/65534/bus"
"HOME=/var/lib/hakurei/u0/a5"
"PIPEWIRE_REMOTE=/run/user/65534/pipewire-0"
"SHELL=/run/current-system/sw/bin/bash"
"TERM=linux"
"USER=u0_a5"
@@ -57,6 +56,7 @@ in
"XDG_RUNTIME_DIR=/run/user/65534"
"XDG_SESSION_CLASS=user"
"XDG_SESSION_TYPE=wayland"
"PULSE_SERVER=unix:/run/user/65534/pulse/native"
];
fs = fs "dead" {
@@ -98,6 +98,7 @@ in
"fstab" = fs "80001ff" null null;
"hsurc" = fs "80001ff" null null;
"fuse.conf" = fs "80001ff" null null;
"gai.conf" = fs "80001ff" null null;
"group" = fs "180" null "hakurei:x:65534:\n";
"host.conf" = fs "80001ff" null null;
"hostname" = fs "80001ff" null null;
@@ -160,12 +161,8 @@ in
user = fs "800001ed" {
"65534" = fs "800001f8" {
bus = fs "10001fd" null null;
pulse = fs "800001c0" {
native = fs "10001ff" null null;
pid = fs "1a4" null null;
} null;
pulse = fs "800001c0" { native = fs "10001ff" null null; } null;
wayland-0 = fs "1000038" null null;
pipewire-0 = fs "1000038" null null;
} null;
} null;
} null;
@@ -214,8 +211,6 @@ in
home-manager = fs "800001ed" { gcroots = fs "800001ed" { current-home = fs "80001ff" null null; } null; } null;
nix = fs "800001ed" {
profiles = fs "800001ed" {
home-manager = fs "80001ff" null null;
home-manager-1-link = fs "80001ff" null null;
profile = fs "80001ff" null null;
profile-1-link = fs "80001ff" null null;
} null;
@@ -250,15 +245,14 @@ in
(ent "/" "/dev/mqueue" "rw,nosuid,nodev,noexec,relatime" "mqueue" "mqueue" "rw")
(ent "/" "/dev/shm" "rw,nosuid,nodev,relatime" "tmpfs" "ephemeral" "rw,uid=10005,gid=10005")
(ent "/" "/run/user" "rw,nosuid,nodev,relatime" "tmpfs" "ephemeral" "rw,size=16384k,mode=755,uid=10005,gid=10005")
(ent "/tmp/hakurei.0/runtime/5" "/run/user/65534" "rw,nosuid,nodev,relatime" "ext4" "/dev/disk/by-label/nixos" "rw")
(ent "/tmp/hakurei.0/tmpdir/5" "/tmp" "rw,nosuid,nodev,relatime" "ext4" "/dev/disk/by-label/nixos" "rw")
(ent "/tmp/hakurei.0/runtime/5" "/run/user/65534" "rw,nosuid,nodev,relatime" "ext4" "/dev/vda" "rw")
(ent "/tmp/hakurei.0/tmpdir/5" "/tmp" "rw,nosuid,nodev,relatime" "ext4" "/dev/vda" "rw")
(ent ignore "/etc/passwd" "ro,nosuid,nodev,relatime" "tmpfs" "rootfs" "rw,uid=10005,gid=10005")
(ent ignore "/etc/group" "ro,nosuid,nodev,relatime" "tmpfs" "rootfs" "rw,uid=10005,gid=10005")
(ent ignore "/run/user/65534/wayland-0" "ro,nosuid,nodev,relatime" "ext4" "/dev/disk/by-label/nixos" "rw")
(ent ignore "/run/user/65534/pipewire-0" "ro,nosuid,nodev,relatime" "ext4" "/dev/disk/by-label/nixos" "rw")
(ent ignore "/run/user/65534/bus" "ro,nosuid,nodev,relatime" "ext4" "/dev/disk/by-label/nixos" "rw")
(ent "/bin" "/bin" "ro,nosuid,nodev,relatime" "ext4" "/dev/disk/by-label/nixos" "rw")
(ent "/usr/bin" "/usr/bin" "ro,nosuid,nodev,relatime" "ext4" "/dev/disk/by-label/nixos" "rw")
(ent ignore "/run/user/65534/wayland-0" "ro,nosuid,nodev,relatime" "ext4" "/dev/vda" "rw")
(ent ignore "/run/user/65534/bus" "ro,nosuid,nodev,relatime" "ext4" "/dev/vda" "rw")
(ent "/bin" "/bin" "ro,nosuid,nodev,relatime" "ext4" "/dev/vda" "rw")
(ent "/usr/bin" "/usr/bin" "ro,nosuid,nodev,relatime" "ext4" "/dev/vda" "rw")
(ent "/" "/nix/store" "ro,nosuid,nodev,relatime" "overlay" "overlay" "rw,lowerdir=/mnt-root/nix/.ro-store,upperdir=/mnt-root/nix/.rw-store/upper,workdir=/mnt-root/nix/.rw-store/work,uuid=on")
(ent "/block" "/sys/block" "ro,nosuid,nodev,noexec,relatime" "sysfs" "sysfs" "rw")
(ent "/bus" "/sys/bus" "ro,nosuid,nodev,noexec,relatime" "sysfs" "sysfs" "rw")
@@ -266,9 +260,10 @@ in
(ent "/dev" "/sys/dev" "ro,nosuid,nodev,noexec,relatime" "sysfs" "sysfs" "rw")
(ent "/devices" "/sys/devices" "ro,nosuid,nodev,noexec,relatime" "sysfs" "sysfs" "rw")
(ent "/dri" "/dev/dri" "rw,nosuid" "devtmpfs" "devtmpfs" ignore)
(ent "/var/tmp" "/var/tmp" "rw,nosuid,nodev,relatime" "ext4" "/dev/disk/by-label/nixos" "rw")
(ent "/etc" ignore "ro,nosuid,nodev,relatime" "ext4" "/dev/disk/by-label/nixos" "rw")
(ent "/var/lib/hakurei/u0/a5" "/var/lib/hakurei/u0/a5" "rw,nosuid,nodev,relatime" "ext4" "/dev/disk/by-label/nixos" "rw")
(ent "/var/tmp" "/var/tmp" "rw,nosuid,nodev,relatime" "ext4" "/dev/vda" "rw")
(ent "/etc" ignore "ro,nosuid,nodev,relatime" "ext4" "/dev/vda" "rw")
(ent "/var/lib/hakurei/u0/a5" "/var/lib/hakurei/u0/a5" "rw,nosuid,nodev,relatime" "ext4" "/dev/vda" "rw")
(ent ignore "/run/user/65534/pulse/native" "ro,nosuid,nodev,relatime" "ext4" "/dev/vda" "rw")
];
seccomp = true;

View File

@@ -49,7 +49,6 @@ in
env = [
"DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/65534/bus"
"HOME=/var/lib/hakurei/u0/a1"
"PIPEWIRE_REMOTE=/run/user/65534/pipewire-0"
"SHELL=/run/current-system/sw/bin/bash"
"TERM=linux"
"USER=u0_a1"
@@ -57,6 +56,7 @@ in
"XDG_RUNTIME_DIR=/run/user/65534"
"XDG_SESSION_CLASS=user"
"XDG_SESSION_TYPE=wayland"
"PULSE_SERVER=unix:/run/user/65534/pulse/native"
];
fs = fs "dead" {
@@ -97,6 +97,7 @@ in
"fstab" = fs "80001ff" null null;
"hsurc" = fs "80001ff" null null;
"fuse.conf" = fs "80001ff" null null;
"gai.conf" = fs "80001ff" null null;
"group" = fs "180" null "hakurei:x:65534:\n";
"host.conf" = fs "80001ff" null null;
"hostname" = fs "80001ff" null null;
@@ -159,12 +160,8 @@ in
user = fs "800001ed" {
"65534" = fs "800001c0" {
bus = fs "10001fd" null null;
pulse = fs "800001c0" {
native = fs "10001ff" null null;
pid = fs "1a4" null null;
} null;
pulse = fs "800001c0" { native = fs "10001ff" null null; } null;
wayland-0 = fs "1000038" null null;
pipewire-0 = fs "1000038" null null;
} null;
} null;
} null;
@@ -213,8 +210,6 @@ in
home-manager = fs "800001ed" { gcroots = fs "800001ed" { current-home = fs "80001ff" null null; } null; } null;
nix = fs "800001ed" {
profiles = fs "800001ed" {
home-manager = fs "80001ff" null null;
home-manager-1-link = fs "80001ff" null null;
profile = fs "80001ff" null null;
profile-1-link = fs "80001ff" null null;
} null;
@@ -251,11 +246,10 @@ in
(ent "/" "/tmp" "rw,nosuid,nodev,relatime" "tmpfs" "ephemeral" "rw,uid=10001,gid=10001")
(ent ignore "/etc/passwd" "ro,nosuid,nodev,relatime" "tmpfs" "rootfs" "rw,uid=10001,gid=10001")
(ent ignore "/etc/group" "ro,nosuid,nodev,relatime" "tmpfs" "rootfs" "rw,uid=10001,gid=10001")
(ent ignore "/run/user/65534/wayland-0" "ro,nosuid,nodev,relatime" "ext4" "/dev/disk/by-label/nixos" "rw")
(ent ignore "/run/user/65534/pipewire-0" "ro,nosuid,nodev,relatime" "ext4" "/dev/disk/by-label/nixos" "rw")
(ent ignore "/run/user/65534/bus" "ro,nosuid,nodev,relatime" "ext4" "/dev/disk/by-label/nixos" "rw")
(ent "/bin" "/bin" "ro,nosuid,nodev,relatime" "ext4" "/dev/disk/by-label/nixos" "rw")
(ent "/usr/bin" "/usr/bin" "ro,nosuid,nodev,relatime" "ext4" "/dev/disk/by-label/nixos" "rw")
(ent ignore "/run/user/65534/wayland-0" "ro,nosuid,nodev,relatime" "ext4" "/dev/vda" "rw")
(ent ignore "/run/user/65534/bus" "ro,nosuid,nodev,relatime" "ext4" "/dev/vda" "rw")
(ent "/bin" "/bin" "ro,nosuid,nodev,relatime" "ext4" "/dev/vda" "rw")
(ent "/usr/bin" "/usr/bin" "ro,nosuid,nodev,relatime" "ext4" "/dev/vda" "rw")
(ent "/" "/nix/store" "ro,nosuid,nodev,relatime" "overlay" "overlay" "rw,lowerdir=/mnt-root/nix/.ro-store,upperdir=/mnt-root/nix/.rw-store/upper,workdir=/mnt-root/nix/.rw-store/work,uuid=on")
(ent "/block" "/sys/block" "ro,nosuid,nodev,noexec,relatime" "sysfs" "sysfs" "rw")
(ent "/bus" "/sys/bus" "ro,nosuid,nodev,noexec,relatime" "sysfs" "sysfs" "rw")
@@ -263,9 +257,10 @@ in
(ent "/dev" "/sys/dev" "ro,nosuid,nodev,noexec,relatime" "sysfs" "sysfs" "rw")
(ent "/devices" "/sys/devices" "ro,nosuid,nodev,noexec,relatime" "sysfs" "sysfs" "rw")
(ent "/dri" "/dev/dri" "rw,nosuid" "devtmpfs" "devtmpfs" ignore)
(ent "/var/tmp" "/var/tmp" "rw,nosuid,nodev,relatime" "ext4" "/dev/disk/by-label/nixos" "rw")
(ent "/etc" ignore "ro,nosuid,nodev,relatime" "ext4" "/dev/disk/by-label/nixos" "rw")
(ent "/var/lib/hakurei/u0/a1" "/var/lib/hakurei/u0/a1" "rw,nosuid,nodev,relatime" "ext4" "/dev/disk/by-label/nixos" "rw")
(ent "/var/tmp" "/var/tmp" "rw,nosuid,nodev,relatime" "ext4" "/dev/vda" "rw")
(ent "/etc" ignore "ro,nosuid,nodev,relatime" "ext4" "/dev/vda" "rw")
(ent "/var/lib/hakurei/u0/a1" "/var/lib/hakurei/u0/a1" "rw,nosuid,nodev,relatime" "ext4" "/dev/vda" "rw")
(ent ignore "/run/user/65534/pulse/native" "ro,nosuid,nodev,relatime" "ext4" "/dev/vda" "rw")
];
seccomp = true;

View File

@@ -50,7 +50,6 @@ in
"DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/65534/bus"
"DISPLAY=:0"
"HOME=/var/lib/hakurei/u0/a2"
"PIPEWIRE_REMOTE=/run/user/65534/pipewire-0"
"SHELL=/run/current-system/sw/bin/bash"
"TERM=linux"
"USER=u0_a2"
@@ -58,6 +57,7 @@ in
"XDG_RUNTIME_DIR=/run/user/65534"
"XDG_SESSION_CLASS=user"
"XDG_SESSION_TYPE=wayland"
"PULSE_SERVER=unix:/run/user/65534/pulse/native"
];
fs = fs "dead" {
@@ -102,6 +102,7 @@ in
"fstab" = fs "80001ff" null null;
"hsurc" = fs "80001ff" null null;
"fuse.conf" = fs "80001ff" null null;
"gai.conf" = fs "80001ff" null null;
"group" = fs "180" null "hakurei:x:65534:\n";
"host.conf" = fs "80001ff" null null;
"hostname" = fs "80001ff" null null;
@@ -164,12 +165,8 @@ in
user = fs "800001ed" {
"65534" = fs "800001f8" {
bus = fs "10001fd" null null;
pulse = fs "800001c0" {
native = fs "10001ff" null null;
pid = fs "1a4" null null;
} null;
pulse = fs "800001c0" { native = fs "10001ff" null null; } null;
wayland-0 = fs "1000038" null null;
pipewire-0 = fs "1000038" null null;
} null;
} null;
} null;
@@ -220,8 +217,6 @@ in
home-manager = fs "800001ed" { gcroots = fs "800001ed" { current-home = fs "80001ff" null null; } null; } null;
nix = fs "800001ed" {
profiles = fs "800001ed" {
home-manager = fs "80001ff" null null;
home-manager-1-link = fs "80001ff" null null;
profile = fs "80001ff" null null;
profile-1-link = fs "80001ff" null null;
} null;
@@ -257,16 +252,15 @@ in
(ent "/" "/dev/mqueue" "rw,nosuid,nodev,noexec,relatime" "mqueue" "mqueue" "rw")
(ent "/" "/dev/shm" "rw,nosuid,nodev,relatime" "tmpfs" "ephemeral" "rw,uid=10002,gid=10002")
(ent "/" "/run/user" "rw,nosuid,nodev,relatime" "tmpfs" "ephemeral" "rw,size=16384k,mode=755,uid=10002,gid=10002")
(ent "/tmp/hakurei.0/runtime/2" "/run/user/65534" "rw,nosuid,nodev,relatime" "ext4" "/dev/disk/by-label/nixos" "rw")
(ent "/tmp/hakurei.0/runtime/2" "/run/user/65534" "rw,nosuid,nodev,relatime" "ext4" "/dev/vda" "rw")
(ent "/" "/tmp" "rw,nosuid,nodev,relatime" "tmpfs" "ephemeral" "rw,uid=10002,gid=10002")
(ent ignore "/etc/passwd" "ro,nosuid,nodev,relatime" "tmpfs" "rootfs" "rw,uid=10002,gid=10002")
(ent ignore "/etc/group" "ro,nosuid,nodev,relatime" "tmpfs" "rootfs" "rw,uid=10002,gid=10002")
(ent ignore "/run/user/65534/wayland-0" "ro,nosuid,nodev,relatime" "ext4" "/dev/disk/by-label/nixos" "rw")
(ent "/tmp/.X11-unix" "/tmp/.X11-unix" "ro,nosuid,nodev,relatime" "ext4" "/dev/disk/by-label/nixos" "rw")
(ent ignore "/run/user/65534/pipewire-0" "ro,nosuid,nodev,relatime" "ext4" "/dev/disk/by-label/nixos" "rw")
(ent ignore "/run/user/65534/bus" "ro,nosuid,nodev,relatime" "ext4" "/dev/disk/by-label/nixos" "rw")
(ent "/bin" "/bin" "ro,nosuid,nodev,relatime" "ext4" "/dev/disk/by-label/nixos" "rw")
(ent "/usr/bin" "/usr/bin" "ro,nosuid,nodev,relatime" "ext4" "/dev/disk/by-label/nixos" "rw")
(ent ignore "/run/user/65534/wayland-0" "ro,nosuid,nodev,relatime" "ext4" "/dev/vda" "rw")
(ent "/tmp/.X11-unix" "/tmp/.X11-unix" "ro,nosuid,nodev,relatime" "ext4" "/dev/vda" "rw")
(ent ignore "/run/user/65534/bus" "ro,nosuid,nodev,relatime" "ext4" "/dev/vda" "rw")
(ent "/bin" "/bin" "ro,nosuid,nodev,relatime" "ext4" "/dev/vda" "rw")
(ent "/usr/bin" "/usr/bin" "ro,nosuid,nodev,relatime" "ext4" "/dev/vda" "rw")
(ent "/" "/nix/store" "ro,nosuid,nodev,relatime" "overlay" "overlay" "rw,lowerdir=/mnt-root/nix/.ro-store,upperdir=/mnt-root/nix/.rw-store/upper,workdir=/mnt-root/nix/.rw-store/work,uuid=on")
(ent "/block" "/sys/block" "ro,nosuid,nodev,noexec,relatime" "sysfs" "sysfs" "rw")
(ent "/bus" "/sys/bus" "ro,nosuid,nodev,noexec,relatime" "sysfs" "sysfs" "rw")
@@ -274,12 +268,13 @@ in
(ent "/dev" "/sys/dev" "ro,nosuid,nodev,noexec,relatime" "sysfs" "sysfs" "rw")
(ent "/devices" "/sys/devices" "ro,nosuid,nodev,noexec,relatime" "sysfs" "sysfs" "rw")
(ent "/dri" "/dev/dri" "rw,nosuid" "devtmpfs" "devtmpfs" ignore)
(ent "/var/tmp" "/var/tmp" "rw,nosuid,nodev,relatime" "ext4" "/dev/disk/by-label/nixos" "rw")
(ent "/var/cache" "/var/cache" "rw,nosuid,nodev,relatime" "ext4" "/dev/disk/by-label/nixos" "rw")
(ent "/var/tmp" "/var/tmp" "rw,nosuid,nodev,relatime" "ext4" "/dev/vda" "rw")
(ent "/var/cache" "/var/cache" "rw,nosuid,nodev,relatime" "ext4" "/dev/vda" "rw")
(ent "/" "/.hakurei/.ro-store" "rw,relatime" "overlay" "overlay" "ro,lowerdir=/host/nix/.ro-store:/host/nix/.rw-store/upper,redirect_dir=nofollow,userxattr")
(ent "/" "/.hakurei/store" "rw,relatime" "overlay" "overlay" "rw,lowerdir=/host/nix/.ro-store:/host/nix/.rw-store/upper,upperdir=/host/tmp/.hakurei-store-rw/upper,workdir=/host/tmp/.hakurei-store-rw/work,redirect_dir=nofollow,uuid=on,userxattr")
(ent "/etc" ignore "ro,nosuid,nodev,relatime" "ext4" "/dev/disk/by-label/nixos" "rw")
(ent "/var/lib/hakurei/u0/a2" "/var/lib/hakurei/u0/a2" "rw,nosuid,nodev,relatime" "ext4" "/dev/disk/by-label/nixos" "rw")
(ent "/etc" ignore "ro,nosuid,nodev,relatime" "ext4" "/dev/vda" "rw")
(ent "/var/lib/hakurei/u0/a2" "/var/lib/hakurei/u0/a2" "rw,nosuid,nodev,relatime" "ext4" "/dev/vda" "rw")
(ent ignore "/run/user/65534/pulse/native" "ro,nosuid,nodev,relatime" "ext4" "/dev/vda" "rw")
];
seccomp = true;

View File

@@ -6,7 +6,7 @@
}:
let
testProgram = pkgs.callPackage ./tool/package.nix { inherit (config.environment.hakurei.package) version; };
testCases = import ./case pkgs.system lib testProgram;
testCases = import ./case pkgs.stdenv.hostPlatform.system lib testProgram;
in
{
users.users = {
@@ -33,7 +33,7 @@ in
hakurei -v run hakurei-test \
-p "/var/tmp/.hakurei-check-ok.0" \
-t ${toString (builtins.toFile "hakurei-pd-want.json" (builtins.toJSON testCases.pd.want))} \
-s ${testCases.pd.expectedFilter.${pkgs.system}} "$@"
-s ${testCases.pd.expectedFilter.${pkgs.stdenv.hostPlatform.system}} "$@"
'')
];

View File

@@ -1,12 +1,12 @@
{
lib,
nixosTest,
testers,
self,
withRace ? false,
}:
nixosTest {
testers.nixosTest {
name = "hakurei-sandbox" + (if withRace then "-race" else "");
nodes.machine =
{ options, pkgs, ... }:

View File

@@ -79,8 +79,10 @@ check_sandbox("device")
check_sandbox("pdlike")
# Exit Sway and verify process exit status 0:
machine.wait_until_fails("pgrep -x hakurei", timeout=5)
swaymsg("exit", succeed=False)
machine.wait_for_file("/tmp/sway-exit-ok")
# Print hakurei runDir contents:
print(machine.fail("ls /run/user/1000/hakurei"))
machine.succeed("find /tmp -maxdepth 1 -type d -name '.hakurei-shim-*' -print -exec false '{}' +")

View File

@@ -226,15 +226,17 @@ machine.send_chars("clear; pactl info && touch /var/tmp/pulse-ok\n")
machine.wait_for_file("/var/tmp/pulse-ok", timeout=15)
collect_state_ui("pulse_wayland")
check_state("pa-foot", {"wayland": True, "pipewire": True})
# Test PipeWire:
machine.send_chars("clear; pw-cli i 0 && touch /var/tmp/pw-ok\n")
machine.wait_for_file("/var/tmp/pw-ok", timeout=15)
collect_state_ui("pipewire_wayland")
machine.fail("find /tmp -maxdepth 1 -type d -name '.hakurei-shim-*' -print -exec false '{}' +")
machine.send_chars("exit\n")
machine.wait_until_fails("pgrep foot", timeout=5)
machine.wait_until_fails("pgrep -x hakurei", timeout=5)
machine.succeed("find /tmp -maxdepth 1 -type d -name '.hakurei-shim-*' -print -exec false '{}' +")
# Test PipeWire SecurityContext:
machine.succeed("sudo -u alice -i XDG_RUNTIME_DIR=/run/user/1000 hakurei -v run --pulse pactl info")
machine.fail("sudo -u alice -i XDG_RUNTIME_DIR=/run/user/1000 hakurei -v run --pulse pactl set-sink-mute @DEFAULT_SINK@ toggle")
# Test PipeWire direct access:
machine.succeed("sudo -u alice -i XDG_RUNTIME_DIR=/run/user/1000 pw-dump")
machine.fail("sudo -u alice -i XDG_RUNTIME_DIR=/run/user/1000 hakurei -v run --pipewire pw-dump")
# Test XWayland (foot does not support X):
swaymsg("exec x11-alacritty")
@@ -280,6 +282,7 @@ machine.send_key("ctrl-c")
machine.wait_until_fails("pgrep foot", timeout=5)
# Exit Sway and verify process exit status 0:
machine.wait_until_fails("pgrep -x hakurei", timeout=5)
swaymsg("exit", succeed=False)
machine.wait_for_file("/tmp/sway-exit-ok")
@@ -289,6 +292,7 @@ print(machine.succeed("find /tmp/hakurei.0 "
+ "-path '/tmp/hakurei.0/tmpdir/*/*' -prune -o "
+ "-print"))
print(machine.succeed("find /run/user/1000/hakurei"))
machine.succeed("find /tmp -maxdepth 1 -type d -name '.hakurei-shim-*' -print -exec false '{}' +")
# Verify go test status:
machine.wait_for_file("/tmp/hakurei-test-done")