From a0b4e47acc228e48165966d8e139277b8f6f450e Mon Sep 17 00:00:00 2001 From: Ophestra Date: Wed, 29 Oct 2025 04:32:43 +0900 Subject: internal/outcome: rename from app This is less ambiguous, and more accurately describes the purpose of the package. Signed-off-by: Ophestra --- cmd/hakurei/command.go | 14 +- cmd/hakurei/parse.go | 6 +- cmd/hakurei/print.go | 6 +- cmd/hakurei/print_test.go | 2 +- internal/app/app.go | 28 -- internal/app/app_test.go | 940 ----------------------------------- internal/app/dispatcher.go | 163 ------ internal/app/dispatcher_test.go | 644 ------------------------ internal/app/finalise.go | 85 ---- internal/app/hsu.go | 101 ---- internal/app/hsu_test.go | 84 ---- internal/app/outcome.go | 302 ----------- internal/app/outcome_test.go | 34 -- internal/app/process.go | 321 ------------ internal/app/shim-signal.c | 65 --- internal/app/shim-signal.h | 3 - internal/app/shim.go | 235 --------- internal/app/shim_test.go | 156 ------ internal/app/spaccount.go | 56 --- internal/app/spaccount_test.go | 72 --- internal/app/spcontainer.go | 391 --------------- internal/app/spcontainer_test.go | 477 ------------------ internal/app/spdbus.go | 58 --- internal/app/spdbus_test.go | 190 ------- internal/app/sppulse.go | 210 -------- internal/app/sppulse_test.go | 460 ----------------- internal/app/spruntime.go | 125 ----- internal/app/spruntime_test.go | 128 ----- internal/app/sptmpdir.go | 44 -- internal/app/sptmpdir_test.go | 34 -- internal/app/spwayland.go | 63 --- internal/app/spwayland_test.go | 104 ---- internal/app/spx11.go | 71 --- internal/app/spx11_test.go | 119 ----- internal/app/state/data.go | 52 -- internal/app/state/data_test.go | 145 ------ internal/app/state/header.go | 86 ---- internal/app/state/header_test.go | 184 ------- internal/app/state/join.go | 64 --- internal/app/state/segment.go | 161 ------ internal/app/state/segment_test.go | 259 ---------- internal/app/state/state.go | 131 ----- internal/app/state/state_test.go | 120 ----- internal/app/state/store.go | 162 ------ internal/app/state/store_test.go | 254 ---------- internal/outcome/dispatcher.go | 163 ++++++ internal/outcome/dispatcher_test.go | 662 ++++++++++++++++++++++++ internal/outcome/finalise.go | 85 ++++ internal/outcome/hsu.go | 101 ++++ internal/outcome/hsu_test.go | 84 ++++ internal/outcome/main.go | 27 + internal/outcome/main_test.go | 923 ++++++++++++++++++++++++++++++++++ internal/outcome/outcome.go | 303 +++++++++++ internal/outcome/outcome_test.go | 34 ++ internal/outcome/process.go | 321 ++++++++++++ internal/outcome/shim-signal.c | 65 +++ internal/outcome/shim-signal.h | 3 + internal/outcome/shim.go | 235 +++++++++ internal/outcome/shim_test.go | 156 ++++++ internal/outcome/spaccount.go | 56 +++ internal/outcome/spaccount_test.go | 72 +++ internal/outcome/spcontainer.go | 391 +++++++++++++++ internal/outcome/spcontainer_test.go | 477 ++++++++++++++++++ internal/outcome/spdbus.go | 58 +++ internal/outcome/spdbus_test.go | 190 +++++++ internal/outcome/sppulse.go | 210 ++++++++ internal/outcome/sppulse_test.go | 460 +++++++++++++++++ internal/outcome/spruntime.go | 125 +++++ internal/outcome/spruntime_test.go | 128 +++++ internal/outcome/sptmpdir.go | 44 ++ internal/outcome/sptmpdir_test.go | 34 ++ internal/outcome/spwayland.go | 63 +++ internal/outcome/spwayland_test.go | 104 ++++ internal/outcome/spx11.go | 71 +++ internal/outcome/spx11_test.go | 119 +++++ internal/state/data.go | 52 ++ internal/state/data_test.go | 145 ++++++ internal/state/header.go | 86 ++++ internal/state/header_test.go | 184 +++++++ internal/state/join.go | 64 +++ internal/state/segment.go | 161 ++++++ internal/state/segment_test.go | 259 ++++++++++ internal/state/state.go | 131 +++++ internal/state/state_test.go | 120 +++++ internal/state/store.go | 162 ++++++ internal/state/store_test.go | 254 ++++++++++ 86 files changed, 7396 insertions(+), 7395 deletions(-) delete mode 100644 internal/app/app.go delete mode 100644 internal/app/app_test.go delete mode 100644 internal/app/dispatcher.go delete mode 100644 internal/app/dispatcher_test.go delete mode 100644 internal/app/finalise.go delete mode 100644 internal/app/hsu.go delete mode 100644 internal/app/hsu_test.go delete mode 100644 internal/app/outcome.go delete mode 100644 internal/app/outcome_test.go delete mode 100644 internal/app/process.go delete mode 100644 internal/app/shim-signal.c delete mode 100644 internal/app/shim-signal.h delete mode 100644 internal/app/shim.go delete mode 100644 internal/app/shim_test.go delete mode 100644 internal/app/spaccount.go delete mode 100644 internal/app/spaccount_test.go delete mode 100644 internal/app/spcontainer.go delete mode 100644 internal/app/spcontainer_test.go delete mode 100644 internal/app/spdbus.go delete mode 100644 internal/app/spdbus_test.go delete mode 100644 internal/app/sppulse.go delete mode 100644 internal/app/sppulse_test.go delete mode 100644 internal/app/spruntime.go delete mode 100644 internal/app/spruntime_test.go delete mode 100644 internal/app/sptmpdir.go delete mode 100644 internal/app/sptmpdir_test.go delete mode 100644 internal/app/spwayland.go delete mode 100644 internal/app/spwayland_test.go delete mode 100644 internal/app/spx11.go delete mode 100644 internal/app/spx11_test.go delete mode 100644 internal/app/state/data.go delete mode 100644 internal/app/state/data_test.go delete mode 100644 internal/app/state/header.go delete mode 100644 internal/app/state/header_test.go delete mode 100644 internal/app/state/join.go delete mode 100644 internal/app/state/segment.go delete mode 100644 internal/app/state/segment_test.go delete mode 100644 internal/app/state/state.go delete mode 100644 internal/app/state/state_test.go delete mode 100644 internal/app/state/store.go delete mode 100644 internal/app/state/store_test.go create mode 100644 internal/outcome/dispatcher.go create mode 100644 internal/outcome/dispatcher_test.go create mode 100644 internal/outcome/finalise.go create mode 100644 internal/outcome/hsu.go create mode 100644 internal/outcome/hsu_test.go create mode 100644 internal/outcome/main.go create mode 100644 internal/outcome/main_test.go create mode 100644 internal/outcome/outcome.go create mode 100644 internal/outcome/outcome_test.go create mode 100644 internal/outcome/process.go create mode 100644 internal/outcome/shim-signal.c create mode 100644 internal/outcome/shim-signal.h create mode 100644 internal/outcome/shim.go create mode 100644 internal/outcome/shim_test.go create mode 100644 internal/outcome/spaccount.go create mode 100644 internal/outcome/spaccount_test.go create mode 100644 internal/outcome/spcontainer.go create mode 100644 internal/outcome/spcontainer_test.go create mode 100644 internal/outcome/spdbus.go create mode 100644 internal/outcome/spdbus_test.go create mode 100644 internal/outcome/sppulse.go create mode 100644 internal/outcome/sppulse_test.go create mode 100644 internal/outcome/spruntime.go create mode 100644 internal/outcome/spruntime_test.go create mode 100644 internal/outcome/sptmpdir.go create mode 100644 internal/outcome/sptmpdir_test.go create mode 100644 internal/outcome/spwayland.go create mode 100644 internal/outcome/spwayland_test.go create mode 100644 internal/outcome/spx11.go create mode 100644 internal/outcome/spx11_test.go create mode 100644 internal/state/data.go create mode 100644 internal/state/data_test.go create mode 100644 internal/state/header.go create mode 100644 internal/state/header_test.go create mode 100644 internal/state/join.go create mode 100644 internal/state/segment.go create mode 100644 internal/state/segment_test.go create mode 100644 internal/state/state.go create mode 100644 internal/state/state_test.go create mode 100644 internal/state/store.go create mode 100644 internal/state/store_test.go diff --git a/cmd/hakurei/command.go b/cmd/hakurei/command.go index 968bb561..f8173e60 100644 --- a/cmd/hakurei/command.go +++ b/cmd/hakurei/command.go @@ -18,9 +18,9 @@ import ( "hakurei.app/container/fhs" "hakurei.app/hst" "hakurei.app/internal" - "hakurei.app/internal/app" - "hakurei.app/internal/app/state" "hakurei.app/internal/env" + "hakurei.app/internal/outcome" + "hakurei.app/internal/state" "hakurei.app/message" "hakurei.app/system/dbus" ) @@ -51,7 +51,7 @@ func buildCommand(ctx context.Context, msg message.Msg, early *earlyHardeningErr Flag(&flagVerbose, "v", command.BoolFlag(false), "Increase log verbosity"). Flag(&flagJSON, "json", command.BoolFlag(false), "Serialise output in JSON when applicable") - c.Command("shim", command.UsageInternal, func([]string) error { app.Shim(msg); return errSuccess }) + c.Command("shim", command.UsageInternal, func([]string) error { outcome.Shim(msg); return errSuccess }) c.Command("app", "Load and start container from configuration file", func(args []string) error { if len(args) < 1 { @@ -64,7 +64,7 @@ func buildCommand(ctx context.Context, msg message.Msg, early *earlyHardeningErr config.Container.Args = append(config.Container.Args, args[1:]...) } - app.Main(ctx, msg, config) + outcome.Main(ctx, msg, config) panic("unreachable") }) @@ -96,7 +96,7 @@ func buildCommand(ctx context.Context, msg message.Msg, early *earlyHardeningErr passwd *user.User passwdOnce sync.Once passwdFunc = func() { - us := strconv.Itoa(app.HsuUid(new(app.Hsu).MustID(msg), flagIdentity)) + us := strconv.Itoa(outcome.HsuUid(new(outcome.Hsu).MustID(msg), flagIdentity)) if u, err := user.LookupId(us); err != nil { msg.Verbosef("cannot look up uid %s", us) passwd = &user.User{ @@ -258,7 +258,7 @@ func buildCommand(ctx context.Context, msg message.Msg, early *earlyHardeningErr } } - app.Main(ctx, msg, config) + outcome.Main(ctx, msg, config) panic("unreachable") }). Flag(&flagDBusConfigSession, "dbus-config", command.StringFlag("builtin"), @@ -321,7 +321,7 @@ func buildCommand(ctx context.Context, msg message.Msg, early *earlyHardeningErr var flagShort bool c.NewCommand("ps", "List active instances", func(args []string) error { var sc hst.Paths - env.CopyPaths().Copy(&sc, new(app.Hsu).MustID(nil)) + env.CopyPaths().Copy(&sc, new(outcome.Hsu).MustID(nil)) printPs(os.Stdout, time.Now().UTC(), state.NewMulti(msg, sc.RunDirPath), flagShort, flagJSON) return errSuccess }).Flag(&flagShort, "short", command.BoolFlag(false), "Print instance id") diff --git a/cmd/hakurei/parse.go b/cmd/hakurei/parse.go index 70419751..bbb9e81c 100644 --- a/cmd/hakurei/parse.go +++ b/cmd/hakurei/parse.go @@ -11,9 +11,9 @@ import ( "syscall" "hakurei.app/hst" - "hakurei.app/internal/app" - "hakurei.app/internal/app/state" "hakurei.app/internal/env" + "hakurei.app/internal/outcome" + "hakurei.app/internal/state" "hakurei.app/message" ) @@ -84,7 +84,7 @@ func shortIdentifierString(s string) string { func tryIdentifier(msg message.Msg, name string) (config *hst.Config, entry *hst.State) { return tryIdentifierEntries(msg, name, func() map[hst.ID]*hst.State { var sc hst.Paths - env.CopyPaths().Copy(&sc, new(app.Hsu).MustID(nil)) + env.CopyPaths().Copy(&sc, new(outcome.Hsu).MustID(nil)) s := state.NewMulti(msg, sc.RunDirPath) if entries, err := state.Join(s); err != nil { msg.GetLogger().Printf("cannot join store: %v", err) // not fatal diff --git a/cmd/hakurei/print.go b/cmd/hakurei/print.go index 0dcb59ee..ff4764ca 100644 --- a/cmd/hakurei/print.go +++ b/cmd/hakurei/print.go @@ -12,9 +12,9 @@ import ( "hakurei.app/hst" "hakurei.app/internal" - "hakurei.app/internal/app" - "hakurei.app/internal/app/state" "hakurei.app/internal/env" + "hakurei.app/internal/outcome" + "hakurei.app/internal/state" "hakurei.app/message" ) @@ -23,7 +23,7 @@ func printShowSystem(output io.Writer, short, flagJSON bool) { t := newPrinter(output) defer t.MustFlush() - info := &hst.Info{Version: internal.Version(), User: new(app.Hsu).MustID(nil)} + info := &hst.Info{Version: internal.Version(), User: new(outcome.Hsu).MustID(nil)} env.CopyPaths().Copy(&info.Paths, info.User) if flagJSON { diff --git a/cmd/hakurei/print_test.go b/cmd/hakurei/print_test.go index 343073ab..abdcefc6 100644 --- a/cmd/hakurei/print_test.go +++ b/cmd/hakurei/print_test.go @@ -6,7 +6,7 @@ import ( "time" "hakurei.app/hst" - "hakurei.app/internal/app/state" + "hakurei.app/internal/state" ) var ( diff --git a/internal/app/app.go b/internal/app/app.go deleted file mode 100644 index 03608517..00000000 --- a/internal/app/app.go +++ /dev/null @@ -1,28 +0,0 @@ -// Package app implements high-level hakurei container behaviour. -package app - -import ( - "context" - "log" - "os" - - "hakurei.app/hst" - "hakurei.app/message" -) - -// Main runs an app according to [hst.Config] and terminates. Main does not return. -func Main(ctx context.Context, msg message.Msg, config *hst.Config) { - var id hst.ID - if err := hst.NewInstanceID(&id); err != nil { - log.Fatal(err.Error()) - } - - seal := outcome{syscallDispatcher: direct{msg}} - if err := seal.finalise(ctx, msg, &id, config); err != nil { - printMessageError("cannot seal app:", err) - os.Exit(1) - } - - seal.main(msg) - panic("unreachable") -} diff --git a/internal/app/app_test.go b/internal/app/app_test.go deleted file mode 100644 index 96113eb8..00000000 --- a/internal/app/app_test.go +++ /dev/null @@ -1,940 +0,0 @@ -package app - -import ( - "bytes" - "encoding/gob" - "encoding/json" - "errors" - "fmt" - "io" - "io/fs" - "log" - "os/exec" - "os/user" - "reflect" - "syscall" - "testing" - "time" - - "hakurei.app/container" - "hakurei.app/container/check" - "hakurei.app/container/comp" - "hakurei.app/container/fhs" - "hakurei.app/container/seccomp" - "hakurei.app/hst" - "hakurei.app/message" - "hakurei.app/system" - "hakurei.app/system/acl" - "hakurei.app/system/dbus" -) - -func TestApp(t *testing.T) { - t.Parallel() - msg := message.NewMsg(nil) - msg.SwapVerbose(testing.Verbose()) - - testCases := []struct { - name string - k syscallDispatcher - config *hst.Config - id hst.ID - wantSys *system.I - wantParams *container.Params - }{ - {"template", new(stubNixOS), hst.Template(), checkExpectInstanceId, system.New(panicMsgContext{}, message.NewMsg(nil), 1000009). - // spParamsOp - Ensure(m("/tmp/hakurei.0"), 0711). - - // spRuntimeOp - Ensure(m("/tmp/hakurei.0/runtime"), 0700). - UpdatePermType(system.User, m("/tmp/hakurei.0/runtime"), acl.Execute). - Ensure(m("/tmp/hakurei.0/runtime/9"), 0700). - UpdatePermType(system.User, m("/tmp/hakurei.0/runtime/9"), acl.Read, acl.Write, acl.Execute). - - // spTmpdirOp - Ensure(m("/tmp/hakurei.0/tmpdir"), 0700). - UpdatePermType(system.User, m("/tmp/hakurei.0/tmpdir"), acl.Execute). - Ensure(m("/tmp/hakurei.0/tmpdir/9"), 01700). - UpdatePermType(system.User, m("/tmp/hakurei.0/tmpdir/9"), acl.Read, acl.Write, acl.Execute). - - // instance - Ephemeral(system.Process, m("/tmp/hakurei.0/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), 0711). - - // spWaylandOp - Wayland( - m("/tmp/hakurei.0/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/wayland"), - m("/run/user/1971/wayland-0"), - "org.chromium.Chromium", - "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - ). - - // ensureRuntimeDir - Ensure(m("/run/user/1971/hakurei"), 0700). - UpdatePermType(system.User, m("/run/user/1971/hakurei"), acl.Execute). - Ensure(m("/run/user/1971"), 0700). - UpdatePermType(system.User, m("/run/user/1971"), acl.Execute). - - // runtime - Ephemeral(system.Process, m("/run/user/1971/hakurei/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), 0700). - UpdatePerm(m("/run/user/1971/hakurei/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), acl.Execute). - - // spPulseOp - Link(m("/run/user/1971/pulse/native"), m("/run/user/1971/hakurei/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/pulse")). - - // spDBusOp - MustProxyDBus( - hst.Template().SessionBus, - hst.Template().SystemBus, dbus.ProxyPair{ - "unix:path=/run/user/1971/bus", - "/tmp/hakurei.0/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/bus", - }, dbus.ProxyPair{ - "unix:path=/var/run/dbus/system_bus_socket", - "/tmp/hakurei.0/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/system_bus_socket", - }, - ).UpdatePerm(m("/tmp/hakurei.0/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/bus"), acl.Read, acl.Write). - UpdatePerm(m("/tmp/hakurei.0/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/system_bus_socket"), acl.Read, acl.Write). - - // spFilesystemOp - Ensure(m("/var/lib/hakurei/u0"), 0700). - UpdatePermType(system.User, m("/var/lib/hakurei/u0"), acl.Execute). - UpdatePermType(system.User, m("/var/lib/hakurei/u0/org.chromium.Chromium"), acl.Read, acl.Write, acl.Execute), &container.Params{ - - Dir: m("/data/data/org.chromium.Chromium"), - Env: []string{ - "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1971/bus", - "DBUS_SYSTEM_BUS_ADDRESS=unix:path=/var/run/dbus/system_bus_socket", - "GOOGLE_API_KEY=AIzaSyBHDrl33hwRp4rMQY0ziRbj8K9LPA6vUCY", - "GOOGLE_DEFAULT_CLIENT_ID=77185425430.apps.googleusercontent.com", - "GOOGLE_DEFAULT_CLIENT_SECRET=OTJgUOQcT7lO7GsGZq2G4IlT", - "HOME=/data/data/org.chromium.Chromium", - "PULSE_COOKIE=/.hakurei/pulse-cookie", - "PULSE_SERVER=unix:/run/user/1971/pulse/native", - "SHELL=/run/current-system/sw/bin/zsh", - "TERM=xterm-256color", - "USER=chronos", - "WAYLAND_DISPLAY=wayland-0", - "XDG_RUNTIME_DIR=/run/user/1971", - "XDG_SESSION_CLASS=user", - "XDG_SESSION_TYPE=wayland", - }, - - // spParamsOp - Hostname: "localhost", - RetainSession: true, - HostNet: true, - HostAbstract: true, - Path: m("/run/current-system/sw/bin/chromium"), - Args: []string{ - "chromium", - "--ignore-gpu-blocklist", - "--disable-smooth-scrolling", - "--enable-features=UseOzonePlatform", - "--ozone-platform=wayland", - }, - SeccompFlags: seccomp.AllowMultiarch, - Uid: 1971, - Gid: 100, - - Ops: new(container.Ops). - // resolveRoot - Root(m("/var/lib/hakurei/base/org.debian"), comp.BindWritable). - // spParamsOp - Proc(fhs.AbsProc). - Tmpfs(hst.AbsPrivateTmp, 1<<12, 0755). - Bind(fhs.AbsDev, fhs.AbsDev, comp.BindWritable|comp.BindDevice). - Tmpfs(fhs.AbsDev.Append("shm"), 0, 01777). - - // spRuntimeOp - Tmpfs(fhs.AbsRunUser, 1<<12, 0755). - Bind(m("/tmp/hakurei.0/runtime/9"), m("/run/user/1971"), comp.BindWritable). - - // spTmpdirOp - Bind(m("/tmp/hakurei.0/tmpdir/9"), fhs.AbsTmp, comp.BindWritable). - - // spAccountOp - Place(m("/etc/passwd"), []byte("chronos:x:1971:100:Hakurei:/data/data/org.chromium.Chromium:/run/current-system/sw/bin/zsh\n")). - Place(m("/etc/group"), []byte("hakurei:x:100:\n")). - - // spWaylandOp - Bind(m("/tmp/hakurei.0/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/wayland"), m("/run/user/1971/wayland-0"), 0). - - // spPulseOp - Bind(m("/run/user/1971/hakurei/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/pulse"), m("/run/user/1971/pulse/native"), 0). - Place(m("/.hakurei/pulse-cookie"), bytes.Repeat([]byte{0}, pulseCookieSizeMax)). - - // 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). - - // spFilesystemOp - Etc(fhs.AbsEtc, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"). - Tmpfs(fhs.AbsTmp, 0, 0755). - Overlay(m("/nix/store"), - fhs.AbsVarLib.Append("hakurei/nix/u0/org.chromium.Chromium/rw-store/upper"), - fhs.AbsVarLib.Append("hakurei/nix/u0/org.chromium.Chromium/rw-store/work"), - fhs.AbsVarLib.Append("hakurei/base/org.nixos/ro-store")). - Link(m("/run/current-system"), "/run/current-system", true). - Link(m("/run/opengl-driver"), "/run/opengl-driver", true). - Bind(fhs.AbsVarLib.Append("hakurei/u0/org.chromium.Chromium"), - m("/data/data/org.chromium.Chromium"), - comp.BindWritable|comp.BindEnsure). - Bind(fhs.AbsDev.Append("dri"), fhs.AbsDev.Append("dri"), - comp.BindOptional|comp.BindWritable|comp.BindDevice). - Remount(fhs.AbsRoot, syscall.MS_RDONLY), - }}, - - {"nixos permissive defaults no enablements", new(stubNixOS), &hst.Config{Container: &hst.ContainerConfig{ - Filesystem: []hst.FilesystemConfigJSON{ - {FilesystemConfig: &hst.FSBind{ - Target: fhs.AbsRoot, - Source: fhs.AbsRoot, - Write: true, - Special: true, - }}, - {FilesystemConfig: &hst.FSBind{ - Source: fhs.AbsDev.Append("kvm"), - Device: true, - Optional: true, - }}, - {FilesystemConfig: &hst.FSBind{ - Target: fhs.AbsEtc, - Source: fhs.AbsEtc, - Special: true, - }}, - }, - - Username: "chronos", - Shell: m("/run/current-system/sw/bin/zsh"), - Home: m("/home/chronos"), - - Path: m("/run/current-system/sw/bin/zsh"), - Args: []string{"/run/current-system/sw/bin/zsh"}, - - Flags: hst.FUserns | hst.FHostNet | hst.FHostAbstract | hst.FTty | hst.FShareRuntime | hst.FShareTmpdir, - }}, hst.ID{ - 0x4a, 0x45, 0x0b, 0x65, - 0x96, 0xd7, 0xbc, 0x15, - 0xbd, 0x01, 0x78, 0x0e, - 0xb9, 0xa6, 0x07, 0xac, - }, system.New(t.Context(), msg, 1000000). - Ensure(m("/tmp/hakurei.0"), 0711). - Ensure(m("/tmp/hakurei.0/runtime"), 0700). - UpdatePermType(system.User, m("/tmp/hakurei.0/runtime"), acl.Execute). - Ensure(m("/tmp/hakurei.0/runtime/0"), 0700). - UpdatePermType(system.User, m("/tmp/hakurei.0/runtime/0"), acl.Read, acl.Write, acl.Execute). - Ensure(m("/tmp/hakurei.0/tmpdir"), 0700). - UpdatePermType(system.User, m("/tmp/hakurei.0/tmpdir"), acl.Execute). - Ensure(m("/tmp/hakurei.0/tmpdir/0"), 01700). - UpdatePermType(system.User, m("/tmp/hakurei.0/tmpdir/0"), acl.Read, acl.Write, acl.Execute), &container.Params{ - - Dir: m("/home/chronos"), - Path: m("/run/current-system/sw/bin/zsh"), - Args: []string{"/run/current-system/sw/bin/zsh"}, - Env: []string{ - "HOME=/home/chronos", - "SHELL=/run/current-system/sw/bin/zsh", - "TERM=xterm-256color", - "USER=chronos", - "XDG_RUNTIME_DIR=/run/user/65534", - "XDG_SESSION_CLASS=user", - "XDG_SESSION_TYPE=tty", - }, - Ops: new(container.Ops). - Root(m("/"), comp.BindWritable). - Proc(m("/proc/")). - Tmpfs(hst.AbsPrivateTmp, 4096, 0755). - DevWritable(m("/dev/"), true). - Tmpfs(m("/dev/shm"), 0, 01777). - Tmpfs(m("/run/user/"), 4096, 0755). - Bind(m("/tmp/hakurei.0/runtime/0"), m("/run/user/65534"), comp.BindWritable). - Bind(m("/tmp/hakurei.0/tmpdir/0"), m("/tmp/"), comp.BindWritable). - Place(m("/etc/passwd"), []byte("chronos:x:65534:65534:Hakurei:/home/chronos:/run/current-system/sw/bin/zsh\n")). - Place(m("/etc/group"), []byte("hakurei:x:65534:\n")). - Bind(m("/dev/kvm"), m("/dev/kvm"), comp.BindWritable|comp.BindDevice|comp.BindOptional). - Etc(m("/etc/"), "4a450b6596d7bc15bd01780eb9a607ac"). - Tmpfs(m("/run/user/1971"), 8192, 0755). - Tmpfs(m("/run/nscd"), 8192, 0755). - Tmpfs(m("/run/dbus"), 8192, 0755). - Remount(m("/dev/"), syscall.MS_RDONLY). - Remount(m("/"), syscall.MS_RDONLY), - SeccompPresets: comp.PresetExt | comp.PresetDenyDevel, - HostNet: true, - HostAbstract: true, - RetainSession: true, - ForwardCancel: true, - }}, - - {"nixos permissive defaults chromium", new(stubNixOS), &hst.Config{ - ID: "org.chromium.Chromium", - Identity: 9, - Groups: []string{"video"}, - SessionBus: &hst.BusConfig{ - Talk: []string{ - "org.freedesktop.Notifications", - "org.freedesktop.FileManager1", - "org.freedesktop.ScreenSaver", - "org.freedesktop.secrets", - "org.kde.kwalletd5", - "org.kde.kwalletd6", - "org.gnome.SessionManager", - }, - Own: []string{ - "org.chromium.Chromium.*", - "org.mpris.MediaPlayer2.org.chromium.Chromium.*", - "org.mpris.MediaPlayer2.chromium.*", - }, - Call: map[string]string{ - "org.freedesktop.portal.*": "*", - }, - Broadcast: map[string]string{ - "org.freedesktop.portal.*": "@/org/freedesktop/portal/*", - }, - Filter: true, - }, - SystemBus: &hst.BusConfig{ - Talk: []string{ - "org.bluez", - "org.freedesktop.Avahi", - "org.freedesktop.UPower", - }, - Filter: true, - }, - Enablements: hst.NewEnablements(hst.EWayland | hst.EDBus | hst.EPulse), - - Container: &hst.ContainerConfig{ - Filesystem: []hst.FilesystemConfigJSON{ - {FilesystemConfig: &hst.FSBind{ - Target: fhs.AbsRoot, - Source: fhs.AbsRoot, - Write: true, - Special: true, - }}, - {FilesystemConfig: &hst.FSBind{ - Source: fhs.AbsDev.Append("dri"), - Device: true, - Optional: true, - }}, - {FilesystemConfig: &hst.FSBind{ - Source: fhs.AbsDev.Append("kvm"), - Device: true, - Optional: true, - }}, - {FilesystemConfig: &hst.FSBind{ - Target: fhs.AbsEtc, - Source: fhs.AbsEtc, - Special: true, - }}, - }, - - Username: "chronos", - Shell: m("/run/current-system/sw/bin/zsh"), - Home: m("/home/chronos"), - - Path: m("/run/current-system/sw/bin/zsh"), - Args: []string{"zsh", "-c", "exec chromium "}, - - Flags: hst.FUserns | hst.FHostNet | hst.FHostAbstract | hst.FTty | hst.FShareRuntime | hst.FShareTmpdir, - }, - }, hst.ID{ - 0xeb, 0xf0, 0x83, 0xd1, - 0xb1, 0x75, 0x91, 0x17, - 0x82, 0xd4, 0x13, 0x36, - 0x9b, 0x64, 0xce, 0x7c, - }, system.New(t.Context(), msg, 1000009). - Ensure(m("/tmp/hakurei.0"), 0711). - Ensure(m("/tmp/hakurei.0/runtime"), 0700).UpdatePermType(system.User, m("/tmp/hakurei.0/runtime"), acl.Execute). - Ensure(m("/tmp/hakurei.0/runtime/9"), 0700).UpdatePermType(system.User, m("/tmp/hakurei.0/runtime/9"), acl.Read, acl.Write, acl.Execute). - Ensure(m("/tmp/hakurei.0/tmpdir"), 0700).UpdatePermType(system.User, m("/tmp/hakurei.0/tmpdir"), acl.Execute). - 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"). - Ensure(m("/run/user/1971/hakurei"), 0700).UpdatePermType(system.User, m("/run/user/1971/hakurei"), acl.Execute). - Ensure(m("/run/user/1971"), 0700).UpdatePermType(system.User, m("/run/user/1971"), acl.Execute). // this is ordered as is because the previous Ensure only calls mkdir if XDG_RUNTIME_DIR is unset - Ephemeral(system.Process, m("/run/user/1971/hakurei/ebf083d1b175911782d413369b64ce7c"), 0700).UpdatePermType(system.Process, m("/run/user/1971/hakurei/ebf083d1b175911782d413369b64ce7c"), acl.Execute). - Link(m("/run/user/1971/pulse/native"), m("/run/user/1971/hakurei/ebf083d1b175911782d413369b64ce7c/pulse")). - MustProxyDBus(&hst.BusConfig{ - Talk: []string{ - "org.freedesktop.Notifications", - "org.freedesktop.FileManager1", - "org.freedesktop.ScreenSaver", - "org.freedesktop.secrets", - "org.kde.kwalletd5", - "org.kde.kwalletd6", - "org.gnome.SessionManager", - }, - Own: []string{ - "org.chromium.Chromium.*", - "org.mpris.MediaPlayer2.org.chromium.Chromium.*", - "org.mpris.MediaPlayer2.chromium.*", - }, - Call: map[string]string{ - "org.freedesktop.portal.*": "*", - }, - Broadcast: map[string]string{ - "org.freedesktop.portal.*": "@/org/freedesktop/portal/*", - }, - Filter: true, - }, &hst.BusConfig{ - Talk: []string{ - "org.bluez", - "org.freedesktop.Avahi", - "org.freedesktop.UPower", - }, - Filter: true, - }, dbus.ProxyPair{ - "unix:path=/run/user/1971/bus", - "/tmp/hakurei.0/ebf083d1b175911782d413369b64ce7c/bus", - }, dbus.ProxyPair{ - "unix:path=/var/run/dbus/system_bus_socket", - "/tmp/hakurei.0/ebf083d1b175911782d413369b64ce7c/system_bus_socket", - }). - UpdatePerm(m("/tmp/hakurei.0/ebf083d1b175911782d413369b64ce7c/bus"), acl.Read, acl.Write). - UpdatePerm(m("/tmp/hakurei.0/ebf083d1b175911782d413369b64ce7c/system_bus_socket"), acl.Read, acl.Write), &container.Params{ - - Dir: m("/home/chronos"), - Path: m("/run/current-system/sw/bin/zsh"), - Args: []string{"zsh", "-c", "exec chromium "}, - Env: []string{ - "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/65534/bus", - "DBUS_SYSTEM_BUS_ADDRESS=unix:path=/var/run/dbus/system_bus_socket", - "HOME=/home/chronos", - "PULSE_COOKIE=" + hst.PrivateTmp + "/pulse-cookie", - "PULSE_SERVER=unix:/run/user/65534/pulse/native", - "SHELL=/run/current-system/sw/bin/zsh", - "TERM=xterm-256color", - "USER=chronos", - "WAYLAND_DISPLAY=wayland-0", - "XDG_RUNTIME_DIR=/run/user/65534", - "XDG_SESSION_CLASS=user", - "XDG_SESSION_TYPE=wayland", - }, - Ops: new(container.Ops). - Root(m("/"), comp.BindWritable). - Proc(m("/proc/")). - Tmpfs(hst.AbsPrivateTmp, 4096, 0755). - DevWritable(m("/dev/"), true). - Tmpfs(m("/dev/shm"), 0, 01777). - Tmpfs(m("/run/user/"), 4096, 0755). - Bind(m("/tmp/hakurei.0/runtime/9"), m("/run/user/65534"), comp.BindWritable). - Bind(m("/tmp/hakurei.0/tmpdir/9"), m("/tmp/"), comp.BindWritable). - Place(m("/etc/passwd"), []byte("chronos:x:65534:65534:Hakurei:/home/chronos:/run/current-system/sw/bin/zsh\n")). - Place(m("/etc/group"), []byte("hakurei:x:65534:\n")). - Bind(m("/tmp/hakurei.0/ebf083d1b175911782d413369b64ce7c/wayland"), m("/run/user/65534/wayland-0"), 0). - Bind(m("/run/user/1971/hakurei/ebf083d1b175911782d413369b64ce7c/pulse"), m("/run/user/65534/pulse/native"), 0). - Place(m(hst.PrivateTmp+"/pulse-cookie"), bytes.Repeat([]byte{0}, pulseCookieSizeMax)). - Bind(m("/tmp/hakurei.0/ebf083d1b175911782d413369b64ce7c/bus"), m("/run/user/65534/bus"), 0). - Bind(m("/tmp/hakurei.0/ebf083d1b175911782d413369b64ce7c/system_bus_socket"), m("/var/run/dbus/system_bus_socket"), 0). - Bind(m("/dev/dri"), m("/dev/dri"), comp.BindWritable|comp.BindDevice|comp.BindOptional). - Bind(m("/dev/kvm"), m("/dev/kvm"), comp.BindWritable|comp.BindDevice|comp.BindOptional). - Etc(m("/etc/"), "ebf083d1b175911782d413369b64ce7c"). - Tmpfs(m("/run/user/1971"), 8192, 0755). - Tmpfs(m("/run/nscd"), 8192, 0755). - Tmpfs(m("/run/dbus"), 8192, 0755). - Remount(m("/dev/"), syscall.MS_RDONLY). - Remount(m("/"), syscall.MS_RDONLY), - SeccompPresets: comp.PresetExt | comp.PresetDenyDevel, - HostNet: true, - HostAbstract: true, - RetainSession: true, - ForwardCancel: true, - }}, - - {"nixos chromium direct wayland", new(stubNixOS), &hst.Config{ - ID: "org.chromium.Chromium", - Enablements: hst.NewEnablements(hst.EWayland | hst.EDBus | hst.EPulse), - Container: &hst.ContainerConfig{ - Env: nil, - Filesystem: []hst.FilesystemConfigJSON{ - f(&hst.FSBind{Source: m("/bin")}), - f(&hst.FSBind{Source: m("/usr/bin/")}), - f(&hst.FSBind{Source: m("/nix/store")}), - f(&hst.FSBind{Source: m("/run/current-system")}), - f(&hst.FSBind{Source: m("/sys/block"), Optional: true}), - f(&hst.FSBind{Source: m("/sys/bus"), Optional: true}), - f(&hst.FSBind{Source: m("/sys/class"), Optional: true}), - f(&hst.FSBind{Source: m("/sys/dev"), Optional: true}), - f(&hst.FSBind{Source: m("/sys/devices"), Optional: true}), - f(&hst.FSBind{Source: m("/run/opengl-driver")}), - f(&hst.FSBind{Source: m("/dev/dri"), Device: true, Optional: true}), - f(&hst.FSBind{Source: m("/etc/"), Target: m("/etc/"), Special: true}), - f(&hst.FSBind{Source: m("/var/lib/persist/module/hakurei/0/1"), Write: true, Ensure: true}), - }, - - Username: "u0_a1", - Shell: m("/run/current-system/sw/bin/zsh"), - Home: m("/var/lib/persist/module/hakurei/0/1"), - - Path: m("/nix/store/yqivzpzzn7z5x0lq9hmbzygh45d8rhqd-chromium-start"), - - Flags: hst.FUserns | hst.FHostNet | hst.FMapRealUID | hst.FShareRuntime | hst.FShareTmpdir, - }, - SystemBus: &hst.BusConfig{ - Talk: []string{"org.bluez", "org.freedesktop.Avahi", "org.freedesktop.UPower"}, - Filter: true, - }, - SessionBus: &hst.BusConfig{ - Talk: []string{ - "org.freedesktop.FileManager1", "org.freedesktop.Notifications", - "org.freedesktop.ScreenSaver", "org.freedesktop.secrets", - "org.kde.kwalletd5", "org.kde.kwalletd6", - }, - Own: []string{ - "org.chromium.Chromium.*", - "org.mpris.MediaPlayer2.org.chromium.Chromium.*", - "org.mpris.MediaPlayer2.chromium.*", - }, - Call: map[string]string{}, Broadcast: map[string]string{}, - Filter: true, - }, - DirectWayland: true, - - Identity: 1, Groups: []string{}, - }, hst.ID{ - 0x8e, 0x2c, 0x76, 0xb0, - 0x66, 0xda, 0xbe, 0x57, - 0x4c, 0xf0, 0x73, 0xbd, - 0xb4, 0x6e, 0xb5, 0xc1, - }, system.New(t.Context(), msg, 1000001). - Ensure(m("/tmp/hakurei.0"), 0711). - Ensure(m("/tmp/hakurei.0/runtime"), 0700).UpdatePermType(system.User, m("/tmp/hakurei.0/runtime"), acl.Execute). - Ensure(m("/tmp/hakurei.0/runtime/1"), 0700).UpdatePermType(system.User, m("/tmp/hakurei.0/runtime/1"), acl.Read, acl.Write, acl.Execute). - Ensure(m("/tmp/hakurei.0/tmpdir"), 0700).UpdatePermType(system.User, m("/tmp/hakurei.0/tmpdir"), acl.Execute). - Ensure(m("/tmp/hakurei.0/tmpdir/1"), 01700).UpdatePermType(system.User, m("/tmp/hakurei.0/tmpdir/1"), acl.Read, acl.Write, acl.Execute). - Ensure(m("/run/user/1971/hakurei"), 0700).UpdatePermType(system.User, m("/run/user/1971/hakurei"), acl.Execute). - Ensure(m("/run/user/1971"), 0700).UpdatePermType(system.User, m("/run/user/1971"), acl.Execute). // this is ordered as is because the previous Ensure only calls mkdir if XDG_RUNTIME_DIR is unset - UpdatePermType(hst.EWayland, m("/run/user/1971/wayland-0"), acl.Read, acl.Write, acl.Execute). - Ephemeral(system.Process, m("/run/user/1971/hakurei/8e2c76b066dabe574cf073bdb46eb5c1"), 0700).UpdatePermType(system.Process, m("/run/user/1971/hakurei/8e2c76b066dabe574cf073bdb46eb5c1"), acl.Execute). - Link(m("/run/user/1971/pulse/native"), m("/run/user/1971/hakurei/8e2c76b066dabe574cf073bdb46eb5c1/pulse")). - Ephemeral(system.Process, m("/tmp/hakurei.0/8e2c76b066dabe574cf073bdb46eb5c1"), 0711). - MustProxyDBus(&hst.BusConfig{ - Talk: []string{ - "org.freedesktop.FileManager1", "org.freedesktop.Notifications", - "org.freedesktop.ScreenSaver", "org.freedesktop.secrets", - "org.kde.kwalletd5", "org.kde.kwalletd6", - }, - Own: []string{ - "org.chromium.Chromium.*", - "org.mpris.MediaPlayer2.org.chromium.Chromium.*", - "org.mpris.MediaPlayer2.chromium.*", - }, - Call: map[string]string{}, Broadcast: map[string]string{}, - Filter: true, - }, &hst.BusConfig{ - Talk: []string{ - "org.bluez", - "org.freedesktop.Avahi", - "org.freedesktop.UPower", - }, - Filter: true, - }, dbus.ProxyPair{ - "unix:path=/run/user/1971/bus", - "/tmp/hakurei.0/8e2c76b066dabe574cf073bdb46eb5c1/bus", - }, dbus.ProxyPair{ - "unix:path=/var/run/dbus/system_bus_socket", - "/tmp/hakurei.0/8e2c76b066dabe574cf073bdb46eb5c1/system_bus_socket", - }). - UpdatePerm(m("/tmp/hakurei.0/8e2c76b066dabe574cf073bdb46eb5c1/bus"), acl.Read, acl.Write). - UpdatePerm(m("/tmp/hakurei.0/8e2c76b066dabe574cf073bdb46eb5c1/system_bus_socket"), acl.Read, acl.Write), &container.Params{ - - Uid: 1971, - Gid: 100, - Dir: m("/var/lib/persist/module/hakurei/0/1"), - Path: m("/nix/store/yqivzpzzn7z5x0lq9hmbzygh45d8rhqd-chromium-start"), - Args: []string{"/nix/store/yqivzpzzn7z5x0lq9hmbzygh45d8rhqd-chromium-start"}, - Env: []string{ - "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1971/bus", - "DBUS_SYSTEM_BUS_ADDRESS=unix:path=/var/run/dbus/system_bus_socket", - "HOME=/var/lib/persist/module/hakurei/0/1", - "PULSE_COOKIE=" + hst.PrivateTmp + "/pulse-cookie", - "PULSE_SERVER=unix:/run/user/1971/pulse/native", - "SHELL=/run/current-system/sw/bin/zsh", - "TERM=xterm-256color", - "USER=u0_a1", - "WAYLAND_DISPLAY=wayland-0", - "XDG_RUNTIME_DIR=/run/user/1971", - "XDG_SESSION_CLASS=user", - "XDG_SESSION_TYPE=wayland", - }, - Ops: new(container.Ops). - Proc(m("/proc/")). - Tmpfs(hst.AbsPrivateTmp, 4096, 0755). - DevWritable(m("/dev/"), true). - Tmpfs(m("/dev/shm"), 0, 01777). - Tmpfs(m("/run/user/"), 4096, 0755). - Bind(m("/tmp/hakurei.0/runtime/1"), m("/run/user/1971"), comp.BindWritable). - Bind(m("/tmp/hakurei.0/tmpdir/1"), m("/tmp/"), comp.BindWritable). - Place(m("/etc/passwd"), []byte("u0_a1:x:1971:100:Hakurei:/var/lib/persist/module/hakurei/0/1:/run/current-system/sw/bin/zsh\n")). - Place(m("/etc/group"), []byte("hakurei:x:100:\n")). - Bind(m("/run/user/1971/wayland-0"), m("/run/user/1971/wayland-0"), 0). - Bind(m("/run/user/1971/hakurei/8e2c76b066dabe574cf073bdb46eb5c1/pulse"), m("/run/user/1971/pulse/native"), 0). - Place(m(hst.PrivateTmp+"/pulse-cookie"), bytes.Repeat([]byte{0}, pulseCookieSizeMax)). - Bind(m("/tmp/hakurei.0/8e2c76b066dabe574cf073bdb46eb5c1/bus"), m("/run/user/1971/bus"), 0). - Bind(m("/tmp/hakurei.0/8e2c76b066dabe574cf073bdb46eb5c1/system_bus_socket"), m("/var/run/dbus/system_bus_socket"), 0). - Bind(m("/bin"), m("/bin"), 0). - Bind(m("/usr/bin/"), m("/usr/bin/"), 0). - Bind(m("/nix/store"), m("/nix/store"), 0). - Bind(m("/run/current-system"), m("/run/current-system"), 0). - Bind(m("/sys/block"), m("/sys/block"), comp.BindOptional). - Bind(m("/sys/bus"), m("/sys/bus"), comp.BindOptional). - Bind(m("/sys/class"), m("/sys/class"), comp.BindOptional). - Bind(m("/sys/dev"), m("/sys/dev"), comp.BindOptional). - Bind(m("/sys/devices"), m("/sys/devices"), comp.BindOptional). - Bind(m("/run/opengl-driver"), m("/run/opengl-driver"), 0). - Bind(m("/dev/dri"), m("/dev/dri"), comp.BindDevice|comp.BindWritable|comp.BindOptional). - Etc(m("/etc/"), "8e2c76b066dabe574cf073bdb46eb5c1"). - Bind(m("/var/lib/persist/module/hakurei/0/1"), m("/var/lib/persist/module/hakurei/0/1"), comp.BindWritable|comp.BindEnsure). - Remount(m("/dev/"), syscall.MS_RDONLY). - Remount(m("/"), syscall.MS_RDONLY), - SeccompPresets: comp.PresetExt | comp.PresetDenyTTY | comp.PresetDenyDevel, - HostNet: true, - ForwardCancel: true, - }}, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - gr, gw := io.Pipe() - - var gotSys *system.I - { - sPriv := newOutcomeState(tc.k, msg, &tc.id, tc.config, &Hsu{k: tc.k}) - if err := sPriv.populateLocal(tc.k, msg); err != nil { - t.Fatalf("populateLocal: error = %#v", err) - } - - gotSys = system.New(t.Context(), msg, sPriv.uid.unwrap()) - if err := sPriv.newSys(tc.config, gotSys).toSystem(); err != nil { - t.Fatalf("toSystem: error = %#v", err) - } - - go func() { - e := gob.NewEncoder(gw) - if err := errors.Join(e.Encode(&sPriv)); err != nil { - t.Errorf("Encode: error = %v", err) - panic("unexpected encode fault") - } - }() - } - - var gotParams *container.Params - { - var sShim outcomeState - - d := gob.NewDecoder(gr) - if err := errors.Join(d.Decode(&sShim)); err != nil { - t.Fatalf("Decode: error = %v", err) - } - if err := sShim.populateLocal(tc.k, msg); err != nil { - t.Fatalf("populateLocal: error = %#v", err) - } - - stateParams := sShim.newParams() - for _, op := range sShim.Shim.Ops { - if err := op.toContainer(stateParams); err != nil { - t.Fatalf("toContainer: error = %#v", err) - } - } - gotParams = stateParams.params - } - - t.Run("sys", func(t *testing.T) { - if !gotSys.Equal(tc.wantSys) { - t.Errorf("toSystem: sys = %#v, want %#v", gotSys, tc.wantSys) - } - }) - - t.Run("params", func(t *testing.T) { - if !reflect.DeepEqual(gotParams, tc.wantParams) { - t.Errorf("toContainer: params =\n%s\n, want\n%s", mustMarshal(gotParams), mustMarshal(tc.wantParams)) - } - }) - }) - } -} - -func mustMarshal(v any) string { - if b, err := json.Marshal(v); err != nil { - panic(err.Error()) - } else { - return string(b) - } -} - -func stubDirEntries(names ...string) (e []fs.DirEntry, err error) { - e = make([]fs.DirEntry, len(names)) - for i, name := range names { - e[i] = stubDirEntryPath(name) - } - return -} - -type stubDirEntryPath string - -func (p stubDirEntryPath) Name() string { return string(p) } -func (p stubDirEntryPath) IsDir() bool { panic("attempted to call IsDir") } -func (p stubDirEntryPath) Type() fs.FileMode { panic("attempted to call Type") } -func (p stubDirEntryPath) Info() (fs.FileInfo, error) { panic("attempted to call Info") } - -type stubFileInfoMode fs.FileMode - -func (s stubFileInfoMode) Name() string { panic("attempted to call Name") } -func (s stubFileInfoMode) Size() int64 { panic("attempted to call Size") } -func (s stubFileInfoMode) Mode() fs.FileMode { return fs.FileMode(s) } -func (s stubFileInfoMode) ModTime() time.Time { panic("attempted to call ModTime") } -func (s stubFileInfoMode) IsDir() bool { panic("attempted to call IsDir") } -func (s stubFileInfoMode) Sys() any { panic("attempted to call Sys") } - -type stubFileInfoIsDir bool - -func (s stubFileInfoIsDir) Name() string { panic("attempted to call Name") } -func (s stubFileInfoIsDir) Size() int64 { panic("attempted to call Size") } -func (s stubFileInfoIsDir) Mode() fs.FileMode { panic("attempted to call Mode") } -func (s stubFileInfoIsDir) ModTime() time.Time { panic("attempted to call ModTime") } -func (s stubFileInfoIsDir) IsDir() bool { return bool(s) } -func (s stubFileInfoIsDir) Sys() any { panic("attempted to call Sys") } - -type stubFileInfoPulseCookie struct{ stubFileInfoIsDir } - -func (s stubFileInfoPulseCookie) Size() int64 { return pulseCookieSizeMax } - -type stubOsFileReadCloser struct{ io.ReadCloser } - -func (s stubOsFileReadCloser) Name() string { panic("attempting to call Name") } -func (s stubOsFileReadCloser) Write([]byte) (int, error) { panic("attempting to call Write") } -func (s stubOsFileReadCloser) Stat() (fs.FileInfo, error) { panic("attempting to call Stat") } - -type stubNixOS struct { - usernameErr map[string]error - panicDispatcher -} - -func (k *stubNixOS) getpid() int { return 0xdeadbeef } -func (k *stubNixOS) getuid() int { return 1971 } -func (k *stubNixOS) getgid() int { return 100 } - -func (k *stubNixOS) lookupEnv(key string) (string, bool) { - switch key { - case "SHELL": - return "/run/current-system/sw/bin/zsh", true - case "TERM": - return "xterm-256color", true - case "WAYLAND_DISPLAY": - return "wayland-0", true - case "PULSE_COOKIE": - return "", false - case "HOME": - return "/home/ophestra", true - case "XDG_RUNTIME_DIR": - return "/run/user/1971", true - case "XDG_CONFIG_HOME": - return "/home/ophestra/xdg/config", true - case "DBUS_SYSTEM_BUS_ADDRESS": - return "", false - default: - panic(fmt.Sprintf("attempted to access unexpected environment variable %q", key)) - } -} - -func (k *stubNixOS) stat(name string) (fs.FileInfo, error) { - switch name { - case "/var/run/nscd": - return nil, nil - case "/run/user/1971/pulse": - return nil, nil - case "/run/user/1971/pulse/native": - return stubFileInfoMode(0666), nil - case "/home/ophestra/.pulse-cookie": - return stubFileInfoIsDir(true), nil - case "/home/ophestra/xdg/config/pulse/cookie": - return stubFileInfoPulseCookie{false}, nil - default: - panic(fmt.Sprintf("attempted to stat unexpected path %q", name)) - } -} - -func (k *stubNixOS) open(name string) (osFile, error) { - switch name { - case "/home/ophestra/xdg/config/pulse/cookie": - return stubOsFileReadCloser{io.NopCloser(bytes.NewReader(bytes.Repeat([]byte{0}, pulseCookieSizeMax)))}, nil - default: - panic(fmt.Sprintf("attempted to open unexpected path %q", name)) - } -} - -func (k *stubNixOS) readdir(name string) ([]fs.DirEntry, error) { - switch name { - case "/": - return stubDirEntries("bin", "boot", "dev", "etc", "home", "lib", - "lib64", "nix", "proc", "root", "run", "srv", "sys", "tmp", "usr", "var") - - case "/run": - return stubDirEntries("agetty.reload", "binfmt", "booted-system", - "credentials", "cryptsetup", "current-system", "dbus", "host", "keys", - "libvirt", "libvirtd.pid", "lock", "log", "lvm", "mount", "NetworkManager", - "nginx", "nixos", "nscd", "opengl-driver", "pppd", "resolvconf", "sddm", - "store", "syncoid", "system", "systemd", "tmpfiles.d", "udev", "udisks2", - "user", "utmp", "virtlogd.pid", "wrappers", "zed.pid", "zed.state") - - case "/etc": - return stubDirEntries("alsa", "bashrc", "binfmt.d", "dbus-1", "default", - "ethertypes", "fonts", "fstab", "fuse.conf", "group", "host.conf", "hostid", - "hostname", "hostname.CHECKSUM", "hosts", "inputrc", "ipsec.d", "issue", "kbd", - "libblockdev", "locale.conf", "localtime", "login.defs", "lsb-release", "lvm", - "machine-id", "man_db.conf", "modprobe.d", "modules-load.d", "mtab", "nanorc", - "netgroup", "NetworkManager", "nix", "nixos", "NIXOS", "nscd.conf", "nsswitch.conf", - "opensnitchd", "os-release", "pam", "pam.d", "passwd", "pipewire", "pki", "polkit-1", - "profile", "protocols", "qemu", "resolv.conf", "resolvconf.conf", "rpc", "samba", - "sddm.conf", "secureboot", "services", "set-environment", "shadow", "shells", "ssh", - "ssl", "static", "subgid", "subuid", "sudoers", "sysctl.d", "systemd", "terminfo", - "tmpfiles.d", "udev", "udisks2", "UPower", "vconsole.conf", "X11", "zfs", "zinputrc", - "zoneinfo", "zprofile", "zshenv", "zshrc") - - case "/var/lib/hakurei/base/org.debian": - return stubDirEntries("bin", "dev", "etc", "home", "lib64", "lost+found", - "mnt", "nix", "proc", "root", "run", "srv", "sys", "tmp", "usr", "var") - - default: - panic(fmt.Sprintf("attempted to read unexpected directory %q", name)) - } -} - -func (k *stubNixOS) tempdir() string { return "/tmp/" } - -func (k *stubNixOS) evalSymlinks(path string) (string, error) { - switch path { - case "/var/run/nscd": - return "/run/nscd", nil - case "/run/user/1971": - return "/run/user/1971", nil - case "/tmp/hakurei.0": - return "/tmp/hakurei.0", nil - case "/var/run/dbus": - return "/run/dbus", nil - case "/dev/kvm": - return "/dev/kvm", nil - case "/etc/": - return "/etc/", nil - case "/bin": - return "/bin", nil - case "/boot": - return "/boot", nil - case "/home": - return "/home", nil - case "/lib": - return "/lib", nil - case "/lib64": - return "/lib64", nil - case "/nix": - return "/nix", nil - case "/root": - return "/root", nil - case "/run": - return "/run", nil - case "/srv": - return "/srv", nil - case "/sys": - return "/sys", nil - case "/usr": - return "/usr", nil - case "/var": - return "/var", nil - case "/dev/dri": - return "/dev/dri", nil - case "/usr/bin/": - return "/usr/bin/", nil - case "/nix/store": - return "/nix/store", nil - case "/run/current-system": - return "/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-nixos-system-satori-25.05.99999999.aaaaaaa", nil - case "/sys/block": - return "/sys/block", nil - case "/sys/bus": - return "/sys/bus", nil - case "/sys/class": - return "/sys/class", nil - case "/sys/dev": - return "/sys/dev", nil - case "/sys/devices": - return "/sys/devices", nil - case "/run/opengl-driver": - return "/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-graphics-drivers", nil - case "/var/lib/persist/module/hakurei/0/1": - return "/var/lib/persist/module/hakurei/0/1", nil - - case "/var/lib/hakurei/nix/u0/org.chromium.Chromium/rw-store/upper": - return "/var/lib/hakurei/nix/u0/org.chromium.Chromium/rw-store/upper", nil - case "/var/lib/hakurei/nix/u0/org.chromium.Chromium/rw-store/work": - return "/var/lib/hakurei/nix/u0/org.chromium.Chromium/rw-store/work", nil - case "/var/lib/hakurei/base/org.nixos/ro-store": - return "/var/lib/hakurei/base/org.nixos/ro-store", nil - case "/var/lib/hakurei/u0/org.chromium.Chromium": - return "/var/lib/hakurei/u0/org.chromium.Chromium", nil - case "/var/lib/hakurei/base/org.debian/bin": - return "/var/lib/hakurei/base/org.debian/bin", nil - case "/var/lib/hakurei/base/org.debian/home": - return "/var/lib/hakurei/base/org.debian/home", nil - case "/var/lib/hakurei/base/org.debian/lib64": - return "/var/lib/hakurei/base/org.debian/lib64", nil - case "/var/lib/hakurei/base/org.debian/lost+found": - return "/var/lib/hakurei/base/org.debian/lost+found", nil - case "/var/lib/hakurei/base/org.debian/nix": - return "/var/lib/hakurei/base/org.debian/nix", nil - case "/var/lib/hakurei/base/org.debian/root": - return "/var/lib/hakurei/base/org.debian/root", nil - case "/var/lib/hakurei/base/org.debian/run": - return "/var/lib/hakurei/base/org.debian/run", nil - case "/var/lib/hakurei/base/org.debian/srv": - return "/var/lib/hakurei/base/org.debian/srv", nil - case "/var/lib/hakurei/base/org.debian/sys": - return "/var/lib/hakurei/base/org.debian/sys", nil - case "/var/lib/hakurei/base/org.debian/usr": - return "/var/lib/hakurei/base/org.debian/usr", nil - case "/var/lib/hakurei/base/org.debian/var": - return "/var/lib/hakurei/base/org.debian/var", nil - - default: - panic(fmt.Sprintf("attempted to evaluate unexpected path %q", path)) - } -} - -func (k *stubNixOS) lookupGroupId(name string) (string, error) { - switch name { - case "video": - return "26", nil - default: - return "", user.UnknownGroupError(name) - } -} - -func (k *stubNixOS) cmdOutput(cmd *exec.Cmd) ([]byte, error) { - switch cmd.Path { - case "/proc/nonexistent/hsu": - return []byte{'0'}, nil - default: - panic(fmt.Sprintf("unexpected cmd %#v", cmd)) - } -} - -func (k *stubNixOS) overflowUid(message.Msg) int { return 65534 } -func (k *stubNixOS) overflowGid(message.Msg) int { return 65534 } - -func (k *stubNixOS) mustHsuPath() *check.Absolute { return m("/proc/nonexistent/hsu") } - -func (k *stubNixOS) dbusAddress() (string, string) { - return "unix:path=/run/user/1971/bus", "unix:path=/var/run/dbus/system_bus_socket" -} - -func (k *stubNixOS) fatalf(format string, v ...any) { panic(fmt.Sprintf(format, v...)) } - -func (k *stubNixOS) isVerbose() bool { return true } -func (k *stubNixOS) verbose(v ...any) { log.Print(v...) } -func (k *stubNixOS) verbosef(format string, v ...any) { log.Printf(format, v...) } - -func m(pathname string) *check.Absolute { - return check.MustAbs(pathname) -} - -func f(c hst.FilesystemConfig) hst.FilesystemConfigJSON { - return hst.FilesystemConfigJSON{FilesystemConfig: c} -} diff --git a/internal/app/dispatcher.go b/internal/app/dispatcher.go deleted file mode 100644 index b081ece1..00000000 --- a/internal/app/dispatcher.go +++ /dev/null @@ -1,163 +0,0 @@ -package app - -import ( - "context" - "io" - "io/fs" - "os" - "os/exec" - "os/signal" - "os/user" - "path/filepath" - - "hakurei.app/container" - "hakurei.app/container/check" - "hakurei.app/container/seccomp" - "hakurei.app/internal" - "hakurei.app/message" - "hakurei.app/system/dbus" -) - -// osFile represents [os.File]. -type osFile interface { - Name() string - io.Writer - fs.File -} - -// syscallDispatcher provides methods that make state-dependent system calls as part of their behaviour. -type syscallDispatcher interface { - // new starts a goroutine with a new instance of syscallDispatcher. - // A syscallDispatcher must never be used in any goroutine other than the one owning it, - // just synchronising access is not enough, as this is for test instrumentation. - new(f func(k syscallDispatcher, msg message.Msg)) - - // getpid provides [os.Getpid]. - getpid() int - // getuid provides [os.Getuid]. - getuid() int - // getgid provides [os.Getgid]. - getgid() int - // lookupEnv provides [os.LookupEnv]. - lookupEnv(key string) (string, bool) - // pipe provides os.Pipe. - pipe() (r, w *os.File, err error) - // stat provides [os.Stat]. - stat(name string) (os.FileInfo, error) - // open provides [os.Open]. - open(name string) (osFile, error) - // readdir provides [os.ReadDir]. - readdir(name string) ([]os.DirEntry, error) - // tempdir provides [os.TempDir]. - tempdir() string - // exit provides [os.Exit]. - exit(code int) - - // evalSymlinks provides [filepath.EvalSymlinks]. - evalSymlinks(path string) (string, error) - - // lookupGroupId calls [user.LookupGroup] and returns the Gid field of the resulting [user.Group] struct. - lookupGroupId(name string) (string, error) - - // cmdOutput provides the Output method of [exec.Cmd]. - cmdOutput(cmd *exec.Cmd) ([]byte, error) - - // notifyContext provides [signal.NotifyContext]. - notifyContext(parent context.Context, signals ...os.Signal) (ctx context.Context, stop context.CancelFunc) - - // prctl provides [container.Prctl]. - prctl(op, arg2, arg3 uintptr) error - // overflowUid provides [container.OverflowUid]. - overflowUid(msg message.Msg) int - // overflowGid provides [container.OverflowGid]. - overflowGid(msg message.Msg) int - // setDumpable provides [container.SetDumpable]. - setDumpable(dumpable uintptr) error - // receive provides [container.Receive]. - receive(key string, e any, fdp *uintptr) (closeFunc func() error, err error) - - // containerStart provides the Start method of [container.Container]. - containerStart(z *container.Container) error - // containerStart provides the Serve method of [container.Container]. - containerServe(z *container.Container) error - // containerStart provides the Wait method of [container.Container]. - containerWait(z *container.Container) error - - // seccompLoad provides [seccomp.Load]. - seccompLoad(rules []seccomp.NativeRule, flags seccomp.ExportFlag) error - - // mustHsuPath provides [internal.MustHsuPath]. - mustHsuPath() *check.Absolute - - // dbusAddress provides [dbus.Address]. - dbusAddress() (session, system string) - - // setupContSignal provides setupContSignal. - setupContSignal(pid int) (io.ReadCloser, func(), error) - - // getMsg returns the [message.Msg] held by syscallDispatcher. - getMsg() message.Msg - // fatal provides [log.Fatal]. - fatal(v ...any) - // fatalf provides [log.Fatalf]. - fatalf(format string, v ...any) -} - -// direct implements syscallDispatcher on the current kernel. -type direct struct{ msg message.Msg } - -func (k direct) new(f func(k syscallDispatcher, msg message.Msg)) { go f(k, k.msg) } - -func (direct) getpid() int { return os.Getpid() } -func (direct) getuid() int { return os.Getuid() } -func (direct) getgid() int { return os.Getgid() } -func (direct) lookupEnv(key string) (string, bool) { return os.LookupEnv(key) } -func (direct) pipe() (r, w *os.File, err error) { return os.Pipe() } -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) exit(code int) { os.Exit(code) } - -func (direct) evalSymlinks(path string) (string, error) { return filepath.EvalSymlinks(path) } - -func (direct) lookupGroupId(name string) (gid string, err error) { - var group *user.Group - group, err = user.LookupGroup(name) - if group != nil { - gid = group.Gid - } - return -} - -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) { - return signal.NotifyContext(parent, signals...) -} - -func (direct) prctl(op, arg2, arg3 uintptr) error { return container.Prctl(op, arg2, arg3) } -func (direct) overflowUid(msg message.Msg) int { return container.OverflowUid(msg) } -func (direct) overflowGid(msg message.Msg) int { return container.OverflowGid(msg) } -func (direct) setDumpable(dumpable uintptr) error { return container.SetDumpable(dumpable) } -func (direct) receive(key string, e any, fdp *uintptr) (func() error, error) { - return container.Receive(key, e, fdp) -} - -func (direct) containerStart(z *container.Container) error { return z.Start() } -func (direct) containerServe(z *container.Container) error { return z.Serve() } -func (direct) containerWait(z *container.Container) error { return z.Wait() } - -func (direct) seccompLoad(rules []seccomp.NativeRule, flags seccomp.ExportFlag) error { - return seccomp.Load(rules, flags) -} - -func (direct) mustHsuPath() *check.Absolute { return internal.MustHsuPath() } - -func (direct) dbusAddress() (session, system string) { return dbus.Address() } - -func (direct) setupContSignal(pid int) (io.ReadCloser, func(), error) { return setupContSignal(pid) } - -func (k direct) getMsg() message.Msg { return k.msg } -func (k direct) fatal(v ...any) { k.msg.GetLogger().Fatal(v...) } -func (k direct) fatalf(format string, v ...any) { k.msg.GetLogger().Fatalf(format, v...) } diff --git a/internal/app/dispatcher_test.go b/internal/app/dispatcher_test.go deleted file mode 100644 index 839e9e42..00000000 --- a/internal/app/dispatcher_test.go +++ /dev/null @@ -1,644 +0,0 @@ -package app - -import ( - "bytes" - "context" - "io" - "io/fs" - "log" - "maps" - "os" - "os/exec" - "reflect" - "slices" - "sync" - "testing" - "time" - "unsafe" - - "hakurei.app/container" - "hakurei.app/container/check" - "hakurei.app/container/seccomp" - "hakurei.app/container/stub" - "hakurei.app/hst" - "hakurei.app/message" - "hakurei.app/system" -) - -// call initialises a [stub.Call]. -// This keeps composites analysis happy without making the test cases too bloated. -func call(name string, args stub.ExpectArgs, ret any, err error) stub.Call { - return stub.Call{Name: name, Args: args, Ret: ret, Err: err} -} - -const ( - // checkExpectUid is the uid value used by checkOpBehaviour to initialise [system.I]. - checkExpectUid = 0xcafebabe - // wantAutoEtcPrefix is the autoetc prefix corresponding to checkExpectInstanceId. - wantAutoEtcPrefix = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - // wantInstancePrefix is the SharePath corresponding to checkExpectInstanceId. - wantInstancePrefix = container.Nonexistent + "/tmp/hakurei.0/" + wantAutoEtcPrefix - - // wantRuntimePath is the XDG_RUNTIME_DIR value returned during testing. - wantRuntimePath = "/proc/nonexistent/xdg_runtime_dir" - // wantRunDirPath is the RunDirPath value resolved during testing. - wantRunDirPath = wantRuntimePath + "/hakurei" - // wantRuntimeSharePath is the runtimeSharePath value resolved during testing. - wantRuntimeSharePath = wantRunDirPath + "/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" -) - -// checkExpectInstanceId is the [hst.ID] value used by checkOpBehaviour to initialise outcomeState. -var checkExpectInstanceId = *(*hst.ID)(bytes.Repeat([]byte{0xaa}, len(hst.ID{}))) - -type ( - // pStateSysFunc is called before each test case is run to prepare outcomeStateSys. - pStateSysFunc = func(state *outcomeStateSys) - // pStateContainerFunc is called before each test case is run to prepare outcomeStateParams. - pStateContainerFunc = func(state *outcomeStateParams) - - // extraCheckSysFunc is called to check outcomeStateSys and must not have side effects. - extraCheckSysFunc = func(t *testing.T, state *outcomeStateSys) - // extraCheckParamsFunc is called to check outcomeStateParams and must not have side effects. - extraCheckParamsFunc = func(t *testing.T, state *outcomeStateParams) -) - -// insertsOps prepares outcomeStateParams to allow [container.Op] to be inserted. -func insertsOps(next pStateContainerFunc) pStateContainerFunc { - return func(state *outcomeStateParams) { - state.params.Ops = new(container.Ops) - - if next != nil { - next(state) - } - } -} - -// afterSpRuntimeOp prepares outcomeStateParams for an outcomeOp meant to run after spRuntimeOp. -func afterSpRuntimeOp(next pStateContainerFunc) pStateContainerFunc { - return func(state *outcomeStateParams) { - // emulates spRuntimeOp - state.runtimeDir = m("/run/user/1000") - - if next != nil { - next(state) - } - } -} - -// sysUsesInstance checks for use of the outcomeStateSys.instance method. -func sysUsesInstance(next extraCheckSysFunc) extraCheckSysFunc { - return func(t *testing.T, state *outcomeStateSys) { - if want := m(wantInstancePrefix); !reflect.DeepEqual(state.sharePath, want) { - t.Errorf("outcomeStateSys: sharePath = %v, want %v", state.sharePath, want) - } - - if next != nil { - next(t, state) - } - } -} - -// sysUsesRuntime checks for use of the outcomeStateSys.runtime method. -func sysUsesRuntime(next extraCheckSysFunc) extraCheckSysFunc { - return func(t *testing.T, state *outcomeStateSys) { - if want := m(wantRuntimeSharePath); !reflect.DeepEqual(state.runtimeSharePath, want) { - t.Errorf("outcomeStateSys: runtimeSharePath = %v, want %v", state.runtimeSharePath, want) - } - - if next != nil { - next(t, state) - } - } -} - -// paramsWantEnv checks outcomeStateParams.env for inserted entries on top of [hst.Config]. -func paramsWantEnv(config *hst.Config, wantEnv map[string]string, next extraCheckParamsFunc) extraCheckParamsFunc { - want := make(map[string]string, len(wantEnv)+len(config.Container.Env)) - maps.Copy(want, wantEnv) - maps.Copy(want, config.Container.Env) - return func(t *testing.T, state *outcomeStateParams) { - if !maps.Equal(state.env, want) { - t.Errorf("toContainer: env = %#v, want %#v", state.env, want) - } - - if next != nil { - next(t, state) - } - } -} - -// opBehaviourTestCase checks outcomeOp behaviour against outcomeStateSys and outcomeStateParams. -type opBehaviourTestCase struct { - name string - // newOp returns a new instance of outcomeOp under testing that is safe to clobber. - newOp func(isShim, clearUnexported bool) outcomeOp - // newConfig returns a new instance of [hst.Config] that is checked not to be clobbered by outcomeOp. - newConfig func() *hst.Config - - // pStateSys is called before outcomeOp.toSystem to prepare outcomeStateSys. - pStateSys pStateSysFunc - // toSystem are expected syscallDispatcher calls during outcomeOp.toSystem. - toSystem []stub.Call - // wantSys is the expected [system.I] state after outcomeOp.toSystem. - wantSys *system.I - // extraCheckSys is called after outcomeOp.toSystem to check the state of outcomeStateSys. - extraCheckSys extraCheckSysFunc - // wantErrSystem is the expected error value returned by outcomeOp.toSystem. - // Further testing is skipped if not nil. - wantErrSystem error - - // pStateContainer is called before outcomeOp.toContainer to prepare outcomeStateParams. - pStateContainer pStateContainerFunc - // toContainer are expected syscallDispatcher calls during outcomeOp.toContainer. - toContainer []stub.Call - // wantParams is the expected [container.Params] after outcomeOp.toContainer. - wantParams *container.Params - // extraCheckParams is called after outcomeOp.toContainer to check the state of outcomeStateParams. - extraCheckParams extraCheckParamsFunc - // wantErrContainer is the expected error value returned by outcomeOp.toContainer. - wantErrContainer error -} - -// checkOpBehaviour runs a slice of opBehaviourTestCase. -func checkOpBehaviour(t *testing.T, testCases []opBehaviourTestCase) { - t.Helper() - - wantNewState := []stub.Call{ - // newOutcomeState - call("getpid", stub.ExpectArgs{}, 0xdead, nil), - call("isVerbose", stub.ExpectArgs{}, true, nil), - call("mustHsuPath", stub.ExpectArgs{}, m(container.Nonexistent), nil), - call("cmdOutput", stub.ExpectArgs{container.Nonexistent, os.Stderr, []string{}, "/"}, []byte("0"), nil), - call("tempdir", stub.ExpectArgs{}, container.Nonexistent+"/tmp", nil), - call("lookupEnv", stub.ExpectArgs{"XDG_RUNTIME_DIR"}, wantRuntimePath, nil), - call("getuid", stub.ExpectArgs{}, 1000, nil), - call("getgid", stub.ExpectArgs{}, 100, nil), - - // populateLocal - call("verbosef", stub.ExpectArgs{"process share directory at %q, runtime directory at %q", []any{ - m(container.Nonexistent + "/tmp/hakurei.0"), - m(container.Nonexistent + "/xdg_runtime_dir/hakurei"), - }}, nil, nil), - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - t.Helper() - t.Parallel() - - wantCallsFull := slices.Concat(wantNewState, tc.toSystem, []stub.Call{{Name: stub.CallSeparator}}) - if tc.wantErrSystem == nil { - wantCallsFull = append(wantCallsFull, slices.Concat(wantNewState, tc.toContainer)...) - } - - wantConfig := tc.newConfig() - k := &kstub{panicDispatcher{}, stub.New(t, - func(s *stub.Stub[syscallDispatcher]) syscallDispatcher { return &kstub{panicDispatcher{}, s} }, - stub.Expect{Calls: wantCallsFull}, - )} - defer stub.HandleExit(t) - - { - config := tc.newConfig() - s := newOutcomeState(k, k, &checkExpectInstanceId, config, &Hsu{k: k}) - if err := s.populateLocal(k, k); err != nil { - t.Fatalf("populateLocal: error = %v", err) - } - stateSys := s.newSys(config, system.New(panicMsgContext{}, k, checkExpectUid)) - if tc.pStateSys != nil { - tc.pStateSys(stateSys) - } - op := tc.newOp(false, true) - - if err := op.toSystem(stateSys); !reflect.DeepEqual(err, tc.wantErrSystem) { - t.Fatalf("toSystem: error = %#v, want %#v", err, tc.wantErrSystem) - } - k.Expects(stub.CallSeparator) - if !reflect.DeepEqual(config, wantConfig) { - t.Errorf("toSystem clobbered config: %#v, want %#v", config, wantConfig) - } - - if tc.wantErrSystem != nil { - goto out - } - - if !stateSys.sys.Equal(tc.wantSys) { - t.Errorf("toSystem: %#v, want %#v", stateSys.sys, tc.wantSys) - } - if tc.extraCheckSys != nil { - tc.extraCheckSys(t, stateSys) - } - if wantOpSys := tc.newOp(true, false); !reflect.DeepEqual(op, wantOpSys) { - t.Errorf("toSystem: op = %#v, want %#v", op, wantOpSys) - } - } - - { - config := tc.newConfig() - s := newOutcomeState(k, k, &checkExpectInstanceId, config, &Hsu{k: k}) - stateParams := s.newParams() - if err := s.populateLocal(k, k); err != nil { - t.Fatalf("populateLocal: error = %v", err) - } - if tc.pStateContainer != nil { - tc.pStateContainer(stateParams) - } - op := tc.newOp(true, true) - - if err := op.toContainer(stateParams); !reflect.DeepEqual(err, tc.wantErrContainer) { - t.Fatalf("toContainer: error = %#v, want %#v", err, tc.wantErrContainer) - } - - if tc.wantErrContainer != nil { - goto out - } - - if !reflect.DeepEqual(stateParams.params, tc.wantParams) { - t.Errorf("toContainer:\n%s\nwant\n%s", mustMarshal(stateParams.params), mustMarshal(tc.wantParams)) - } - if tc.extraCheckParams != nil { - tc.extraCheckParams(t, stateParams) - } - } - - out: - k.VisitIncomplete(func(s *stub.Stub[syscallDispatcher]) { - count := k.Pos() - 1 // separator - if count-len(wantNewState) < len(tc.toSystem) { - t.Errorf("toSystem: %d calls, want %d", count-len(wantNewState), len(tc.toSystem)) - } else { - t.Errorf("toContainer: %d calls, want %d", count-len(tc.toSystem)-2*len(wantNewState), len(tc.toContainer)) - } - }) - }) - } -} - -func newI() *system.I { return system.New(panicMsgContext{}, panicMsgContext{}, checkExpectUid) } - -// simpleTestCase is a simple freeform test case utilising kstub. -type simpleTestCase struct { - name string - f func(k *kstub) error - // want are expected syscallDispatcher calls during f. - want stub.Expect - // wantErr is the expected error value returned by f. - wantErr error -} - -// checkSimple runs a slice of simpleTestCase. -func checkSimple(t *testing.T, fname string, testCases []simpleTestCase) { - t.Helper() - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - t.Helper() - t.Parallel() - - defer stub.HandleExit(t) - k := &kstub{panicDispatcher{}, stub.New(t, func(s *stub.Stub[syscallDispatcher]) syscallDispatcher { return &kstub{panicDispatcher{}, s} }, tc.want)} - if err := tc.f(k); !reflect.DeepEqual(err, tc.wantErr) { - t.Errorf("%s: error = %#v, want %#v", fname, err, tc.wantErr) - } - k.VisitIncomplete(func(s *stub.Stub[syscallDispatcher]) { - t.Helper() - - t.Errorf("%s: %d calls, want %d", fname, s.Pos(), s.Len()) - }) - }) - } -} - -// kstub partially implements syscallDispatcher via [stub.Stub]. -type kstub struct { - panicDispatcher - *stub.Stub[syscallDispatcher] -} - -func (k *kstub) new(f func(k syscallDispatcher, msg message.Msg)) { - k.New(func(k syscallDispatcher) { f(k, k.(*kstub)) }) -} - -func (k *kstub) getpid() int { k.Helper(); return k.Expects("getpid").Ret.(int) } -func (k *kstub) getuid() int { k.Helper(); return k.Expects("getuid").Ret.(int) } -func (k *kstub) getgid() int { k.Helper(); return k.Expects("getgid").Ret.(int) } -func (k *kstub) lookupEnv(key string) (string, bool) { - k.Helper() - expect := k.Expects("lookupEnv") - if expect.Error( - stub.CheckArg(k.Stub, "key", key, 0)) != nil { - k.FailNow() - } - if expect.Ret == nil { - return "\x00", false - } - return expect.Ret.(string), true -} -func (k *kstub) stat(name string) (os.FileInfo, error) { - k.Helper() - expect := k.Expects("stat") - return expect.Ret.(os.FileInfo), expect.Error( - stub.CheckArg(k.Stub, "name", name, 0)) -} -func (k *kstub) open(name string) (osFile, error) { - k.Helper() - expect := k.Expects("open") - return expect.Ret.(osFile), expect.Error( - stub.CheckArg(k.Stub, "name", name, 0)) -} -func (k *kstub) readdir(name string) ([]os.DirEntry, error) { - k.Helper() - expect := k.Expects("readdir") - return expect.Ret.([]os.DirEntry), expect.Error( - stub.CheckArg(k.Stub, "name", name, 0)) -} -func (k *kstub) tempdir() string { k.Helper(); return k.Expects("tempdir").Ret.(string) } -func (k *kstub) evalSymlinks(path string) (string, error) { - k.Helper() - expect := k.Expects("evalSymlinks") - return expect.Ret.(string), expect.Error( - stub.CheckArg(k.Stub, "path", path, 0)) -} - -func (k *kstub) prctl(op, arg2, arg3 uintptr) error { - k.Helper() - return k.Expects("prctl").Error( - stub.CheckArg(k.Stub, "op", op, 0), - stub.CheckArg(k.Stub, "arg2", arg2, 1), - stub.CheckArg(k.Stub, "arg3", arg3, 2)) -} - -func (k *kstub) setDumpable(dumpable uintptr) error { - k.Helper() - return k.Expects("setDumpable").Error( - stub.CheckArg(k.Stub, "dumpable", dumpable, 0)) -} - -func (k *kstub) receive(key string, e any, fdp *uintptr) (closeFunc func() error, err error) { - k.Helper() - expect := k.Expects("receive") - reflect.ValueOf(e).Elem().Set(reflect.ValueOf(expect.Args[1])) - if expect.Args[2] != nil { - *fdp = expect.Args[2].(uintptr) - } - return func() error { return k.Expects("closeReceive").Err }, expect.Error( - stub.CheckArg(k.Stub, "key", key, 0)) -} - -func (k *kstub) expectCheckContainer(expect *stub.Call, z *container.Container) error { - k.Helper() - err := expect.Error( - stub.CheckArgReflect(k.Stub, "params", &z.Params, 0)) - if err != nil { - k.Errorf("params:\n%s\n%s", mustMarshal(&z.Params), mustMarshal(expect.Args[0])) - } - return err -} - -func (k *kstub) containerStart(z *container.Container) error { - k.Helper() - return k.expectCheckContainer(k.Expects("containerStart"), z) -} -func (k *kstub) containerServe(z *container.Container) error { - k.Helper() - return k.expectCheckContainer(k.Expects("containerServe"), z) -} -func (k *kstub) containerWait(z *container.Container) error { - k.Helper() - return k.expectCheckContainer(k.Expects("containerWait"), z) -} - -func (k *kstub) seccompLoad(rules []seccomp.NativeRule, flags seccomp.ExportFlag) error { - k.Helper() - return k.Expects("seccompLoad").Error( - stub.CheckArgReflect(k.Stub, "rules", rules, 0), - stub.CheckArg(k.Stub, "flags", flags, 1)) -} - -func (k *kstub) cmdOutput(cmd *exec.Cmd) ([]byte, error) { - k.Helper() - expect := k.Expects("cmdOutput") - return expect.Ret.([]byte), expect.Error( - stub.CheckArg(k.Stub, "cmd.Path", cmd.Path, 0), - stub.CheckArgReflect(k.Stub, "cmd.Stderr", cmd.Stderr, 1), - stub.CheckArgReflect(k.Stub, "cmd.Env", cmd.Env, 2), - stub.CheckArg(k.Stub, "cmd.Dir", cmd.Dir, 3)) -} - -func (k *kstub) notifyContext(parent context.Context, signals ...os.Signal) (ctx context.Context, stop context.CancelFunc) { - k.Helper() - if k.Expects("notifyContext").Error( - stub.CheckArgReflect(k.Stub, "parent", parent, 0), - stub.CheckArgReflect(k.Stub, "signals", signals, 1)) != nil { - k.FailNow() - } - return k.Context(), func() { k.Helper(); k.Expects("notifyContextStop") } -} - -func (k *kstub) mustHsuPath() *check.Absolute { - k.Helper() - return k.Expects("mustHsuPath").Ret.(*check.Absolute) -} - -func (k *kstub) dbusAddress() (session, system string) { - k.Helper() - ret := k.Expects("dbusAddress").Ret.([2]string) - return ret[0], ret[1] -} - -// stubTrackReader embeds kstub but switches the underlying [stub.Stub] index to sub on its first Read. -// The resulting kstub does not share any state with the instance passed to the instrumented goroutine. -// Therefore, any method making use of such must not be called. -type stubTrackReader struct { - sub int - subOnce sync.Once - - *kstub -} - -func (r *stubTrackReader) Read(p []byte) (n int, err error) { - r.subOnce.Do(func() { - subVal := reflect.ValueOf(r.kstub.Stub).Elem().FieldByName("sub") - r.kstub = &kstub{panicDispatcher{}, reflect. - NewAt(subVal.Type(), unsafe.Pointer(subVal.UnsafeAddr())).Elem(). - Interface().([]*stub.Stub[syscallDispatcher])[r.sub]} - }) - - return r.kstub.Read(p) -} - -func (k *kstub) setupContSignal(pid int) (io.ReadCloser, func(), error) { - k.Helper() - expect := k.Expects("setupContSignal") - return &stubTrackReader{sub: expect.Ret.(int), kstub: k}, func() { k.Expects("wKeepAlive") }, expect.Error( - stub.CheckArg(k.Stub, "pid", pid, 0)) -} - -func (k *kstub) getMsg() message.Msg { k.Helper(); k.Expects("getMsg"); return k } - -func (k *kstub) Close() error { k.Helper(); return k.Expects("rcClose").Err } -func (k *kstub) Read(p []byte) (n int, err error) { - k.Helper() - expect := k.Expects("rcRead") - - // special case to terminate exit outcomes goroutine - // to proceed with further testing of the entrypoint - if expect.Ret == nil { - panic(stub.PanicExit) - } - - return copy(p, expect.Ret.([]byte)), expect.Err -} - -func (k *kstub) GetLogger() *log.Logger { k.Helper(); return k.Expects("getLogger").Ret.(*log.Logger) } -func (k *kstub) IsVerbose() bool { k.Helper(); return k.Expects("isVerbose").Ret.(bool) } -func (k *kstub) SwapVerbose(verbose bool) bool { - k.Helper() - expect := k.Expects("swapVerbose") - if expect.Error( - stub.CheckArg(k.Stub, "verbose", verbose, 0)) != nil { - k.FailNow() - } - return expect.Ret.(bool) -} - -// ignoreValue marks a value to be ignored by the test suite. -type ignoreValue struct{} - -func (k *kstub) Verbose(v ...any) { - k.Helper() - expect := k.Expects("verbose") - - // translate ignores in v - if want, ok := expect.Args[0].([]any); ok && len(v) == len(want) { - for i, a := range want { - if _, ok = a.(ignoreValue); ok { - v[i] = ignoreValue{} - } - } - } - - if expect.Error( - stub.CheckArgReflect(k.Stub, "v", v, 0)) != nil { - k.FailNow() - } -} - -func (k *kstub) Verbosef(format string, v ...any) { - k.Helper() - if k.Expects("verbosef").Error( - stub.CheckArg(k.Stub, "format", format, 0), - stub.CheckArgReflect(k.Stub, "v", v, 1)) != nil { - k.FailNow() - } -} - -func (k *kstub) Suspend() bool { k.Helper(); return k.Expects("suspend").Ret.(bool) } -func (k *kstub) Resume() bool { k.Helper(); return k.Expects("resume").Ret.(bool) } -func (k *kstub) BeforeExit() { k.Helper(); k.Expects("beforeExit") } - -// stubOsFile partially implements osFile. -type stubOsFile struct { - closeErr error - - io.Reader - io.Writer -} - -func (f *stubOsFile) Close() error { return f.closeErr } -func (f *stubOsFile) Name() string { panic("unreachable") } -func (f *stubOsFile) Stat() (fs.FileInfo, error) { panic("unreachable") } - -// stubFi partially implements [os.FileInfo]. Can be passed as nil to assert all methods unreachable. -type stubFi struct { - size int64 - mode os.FileMode - isDir bool -} - -func (fi *stubFi) Name() string { panic("unreachable") } -func (fi *stubFi) ModTime() time.Time { panic("unreachable") } -func (fi *stubFi) Sys() any { panic("unreachable") } -func (fi *stubFi) Size() int64 { return fi.size } -func (fi *stubFi) Mode() os.FileMode { return fi.mode } -func (fi *stubFi) IsDir() bool { return fi.isDir } - -// stubDir returns a slice of [os.DirEntry] with only their Name method implemented. -func stubDir(names ...string) []os.DirEntry { - d := make([]os.DirEntry, len(names)) - for i, name := range names { - d[i] = nameDentry(name) - } - return d -} - -// nameDentry implements the Name method on [os.DirEntry]. -type nameDentry string - -func (e nameDentry) Name() string { return string(e) } -func (nameDentry) IsDir() bool { panic("unreachable") } -func (nameDentry) Type() fs.FileMode { panic("unreachable") } -func (nameDentry) Info() (fs.FileInfo, error) { panic("unreachable") } - -// errorReader implements [io.Reader] that unconditionally returns -1, val. -type errorReader struct{ val error } - -func (r errorReader) Read([]byte) (int, error) { return -1, r.val } - -// panicMsgContext implements [message.Msg] and [context.Context] with methods wrapping panic. -// This should be assigned to test cases to be checked against. -type panicMsgContext struct{} - -func (panicMsgContext) GetLogger() *log.Logger { panic("unreachable") } -func (panicMsgContext) IsVerbose() bool { panic("unreachable") } -func (panicMsgContext) SwapVerbose(bool) bool { panic("unreachable") } -func (panicMsgContext) Verbose(...any) { panic("unreachable") } -func (panicMsgContext) Verbosef(string, ...any) { panic("unreachable") } -func (panicMsgContext) Suspend() bool { panic("unreachable") } -func (panicMsgContext) Resume() bool { panic("unreachable") } -func (panicMsgContext) BeforeExit() { panic("unreachable") } - -func (panicMsgContext) Deadline() (time.Time, bool) { panic("unreachable") } -func (panicMsgContext) Done() <-chan struct{} { panic("unreachable") } -func (panicMsgContext) Err() error { panic("unreachable") } -func (panicMsgContext) Value(any) any { panic("unreachable") } - -// panicDispatcher implements syscallDispatcher with methods wrapping panic. -// This type is meant to be embedded in partial syscallDispatcher implementations. -type panicDispatcher struct{} - -func (panicDispatcher) new(func(k syscallDispatcher, msg message.Msg)) { panic("unreachable") } -func (panicDispatcher) getpid() int { panic("unreachable") } -func (panicDispatcher) getuid() int { panic("unreachable") } -func (panicDispatcher) getgid() int { panic("unreachable") } -func (panicDispatcher) lookupEnv(string) (string, bool) { panic("unreachable") } -func (panicDispatcher) pipe() (*os.File, *os.File, error) { panic("unreachable") } -func (panicDispatcher) stat(string) (os.FileInfo, error) { panic("unreachable") } -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) 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) cmdOutput(*exec.Cmd) ([]byte, error) { panic("unreachable") } -func (panicDispatcher) overflowUid(message.Msg) int { panic("unreachable") } -func (panicDispatcher) overflowGid(message.Msg) int { panic("unreachable") } -func (panicDispatcher) setDumpable(uintptr) error { panic("unreachable") } -func (panicDispatcher) receive(string, any, *uintptr) (func() error, error) { panic("unreachable") } -func (panicDispatcher) containerStart(*container.Container) error { panic("unreachable") } -func (panicDispatcher) containerServe(*container.Container) error { panic("unreachable") } -func (panicDispatcher) containerWait(*container.Container) error { panic("unreachable") } -func (panicDispatcher) mustHsuPath() *check.Absolute { panic("unreachable") } -func (panicDispatcher) dbusAddress() (string, string) { panic("unreachable") } -func (panicDispatcher) setupContSignal(int) (io.ReadCloser, func(), error) { panic("unreachable") } -func (panicDispatcher) getMsg() message.Msg { panic("unreachable") } -func (panicDispatcher) fatal(...any) { panic("unreachable") } -func (panicDispatcher) fatalf(string, ...any) { panic("unreachable") } - -func (panicDispatcher) notifyContext(context.Context, ...os.Signal) (context.Context, context.CancelFunc) { - panic("unreachable") -} -func (panicDispatcher) seccompLoad([]seccomp.NativeRule, seccomp.ExportFlag) error { - panic("unreachable") -} diff --git a/internal/app/finalise.go b/internal/app/finalise.go deleted file mode 100644 index 6cb4f586..00000000 --- a/internal/app/finalise.go +++ /dev/null @@ -1,85 +0,0 @@ -package app - -import ( - "context" - "errors" - "fmt" - "os" - "os/user" - "sync/atomic" - - "hakurei.app/hst" - "hakurei.app/message" - "hakurei.app/system" -) - -func newWithMessage(msg string) error { return newWithMessageError(msg, os.ErrInvalid) } -func newWithMessageError(msg string, err error) error { - return &hst.AppError{Step: "finalise", Err: err, Msg: msg} -} - -// An outcome is the runnable state of a hakurei container via [hst.Config]. -type outcome struct { - // Supplementary group ids. Populated during finalise. - supp []string - // Resolved priv side operating system interactions. Populated during finalise. - sys *system.I - // Transmitted to shim. Populated during finalise. - state *outcomeState - // Kept for saving to [state]. - config *hst.Config - - // Whether the current process is in outcome.main. - active atomic.Bool - - ctx context.Context - syscallDispatcher -} - -func (k *outcome) finalise(ctx context.Context, msg message.Msg, id *hst.ID, config *hst.Config) error { - if ctx == nil || id == nil { - // unreachable - panic("invalid call to finalise") - } - if k.ctx != nil || k.sys != nil || k.state != nil { - // unreachable - panic("attempting to finalise twice") - } - k.ctx = ctx - - if err := config.Validate(); err != nil { - return err - } - - // hsu expects numerical group ids - supp := make([]string, len(config.Groups)) - for i, name := range config.Groups { - if gid, err := k.lookupGroupId(name); err != nil { - var unknownGroupError user.UnknownGroupError - if errors.As(err, &unknownGroupError) { - return newWithMessageError(fmt.Sprintf("unknown group %q", name), unknownGroupError) - } else { - return &hst.AppError{Step: "look up group by name", Err: err} - } - } else { - supp[i] = gid - } - } - - // early validation complete at this point - s := newOutcomeState(k.syscallDispatcher, msg, id, config, &Hsu{k: k}) - if err := s.populateLocal(k.syscallDispatcher, msg); err != nil { - return err - } - - sys := system.New(k.ctx, msg, s.uid.unwrap()) - if err := s.newSys(config, sys).toSystem(); err != nil { - return err - } - - k.sys = sys - k.supp = supp - k.state = s - k.config = config - return nil -} diff --git a/internal/app/hsu.go b/internal/app/hsu.go deleted file mode 100644 index 1286249a..00000000 --- a/internal/app/hsu.go +++ /dev/null @@ -1,101 +0,0 @@ -package app - -import ( - "errors" - "fmt" - "log" - "os" - "os/exec" - "strconv" - "sync" - - "hakurei.app/container/fhs" - "hakurei.app/hst" - "hakurei.app/message" -) - -// Hsu caches responses from cmd/hsu. -type Hsu struct { - idOnce sync.Once - idErr error - id int - - kOnce sync.Once - - // msg is not populated - k syscallDispatcher -} - -var ErrHsuAccess = errors.New("current user is not in the hsurc file") - -// ensureDispatcher ensures Hsu.k is not nil. -func (h *Hsu) ensureDispatcher() { - h.kOnce.Do(func() { - if h.k == nil { - h.k = direct{} - } - }) -} - -// ID returns the current user hsurc identifier. -// [ErrHsuAccess] is returned if the current user is not in hsurc. -func (h *Hsu) ID() (int, error) { - h.ensureDispatcher() - h.idOnce.Do(func() { - h.id = -1 - hsuPath := h.k.mustHsuPath().String() - - cmd := exec.Command(hsuPath) - cmd.Path = hsuPath - cmd.Stderr = os.Stderr // pass through fatal messages - cmd.Env = make([]string, 0) - cmd.Dir = fhs.Root - var ( - p []byte - exitError *exec.ExitError - ) - - const step = "obtain uid from hsu" - if p, h.idErr = h.k.cmdOutput(cmd); h.idErr == nil { - h.id, h.idErr = strconv.Atoi(string(p)) - if h.idErr != nil { - h.idErr = &hst.AppError{Step: step, Err: h.idErr, Msg: "invalid uid string from hsu"} - } - } else if errors.As(h.idErr, &exitError) && exitError != nil && exitError.ExitCode() == 1 { - // hsu prints an error message in this case - h.idErr = &hst.AppError{Step: step, Err: ErrHsuAccess} - } else if errors.Is(h.idErr, os.ErrNotExist) { - h.idErr = &hst.AppError{Step: step, Err: h.idErr, - Msg: fmt.Sprintf("the setuid helper is missing: %s", hsuPath)} - } - }) - - return h.id, h.idErr -} - -// MustID calls [Hsu.ID] and terminates on error. -func (h *Hsu) MustID(msg message.Msg) int { - id, err := h.ID() - if err == nil { - return id - } - - const fallback = "cannot retrieve user id from setuid wrapper:" - if errors.Is(err, ErrHsuAccess) { - if msg != nil { - msg.Verbose("*"+fallback, err) - } - os.Exit(1) - return -0xdeadbeef // not reached - } else if m, ok := message.GetMessage(err); ok { - log.Fatal(m) - return -0xdeadbeef // not reached - } else { - log.Fatalln(fallback, err) - return -0xdeadbeef // not reached - } -} - -// HsuUid returns target uid for the stable hsu uid format. -// No bounds check is performed, a value retrieved by [Hsu] is expected. -func HsuUid(id, identity int) int { return 1000000 + id*10000 + identity } diff --git a/internal/app/hsu_test.go b/internal/app/hsu_test.go deleted file mode 100644 index fad724da..00000000 --- a/internal/app/hsu_test.go +++ /dev/null @@ -1,84 +0,0 @@ -package app - -import ( - "os" - "os/exec" - "reflect" - "strconv" - "syscall" - "testing" - "unsafe" - - "hakurei.app/container/stub" - "hakurei.app/hst" -) - -func TestHsu(t *testing.T) { - t.Parallel() - - t.Run("ensure dispatcher", func(t *testing.T) { - hsu := new(Hsu) - hsu.ensureDispatcher() - - k := direct{} - if !reflect.DeepEqual(hsu.k, k) { - t.Errorf("ensureDispatcher: k = %#v, want %#v", hsu.k, k) - } - }) - - fCheckID := func(k *kstub) error { - hsu := &Hsu{k: k} - id, err := hsu.ID() - k.Verbose(id) - if id0, err0 := hsu.ID(); id0 != id || !reflect.DeepEqual(err0, err) { - t.Fatalf("ID: id0 = %d, err0 = %#v, id = %d, err = %#v", id0, err0, id, err) - } - return err - } - - checkSimple(t, "Hsu.ID", []simpleTestCase{ - {"hsu nonexistent", fCheckID, stub.Expect{Calls: []stub.Call{ - call("mustHsuPath", stub.ExpectArgs{}, m("/run/wrappers/bin/hsu"), nil), - call("cmdOutput", stub.ExpectArgs{"/run/wrappers/bin/hsu", os.Stderr, []string{}, "/"}, ([]byte)(nil), os.ErrNotExist), - call("verbose", stub.ExpectArgs{[]any{-1}}, nil, nil), - }}, &hst.AppError{ - Step: "obtain uid from hsu", - Err: os.ErrNotExist, - Msg: "the setuid helper is missing: /run/wrappers/bin/hsu", - }}, - - {"access", fCheckID, stub.Expect{Calls: []stub.Call{ - call("mustHsuPath", stub.ExpectArgs{}, m("/run/wrappers/bin/hsu"), nil), - call("cmdOutput", stub.ExpectArgs{"/run/wrappers/bin/hsu", os.Stderr, []string{}, "/"}, ([]byte)(nil), makeExitError(1<<8)), - call("verbose", stub.ExpectArgs{[]any{-1}}, nil, nil), - }}, &hst.AppError{ - Step: "obtain uid from hsu", - Err: ErrHsuAccess, - }}, - - {"invalid output", fCheckID, stub.Expect{Calls: []stub.Call{ - call("mustHsuPath", stub.ExpectArgs{}, m("/run/wrappers/bin/hsu"), nil), - call("cmdOutput", stub.ExpectArgs{"/run/wrappers/bin/hsu", os.Stderr, []string{}, "/"}, []byte{0}, nil), - call("verbose", stub.ExpectArgs{[]any{0}}, nil, nil), - }}, &hst.AppError{ - Step: "obtain uid from hsu", - Err: &strconv.NumError{Func: "Atoi", Num: "\x00", Err: strconv.ErrSyntax}, - Msg: "invalid uid string from hsu", - }}, - - {"success", fCheckID, stub.Expect{Calls: []stub.Call{ - call("mustHsuPath", stub.ExpectArgs{}, m("/run/wrappers/bin/hsu"), nil), - call("cmdOutput", stub.ExpectArgs{"/run/wrappers/bin/hsu", os.Stderr, []string{}, "/"}, []byte{'0'}, nil), - call("verbose", stub.ExpectArgs{[]any{0}}, nil, nil), - }}, nil}, - }) -} - -// makeExitError populates syscall.WaitStatus in an [exec.ExitError]. -// Do not reuse this function in a cross-platform package. -func makeExitError(status syscall.WaitStatus) error { - ps := new(os.ProcessState) - statusV := reflect.ValueOf(ps).Elem().FieldByName("status") - *reflect.NewAt(statusV.Type(), unsafe.Pointer(statusV.UnsafeAddr())).Interface().(*syscall.WaitStatus) = status - return &exec.ExitError{ProcessState: ps} -} diff --git a/internal/app/outcome.go b/internal/app/outcome.go deleted file mode 100644 index 59cdfbd8..00000000 --- a/internal/app/outcome.go +++ /dev/null @@ -1,302 +0,0 @@ -package app - -import ( - "errors" - "maps" - "strconv" - - "hakurei.app/container" - "hakurei.app/container/check" - "hakurei.app/hst" - "hakurei.app/internal/env" - "hakurei.app/message" - "hakurei.app/system" - "hakurei.app/system/acl" -) - -// envAllocSize is the initial size of the env map pre-allocated when the configured env map is nil. -// It should be large enough to fit all insertions by outcomeOp.toContainer. -const envAllocSize = 1 << 6 - -func newInt(v int) *stringPair[int] { return &stringPair[int]{v, strconv.Itoa(v)} } - -// stringPair stores a value and its string representation. -type stringPair[T comparable] struct { - v T - s string -} - -func (s *stringPair[T]) unwrap() T { return s.v } -func (s *stringPair[T]) String() string { return s.s } - -// outcomeState is copied to the shim process and available while applying outcomeOp. -// This is transmitted from the priv side to the shim, so exported fields should be kept to a minimum. -type outcomeState struct { - // Params only used by the shim process. Populated by populateEarly. - Shim *shimParams - - // Generated and accounted for by the caller. - ID *hst.ID - // Copied from ID. - id *stringPair[hst.ID] - - // Copied from the [hst.Config] field of the same name. - Identity int - // Copied from Identity. - identity *stringPair[int] - // Returned by [Hsu.MustID]. - UserID int - // Target init namespace uid resolved from UserID and identity. - uid *stringPair[int] - - // Included as part of [hst.Config], transmitted as-is unless permissive defaults. - Container *hst.ContainerConfig - - // Mapped credentials within container user namespace. - Mapuid, Mapgid int - // Copied from their respective exported values. - mapuid, mapgid *stringPair[int] - - // Copied from [EnvPaths] per-process. - sc hst.Paths - *env.Paths - - // Copied via populateLocal. - k syscallDispatcher - // Copied via populateLocal. - msg message.Msg -} - -// valid checks outcomeState to be safe for use with outcomeOp. -func (s *outcomeState) valid() bool { - return s != nil && - s.Shim.valid() && - s.ID != nil && - s.Container != nil && - s.Paths != nil -} - -// newOutcomeState returns the address of a new outcomeState with its exported fields populated via syscallDispatcher. -func newOutcomeState(k syscallDispatcher, msg message.Msg, id *hst.ID, config *hst.Config, hsu *Hsu) *outcomeState { - s := outcomeState{ - Shim: &shimParams{PrivPID: k.getpid(), Verbose: msg.IsVerbose()}, - ID: id, - Identity: config.Identity, - UserID: hsu.MustID(msg), - Paths: env.CopyPathsFunc(k.fatalf, k.tempdir, func(key string) string { v, _ := k.lookupEnv(key); return v }), - Container: config.Container, - } - - // enforce bounds and default early - if s.Container.WaitDelay < 0 { - s.Shim.WaitDelay = 0 - } else if s.Container.WaitDelay == 0 { - s.Shim.WaitDelay = hst.WaitDelayDefault - } else if s.Container.WaitDelay > hst.WaitDelayMax { - s.Shim.WaitDelay = hst.WaitDelayMax - } else { - s.Shim.WaitDelay = s.Container.WaitDelay - } - - if s.Container.Flags&hst.FMapRealUID != 0 { - s.Mapuid, s.Mapgid = k.getuid(), k.getgid() - } else { - s.Mapuid, s.Mapgid = k.overflowUid(msg), k.overflowGid(msg) - } - - return &s -} - -// populateLocal populates unexported fields from transmitted exported fields. -// These fields are cheaper to recompute per-process. -func (s *outcomeState) populateLocal(k syscallDispatcher, msg message.Msg) error { - if !s.valid() || k == nil || msg == nil { - return newWithMessage("impossible outcome state reached") - } - - if s.k != nil || s.msg != nil { - panic("attempting to call populateLocal twice") - } - s.k = k - s.msg = msg - - s.id = &stringPair[hst.ID]{*s.ID, s.ID.String()} - - s.Copy(&s.sc, s.UserID) - msg.Verbosef("process share directory at %q, runtime directory at %q", s.sc.SharePath, s.sc.RunDirPath) - - s.identity = newInt(s.Identity) - s.mapuid, s.mapgid = newInt(s.Mapuid), newInt(s.Mapgid) - s.uid = newInt(HsuUid(s.UserID, s.identity.unwrap())) - - return nil -} - -// instancePath returns a path formatted for outcomeStateSys.instance. -// This method must only be called from outcomeOp.toContainer if -// outcomeOp.toSystem has already called outcomeStateSys.instance. -func (s *outcomeState) instancePath() *check.Absolute { return s.sc.SharePath.Append(s.id.String()) } - -// runtimePath returns a path formatted for outcomeStateSys.runtime. -// This method must only be called from outcomeOp.toContainer if -// outcomeOp.toSystem has already called outcomeStateSys.runtime. -func (s *outcomeState) runtimePath() *check.Absolute { return s.sc.RunDirPath.Append(s.id.String()) } - -// outcomeStateSys wraps outcomeState and [system.I]. Used on the priv side only. -// Implementations of outcomeOp must not access fields other than sys unless explicitly stated. -type outcomeStateSys struct { - // Whether XDG_RUNTIME_DIR is used post hsu. - useRuntimeDir bool - // Process-specific directory in TMPDIR, nil if unused. - sharePath *check.Absolute - // Process-specific directory in XDG_RUNTIME_DIR, nil if unused. - runtimeSharePath *check.Absolute - - // Copied from [hst.Config]. Safe for read by outcomeOp.toSystem. - appId string - // Copied from [hst.Config]. Safe for read by outcomeOp.toSystem. - et hst.Enablement - - // Copied from [hst.Config]. Safe for read by spWaylandOp.toSystem only. - directWayland bool - // Copied header from [hst.Config]. Safe for read by spFilesystemOp.toSystem only. - extraPerms []hst.ExtraPermConfig - // Copied address from [hst.Config]. Safe for read by spDBusOp.toSystem only. - sessionBus, systemBus *hst.BusConfig - - sys *system.I - *outcomeState -} - -// newSys returns the address of a new outcomeStateSys embedding the current outcomeState. -func (s *outcomeState) newSys(config *hst.Config, sys *system.I) *outcomeStateSys { - return &outcomeStateSys{ - appId: config.ID, et: config.Enablements.Unwrap(), - directWayland: config.DirectWayland, extraPerms: config.ExtraPerms, - sessionBus: config.SessionBus, systemBus: config.SystemBus, - sys: sys, outcomeState: s, - } -} - -// newParams returns the address of a new outcomeStateParams embedding the current outcomeState. -func (s *outcomeState) newParams() *outcomeStateParams { - stateParams := outcomeStateParams{params: new(container.Params), outcomeState: s} - if s.Container.Env == nil { - stateParams.env = make(map[string]string, envAllocSize) - } else { - stateParams.env = maps.Clone(s.Container.Env) - } - return &stateParams -} - -// ensureRuntimeDir must be called if access to paths within XDG_RUNTIME_DIR is required. -func (state *outcomeStateSys) ensureRuntimeDir() { - if state.useRuntimeDir { - return - } - state.useRuntimeDir = true - state.sys.Ensure(state.sc.RunDirPath, 0700) - state.sys.UpdatePermType(system.User, state.sc.RunDirPath, acl.Execute) - state.sys.Ensure(state.sc.RuntimePath, 0700) // ensure this dir in case XDG_RUNTIME_DIR is unset - state.sys.UpdatePermType(system.User, state.sc.RuntimePath, acl.Execute) -} - -// instance returns the pathname to a process-specific directory within TMPDIR. -// This directory must only hold entries bound to [system.Process]. -func (state *outcomeStateSys) instance() *check.Absolute { - if state.sharePath != nil { - return state.sharePath - } - state.sharePath = state.instancePath() - state.sys.Ephemeral(system.Process, state.sharePath, 0711) - return state.sharePath -} - -// runtime returns the pathname to a process-specific directory within XDG_RUNTIME_DIR. -// This directory must only hold entries bound to [system.Process]. -func (state *outcomeStateSys) runtime() *check.Absolute { - if state.runtimeSharePath != nil { - return state.runtimeSharePath - } - state.ensureRuntimeDir() - state.runtimeSharePath = state.runtimePath() - state.sys.Ephemeral(system.Process, state.runtimeSharePath, 0700) - state.sys.UpdatePerm(state.runtimeSharePath, acl.Execute) - return state.runtimeSharePath -} - -// outcomeStateParams wraps outcomeState and [container.Params]. Used on the shim side only. -type outcomeStateParams struct { - // Overrides the embedded [container.Params] in [container.Container]. The Env field must not be used. - params *container.Params - // Collapsed into the Env slice in [container.Params] by the final outcomeOp. - env map[string]string - - // Filesystems with the optional root sliced off if present. Populated by spParamsOp. - // Safe for use by spFilesystemOp. - filesystem []hst.FilesystemConfigJSON - - // Inner XDG_RUNTIME_DIR default formatting of `/run/user/%d` via mapped uid. - // Populated by spRuntimeOp. - runtimeDir *check.Absolute - - as hst.ApplyState - *outcomeState -} - -// errNotEnabled is returned by outcomeOp.toSystem and used internally to exclude an outcomeOp from transmission. -var errNotEnabled = errors.New("op not enabled in the configuration") - -// An outcomeOp inflicts an outcome on [system.I] and contains enough information to -// inflict it on [container.Params] in a separate process. -// An implementation of outcomeOp must store cross-process states in exported fields only. -type outcomeOp interface { - // toSystem inflicts the current outcome on [system.I] in the priv side process. - toSystem(state *outcomeStateSys) error - - // toContainer inflicts the current outcome on [container.Params] in the shim process. - // The implementation must not write to the Env field of [container.Params] as it will be overwritten - // by flattened env map. - toContainer(state *outcomeStateParams) error -} - -// toSystem calls the outcomeOp.toSystem method on all outcomeOp implementations and populates shimParams.Ops. -// This function assumes the caller has already called the Validate method on [hst.Config] -// and checked that it returns nil. -func (state *outcomeStateSys) toSystem() error { - if state.Shim == nil || state.Shim.Ops != nil { - return newWithMessage("invalid ops state reached") - } - - ops := [...]outcomeOp{ - // must run first - &spParamsOp{}, - - &spRuntimeOp{}, - spTmpdirOp{}, - spAccountOp{}, - - // optional via enablements - &spWaylandOp{}, - &spX11Op{}, - &spPulseOp{}, - &spDBusOp{}, - - // must run last - &spFilesystemOp{}, - } - - state.Shim.Ops = make([]outcomeOp, 0, len(ops)) - for _, op := range ops { - if err := op.toSystem(state); err != nil { - // this error is used internally to exclude this outcomeOp from transmission - if errors.Is(err, errNotEnabled) { - continue - } - - return err - } - state.Shim.Ops = append(state.Shim.Ops, op) - } - return nil -} diff --git a/internal/app/outcome_test.go b/internal/app/outcome_test.go deleted file mode 100644 index 5ea3ff99..00000000 --- a/internal/app/outcome_test.go +++ /dev/null @@ -1,34 +0,0 @@ -package app - -import ( - "testing" - - "hakurei.app/hst" - "hakurei.app/internal/env" -) - -func TestOutcomeStateValid(t *testing.T) { - t.Parallel() - - testCases := []struct { - name string - s *outcomeState - want bool - }{ - {"nil", nil, false}, - {"zero", new(outcomeState), false}, - {"shim", &outcomeState{Shim: &shimParams{PrivPID: -1, Ops: []outcomeOp{}}, Container: new(hst.ContainerConfig), Paths: new(env.Paths)}, false}, - {"id", &outcomeState{Shim: &shimParams{PrivPID: 1, Ops: []outcomeOp{}}, Container: new(hst.ContainerConfig), Paths: new(env.Paths)}, false}, - {"container", &outcomeState{Shim: &shimParams{PrivPID: 1, Ops: []outcomeOp{}}, ID: new(hst.ID), Paths: new(env.Paths)}, false}, - {"envpaths", &outcomeState{Shim: &shimParams{PrivPID: 1, Ops: []outcomeOp{}}, ID: new(hst.ID), Container: new(hst.ContainerConfig)}, false}, - {"valid", &outcomeState{Shim: &shimParams{PrivPID: 1, Ops: []outcomeOp{}}, ID: new(hst.ID), Container: new(hst.ContainerConfig), Paths: new(env.Paths)}, true}, - } - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - if got := tc.s.valid(); got != tc.want { - t.Errorf("valid: %v, want %v", got, tc.want) - } - }) - } -} diff --git a/internal/app/process.go b/internal/app/process.go deleted file mode 100644 index 7873109a..00000000 --- a/internal/app/process.go +++ /dev/null @@ -1,321 +0,0 @@ -package app - -import ( - "context" - "encoding/gob" - "errors" - "log" - "os" - "os/exec" - "strconv" - "strings" - "syscall" - "time" - - "hakurei.app/container" - "hakurei.app/container/fhs" - "hakurei.app/hst" - "hakurei.app/internal" - "hakurei.app/internal/app/state" - "hakurei.app/message" - "hakurei.app/system" -) - -// Duration to wait for shim to exit on top of container WaitDelay. -const shimWaitTimeout = 5 * time.Second - -// mainState holds persistent state bound to outcome.main. -type mainState struct { - // done is whether beforeExit has been called already. - done bool - - // Time is the exact point in time where the process was created. - // Location must be set to UTC. - // - // Time is nil if no process was ever created. - Time *time.Time - - store state.Store - cancel context.CancelFunc - cmd *exec.Cmd - cmdWait chan error - - k *outcome - message.Msg - uintptr -} - -const ( - // mainNeedsRevert indicates the call to Commit has succeeded. - mainNeedsRevert uintptr = 1 << iota - // mainNeedsDestroy indicates the instance state entry is present in the store. - mainNeedsDestroy -) - -// beforeExit must be called immediately before a call to [os.Exit]. -func (ms mainState) beforeExit(isFault bool) { - if ms.done { - panic("attempting to call beforeExit twice") - } - ms.done = true - defer ms.BeforeExit() - - if isFault && ms.cancel != nil { - ms.cancel() - } - - var hasErr bool - // updates hasErr but does not terminate - perror := func(err error, message string) { - hasErr = true - printMessageError("cannot "+message+":", err) - } - exitCode := 1 - defer func() { - if hasErr { - os.Exit(exitCode) - } - }() - - // this also handles wait for a non-fault termination - if ms.cmd != nil && ms.cmdWait != nil { - waitDone := make(chan struct{}) - - // this ties waitDone to ctx with the additional compensated timeout duration - go func() { <-ms.k.ctx.Done(); time.Sleep(ms.k.state.Shim.WaitDelay + shimWaitTimeout); close(waitDone) }() - - select { - case err := <-ms.cmdWait: - wstatus, ok := ms.cmd.ProcessState.Sys().(syscall.WaitStatus) - if ok { - if v := wstatus.ExitStatus(); v != 0 { - hasErr = true - exitCode = v - } - } - - if ms.IsVerbose() { - if !ok { - if err != nil { - ms.Verbosef("wait: %v", err) - } - } else { - switch { - case wstatus.Exited(): - ms.Verbosef("process %d exited with code %d", ms.cmd.Process.Pid, wstatus.ExitStatus()) - - case wstatus.CoreDump(): - ms.Verbosef("process %d dumped core", ms.cmd.Process.Pid) - - case wstatus.Signaled(): - ms.Verbosef("process %d got %s", ms.cmd.Process.Pid, wstatus.Signal()) - - default: - ms.Verbosef("process %d exited with status %#x", ms.cmd.Process.Pid, wstatus) - } - } - } - - case <-waitDone: - ms.Resume() - // this is only reachable when shim did not exit within shimWaitTimeout, after its WaitDelay has elapsed. - // This is different from the container failing to terminate within its timeout period, as that is enforced - // by the shim. This path is instead reached when there is a lockup in shim preventing it from completing. - log.Printf("process %d did not terminate", ms.cmd.Process.Pid) - } - - ms.Resume() - } - - if ms.uintptr&mainNeedsRevert != 0 { - if ok, err := ms.store.Do(ms.k.state.identity.unwrap(), func(c state.Cursor) { - if ms.uintptr&mainNeedsDestroy != 0 { - if err := c.Destroy(ms.k.state.id.unwrap()); err != nil { - perror(err, "destroy state entry") - } - } - - var rt hst.Enablement - if states, err := c.Load(); err != nil { - // it is impossible to continue from this point; - // revert per-process state here to limit damage - ec := system.Process - if revertErr := ms.k.sys.Revert((*system.Criteria)(&ec)); revertErr != nil { - var joinError interface { - Unwrap() []error - error - } - if !errors.As(revertErr, &joinError) || joinError == nil { - perror(revertErr, "revert system setup") - } else { - for _, v := range joinError.Unwrap() { - perror(v, "revert system setup step") - } - } - } - perror(err, "load instance states") - } else { - ec := system.Process - if l := len(states); l == 0 { - ec |= system.User - } else { - ms.Verbosef("found %d instances, cleaning up without user-scoped operations", l) - } - - // accumulate enablements of remaining launchers - for i, s := range states { - if s.Config != nil { - rt |= s.Config.Enablements.Unwrap() - } else { - log.Printf("state entry %d does not contain config", i) - } - } - - ec |= rt ^ (hst.EWayland | hst.EX11 | hst.EDBus | hst.EPulse) - if ms.IsVerbose() { - if ec > 0 { - ms.Verbose("reverting operations scope", system.TypeString(ec)) - } - } - - if err = ms.k.sys.Revert((*system.Criteria)(&ec)); err != nil { - perror(err, "revert system setup") - } - } - }); err != nil { - if ok { - perror(err, "unlock state store") - } else { - perror(err, "open state store") - } - } - } else if ms.uintptr&mainNeedsDestroy != 0 { - panic("unreachable") - } -} - -// fatal calls printMessageError, performs necessary cleanup, followed by a call to [os.Exit](1). -func (ms mainState) fatal(fallback string, ferr error) { - printMessageError(fallback, ferr) - ms.beforeExit(true) - os.Exit(1) -} - -// main carries out outcome and terminates. main does not return. -func (k *outcome) main(msg message.Msg) { - if !k.active.CompareAndSwap(false, true) { - panic("outcome: attempted to run twice") - } - - if k.ctx == nil || k.sys == nil || k.state == nil { - panic("outcome: did not finalise") - } - - // read comp value early for early failure - hsuPath := internal.MustHsuPath() - - // ms.beforeExit required beyond this point - ms := &mainState{Msg: msg, k: k} - - if err := k.sys.Commit(); err != nil { - ms.fatal("cannot commit system setup:", err) - } - ms.uintptr |= mainNeedsRevert - ms.store = state.NewMulti(msg, k.state.sc.RunDirPath) - - ctx, cancel := context.WithCancel(k.ctx) - defer cancel() - ms.cancel = cancel - - ms.cmd = exec.CommandContext(ctx, hsuPath.String()) - ms.cmd.Stdin, ms.cmd.Stdout, ms.cmd.Stderr = os.Stdin, os.Stdout, os.Stderr - ms.cmd.Dir = fhs.Root // container init enters final working directory - // shim runs in the same session as monitor; see shim.go for behaviour - ms.cmd.Cancel = func() error { return ms.cmd.Process.Signal(syscall.SIGCONT) } - - var e *gob.Encoder - if fd, encoder, err := container.Setup(&ms.cmd.ExtraFiles); err != nil { - ms.fatal("cannot create shim setup pipe:", err) - } else { - e = encoder - ms.cmd.Env = []string{ - // passed through to shim by hsu - shimEnv + "=" + strconv.Itoa(fd), - // interpreted by hsu - "HAKUREI_IDENTITY=" + k.state.identity.String(), - } - } - - if len(k.supp) > 0 { - msg.Verbosef("attaching supplementary group ids %s", k.supp) - // interpreted by hsu - ms.cmd.Env = append(ms.cmd.Env, "HAKUREI_GROUPS="+strings.Join(k.supp, " ")) - } - - msg.Verbosef("setuid helper at %s", hsuPath) - msg.Suspend() - if err := ms.cmd.Start(); err != nil { - ms.fatal("cannot start setuid wrapper:", err) - } - startTime := time.Now().UTC() - ms.cmdWait = make(chan error, 1) - // this ties context back to the life of the process - go func() { ms.cmdWait <- ms.cmd.Wait(); cancel() }() - ms.Time = &startTime - - // unfortunately the I/O here cannot be directly canceled; - // the cancellation path leads to fatal in this case so that is fine - select { - case err := <-func() (setupErr chan error) { - setupErr = make(chan error, 1) - go func() { setupErr <- e.Encode(k.state) }() - return - }(): - if err != nil { - msg.Resume() - ms.fatal("cannot transmit shim config:", err) - } - - case <-ctx.Done(): - msg.Resume() - ms.fatal("shim context canceled:", newWithMessageError("shim setup canceled", ctx.Err())) - } - - // shim accepted setup payload, create process state - if ok, err := ms.store.Do(k.state.identity.unwrap(), func(c state.Cursor) { - if err := c.Save(&hst.State{ - ID: k.state.id.unwrap(), - PID: os.Getpid(), - ShimPID: ms.cmd.Process.Pid, - Config: k.config, - Time: *ms.Time, - }); err != nil { - ms.fatal("cannot save state entry:", err) - } - }); err != nil { - if ok { - ms.uintptr |= mainNeedsDestroy - ms.fatal("cannot unlock state store:", err) - } else { - ms.fatal("cannot open state store:", err) - } - } - // state in store at this point, destroy defunct state entry on termination - ms.uintptr |= mainNeedsDestroy - - // beforeExit ties shim process to context - ms.beforeExit(false) - os.Exit(0) -} - -// printMessageError prints the error message according to [message.GetMessage], -// or fallback prepended to err if an error message is not available. -func printMessageError(fallback string, err error) { - m, ok := message.GetMessage(err) - if !ok { - log.Println(fallback, err) - return - } - - log.Print(m) -} diff --git a/internal/app/shim-signal.c b/internal/app/shim-signal.c deleted file mode 100644 index fc005347..00000000 --- a/internal/app/shim-signal.c +++ /dev/null @@ -1,65 +0,0 @@ -#include "shim-signal.h" -#include -#include -#include -#include -#include - -static pid_t hakurei_shim_param_ppid = -1; -static int hakurei_shim_fd = -1; - -static ssize_t hakurei_shim_write(const void *buf, size_t count) { - int savedErrno = errno; - ssize_t ret = write(hakurei_shim_fd, buf, count); - if (ret == -1 && errno != EAGAIN) - exit(EXIT_FAILURE); - errno = savedErrno; - return ret; -} - -/* see shim_linux.go for handling of the value */ -static void hakurei_shim_sigaction(int sig, siginfo_t *si, void *ucontext) { - if (sig != SIGCONT || si == NULL) { - /* unreachable */ - hakurei_shim_write("\2", 1); - return; - } - - if (si->si_pid == hakurei_shim_param_ppid) { - /* monitor requests shim exit */ - hakurei_shim_write("\0", 1); - return; - } - - /* unexpected si_pid */ - hakurei_shim_write("\3", 1); - - if (getppid() != hakurei_shim_param_ppid) - /* shim orphaned before monitor delivers a signal */ - hakurei_shim_write("\1", 1); -} - -void hakurei_shim_setup_cont_signal(pid_t ppid, int fd) { - if (hakurei_shim_param_ppid != -1 || hakurei_shim_fd != -1) - *(int *)NULL = 0; /* unreachable */ - - struct sigaction new_action = {0}, old_action = {0}; - if (sigaction(SIGCONT, NULL, &old_action) != 0) - return; - if (old_action.sa_handler != SIG_DFL) { - errno = ENOTRECOVERABLE; - return; - } - - new_action.sa_sigaction = hakurei_shim_sigaction; - if (sigemptyset(&new_action.sa_mask) != 0) - return; - new_action.sa_flags = SA_ONSTACK | SA_SIGINFO; - - if (sigaction(SIGCONT, &new_action, NULL) != 0) - return; - - errno = 0; - hakurei_shim_param_ppid = ppid; - hakurei_shim_fd = fd; -} diff --git a/internal/app/shim-signal.h b/internal/app/shim-signal.h deleted file mode 100644 index bdeae3b0..00000000 --- a/internal/app/shim-signal.h +++ /dev/null @@ -1,3 +0,0 @@ -#include - -void hakurei_shim_setup_cont_signal(pid_t ppid, int fd); diff --git a/internal/app/shim.go b/internal/app/shim.go deleted file mode 100644 index 11affddd..00000000 --- a/internal/app/shim.go +++ /dev/null @@ -1,235 +0,0 @@ -package app - -import ( - "context" - "errors" - "io" - "log" - "os" - "os/exec" - "runtime" - "sync/atomic" - "syscall" - "time" - - "hakurei.app/container" - "hakurei.app/container/comp" - "hakurei.app/container/seccomp" - "hakurei.app/hst" - "hakurei.app/message" -) - -//#include "shim-signal.h" -import "C" - -// setupContSignal sets up the SIGCONT signal handler for the cross-uid shim exit hack. -// The signal handler is implemented in C, signals can be processed by reading from the returned reader. -// The returned function must be called after all signal processing concludes. -func setupContSignal(pid int) (io.ReadCloser, func(), error) { - if r, w, err := os.Pipe(); err != nil { - return nil, nil, err - } else if _, err = C.hakurei_shim_setup_cont_signal(C.pid_t(pid), C.int(w.Fd())); err != nil { - _, _ = r.Close(), w.Close() - return nil, nil, err - } else { - return r, func() { runtime.KeepAlive(w) }, nil - } -} - -// shimEnv is the name of the environment variable storing decimal representation of -// setup pipe fd for [container.Receive]. -const shimEnv = "HAKUREI_SHIM" - -// shimParams is embedded in outcomeState and transmitted from priv side to shim. -type shimParams struct { - // Priv side pid, checked against ppid in signal handler for the syscall.SIGCONT hack. - PrivPID int - - // Duration to wait for after the initial process receives os.Interrupt before the container is killed. - // Limits are enforced on the priv side. - WaitDelay time.Duration - - // Verbosity pass through from [message.Msg]. - Verbose bool - - // Outcome setup ops, contains setup state. Populated by outcome.finalise. - Ops []outcomeOp -} - -// valid checks shimParams to be safe for use. -func (p *shimParams) valid() bool { return p != nil && p.PrivPID > 0 } - -// shimName is the prefix used by log.std in the shim process. -const shimName = "shim" - -// Shim is called by the main function of the shim process and runs as the unconstrained target user. -// Shim does not return. -func Shim(msg message.Msg) { - if msg == nil { - msg = message.NewMsg(log.Default()) - } - shimEntrypoint(direct{msg}) -} - -func shimEntrypoint(k syscallDispatcher) { - msg := k.getMsg() - if msg == nil { - panic("attempting to call shimEntrypoint with nil msg") - } else if logger := msg.GetLogger(); logger != nil { - logger.SetPrefix(shimName + ": ") - logger.SetFlags(0) - } - - if err := k.setDumpable(container.SUID_DUMP_DISABLE); err != nil { - k.fatalf("cannot set SUID_DUMP_DISABLE: %s", err) - } - - var ( - state outcomeState - closeSetup func() error - ) - if f, err := k.receive(shimEnv, &state, nil); err != nil { - if errors.Is(err, syscall.EBADF) { - k.fatal("invalid config descriptor") - } - if errors.Is(err, container.ErrReceiveEnv) { - k.fatal(shimEnv + " not set") - } - - k.fatalf("cannot receive shim setup params: %v", err) - } else { - msg.SwapVerbose(state.Shim.Verbose) - closeSetup = f - - if err = state.populateLocal(k, msg); err != nil { - if m, ok := message.GetMessage(err); ok { - k.fatal(m) - } else { - k.fatalf("cannot populate local state: %v", err) - } - } - } - - // the Go runtime does not expose siginfo_t so SIGCONT is handled in C to check si_pid - var signalPipe io.ReadCloser - if r, wKeepAlive, err := k.setupContSignal(state.Shim.PrivPID); err != nil { - switch { - case errors.As(err, new(*os.SyscallError)): // returned by os.Pipe - k.fatal(err.Error()) - return - - case errors.As(err, new(syscall.Errno)): // returned by hakurei_shim_setup_cont_signal - k.fatalf("cannot install SIGCONT handler: %v", err) - return - - default: // unreachable - k.fatalf("cannot set up exit request: %v", err) - return - } - - } else { - defer wKeepAlive() - signalPipe = r - } - - // pdeath_signal delivery is checked as if the dying process called kill(2), see kernel/exit.c - if err := k.prctl(syscall.PR_SET_PDEATHSIG, uintptr(syscall.SIGCONT), 0); err != nil { - k.fatalf("cannot set parent-death signal: %v", err) - } - - stateParams := state.newParams() - for _, op := range state.Shim.Ops { - if err := op.toContainer(stateParams); err != nil { - if m, ok := message.GetMessage(err); ok { - k.fatal(m) - } else { - k.fatalf("cannot create container state: %v", err) - } - } - } - - // shim exit outcomes - var cancelContainer atomic.Pointer[context.CancelFunc] - k.new(func(k syscallDispatcher, msg message.Msg) { - buf := make([]byte, 1) - for { - if _, err := signalPipe.Read(buf); err != nil { - k.fatalf("cannot read from signal pipe: %v", err) - } - - switch buf[0] { - case 0: // got SIGCONT from monitor: shim exit requested - if fp := cancelContainer.Load(); stateParams.params.ForwardCancel && fp != nil && *fp != nil { - (*fp)() - // shim now bound by ShimWaitDelay, implemented below - continue - } - - // setup has not completed, terminate immediately - msg.Resume() - k.exit(hst.ExitRequest) - return - - case 1: // got SIGCONT after adoption: monitor died before delivering signal - msg.BeforeExit() - k.exit(hst.ExitOrphan) - return - - case 2: // unreachable - msg.Verbose("sa_sigaction got invalid siginfo") - - case 3: // got SIGCONT from unexpected process: hopefully the terminal driver - msg.Verbose("got SIGCONT from unexpected process") - - default: // unreachable - k.fatalf("got invalid message %d from signal handler", buf[0]) - } - } - }) - - if stateParams.params.Ops == nil { - k.fatal("invalid container params") - } - - // close setup socket - if err := closeSetup(); err != nil { - msg.Verbosef("cannot close setup pipe: %v", err) - // not fatal - } - - ctx, stop := k.notifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - cancelContainer.Store(&stop) - z := container.New(ctx, msg) - z.Params = *stateParams.params - z.Stdin, z.Stdout, z.Stderr = os.Stdin, os.Stdout, os.Stderr - - // bounds and default enforced in finalise.go - z.WaitDelay = state.Shim.WaitDelay - - if err := k.containerStart(z); err != nil { - printMessageError("cannot start container:", err) - k.exit(hst.ExitFailure) - } - if err := k.containerServe(z); err != nil { - printMessageError("cannot configure container:", err) - } - - if err := k.seccompLoad( - seccomp.Preset(comp.PresetStrict, seccomp.AllowMultiarch), - seccomp.AllowMultiarch, - ); err != nil { - k.fatalf("cannot load syscall filter: %v", err) - } - - if err := k.containerWait(z); err != nil { - var exitError *exec.ExitError - if !errors.As(err, &exitError) { - if errors.Is(err, context.Canceled) { - k.exit(hst.ExitCancel) - } - msg.Verbosef("cannot wait: %v", err) - k.exit(127) - } - k.exit(exitError.ExitCode()) - } -} diff --git a/internal/app/shim_test.go b/internal/app/shim_test.go deleted file mode 100644 index 19dedd6f..00000000 --- a/internal/app/shim_test.go +++ /dev/null @@ -1,156 +0,0 @@ -package app - -import ( - "bytes" - "context" - "log" - "os" - "syscall" - "testing" - - "hakurei.app/container" - "hakurei.app/container/comp" - "hakurei.app/container/fhs" - "hakurei.app/container/seccomp" - "hakurei.app/container/stub" - "hakurei.app/hst" - "hakurei.app/internal/env" -) - -func TestShimEntrypoint(t *testing.T) { - t.Parallel() - shimPreset := seccomp.Preset(comp.PresetStrict, seccomp.AllowMultiarch) - templateParams := &container.Params{ - Dir: m("/data/data/org.chromium.Chromium"), - Env: []string{ - "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus", - "DBUS_SYSTEM_BUS_ADDRESS=unix:path=/var/run/dbus/system_bus_socket", - "GOOGLE_API_KEY=AIzaSyBHDrl33hwRp4rMQY0ziRbj8K9LPA6vUCY", - "GOOGLE_DEFAULT_CLIENT_ID=77185425430.apps.googleusercontent.com", - "GOOGLE_DEFAULT_CLIENT_SECRET=OTJgUOQcT7lO7GsGZq2G4IlT", - "HOME=/data/data/org.chromium.Chromium", - "PULSE_COOKIE=/.hakurei/pulse-cookie", - "PULSE_SERVER=unix:/run/user/1000/pulse/native", - "SHELL=/run/current-system/sw/bin/zsh", - "TERM=xterm-256color", - "USER=chronos", - "WAYLAND_DISPLAY=wayland-0", - "XDG_RUNTIME_DIR=/run/user/1000", - "XDG_SESSION_CLASS=user", - "XDG_SESSION_TYPE=wayland", - }, - - // spParamsOp - Hostname: "localhost", - RetainSession: true, - HostNet: true, - HostAbstract: true, - ForwardCancel: true, - Path: m("/run/current-system/sw/bin/chromium"), - Args: []string{ - "chromium", - "--ignore-gpu-blocklist", - "--disable-smooth-scrolling", - "--enable-features=UseOzonePlatform", - "--ozone-platform=wayland", - }, - SeccompFlags: seccomp.AllowMultiarch, - Uid: 1000, - Gid: 100, - - Ops: new(container.Ops). - // resolveRoot - Root(m("/var/lib/hakurei/base/org.debian"), comp.BindWritable). - // spParamsOp - Proc(fhs.AbsProc). - Tmpfs(hst.AbsPrivateTmp, 1<<12, 0755). - Bind(fhs.AbsDev, fhs.AbsDev, comp.BindWritable|comp.BindDevice). - Tmpfs(fhs.AbsDev.Append("shm"), 0, 01777). - - // spRuntimeOp - Tmpfs(fhs.AbsRunUser, 1<<12, 0755). - Bind(m("/tmp/hakurei.10/runtime/9999"), m("/run/user/1000"), comp.BindWritable). - - // spTmpdirOp - Bind(m("/tmp/hakurei.10/tmpdir/9999"), fhs.AbsTmp, comp.BindWritable). - - // spAccountOp - Place(m("/etc/passwd"), []byte("chronos:x:1000:100:Hakurei:/data/data/org.chromium.Chromium:/run/current-system/sw/bin/zsh\n")). - Place(m("/etc/group"), []byte("hakurei:x:100:\n")). - - // spWaylandOp - Bind(m("/tmp/hakurei.10/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/wayland"), m("/run/user/1000/wayland-0"), 0). - - // spPulseOp - Bind(m("/run/user/1000/hakurei/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/pulse"), m("/run/user/1000/pulse/native"), 0). - Place(m("/.hakurei/pulse-cookie"), bytes.Repeat([]byte{0}, pulseCookieSizeMax)). - - // spDBusOp - Bind(m("/tmp/hakurei.10/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/bus"), m("/run/user/1000/bus"), 0). - Bind(m("/tmp/hakurei.10/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/system_bus_socket"), m("/var/run/dbus/system_bus_socket"), 0). - - // spFilesystemOp - Etc(fhs.AbsEtc, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"). - Tmpfs(fhs.AbsTmp, 0, 0755). - Overlay(m("/nix/store"), - fhs.AbsVarLib.Append("hakurei/nix/u0/org.chromium.Chromium/rw-store/upper"), - fhs.AbsVarLib.Append("hakurei/nix/u0/org.chromium.Chromium/rw-store/work"), - fhs.AbsVarLib.Append("hakurei/base/org.nixos/ro-store")). - Link(m("/run/current-system"), "/run/current-system", true). - Link(m("/run/opengl-driver"), "/run/opengl-driver", true). - Bind(fhs.AbsVarLib.Append("hakurei/u0/org.chromium.Chromium"), - m("/data/data/org.chromium.Chromium"), - comp.BindWritable|comp.BindEnsure). - Bind(fhs.AbsDev.Append("dri"), fhs.AbsDev.Append("dri"), - comp.BindOptional|comp.BindWritable|comp.BindDevice). - Remount(fhs.AbsRoot, syscall.MS_RDONLY), - } - - checkSimple(t, "shimEntrypoint", []simpleTestCase{ - {"success", func(k *kstub) error { shimEntrypoint(k); return nil }, stub.Expect{Calls: []stub.Call{ - call("getMsg", stub.ExpectArgs{}, nil, nil), - call("getLogger", stub.ExpectArgs{}, (*log.Logger)(nil), nil), - call("setDumpable", stub.ExpectArgs{uintptr(container.SUID_DUMP_DISABLE)}, nil, nil), - call("receive", stub.ExpectArgs{"HAKUREI_SHIM", outcomeState{ - Shim: &shimParams{PrivPID: 0xbad, WaitDelay: 0xf, Verbose: true, Ops: []outcomeOp{ - &spParamsOp{"xterm-256color", true}, - &spRuntimeOp{sessionTypeWayland}, - spTmpdirOp{}, - spAccountOp{}, - &spWaylandOp{}, - &spPulseOp{(*[256]byte)(bytes.Repeat([]byte{0}, pulseCookieSizeMax)), pulseCookieSizeMax}, - &spDBusOp{true}, - &spFilesystemOp{}, - }}, - - ID: &checkExpectInstanceId, - Identity: hst.IdentityMax, - UserID: 10, - Container: hst.Template().Container, - Mapuid: 1000, - Mapgid: 100, - Paths: &env.Paths{TempDir: fhs.AbsTmp, RuntimePath: fhs.AbsRunUser.Append("1000")}, - }, nil}, nil, nil), - call("swapVerbose", stub.ExpectArgs{true}, false, nil), - call("verbosef", stub.ExpectArgs{"process share directory at %q, runtime directory at %q", []any{m("/tmp/hakurei.10"), m("/run/user/1000/hakurei")}}, nil, nil), - call("setupContSignal", stub.ExpectArgs{0xbad}, 0, nil), - call("prctl", stub.ExpectArgs{uintptr(syscall.PR_SET_PDEATHSIG), uintptr(syscall.SIGCONT), uintptr(0)}, nil, nil), - call("New", stub.ExpectArgs{}, nil, nil), - call("closeReceive", stub.ExpectArgs{}, nil, nil), - call("notifyContext", stub.ExpectArgs{context.Background(), []os.Signal{os.Interrupt, syscall.SIGTERM}}, nil, nil), - call("containerStart", stub.ExpectArgs{templateParams}, nil, nil), - call("containerServe", stub.ExpectArgs{templateParams}, nil, nil), - call("seccompLoad", stub.ExpectArgs{shimPreset, seccomp.AllowMultiarch}, nil, nil), - call("containerWait", stub.ExpectArgs{templateParams}, nil, nil), - - // deferred - call("wKeepAlive", stub.ExpectArgs{}, nil, nil), - }, Tracks: []stub.Expect{{Calls: []stub.Call{ - call("rcRead", stub.ExpectArgs{}, []byte{2}, nil), - call("verbose", stub.ExpectArgs{[]any{"sa_sigaction got invalid siginfo"}}, nil, nil), - call("rcRead", stub.ExpectArgs{}, []byte{3}, nil), - call("verbose", stub.ExpectArgs{[]any{"got SIGCONT from unexpected process"}}, nil, nil), - call("rcRead", stub.ExpectArgs{}, nil, nil), // stub terminates this goroutine - }}}}, nil}, - }) -} diff --git a/internal/app/spaccount.go b/internal/app/spaccount.go deleted file mode 100644 index c9f08fe9..00000000 --- a/internal/app/spaccount.go +++ /dev/null @@ -1,56 +0,0 @@ -package app - -import ( - "encoding/gob" - "fmt" - "syscall" - - "hakurei.app/container/fhs" - "hakurei.app/internal/validate" -) - -func init() { gob.Register(spAccountOp{}) } - -// spAccountOp sets up user account emulation inside the container. -type spAccountOp struct{} - -func (s spAccountOp) toSystem(state *outcomeStateSys) error { - // do checks here to fail before fork/exec - if state.Container == nil || state.Container.Home == nil || state.Container.Shell == nil { - // unreachable - return syscall.ENOTRECOVERABLE - } - - // default is applied in toContainer - if state.Container.Username != "" && !validate.IsValidUsername(state.Container.Username) { - return newWithMessage(fmt.Sprintf("invalid user name %q", state.Container.Username)) - } - return nil -} - -func (s spAccountOp) toContainer(state *outcomeStateParams) error { - const fallbackUsername = "chronos" - - username := state.Container.Username - if username == "" { - username = fallbackUsername - } - - state.params.Dir = state.Container.Home - state.env["HOME"] = state.Container.Home.String() - state.env["USER"] = username - state.env["SHELL"] = state.Container.Shell.String() - - state.params. - Place(fhs.AbsEtc.Append("passwd"), - []byte(username+":x:"+ - state.mapuid.String()+":"+ - state.mapgid.String()+ - ":Hakurei:"+ - state.Container.Home.String()+":"+ - state.Container.Shell.String()+"\n")). - Place(fhs.AbsEtc.Append("group"), - []byte("hakurei:x:"+state.mapgid.String()+":\n")) - - return nil -} diff --git a/internal/app/spaccount_test.go b/internal/app/spaccount_test.go deleted file mode 100644 index c5fb7b5b..00000000 --- a/internal/app/spaccount_test.go +++ /dev/null @@ -1,72 +0,0 @@ -package app - -import ( - "os" - "syscall" - "testing" - - "hakurei.app/container" - "hakurei.app/container/stub" - "hakurei.app/hst" -) - -func TestSpAccountOp(t *testing.T) { - t.Parallel() - config := hst.Template() - - checkOpBehaviour(t, []opBehaviourTestCase{ - {"invalid state", func(bool, bool) outcomeOp { return spAccountOp{} }, func() *hst.Config { - c := hst.Template() - c.Container.Shell = nil - return c - }, nil, []stub.Call{ - // this op performs basic validation and does not make calls during toSystem - }, nil, nil, syscall.ENOTRECOVERABLE, nil, nil, nil, nil, nil}, - - {"invalid user name", func(bool, bool) outcomeOp { return spAccountOp{} }, func() *hst.Config { - c := hst.Template() - c.Container.Username = "9" - return c - }, nil, []stub.Call{ - // this op performs basic validation and does not make calls during toSystem - }, nil, nil, &hst.AppError{ - Step: "finalise", - Err: os.ErrInvalid, - Msg: `invalid user name "9"`, - }, nil, nil, nil, nil, nil}, - - {"success fallback username", func(bool, bool) outcomeOp { return spAccountOp{} }, func() *hst.Config { - c := hst.Template() - c.Container.Username = "" - return c - }, nil, []stub.Call{ - // this op performs basic validation and does not make calls during toSystem - }, newI(), nil, nil, insertsOps(nil), []stub.Call{ - // this op configures the container state and does not make calls during toContainer - }, &container.Params{ - Dir: config.Container.Home, - Ops: new(container.Ops). - Place(m("/etc/passwd"), []byte("chronos:x:1000:100:Hakurei:/data/data/org.chromium.Chromium:/run/current-system/sw/bin/zsh\n")). - Place(m("/etc/group"), []byte("hakurei:x:100:\n")), - }, paramsWantEnv(config, map[string]string{ - "HOME": config.Container.Home.String(), - "USER": config.Container.Username, - "SHELL": config.Container.Shell.String(), - }, nil), nil}, - - {"success", func(bool, bool) outcomeOp { return spAccountOp{} }, hst.Template, nil, []stub.Call{ - // this op performs basic validation and does not make calls during toSystem - }, newI(), nil, nil, insertsOps(nil), []stub.Call{ - // this op configures the container state and does not make calls during toContainer - }, &container.Params{ - Dir: config.Container.Home, - Ops: new(container.Ops). - Place(m("/etc/passwd"), []byte("chronos:x:1000:100:Hakurei:/data/data/org.chromium.Chromium:/run/current-system/sw/bin/zsh\n")). - Place(m("/etc/group"), []byte("hakurei:x:100:\n")), - }, paramsWantEnv(config, map[string]string{ - "HOME": config.Container.Home.String(), - "USER": config.Container.Username, - "SHELL": config.Container.Shell.String(), - }, nil), nil}, - }) -} diff --git a/internal/app/spcontainer.go b/internal/app/spcontainer.go deleted file mode 100644 index d411cb9f..00000000 --- a/internal/app/spcontainer.go +++ /dev/null @@ -1,391 +0,0 @@ -package app - -import ( - "encoding/gob" - "errors" - "io/fs" - "os" - "path" - "slices" - "strconv" - "syscall" - - "hakurei.app/container" - "hakurei.app/container/check" - "hakurei.app/container/comp" - "hakurei.app/container/fhs" - "hakurei.app/container/seccomp" - "hakurei.app/hst" - "hakurei.app/internal/validate" - "hakurei.app/message" - "hakurei.app/system" - "hakurei.app/system/acl" - "hakurei.app/system/dbus" -) - -const varRunNscd = fhs.Var + "run/nscd" - -func init() { gob.Register(new(spParamsOp)) } - -// spParamsOp initialises unordered fields of [container.Params] and the optional root filesystem. -// This outcomeOp is hardcoded to always run first. -type spParamsOp struct { - // Value of $TERM, stored during toSystem. - Term string - // Whether $TERM is set, stored during toSystem. - TermSet bool -} - -func (s *spParamsOp) toSystem(state *outcomeStateSys) error { - s.Term, s.TermSet = state.k.lookupEnv("TERM") - state.sys.Ensure(state.sc.SharePath, 0711) - return nil -} - -func (s *spParamsOp) toContainer(state *outcomeStateParams) error { - // pass $TERM for proper terminal I/O in initial process - if s.TermSet { - state.env["TERM"] = s.Term - } - - // in practice there should be less than 30 system mount points - const preallocateOpsCount = 1 << 5 - - state.params.Hostname = state.Container.Hostname - state.params.RetainSession = state.Container.Flags&hst.FTty != 0 - state.params.HostNet = state.Container.Flags&hst.FHostNet != 0 - state.params.HostAbstract = state.Container.Flags&hst.FHostAbstract != 0 - - if state.Container.Path == nil { - return newWithMessage("invalid program path") - } - state.params.Path = state.Container.Path - - if len(state.Container.Args) == 0 { - state.params.Args = []string{state.Container.Path.String()} - } else { - state.params.Args = state.Container.Args - } - - // the container is canceled when shim is requested to exit or receives an interrupt or termination signal; - // this behaviour is implemented in the shim - state.params.ForwardCancel = state.Shim.WaitDelay > 0 - - if state.Container.Flags&hst.FMultiarch != 0 { - state.params.SeccompFlags |= seccomp.AllowMultiarch - } - - if state.Container.Flags&hst.FSeccompCompat == 0 { - state.params.SeccompPresets |= comp.PresetExt - } - if state.Container.Flags&hst.FDevel == 0 { - state.params.SeccompPresets |= comp.PresetDenyDevel - } - if state.Container.Flags&hst.FUserns == 0 { - state.params.SeccompPresets |= comp.PresetDenyNS - } - if state.Container.Flags&hst.FTty == 0 { - state.params.SeccompPresets |= comp.PresetDenyTTY - } - - if state.Container.Flags&hst.FMapRealUID != 0 { - state.params.Uid = state.Mapuid - state.params.Gid = state.Mapgid - } - - { - state.as.AutoEtcPrefix = state.id.String() - ops := make(container.Ops, 0, preallocateOpsCount+len(state.Container.Filesystem)) - state.params.Ops = &ops - state.as.Ops = opsAdapter{&ops} - } - - rootfs, filesystem, _ := resolveRoot(state.Container) - state.filesystem = filesystem - if rootfs != nil { - rootfs.Apply(&state.as) - } - - // early mount points - state.params. - Proc(fhs.AbsProc). - Tmpfs(hst.AbsPrivateTmp, 1<<12, 0755) - if state.Container.Flags&hst.FDevice == 0 { - state.params.DevWritable(fhs.AbsDev, true) - } else { - state.params.Bind(fhs.AbsDev, fhs.AbsDev, comp.BindWritable|comp.BindDevice) - } - // /dev is mounted readonly later on, this prevents /dev/shm from going readonly with it - state.params.Tmpfs(fhs.AbsDev.Append("shm"), 0, 01777) - - return nil -} - -func init() { gob.Register(new(spFilesystemOp)) } - -// spFilesystemOp applies configured filesystems to [container.Params], excluding the optional root filesystem. -// This outcomeOp is hardcoded to always run last. -type spFilesystemOp struct { - // Matched paths to cover. Stored during toSystem. - HidePaths []*check.Absolute -} - -func (s *spFilesystemOp) toSystem(state *outcomeStateSys) error { - /* retrieve paths and hide them if they're made available in the sandbox; - - this feature tries to improve user experience of permissive defaults, and - to warn about issues in custom configuration; it is NOT a security feature - and should not be treated as such, ALWAYS be careful with what you bind */ - hidePaths := []string{ - state.sc.RuntimePath.String(), - state.sc.SharePath.String(), - - // this causes emulated passwd database to be bypassed on some /etc/ setups - varRunNscd, - } - - // dbus.Address does not go through syscallDispatcher - systemBusAddr := dbus.FallbackSystemBusAddress - if addr, ok := state.k.lookupEnv(dbus.SystemBusAddress); ok { - systemBusAddr = addr - } - - if entries, err := dbus.Parse([]byte(systemBusAddr)); err != nil { - return &hst.AppError{Step: "parse dbus address", Err: err} - } else { - // there is usually only one, do not preallocate - for _, entry := range entries { - if entry.Method != "unix" { - continue - } - for _, pair := range entry.Values { - if pair[0] == "path" { - if path.IsAbs(pair[1]) { - // get parent dir of socket - dir := path.Dir(pair[1]) - if dir == "." || dir == fhs.Root { - state.msg.Verbosef("dbus socket %q is in an unusual location", pair[1]) - } - hidePaths = append(hidePaths, dir) - } else { - state.msg.Verbosef("dbus socket %q is not absolute", pair[1]) - } - } - } - } - } - hidePathMatch := make([]bool, len(hidePaths)) - for i := range hidePaths { - if err := evalSymlinks(state.msg, state.k, &hidePaths[i]); err != nil { - return &hst.AppError{Step: "evaluate path hiding target", Err: err} - } - } - - _, filesystem, autoroot := resolveRoot(state.Container) - - var hidePathSourceCount int - for i, c := range filesystem { - if !c.Valid() { - return newWithMessage("invalid filesystem at index " + strconv.Itoa(i)) - } - - // fs counter - hidePathSourceCount += len(c.Host()) - } - - // AutoRootOp is a collection of many BindMountOp internally - var autoRootEntries []fs.DirEntry - if autoroot != nil { - if d, err := state.k.readdir(autoroot.Source.String()); err != nil { - return &hst.AppError{Step: "access autoroot source", Err: err} - } else { - // autoroot counter - hidePathSourceCount += len(d) - autoRootEntries = d - } - } - - hidePathSource := make([]*check.Absolute, 0, hidePathSourceCount) - - // fs append - for _, c := range filesystem { - // all entries already checked above - hidePathSource = append(hidePathSource, c.Host()...) - } - - // autoroot append - if autoroot != nil { - for _, ent := range autoRootEntries { - name := ent.Name() - if container.IsAutoRootBindable(state.msg, name) { - hidePathSource = append(hidePathSource, autoroot.Source.Append(name)) - } - } - } - - // evaluated path, input path - hidePathSourceEval := make([][2]string, len(hidePathSource)) - for i, a := range hidePathSource { - if a == nil { - // unreachable - return newWithMessage("impossible path hiding state reached") - } - - hidePathSourceEval[i] = [2]string{a.String(), a.String()} - if err := evalSymlinks(state.msg, state.k, &hidePathSourceEval[i][0]); err != nil { - return &hst.AppError{Step: "evaluate path hiding source", Err: err} - } - } - - for _, p := range hidePathSourceEval { - for i := range hidePaths { - // skip matched entries - if hidePathMatch[i] { - continue - } - - if ok, err := validate.DeepContainsH(p[0], hidePaths[i]); err != nil { - return &hst.AppError{Step: "determine path hiding outcome", Err: err} - } else if ok { - hidePathMatch[i] = true - state.msg.Verbosef("hiding path %q from %q", hidePaths[i], p[1]) - } - } - } - - // copy matched paths for shim - for i, ok := range hidePathMatch { - if ok { - if a, err := check.NewAbs(hidePaths[i]); err != nil { - return newWithMessage("invalid path hiding candidate " + strconv.Quote(hidePaths[i])) - } else { - s.HidePaths = append(s.HidePaths, a) - } - } - } - - // append ExtraPerms last - flattenExtraPerms(state.sys, state.extraPerms) - return nil -} - -func (s *spFilesystemOp) toContainer(state *outcomeStateParams) error { - for i, c := range state.filesystem { - if !c.Valid() { - return newWithMessage("invalid filesystem at index " + strconv.Itoa(i)) - } - c.Apply(&state.as) - } - - for _, a := range s.HidePaths { - state.params.Tmpfs(a, 1<<13, 0755) - } - - // no more configured paths beyond this point - if state.Container.Flags&hst.FDevice == 0 { - state.params.Remount(fhs.AbsDev, syscall.MS_RDONLY) - } - state.params.Remount(fhs.AbsRoot, syscall.MS_RDONLY) - - state.params.Env = make([]string, 0, len(state.env)) - for key, value := range state.env { - // key validated early via hst - state.params.Env = append(state.params.Env, key+"="+value) - } - slices.Sort(state.params.Env) - - return nil -} - -// resolveRoot handles the root filesystem special case for [hst.FilesystemConfig] and additionally resolves autoroot -// as it requires special handling during path hiding. -func resolveRoot(c *hst.ContainerConfig) (rootfs hst.FilesystemConfig, filesystem []hst.FilesystemConfigJSON, autoroot *hst.FSBind) { - // root filesystem special case - filesystem = c.Filesystem - // valid happens late, so root gets it here - if len(filesystem) > 0 && filesystem[0].Valid() && filesystem[0].Path().String() == fhs.Root { - // if the first element targets /, it is inserted early and excluded from path hiding - rootfs = filesystem[0].FilesystemConfig - filesystem = filesystem[1:] - - // autoroot requires special handling during path hiding - if b, ok := rootfs.(*hst.FSBind); ok && b.IsAutoRoot() { - autoroot = b - } - } - return -} - -// evalSymlinks calls syscallDispatcher.evalSymlinks but discards errors unwrapping to [fs.ErrNotExist]. -func evalSymlinks(msg message.Msg, k syscallDispatcher, v *string) error { - if p, err := k.evalSymlinks(*v); err != nil { - if !errors.Is(err, fs.ErrNotExist) { - return err - } - msg.Verbosef("path %q does not yet exist", *v) - } else { - *v = p - } - return nil -} - -// flattenExtraPerms expands a slice of [hst.ExtraPermConfig] into [system.I]. -func flattenExtraPerms(sys *system.I, extraPerms []hst.ExtraPermConfig) { - for i := range extraPerms { - p := &extraPerms[i] - if p.Path == nil { - continue - } - - if p.Ensure { - sys.Ensure(p.Path, 0700) - } - - perms := make(acl.Perms, 0, 3) - if p.Read { - perms = append(perms, acl.Read) - } - if p.Write { - perms = append(perms, acl.Write) - } - if p.Execute { - perms = append(perms, acl.Execute) - } - sys.UpdatePermType(system.User, p.Path, perms...) - } -} - -// opsAdapter implements [hst.Ops] on [container.Ops]. -type opsAdapter struct{ *container.Ops } - -func (p opsAdapter) Tmpfs(target *check.Absolute, size int, perm os.FileMode) hst.Ops { - return opsAdapter{p.Ops.Tmpfs(target, size, perm)} -} - -func (p opsAdapter) Readonly(target *check.Absolute, perm os.FileMode) hst.Ops { - return opsAdapter{p.Ops.Readonly(target, perm)} -} - -func (p opsAdapter) Bind(source, target *check.Absolute, flags int) hst.Ops { - return opsAdapter{p.Ops.Bind(source, target, flags)} -} - -func (p opsAdapter) Overlay(target, state, work *check.Absolute, layers ...*check.Absolute) hst.Ops { - return opsAdapter{p.Ops.Overlay(target, state, work, layers...)} -} - -func (p opsAdapter) OverlayReadonly(target *check.Absolute, layers ...*check.Absolute) hst.Ops { - return opsAdapter{p.Ops.OverlayReadonly(target, layers...)} -} - -func (p opsAdapter) Link(target *check.Absolute, linkName string, dereference bool) hst.Ops { - return opsAdapter{p.Ops.Link(target, linkName, dereference)} -} - -func (p opsAdapter) Root(host *check.Absolute, flags int) hst.Ops { - return opsAdapter{p.Ops.Root(host, flags)} -} - -func (p opsAdapter) Etc(host *check.Absolute, prefix string) hst.Ops { - return opsAdapter{p.Ops.Etc(host, prefix)} -} diff --git a/internal/app/spcontainer_test.go b/internal/app/spcontainer_test.go deleted file mode 100644 index 154b9297..00000000 --- a/internal/app/spcontainer_test.go +++ /dev/null @@ -1,477 +0,0 @@ -package app - -import ( - "errors" - "os" - "reflect" - "syscall" - "testing" - - "hakurei.app/container" - "hakurei.app/container/check" - "hakurei.app/container/comp" - "hakurei.app/container/fhs" - "hakurei.app/container/seccomp" - "hakurei.app/container/stub" - "hakurei.app/hst" - "hakurei.app/system" - "hakurei.app/system/acl" - "hakurei.app/system/dbus" -) - -func TestSpParamsOp(t *testing.T) { - t.Parallel() - config := hst.Template() - - checkOpBehaviour(t, []opBehaviourTestCase{ - {"invalid program path", func(isShim, _ bool) outcomeOp { - if !isShim { - return new(spParamsOp) - } - return &spParamsOp{Term: "xterm", TermSet: true} - }, func() *hst.Config { - c := hst.Template() - c.Container.Path = nil - return c - }, nil, []stub.Call{ - call("lookupEnv", stub.ExpectArgs{"TERM"}, "xterm", nil), - }, newI(). - Ensure(m(container.Nonexistent+"/tmp/hakurei.0"), 0711), nil, nil, nil, []stub.Call{ - // this op configures the container state and does not make calls during toContainer - }, nil, nil, &hst.AppError{ - Step: "finalise", - Err: os.ErrInvalid, - Msg: "invalid program path", - }}, - - {"success defaultargs secure", func(isShim, _ bool) outcomeOp { - if !isShim { - return new(spParamsOp) - } - return &spParamsOp{Term: "xterm", TermSet: true} - }, func() *hst.Config { - c := hst.Template() - c.Container.Args = nil - c.Container.Flags = hst.FHostNet | hst.FHostAbstract | hst.FMapRealUID - return c - }, nil, []stub.Call{ - call("lookupEnv", stub.ExpectArgs{"TERM"}, "xterm", nil), - }, newI(). - Ensure(m(container.Nonexistent+"/tmp/hakurei.0"), 0711), nil, nil, nil, []stub.Call{ - // this op configures the container state and does not make calls during toContainer - }, &container.Params{ - Hostname: config.Container.Hostname, - HostNet: true, - HostAbstract: true, - Path: config.Container.Path, - Args: []string{config.Container.Path.String()}, - SeccompPresets: comp.PresetExt | comp.PresetDenyDevel | comp.PresetDenyNS | comp.PresetDenyTTY, - Uid: 1000, - Gid: 100, - Ops: new(container.Ops). - Root(m("/var/lib/hakurei/base/org.debian"), comp.BindWritable). - Proc(fhs.AbsProc).Tmpfs(hst.AbsPrivateTmp, 1<<12, 0755). - DevWritable(fhs.AbsDev, true). - Tmpfs(fhs.AbsDev.Append("shm"), 0, 01777), - }, paramsWantEnv(config, map[string]string{ - "TERM": "xterm", - }, func(t *testing.T, state *outcomeStateParams) { - if state.as.AutoEtcPrefix != wantAutoEtcPrefix { - t.Errorf("toContainer: as.AutoEtcPrefix = %q, want %q", state.as.AutoEtcPrefix, wantAutoEtcPrefix) - } - - wantFilesystems := config.Container.Filesystem[1:] - if !reflect.DeepEqual(state.filesystem, wantFilesystems) { - t.Errorf("toContainer: filesystem = %#v, want %#v", state.filesystem, wantFilesystems) - } - }), nil}, - - {"success", func(isShim, _ bool) outcomeOp { - if !isShim { - return new(spParamsOp) - } - return &spParamsOp{Term: "xterm", TermSet: true} - }, hst.Template, nil, []stub.Call{ - call("lookupEnv", stub.ExpectArgs{"TERM"}, "xterm", nil), - }, newI(). - Ensure(m(container.Nonexistent+"/tmp/hakurei.0"), 0711), nil, nil, nil, []stub.Call{ - // this op configures the container state and does not make calls during toContainer - }, &container.Params{ - Hostname: config.Container.Hostname, - RetainSession: true, - HostNet: true, - HostAbstract: true, - Path: config.Container.Path, - Args: config.Container.Args, - SeccompFlags: seccomp.AllowMultiarch, - Uid: 1000, - Gid: 100, - Ops: new(container.Ops). - Root(m("/var/lib/hakurei/base/org.debian"), comp.BindWritable). - Proc(fhs.AbsProc).Tmpfs(hst.AbsPrivateTmp, 1<<12, 0755). - Bind(fhs.AbsDev, fhs.AbsDev, comp.BindWritable|comp.BindDevice). - Tmpfs(fhs.AbsDev.Append("shm"), 0, 01777), - }, paramsWantEnv(config, map[string]string{ - "TERM": "xterm", - }, func(t *testing.T, state *outcomeStateParams) { - if state.as.AutoEtcPrefix != wantAutoEtcPrefix { - t.Errorf("toContainer: as.AutoEtcPrefix = %q, want %q", state.as.AutoEtcPrefix, wantAutoEtcPrefix) - } - - wantFilesystems := config.Container.Filesystem[1:] - if !reflect.DeepEqual(state.filesystem, wantFilesystems) { - t.Errorf("toContainer: filesystem = %#v, want %#v", state.filesystem, wantFilesystems) - } - }), nil}, - }) -} - -func TestSpFilesystemOp(t *testing.T) { - const nePrefix = container.Nonexistent + "/eval" - var stubDebianRoot = stubDir("bin", "dev", "etc", "home", "lib64", "lost+found", - "mnt", "nix", "proc", "root", "run", "srv", "sys", "tmp", "usr", "var") - config := hst.Template() - - newConfigSmall := func() *hst.Config { - c := hst.Template() - c.Container.Filesystem = []hst.FilesystemConfigJSON{ - {FilesystemConfig: &hst.FSBind{Target: fhs.AbsEtc, Source: fhs.AbsEtc, Special: true}}, - {FilesystemConfig: &hst.FSOverlay{Target: m("/nix/store"), Lower: []*check.Absolute{ - fhs.AbsVarLib.Append("hakurei/base/org.nixos/.ro-store"), - fhs.AbsVarLib.Append("hakurei/base/org.nixos/org.chromium.Chromium"), - }}}, - {FilesystemConfig: &hst.FSEphemeral{Target: hst.AbsPrivateTmp}}, - } - c.Container.Flags &= ^hst.FDevice - return c - } - configSmall := newConfigSmall() - - needsApplyState := func(next pStateContainerFunc) pStateContainerFunc { - return func(state *outcomeStateParams) { - state.as = hst.ApplyState{AutoEtcPrefix: wantAutoEtcPrefix, Ops: opsAdapter{state.params.Ops}} - - if next != nil { - next(state) - } - } - } - - checkOpBehaviour(t, []opBehaviourTestCase{ - {"readdir", func(bool, bool) outcomeOp { - return new(spFilesystemOp) - }, hst.Template, nil, []stub.Call{ - call("lookupEnv", stub.ExpectArgs{dbus.SystemBusAddress}, nil, nil), - call("evalSymlinks", stub.ExpectArgs{container.Nonexistent + "/xdg_runtime_dir"}, nePrefix+"/xdg_runtime_dir", nil), - call("evalSymlinks", stub.ExpectArgs{container.Nonexistent + "/tmp/hakurei.0"}, nePrefix+"/tmp/hakurei.0", nil), - call("evalSymlinks", stub.ExpectArgs{"/var/run/nscd"}, "", &os.PathError{Op: "lstat", Path: "/var/run/nscd", Err: os.ErrNotExist}), - call("verbosef", stub.ExpectArgs{"path %q does not yet exist", []any{"/var/run/nscd"}}, nil, nil), - call("evalSymlinks", stub.ExpectArgs{"/var/run/dbus"}, nePrefix+"/run/dbus", nil), - call("readdir", stub.ExpectArgs{"/var/lib/hakurei/base/org.debian"}, []os.DirEntry{}, stub.UniqueError(2)), - }, nil, nil, &hst.AppError{ - Step: "access autoroot source", - Err: stub.UniqueError(2), - }, nil, nil, nil, nil, nil}, - - {"invalid dbus address", func(bool, bool) outcomeOp { return new(spFilesystemOp) }, func() *hst.Config { - c := newConfigSmall() - c.Container.Filesystem = append(c.Container.Filesystem, hst.FilesystemConfigJSON{FilesystemConfig: invalidFSHost(false)}) - return c - }, nil, []stub.Call{ - call("lookupEnv", stub.ExpectArgs{dbus.SystemBusAddress}, "invalid", nil), - }, nil, nil, &hst.AppError{ - Step: "parse dbus address", - Err: &dbus.BadAddressError{ - Type: dbus.ErrNoColon, - EntryVal: []byte("invalid"), - PairPos: -1, - }, - }, nil, nil, nil, nil, nil}, - - {"invalid fs early", func(bool, bool) outcomeOp { return new(spFilesystemOp) }, func() *hst.Config { - c := newConfigSmall() - c.Container.Filesystem = append(c.Container.Filesystem, hst.FilesystemConfigJSON{FilesystemConfig: invalidFSHost(false)}) - return c - }, nil, []stub.Call{ - call("lookupEnv", stub.ExpectArgs{dbus.SystemBusAddress}, "invalid:meow=0;unix:path=/system_bus_socket;unix:path=system_bus_socket", nil), - call("verbosef", stub.ExpectArgs{"dbus socket %q is in an unusual location", []any{"/system_bus_socket"}}, nil, nil), - call("verbosef", stub.ExpectArgs{"dbus socket %q is not absolute", []any{"system_bus_socket"}}, nil, nil), - call("evalSymlinks", stub.ExpectArgs{container.Nonexistent + "/xdg_runtime_dir"}, nePrefix+"/xdg_runtime_dir", nil), - call("evalSymlinks", stub.ExpectArgs{container.Nonexistent + "/tmp/hakurei.0"}, nePrefix+"/tmp/hakurei.0", nil), - call("evalSymlinks", stub.ExpectArgs{"/var/run/nscd"}, "", &os.PathError{Op: "lstat", Path: "/var/run/nscd", Err: os.ErrNotExist}), - call("verbosef", stub.ExpectArgs{"path %q does not yet exist", []any{"/var/run/nscd"}}, nil, nil), - call("evalSymlinks", stub.ExpectArgs{"/"}, nePrefix+"/etc/dbus", nil), // to match hidePaths - }, nil, nil, &hst.AppError{ - Step: "finalise", - Err: os.ErrInvalid, - Msg: "invalid filesystem at index 3", - }, nil, nil, nil, nil, nil}, - - {"evalSymlinks early", func(bool, bool) outcomeOp { return new(spFilesystemOp) }, newConfigSmall, nil, []stub.Call{ - call("lookupEnv", stub.ExpectArgs{dbus.SystemBusAddress}, "invalid:meow=0;unix:path=/system_bus_socket;unix:path=system_bus_socket", nil), - call("verbosef", stub.ExpectArgs{"dbus socket %q is in an unusual location", []any{"/system_bus_socket"}}, nil, nil), - call("verbosef", stub.ExpectArgs{"dbus socket %q is not absolute", []any{"system_bus_socket"}}, nil, nil), - call("evalSymlinks", stub.ExpectArgs{container.Nonexistent + "/xdg_runtime_dir"}, "", stub.UniqueError(0)), - }, nil, nil, &hst.AppError{ - Step: "evaluate path hiding target", - Err: stub.UniqueError(0), - }, nil, nil, nil, nil, nil}, - - {"host nil abs", func(bool, bool) outcomeOp { return new(spFilesystemOp) }, func() *hst.Config { - c := newConfigSmall() - c.Container.Filesystem = append(c.Container.Filesystem, hst.FilesystemConfigJSON{FilesystemConfig: invalidFSHost(true)}) - return c - }, nil, []stub.Call{ - call("lookupEnv", stub.ExpectArgs{dbus.SystemBusAddress}, "invalid:meow=0;unix:path=/system_bus_socket;unix:path=system_bus_socket", nil), - call("verbosef", stub.ExpectArgs{"dbus socket %q is in an unusual location", []any{"/system_bus_socket"}}, nil, nil), - call("verbosef", stub.ExpectArgs{"dbus socket %q is not absolute", []any{"system_bus_socket"}}, nil, nil), - call("evalSymlinks", stub.ExpectArgs{container.Nonexistent + "/xdg_runtime_dir"}, nePrefix+"/xdg_runtime_dir", nil), - call("evalSymlinks", stub.ExpectArgs{container.Nonexistent + "/tmp/hakurei.0"}, nePrefix+"/tmp/hakurei.0", nil), - call("evalSymlinks", stub.ExpectArgs{"/var/run/nscd"}, "", &os.PathError{Op: "lstat", Path: "/var/run/nscd", Err: os.ErrNotExist}), - call("verbosef", stub.ExpectArgs{"path %q does not yet exist", []any{"/var/run/nscd"}}, nil, nil), - call("evalSymlinks", stub.ExpectArgs{"/"}, nePrefix+"/etc/dbus", nil), // to match hidePaths - call("evalSymlinks", stub.ExpectArgs{"/etc/"}, nePrefix+"/etc", nil), - call("evalSymlinks", stub.ExpectArgs{"/var/lib/hakurei/base/org.nixos/.ro-store"}, nePrefix+"/var/lib/hakurei/base/org.nixos/.ro-store", nil), - call("evalSymlinks", stub.ExpectArgs{"/var/lib/hakurei/base/org.nixos/org.chromium.Chromium"}, "var/lib/hakurei/base/org.nixos/org.chromium.Chromium", nil), - }, nil, nil, &hst.AppError{ - Step: "finalise", - Err: os.ErrInvalid, - Msg: "impossible path hiding state reached", - }, nil, nil, nil, nil, nil}, - - {"evalSymlinks late", func(bool, bool) outcomeOp { return new(spFilesystemOp) }, newConfigSmall, nil, []stub.Call{ - call("lookupEnv", stub.ExpectArgs{dbus.SystemBusAddress}, "invalid:meow=0;unix:path=/system_bus_socket;unix:path=system_bus_socket", nil), - call("verbosef", stub.ExpectArgs{"dbus socket %q is in an unusual location", []any{"/system_bus_socket"}}, nil, nil), - call("verbosef", stub.ExpectArgs{"dbus socket %q is not absolute", []any{"system_bus_socket"}}, nil, nil), - call("evalSymlinks", stub.ExpectArgs{container.Nonexistent + "/xdg_runtime_dir"}, nePrefix+"/xdg_runtime_dir", nil), - call("evalSymlinks", stub.ExpectArgs{container.Nonexistent + "/tmp/hakurei.0"}, nePrefix+"/tmp/hakurei.0", nil), - call("evalSymlinks", stub.ExpectArgs{"/var/run/nscd"}, "", &os.PathError{Op: "lstat", Path: "/var/run/nscd", Err: os.ErrNotExist}), - call("verbosef", stub.ExpectArgs{"path %q does not yet exist", []any{"/var/run/nscd"}}, nil, nil), - call("evalSymlinks", stub.ExpectArgs{"/"}, nePrefix+"/etc/dbus", nil), // to match hidePaths - call("evalSymlinks", stub.ExpectArgs{"/etc/"}, nePrefix+"/etc", stub.UniqueError(1)), - }, nil, nil, &hst.AppError{ - Step: "evaluate path hiding source", - Err: stub.UniqueError(1), - }, nil, nil, nil, nil, nil}, - - {"invalid contains", func(bool, bool) outcomeOp { return new(spFilesystemOp) }, newConfigSmall, nil, []stub.Call{ - call("lookupEnv", stub.ExpectArgs{dbus.SystemBusAddress}, "invalid:meow=0;unix:path=/system_bus_socket;unix:path=system_bus_socket", nil), - call("verbosef", stub.ExpectArgs{"dbus socket %q is in an unusual location", []any{"/system_bus_socket"}}, nil, nil), - call("verbosef", stub.ExpectArgs{"dbus socket %q is not absolute", []any{"system_bus_socket"}}, nil, nil), - call("evalSymlinks", stub.ExpectArgs{container.Nonexistent + "/xdg_runtime_dir"}, nePrefix+"/xdg_runtime_dir", nil), - call("evalSymlinks", stub.ExpectArgs{container.Nonexistent + "/tmp/hakurei.0"}, nePrefix+"/tmp/hakurei.0", nil), - call("evalSymlinks", stub.ExpectArgs{"/var/run/nscd"}, "", &os.PathError{Op: "lstat", Path: "/var/run/nscd", Err: os.ErrNotExist}), - call("verbosef", stub.ExpectArgs{"path %q does not yet exist", []any{"/var/run/nscd"}}, nil, nil), - call("evalSymlinks", stub.ExpectArgs{"/"}, nePrefix+"/etc/dbus", nil), // to match hidePaths - call("evalSymlinks", stub.ExpectArgs{"/etc/"}, nePrefix+"/etc", nil), - call("evalSymlinks", stub.ExpectArgs{"/var/lib/hakurei/base/org.nixos/.ro-store"}, nePrefix+"/var/lib/hakurei/base/org.nixos/.ro-store", nil), - call("evalSymlinks", stub.ExpectArgs{"/var/lib/hakurei/base/org.nixos/org.chromium.Chromium"}, "var/lib/hakurei/base/org.nixos/org.chromium.Chromium", nil), - call("verbosef", stub.ExpectArgs{"hiding path %q from %q", []any{"/proc/nonexistent/eval/etc/dbus", "/etc/"}}, nil, nil), - }, nil, nil, &hst.AppError{ - Step: "determine path hiding outcome", - Err: errors.New("Rel: can't make /proc/nonexistent/eval/xdg_runtime_dir relative to var/lib/hakurei/base/org.nixos/org.chromium.Chromium"), - }, nil, nil, nil, nil, nil}, - - {"invalid hide", func(bool, bool) outcomeOp { return new(spFilesystemOp) }, newConfigSmall, nil, []stub.Call{ - call("lookupEnv", stub.ExpectArgs{dbus.SystemBusAddress}, "invalid:meow=0;unix:path=/system_bus_socket;unix:path=system_bus_socket", nil), - call("verbosef", stub.ExpectArgs{"dbus socket %q is in an unusual location", []any{"/system_bus_socket"}}, nil, nil), - call("verbosef", stub.ExpectArgs{"dbus socket %q is not absolute", []any{"system_bus_socket"}}, nil, nil), - call("evalSymlinks", stub.ExpectArgs{container.Nonexistent + "/xdg_runtime_dir"}, "xdg_runtime_dir", nil), - call("evalSymlinks", stub.ExpectArgs{container.Nonexistent + "/tmp/hakurei.0"}, "tmp/hakurei.0", nil), - call("evalSymlinks", stub.ExpectArgs{"/var/run/nscd"}, "nscd", nil), - call("evalSymlinks", stub.ExpectArgs{"/"}, "nonexistent/dbus", nil), - call("evalSymlinks", stub.ExpectArgs{"/etc/"}, "nonexistent", nil), - call("evalSymlinks", stub.ExpectArgs{"/var/lib/hakurei/base/org.nixos/.ro-store"}, ".ro-store", nil), - call("evalSymlinks", stub.ExpectArgs{"/var/lib/hakurei/base/org.nixos/org.chromium.Chromium"}, "org.chromium.Chromium", nil), - call("verbosef", stub.ExpectArgs{"hiding path %q from %q", []any{"nonexistent/dbus", "/etc/"}}, nil, nil), - }, nil, nil, &hst.AppError{ - Step: "finalise", - Err: os.ErrInvalid, - Msg: `invalid path hiding candidate "nonexistent/dbus"`, - }, nil, nil, nil, nil, nil}, - - {"invalid fs", func(isShim, clearUnexported bool) outcomeOp { - if !isShim { - return new(spFilesystemOp) - } - return &spFilesystemOp{HidePaths: []*check.Absolute{m("/proc/nonexistent/eval/etc/dbus")}} - }, newConfigSmall, nil, []stub.Call{ - call("lookupEnv", stub.ExpectArgs{dbus.SystemBusAddress}, "invalid:meow=0;unix:path=/system_bus_socket;unix:path=system_bus_socket", nil), - call("verbosef", stub.ExpectArgs{"dbus socket %q is in an unusual location", []any{"/system_bus_socket"}}, nil, nil), - call("verbosef", stub.ExpectArgs{"dbus socket %q is not absolute", []any{"system_bus_socket"}}, nil, nil), - call("evalSymlinks", stub.ExpectArgs{container.Nonexistent + "/xdg_runtime_dir"}, nePrefix+"/xdg_runtime_dir", nil), - call("evalSymlinks", stub.ExpectArgs{container.Nonexistent + "/tmp/hakurei.0"}, nePrefix+"/tmp/hakurei.0", nil), - call("evalSymlinks", stub.ExpectArgs{"/var/run/nscd"}, "", &os.PathError{Op: "lstat", Path: "/var/run/nscd", Err: os.ErrNotExist}), - call("verbosef", stub.ExpectArgs{"path %q does not yet exist", []any{"/var/run/nscd"}}, nil, nil), - call("evalSymlinks", stub.ExpectArgs{"/"}, nePrefix+"/etc/dbus", nil), // to match hidePaths - call("evalSymlinks", stub.ExpectArgs{"/etc/"}, nePrefix+"/etc", nil), - call("evalSymlinks", stub.ExpectArgs{"/var/lib/hakurei/base/org.nixos/.ro-store"}, nePrefix+"/var/lib/hakurei/base/org.nixos/.ro-store", nil), - call("evalSymlinks", stub.ExpectArgs{"/var/lib/hakurei/base/org.nixos/org.chromium.Chromium"}, nePrefix+"/var/lib/hakurei/base/org.nixos/org.chromium.Chromium", nil), - call("verbosef", stub.ExpectArgs{"hiding path %q from %q", []any{"/proc/nonexistent/eval/etc/dbus", "/etc/"}}, nil, nil), - }, newI(). - Ensure(m("/var/lib/hakurei/u0"), 0700). - UpdatePermType(system.User, m("/var/lib/hakurei/u0"), - acl.Execute). - UpdatePermType(system.User, m("/var/lib/hakurei/u0/org.chromium.Chromium"), - acl.Read, acl.Write, acl.Execute), nil, nil, insertsOps(needsApplyState(func(state *outcomeStateParams) { - state.filesystem = append(configSmall.Container.Filesystem, hst.FilesystemConfigJSON{}) - })), []stub.Call{ - // this op configures the container state and does not make calls during toContainer - }, nil, nil, &hst.AppError{ - Step: "finalise", - Err: os.ErrInvalid, - Msg: "invalid filesystem at index 3", - }}, - - {"success noroot nodev envdbus strangedbus dbusnotabs hide", func(isShim, clearUnexported bool) outcomeOp { - if !isShim { - return new(spFilesystemOp) - } - return &spFilesystemOp{HidePaths: []*check.Absolute{m("/proc/nonexistent/eval/etc/dbus")}} - }, newConfigSmall, nil, []stub.Call{ - call("lookupEnv", stub.ExpectArgs{dbus.SystemBusAddress}, "invalid:meow=0;unix:path=/system_bus_socket;unix:path=system_bus_socket", nil), - call("verbosef", stub.ExpectArgs{"dbus socket %q is in an unusual location", []any{"/system_bus_socket"}}, nil, nil), - call("verbosef", stub.ExpectArgs{"dbus socket %q is not absolute", []any{"system_bus_socket"}}, nil, nil), - call("evalSymlinks", stub.ExpectArgs{container.Nonexistent + "/xdg_runtime_dir"}, nePrefix+"/xdg_runtime_dir", nil), - call("evalSymlinks", stub.ExpectArgs{container.Nonexistent + "/tmp/hakurei.0"}, nePrefix+"/tmp/hakurei.0", nil), - call("evalSymlinks", stub.ExpectArgs{"/var/run/nscd"}, "", &os.PathError{Op: "lstat", Path: "/var/run/nscd", Err: os.ErrNotExist}), - call("verbosef", stub.ExpectArgs{"path %q does not yet exist", []any{"/var/run/nscd"}}, nil, nil), - call("evalSymlinks", stub.ExpectArgs{"/"}, nePrefix+"/etc/dbus", nil), // to match hidePaths - call("evalSymlinks", stub.ExpectArgs{"/etc/"}, nePrefix+"/etc", nil), - call("evalSymlinks", stub.ExpectArgs{"/var/lib/hakurei/base/org.nixos/.ro-store"}, nePrefix+"/var/lib/hakurei/base/org.nixos/.ro-store", nil), - call("evalSymlinks", stub.ExpectArgs{"/var/lib/hakurei/base/org.nixos/org.chromium.Chromium"}, nePrefix+"/var/lib/hakurei/base/org.nixos/org.chromium.Chromium", nil), - call("verbosef", stub.ExpectArgs{"hiding path %q from %q", []any{"/proc/nonexistent/eval/etc/dbus", "/etc/"}}, nil, nil), - }, newI(). - Ensure(m("/var/lib/hakurei/u0"), 0700). - UpdatePermType(system.User, m("/var/lib/hakurei/u0"), - acl.Execute). - UpdatePermType(system.User, m("/var/lib/hakurei/u0/org.chromium.Chromium"), - acl.Read, acl.Write, acl.Execute), nil, nil, insertsOps(needsApplyState(func(state *outcomeStateParams) { - state.filesystem = configSmall.Container.Filesystem - })), []stub.Call{ - // this op configures the container state and does not make calls during toContainer - }, &container.Params{ - Env: []string{ - "GOOGLE_API_KEY=AIzaSyBHDrl33hwRp4rMQY0ziRbj8K9LPA6vUCY", - "GOOGLE_DEFAULT_CLIENT_ID=77185425430.apps.googleusercontent.com", - "GOOGLE_DEFAULT_CLIENT_SECRET=OTJgUOQcT7lO7GsGZq2G4IlT", - }, - - Ops: new(container.Ops). - Etc(fhs.AbsEtc, wantAutoEtcPrefix). - OverlayReadonly( - check.MustAbs("/nix/store"), - fhs.AbsVarLib.Append("hakurei/base/org.nixos/.ro-store"), - fhs.AbsVarLib.Append("hakurei/base/org.nixos/org.chromium.Chromium")). - Readonly(hst.AbsPrivateTmp, 0755). - Tmpfs(m("/proc/nonexistent/eval/etc/dbus"), 1<<13, 0755). - Remount(fhs.AbsDev, syscall.MS_RDONLY). - Remount(fhs.AbsRoot, syscall.MS_RDONLY), - }, nil, nil}, - - {"success", func(bool, bool) outcomeOp { - return new(spFilesystemOp) - }, hst.Template, nil, []stub.Call{ - call("lookupEnv", stub.ExpectArgs{dbus.SystemBusAddress}, nil, nil), - call("evalSymlinks", stub.ExpectArgs{container.Nonexistent + "/xdg_runtime_dir"}, nePrefix+"/xdg_runtime_dir", nil), - call("evalSymlinks", stub.ExpectArgs{container.Nonexistent + "/tmp/hakurei.0"}, nePrefix+"/tmp/hakurei.0", nil), - call("evalSymlinks", stub.ExpectArgs{"/var/run/nscd"}, "", &os.PathError{Op: "lstat", Path: "/var/run/nscd", Err: os.ErrNotExist}), - call("verbosef", stub.ExpectArgs{"path %q does not yet exist", []any{"/var/run/nscd"}}, nil, nil), - call("evalSymlinks", stub.ExpectArgs{"/var/run/dbus"}, nePrefix+"/run/dbus", nil), - call("readdir", stub.ExpectArgs{"/var/lib/hakurei/base/org.debian"}, stubDebianRoot, nil), - call("evalSymlinks", stub.ExpectArgs{"/etc/"}, nePrefix+"/etc", nil), - call("evalSymlinks", stub.ExpectArgs{"/var/lib/hakurei/nix/u0/org.chromium.Chromium/rw-store/upper"}, nePrefix+"/var/lib/hakurei/nix/u0/org.chromium.Chromium/rw-store/upper", nil), - call("evalSymlinks", stub.ExpectArgs{"/var/lib/hakurei/nix/u0/org.chromium.Chromium/rw-store/work"}, nePrefix+"/var/lib/hakurei/nix/u0/org.chromium.Chromium/rw-store/work", nil), - call("evalSymlinks", stub.ExpectArgs{"/var/lib/hakurei/base/org.nixos/ro-store"}, nePrefix+"/var/lib/hakurei/base/org.nixos/ro-store", nil), - call("evalSymlinks", stub.ExpectArgs{"/var/lib/hakurei/u0/org.chromium.Chromium"}, nePrefix+"/var/lib/hakurei/u0/org.chromium.Chromium", nil), - call("evalSymlinks", stub.ExpectArgs{"/dev/dri"}, nePrefix+"/dev/dri", nil), - call("evalSymlinks", stub.ExpectArgs{"/var/lib/hakurei/base/org.debian/bin"}, nePrefix+"/var/lib/hakurei/base/org.debian/bin", nil), - call("evalSymlinks", stub.ExpectArgs{"/var/lib/hakurei/base/org.debian/home"}, nePrefix+"/var/lib/hakurei/base/org.debian/home", nil), - call("evalSymlinks", stub.ExpectArgs{"/var/lib/hakurei/base/org.debian/lib64"}, nePrefix+"/var/lib/hakurei/base/org.debian/lib64", nil), - call("evalSymlinks", stub.ExpectArgs{"/var/lib/hakurei/base/org.debian/lost+found"}, nePrefix+"/var/lib/hakurei/base/org.debian/lost+found", nil), - call("evalSymlinks", stub.ExpectArgs{"/var/lib/hakurei/base/org.debian/nix"}, nePrefix+"/var/lib/hakurei/base/org.debian/nix", nil), - call("evalSymlinks", stub.ExpectArgs{"/var/lib/hakurei/base/org.debian/root"}, nePrefix+"/var/lib/hakurei/base/org.debian/root", nil), - call("evalSymlinks", stub.ExpectArgs{"/var/lib/hakurei/base/org.debian/run"}, nePrefix+"/var/lib/hakurei/base/org.debian/run", nil), - call("evalSymlinks", stub.ExpectArgs{"/var/lib/hakurei/base/org.debian/srv"}, nePrefix+"/var/lib/hakurei/base/org.debian/srv", nil), - call("evalSymlinks", stub.ExpectArgs{"/var/lib/hakurei/base/org.debian/sys"}, nePrefix+"/var/lib/hakurei/base/org.debian/sys", nil), - call("evalSymlinks", stub.ExpectArgs{"/var/lib/hakurei/base/org.debian/usr"}, nePrefix+"/var/lib/hakurei/base/org.debian/usr", nil), - call("evalSymlinks", stub.ExpectArgs{"/var/lib/hakurei/base/org.debian/var"}, nePrefix+"/var/lib/hakurei/base/org.debian/var", nil), - }, newI(). - Ensure(m("/var/lib/hakurei/u0"), 0700). - UpdatePermType(system.User, m("/var/lib/hakurei/u0"), - acl.Execute). - UpdatePermType(system.User, m("/var/lib/hakurei/u0/org.chromium.Chromium"), - acl.Read, acl.Write, acl.Execute), nil, nil, insertsOps(needsApplyState(func(state *outcomeStateParams) { - state.filesystem = config.Container.Filesystem[1:] - })), []stub.Call{ - // this op configures the container state and does not make calls during toContainer - }, &container.Params{ - Env: []string{ - "GOOGLE_API_KEY=AIzaSyBHDrl33hwRp4rMQY0ziRbj8K9LPA6vUCY", - "GOOGLE_DEFAULT_CLIENT_ID=77185425430.apps.googleusercontent.com", - "GOOGLE_DEFAULT_CLIENT_SECRET=OTJgUOQcT7lO7GsGZq2G4IlT", - }, - - Ops: new(container.Ops). - Etc(fhs.AbsEtc, wantAutoEtcPrefix). - Tmpfs(fhs.AbsTmp, 0, 0755). - Overlay( - check.MustAbs("/nix/store"), - fhs.AbsVarLib.Append("hakurei/nix/u0/org.chromium.Chromium/rw-store/upper"), - fhs.AbsVarLib.Append("hakurei/nix/u0/org.chromium.Chromium/rw-store/work"), - fhs.AbsVarLib.Append("hakurei/base/org.nixos/ro-store")). - Link(fhs.AbsRun.Append("current-system"), "/run/current-system", true). - Link(fhs.AbsRun.Append("opengl-driver"), "/run/opengl-driver", true). - Bind( - fhs.AbsVarLib.Append("hakurei/u0/org.chromium.Chromium"), - check.MustAbs("/data/data/org.chromium.Chromium"), - comp.BindWritable|comp.BindEnsure). - Bind(fhs.AbsDev.Append("dri"), fhs.AbsDev.Append("dri"), comp.BindDevice|comp.BindWritable|comp.BindOptional). - Remount(fhs.AbsRoot, syscall.MS_RDONLY), - }, nil, nil}, - }) -} - -func TestFlattenExtraPerms(t *testing.T) { - t.Parallel() - - testCases := []struct { - name string - perms []hst.ExtraPermConfig - want *system.I - }{ - {"path nil check", append(hst.Template().ExtraPerms, hst.ExtraPermConfig{}), newI(). - Ensure(m("/var/lib/hakurei/u0"), 0700). - UpdatePermType(system.User, m("/var/lib/hakurei/u0"), - acl.Execute). - UpdatePermType(system.User, m("/var/lib/hakurei/u0/org.chromium.Chromium"), - acl.Read, acl.Write, acl.Execute)}, - - {"template", hst.Template().ExtraPerms, newI(). - Ensure(m("/var/lib/hakurei/u0"), 0700). - UpdatePermType(system.User, m("/var/lib/hakurei/u0"), - acl.Execute). - UpdatePermType(system.User, m("/var/lib/hakurei/u0/org.chromium.Chromium"), - acl.Read, acl.Write, acl.Execute)}, - } - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - got := newI() - flattenExtraPerms(got, tc.perms) - if !reflect.DeepEqual(got, tc.want) { - t.Errorf("flattenExtraPerms: sys = %#v, want %#v", got, tc.want) - } - }) - } -} - -// invalidFSHost implements the Host method of [hst.FilesystemConfig] with an invalid response. -type invalidFSHost bool - -func (f invalidFSHost) Valid() bool { return bool(f) } -func (invalidFSHost) Path() *check.Absolute { panic("unreachable") } -func (invalidFSHost) Host() []*check.Absolute { return []*check.Absolute{nil} } -func (invalidFSHost) Apply(*hst.ApplyState) { panic("unreachable") } -func (invalidFSHost) String() string { panic("unreachable") } diff --git a/internal/app/spdbus.go b/internal/app/spdbus.go deleted file mode 100644 index 429674d0..00000000 --- a/internal/app/spdbus.go +++ /dev/null @@ -1,58 +0,0 @@ -package app - -import ( - "encoding/gob" - - "hakurei.app/container/fhs" - "hakurei.app/hst" - "hakurei.app/system/acl" - "hakurei.app/system/dbus" -) - -func init() { gob.Register(new(spDBusOp)) } - -// spDBusOp maintains an xdg-dbus-proxy instance for the container. -// Runs after spRuntimeOp. -type spDBusOp struct { - // Whether to bind the system bus socket. Populated during toSystem. - ProxySystem bool -} - -func (s *spDBusOp) toSystem(state *outcomeStateSys) error { - if state.et&hst.EDBus == 0 { - return errNotEnabled - } - - if state.sessionBus == nil { - state.sessionBus = dbus.NewConfig(state.appId, true, true) - } - - // downstream socket paths - sessionPath, systemPath := state.instance().Append("bus"), state.instance().Append("system_bus_socket") - - var sessionBus, systemBus dbus.ProxyPair - sessionBus[0], systemBus[0] = state.k.dbusAddress() - sessionBus[1], systemBus[1] = sessionPath.String(), systemPath.String() - if err := state.sys.ProxyDBus(state.sessionBus, state.systemBus, sessionBus, systemBus); err != nil { - return err - } - - state.sys.UpdatePerm(sessionPath, acl.Read, acl.Write) - if state.systemBus != nil { - s.ProxySystem = true - state.sys.UpdatePerm(systemPath, acl.Read, acl.Write) - } - return nil -} - -func (s *spDBusOp) toContainer(state *outcomeStateParams) error { - sessionInner := state.runtimeDir.Append("bus") - state.env["DBUS_SESSION_BUS_ADDRESS"] = "unix:path=" + sessionInner.String() - state.params.Bind(state.instancePath().Append("bus"), sessionInner, 0) - if s.ProxySystem { - systemInner := fhs.AbsVar.Append("run/dbus/system_bus_socket") - state.env["DBUS_SYSTEM_BUS_ADDRESS"] = "unix:path=" + systemInner.String() - state.params.Bind(state.instancePath().Append("system_bus_socket"), systemInner, 0) - } - return nil -} diff --git a/internal/app/spdbus_test.go b/internal/app/spdbus_test.go deleted file mode 100644 index 8dcf102c..00000000 --- a/internal/app/spdbus_test.go +++ /dev/null @@ -1,190 +0,0 @@ -package app - -import ( - "syscall" - "testing" - - "hakurei.app/container" - "hakurei.app/container/stub" - "hakurei.app/helper" - "hakurei.app/hst" - "hakurei.app/message" - "hakurei.app/system" - "hakurei.app/system/acl" - "hakurei.app/system/dbus" -) - -func TestSpDBusOp(t *testing.T) { - config := hst.Template() - - checkOpBehaviour(t, []opBehaviourTestCase{ - {"not enabled", func(bool, bool) outcomeOp { - return new(spDBusOp) - }, func() *hst.Config { - c := hst.Template() - *c.Enablements = 0 - return c - }, nil, nil, nil, nil, errNotEnabled, nil, nil, nil, nil, nil}, - - {"invalid", func(bool, bool) outcomeOp { - return new(spDBusOp) - }, func() *hst.Config { - c := hst.Template() - c.SessionBus.Talk[0] += "\x00" - c.SystemBus = nil - return c - }, nil, []stub.Call{ - call("dbusAddress", stub.ExpectArgs{}, [2]string{ - "unix:path=/run/user/1000/bus", - "unix:path=/var/run/dbus/system_bus_socket", - }, nil), - }, nil, sysUsesInstance(nil), &system.OpError{ - Op: "dbus", - Err: syscall.EINVAL, - Msg: "message bus proxy configuration contains NUL byte", - Revert: false, - }, nil, nil, nil, nil, nil}, - - {"success default", func(bool, bool) outcomeOp { - return new(spDBusOp) - }, func() *hst.Config { - c := hst.Template() - c.SessionBus, c.SystemBus = nil, nil - return c - }, nil, []stub.Call{ - call("dbusAddress", stub.ExpectArgs{}, [2]string{ - "unix:path=/run/user/1000/bus", - "unix:path=/var/run/dbus/system_bus_socket", - }, nil), - call("isVerbose", stub.ExpectArgs{}, true, nil), - call("verbose", stub.ExpectArgs{[]any{"session bus proxy:", []string{ - "unix:path=/run/user/1000/bus", - wantInstancePrefix + "/bus", - "--filter", - "--talk=org.freedesktop.DBus", - "--talk=org.freedesktop.Notifications", - "--own=org.chromium.Chromium.*", - "--own=org.mpris.MediaPlayer2.org.chromium.Chromium.*", - "--call=org.freedesktop.portal.*=*", - "--broadcast=org.freedesktop.portal.*=@/org/freedesktop/portal/*", - }}}, nil, nil), - call("verbose", stub.ExpectArgs{[]any{"message bus proxy final args:", helper.MustNewCheckedArgs( - "unix:path=/run/user/1000/bus", - wantInstancePrefix+"/bus", - "--filter", - "--talk=org.freedesktop.DBus", - "--talk=org.freedesktop.Notifications", - "--own=org.chromium.Chromium.*", - "--own=org.mpris.MediaPlayer2.org.chromium.Chromium.*", - "--call=org.freedesktop.portal.*=*", - "--broadcast=org.freedesktop.portal.*=@/org/freedesktop/portal/*", - )}}, nil, nil), - }, func() *system.I { - sys := system.New(panicMsgContext{}, message.NewMsg(nil), checkExpectUid) - sys.Ephemeral(system.Process, m(wantInstancePrefix), 0711) - if err := sys.ProxyDBus( - dbus.NewConfig(config.ID, true, true), nil, - dbus.ProxyPair{"unix:path=/run/user/1000/bus", wantInstancePrefix + "/bus"}, - dbus.ProxyPair{"unix:path=/var/run/dbus/system_bus_socket", wantInstancePrefix + "/system_bus_socket"}, - ); err != nil { - t.Fatalf("cannot prepare sys: %v", err) - } - sys.UpdatePerm(m(wantInstancePrefix+"/bus"), acl.Read, acl.Write) - return sys - }(), sysUsesInstance(nil), nil, insertsOps(afterSpRuntimeOp(nil)), []stub.Call{ - // this op configures the container state and does not make calls during toContainer - }, &container.Params{ - Ops: new(container.Ops). - Bind(m(wantInstancePrefix+"/bus"), - m("/run/user/1000/bus"), 0), - }, paramsWantEnv(config, map[string]string{ - "DBUS_SESSION_BUS_ADDRESS": "unix:path=/run/user/1000/bus", - }, nil), nil}, - - {"success", func(isShim, _ bool) outcomeOp { - if !isShim { - return new(spDBusOp) - } - return &spDBusOp{ProxySystem: true} - }, hst.Template, nil, []stub.Call{ - call("dbusAddress", stub.ExpectArgs{}, [2]string{ - "unix:path=/run/user/1000/bus", - "unix:path=/var/run/dbus/system_bus_socket", - }, nil), - call("isVerbose", stub.ExpectArgs{}, true, nil), - call("verbose", stub.ExpectArgs{[]any{"session bus proxy:", []string{ - "unix:path=/run/user/1000/bus", - wantInstancePrefix + "/bus", - "--filter", - "--talk=org.freedesktop.Notifications", - "--talk=org.freedesktop.FileManager1", - "--talk=org.freedesktop.ScreenSaver", - "--talk=org.freedesktop.secrets", - "--talk=org.kde.kwalletd5", - "--talk=org.kde.kwalletd6", - "--talk=org.gnome.SessionManager", - "--own=org.chromium.Chromium.*", - "--own=org.mpris.MediaPlayer2.org.chromium.Chromium.*", - "--own=org.mpris.MediaPlayer2.chromium.*", - "--call=org.freedesktop.portal.*=*", - "--broadcast=org.freedesktop.portal.*=@/org/freedesktop/portal/*", - }}}, nil, nil), - call("verbose", stub.ExpectArgs{[]any{"system bus proxy:", []string{ - "unix:path=/var/run/dbus/system_bus_socket", - wantInstancePrefix + "/system_bus_socket", - "--filter", - "--talk=org.bluez", - "--talk=org.freedesktop.Avahi", - "--talk=org.freedesktop.UPower", - }}}, nil, nil), - call("verbose", stub.ExpectArgs{[]any{"message bus proxy final args:", helper.MustNewCheckedArgs( - "unix:path=/run/user/1000/bus", - wantInstancePrefix+"/bus", - "--filter", - "--talk=org.freedesktop.Notifications", - "--talk=org.freedesktop.FileManager1", - "--talk=org.freedesktop.ScreenSaver", - "--talk=org.freedesktop.secrets", - "--talk=org.kde.kwalletd5", - "--talk=org.kde.kwalletd6", - "--talk=org.gnome.SessionManager", - "--own=org.chromium.Chromium.*", - "--own=org.mpris.MediaPlayer2.org.chromium.Chromium.*", - "--own=org.mpris.MediaPlayer2.chromium.*", - "--call=org.freedesktop.portal.*=*", - "--broadcast=org.freedesktop.portal.*=@/org/freedesktop/portal/*", - - "unix:path=/var/run/dbus/system_bus_socket", - wantInstancePrefix+"/system_bus_socket", - "--filter", - "--talk=org.bluez", - "--talk=org.freedesktop.Avahi", - "--talk=org.freedesktop.UPower", - )}}, nil, nil), - }, func() *system.I { - sys := system.New(panicMsgContext{}, message.NewMsg(nil), checkExpectUid) - sys.Ephemeral(system.Process, m(wantInstancePrefix), 0711) - if err := sys.ProxyDBus( - config.SessionBus, config.SystemBus, - dbus.ProxyPair{"unix:path=/run/user/1000/bus", wantInstancePrefix + "/bus"}, - dbus.ProxyPair{"unix:path=/var/run/dbus/system_bus_socket", wantInstancePrefix + "/system_bus_socket"}, - ); err != nil { - t.Fatalf("cannot prepare sys: %v", err) - } - sys.UpdatePerm(m(wantInstancePrefix+"/bus"), acl.Read, acl.Write). - UpdatePerm(m(wantInstancePrefix+"/system_bus_socket"), acl.Read, acl.Write) - return sys - }(), sysUsesInstance(nil), nil, insertsOps(afterSpRuntimeOp(nil)), []stub.Call{ - // this op configures the container state and does not make calls during toContainer - }, &container.Params{ - Ops: new(container.Ops). - Bind(m(wantInstancePrefix+"/bus"), - m("/run/user/1000/bus"), 0). - Bind(m(wantInstancePrefix+"/system_bus_socket"), - m("/var/run/dbus/system_bus_socket"), 0), - }, paramsWantEnv(config, map[string]string{ - "DBUS_SESSION_BUS_ADDRESS": "unix:path=/run/user/1000/bus", - "DBUS_SYSTEM_BUS_ADDRESS": "unix:path=/var/run/dbus/system_bus_socket", - }, nil), nil}, - }) -} diff --git a/internal/app/sppulse.go b/internal/app/sppulse.go deleted file mode 100644 index fe63f366..00000000 --- a/internal/app/sppulse.go +++ /dev/null @@ -1,210 +0,0 @@ -package app - -import ( - "encoding/gob" - "errors" - "fmt" - "io" - "io/fs" - "os" - "strconv" - "syscall" - - "hakurei.app/container/check" - "hakurei.app/hst" - "hakurei.app/message" -) - -const pulseCookieSizeMax = 1 << 8 - -func init() { gob.Register(new(spPulseOp)) } - -// spPulseOp exports the PulseAudio server to the container. -// Runs after spRuntimeOp. -type spPulseOp struct { - // PulseAudio cookie data, populated during toSystem if a cookie is present. - Cookie *[pulseCookieSizeMax]byte - // PulseAudio cookie size, populated during toSystem if a cookie is present. - CookieSize int -} - -func (s *spPulseOp) toSystem(state *outcomeStateSys) error { - if state.et&hst.EPulse == 0 { - return errNotEnabled - } - - pulseRuntimeDir, pulseSocket := s.commonPaths(state.outcomeState) - - if _, err := state.k.stat(pulseRuntimeDir.String()); err != nil { - if !errors.Is(err, fs.ErrNotExist) { - return &hst.AppError{Step: fmt.Sprintf("access PulseAudio directory %q", pulseRuntimeDir), Err: err} - } - return newWithMessageError(fmt.Sprintf("PulseAudio directory %q not found", pulseRuntimeDir), err) - } - - if fi, err := state.k.stat(pulseSocket.String()); err != nil { - if !errors.Is(err, fs.ErrNotExist) { - return &hst.AppError{Step: fmt.Sprintf("access PulseAudio socket %q", pulseSocket), Err: err} - } - return newWithMessageError(fmt.Sprintf("PulseAudio directory %q found but socket does not exist", pulseRuntimeDir), err) - } else { - if m := fi.Mode(); m&0o006 != 0o006 { - return newWithMessage(fmt.Sprintf("unexpected permissions on %q: %s", pulseSocket, m)) - } - } - - // pulse socket is world writable and its parent directory DAC permissions prevents access; - // hard link to target-executable share directory to grant access - state.sys.Link(pulseSocket, state.runtime().Append("pulse")) - - // load up to pulseCookieSizeMax bytes of pulse cookie for transmission to shim - if a, err := discoverPulseCookie(state.k); err != nil { - return err - } else if a != nil { - s.Cookie = new([pulseCookieSizeMax]byte) - if s.CookieSize, err = loadFile(state.msg, state.k, "PulseAudio cookie", a.String(), s.Cookie[:]); err != nil { - return err - } - } else { - state.msg.Verbose("cannot locate PulseAudio cookie (tried " + - "$PULSE_COOKIE, " + - "$XDG_CONFIG_HOME/pulse/cookie, " + - "$HOME/.pulse-cookie)") - } - - return nil -} - -func (s *spPulseOp) toContainer(state *outcomeStateParams) error { - innerPulseSocket := state.runtimeDir.Append("pulse", "native") - state.params.Bind(state.runtimePath().Append("pulse"), innerPulseSocket, 0) - state.env["PULSE_SERVER"] = "unix:" + innerPulseSocket.String() - - if s.Cookie != nil { - innerDst := hst.AbsPrivateTmp.Append("/pulse-cookie") - - if s.CookieSize < 0 || s.CookieSize > pulseCookieSizeMax { - return newWithMessage("unexpected PulseAudio cookie size") - } - state.env["PULSE_COOKIE"] = innerDst.String() - state.params.Place(innerDst, s.Cookie[:s.CookieSize]) - } - - return nil -} - -func (s *spPulseOp) commonPaths(state *outcomeState) (pulseRuntimeDir, pulseSocket *check.Absolute) { - // PulseAudio runtime directory (usually `/run/user/%d/pulse`) - pulseRuntimeDir = state.sc.RuntimePath.Append("pulse") - // PulseAudio socket (usually `/run/user/%d/pulse/native`) - pulseSocket = pulseRuntimeDir.Append("native") - return -} - -// discoverPulseCookie attempts to discover the pathname of the PulseAudio cookie of the current user. -// If both returned pathname and error are nil, the cookie is likely unavailable and can be silently skipped. -func discoverPulseCookie(k syscallDispatcher) (*check.Absolute, error) { - const paLocateStep = "locate PulseAudio cookie" - - // from environment - if p, ok := k.lookupEnv("PULSE_COOKIE"); ok { - if a, err := check.NewAbs(p); err != nil { - return nil, &hst.AppError{Step: paLocateStep, Err: err} - } else { - // this takes precedence, do not verify whether the file is accessible - return a, nil - } - } - - // $HOME/.pulse-cookie - if p, ok := k.lookupEnv("HOME"); ok { - var pulseCookiePath *check.Absolute - if a, err := check.NewAbs(p); err != nil { - return nil, &hst.AppError{Step: paLocateStep, Err: err} - } else { - pulseCookiePath = a.Append(".pulse-cookie") - } - - if fi, err := k.stat(pulseCookiePath.String()); err != nil { - if !errors.Is(err, fs.ErrNotExist) { - return nil, &hst.AppError{Step: "access PulseAudio cookie", Err: err} - } - // fallthrough - } else if fi.IsDir() { - // fallthrough - } else { - return pulseCookiePath, nil - } - } - - // $XDG_CONFIG_HOME/pulse/cookie - if p, ok := k.lookupEnv("XDG_CONFIG_HOME"); ok { - var pulseCookiePath *check.Absolute - if a, err := check.NewAbs(p); err != nil { - return nil, &hst.AppError{Step: paLocateStep, Err: err} - } else { - pulseCookiePath = a.Append("pulse", "cookie") - } - - if fi, err := k.stat(pulseCookiePath.String()); err != nil { - if !errors.Is(err, fs.ErrNotExist) { - return nil, &hst.AppError{Step: "access PulseAudio cookie", Err: err} - } - // fallthrough - } else if fi.IsDir() { - // fallthrough - } else { - return pulseCookiePath, nil - } - } - - // cookie not present - // not fatal: authentication is disabled - return nil, nil -} - -// loadFile reads up to len(buf) bytes from the file at pathname. -func loadFile( - msg message.Msg, k syscallDispatcher, - description, pathname string, buf []byte, -) (int, error) { - n := len(buf) - if n == 0 { - return -1, errors.New("invalid buffer") - } - - if fi, err := k.stat(pathname); err != nil { - return -1, &hst.AppError{Step: "access " + description, Err: err} - } else { - if fi.IsDir() { - return -1, &hst.AppError{Step: "read " + description, - Err: &os.PathError{Op: "stat", Path: pathname, Err: syscall.EISDIR}} - } - if s := fi.Size(); s > int64(n) { - return -1, newWithMessageError( - description+" at "+strconv.Quote(pathname)+" exceeds expected size", - &os.PathError{Op: "stat", Path: pathname, Err: syscall.ENOMEM}, - ) - } else if s < int64(n) { - msg.Verbosef("%s at %q is %d bytes shorter than expected", description, pathname, int64(n)-s) - } else { - msg.Verbosef("loading %d bytes from %q", n, pathname) - } - } - - if f, err := k.open(pathname); err != nil { - return -1, &hst.AppError{Step: "open " + description, Err: err} - } else { - if n, err = f.Read(buf); err != nil { - if !errors.Is(err, io.EOF) { - _ = f.Close() - return n, &hst.AppError{Step: "read " + description, Err: err} - } - } - - if err = f.Close(); err != nil { - return n, &hst.AppError{Step: "close " + description, Err: err} - } - return n, nil - } -} diff --git a/internal/app/sppulse_test.go b/internal/app/sppulse_test.go deleted file mode 100644 index c6220731..00000000 --- a/internal/app/sppulse_test.go +++ /dev/null @@ -1,460 +0,0 @@ -package app - -import ( - "bytes" - "errors" - "os" - "syscall" - "testing" - - "hakurei.app/container" - "hakurei.app/container/check" - "hakurei.app/container/stub" - "hakurei.app/hst" - "hakurei.app/system" - "hakurei.app/system/acl" -) - -func TestSpPulseOp(t *testing.T) { - t.Parallel() - - config := hst.Template() - sampleCookie := bytes.Repeat([]byte{0xfc}, pulseCookieSizeMax) - - checkOpBehaviour(t, []opBehaviourTestCase{ - {"not enabled", func(bool, bool) outcomeOp { - return new(spPulseOp) - }, func() *hst.Config { - c := hst.Template() - *c.Enablements = 0 - return c - }, nil, nil, nil, nil, errNotEnabled, nil, nil, nil, nil, nil}, - - {"socketDir stat", func(isShim, _ bool) outcomeOp { - if !isShim { - return new(spPulseOp) - } - return &spPulseOp{Cookie: (*[256]byte)(sampleCookie)} - }, hst.Template, nil, []stub.Call{ - call("stat", stub.ExpectArgs{wantRuntimePath + "/pulse"}, (*stubFi)(nil), stub.UniqueError(2)), - }, nil, nil, &hst.AppError{ - Step: `access PulseAudio directory "/proc/nonexistent/xdg_runtime_dir/pulse"`, - Err: stub.UniqueError(2), - }, nil, nil, nil, nil, nil}, - - {"socketDir nonexistent", func(bool, bool) outcomeOp { - return new(spPulseOp) - }, hst.Template, nil, []stub.Call{ - call("stat", stub.ExpectArgs{wantRuntimePath + "/pulse"}, (*stubFi)(nil), os.ErrNotExist), - }, nil, nil, &hst.AppError{ - Step: "finalise", - Err: os.ErrNotExist, - Msg: `PulseAudio directory "/proc/nonexistent/xdg_runtime_dir/pulse" not found`, - }, nil, nil, nil, nil, nil}, - - {"socket stat", func(bool, bool) outcomeOp { - return new(spPulseOp) - }, hst.Template, nil, []stub.Call{ - call("stat", stub.ExpectArgs{wantRuntimePath + "/pulse"}, (*stubFi)(nil), nil), - call("stat", stub.ExpectArgs{wantRuntimePath + "/pulse/native"}, (*stubFi)(nil), stub.UniqueError(1)), - }, nil, nil, &hst.AppError{ - Step: `access PulseAudio socket "/proc/nonexistent/xdg_runtime_dir/pulse/native"`, - Err: stub.UniqueError(1), - }, nil, nil, nil, nil, nil}, - - {"socket nonexistent", func(bool, bool) outcomeOp { - return new(spPulseOp) - }, hst.Template, nil, []stub.Call{ - call("stat", stub.ExpectArgs{wantRuntimePath + "/pulse"}, (*stubFi)(nil), nil), - call("stat", stub.ExpectArgs{wantRuntimePath + "/pulse/native"}, (*stubFi)(nil), os.ErrNotExist), - }, nil, nil, &hst.AppError{ - Step: "finalise", - Err: os.ErrNotExist, - Msg: `PulseAudio directory "/proc/nonexistent/xdg_runtime_dir/pulse" found but socket does not exist`, - }, nil, nil, nil, nil, nil}, - - {"socket mode", func(bool, bool) outcomeOp { - return new(spPulseOp) - }, hst.Template, nil, []stub.Call{ - call("stat", stub.ExpectArgs{wantRuntimePath + "/pulse"}, (*stubFi)(nil), nil), - call("stat", stub.ExpectArgs{wantRuntimePath + "/pulse/native"}, &stubFi{mode: 0660}, nil), - }, nil, nil, &hst.AppError{ - Step: "finalise", - Err: os.ErrInvalid, - Msg: `unexpected permissions on "/proc/nonexistent/xdg_runtime_dir/pulse/native": -rw-rw----`, - }, nil, nil, nil, nil, nil}, - - {"cookie notAbs", func(bool, bool) outcomeOp { - return new(spPulseOp) - }, hst.Template, nil, []stub.Call{ - call("stat", stub.ExpectArgs{wantRuntimePath + "/pulse"}, (*stubFi)(nil), nil), - call("stat", stub.ExpectArgs{wantRuntimePath + "/pulse/native"}, &stubFi{mode: 0666}, nil), - call("lookupEnv", stub.ExpectArgs{"PULSE_COOKIE"}, "proc/nonexistent/cookie", nil), - }, nil, nil, &hst.AppError{ - Step: "locate PulseAudio cookie", - Err: &check.AbsoluteError{Pathname: "proc/nonexistent/cookie"}, - }, nil, nil, nil, nil, nil}, - - {"cookie loadFile", func(bool, bool) outcomeOp { - return new(spPulseOp) - }, hst.Template, nil, []stub.Call{ - call("stat", stub.ExpectArgs{wantRuntimePath + "/pulse"}, (*stubFi)(nil), nil), - call("stat", stub.ExpectArgs{wantRuntimePath + "/pulse/native"}, &stubFi{mode: 0666}, nil), - call("lookupEnv", stub.ExpectArgs{"PULSE_COOKIE"}, "/proc/nonexistent/cookie", nil), - call("stat", stub.ExpectArgs{"/proc/nonexistent/cookie"}, &stubFi{isDir: false, size: 1 << 8}, nil), - call("verbosef", stub.ExpectArgs{"loading %d bytes from %q", []any{1 << 8, "/proc/nonexistent/cookie"}}, nil, nil), - call("open", stub.ExpectArgs{"/proc/nonexistent/cookie"}, (*stubOsFile)(nil), stub.UniqueError(0)), - }, nil, nil, &hst.AppError{ - Step: "open PulseAudio cookie", - Err: stub.UniqueError(0), - }, nil, nil, nil, nil, nil}, - - {"cookie bad shim size", func(isShim, clearUnexported bool) outcomeOp { - if !isShim { - return new(spPulseOp) - } - op := &spPulseOp{Cookie: (*[pulseCookieSizeMax]byte)(sampleCookie), CookieSize: pulseCookieSizeMax} - if clearUnexported { - op.CookieSize += +0xfd - } - return op - }, hst.Template, nil, []stub.Call{ - call("stat", stub.ExpectArgs{wantRuntimePath + "/pulse"}, (*stubFi)(nil), nil), - call("stat", stub.ExpectArgs{wantRuntimePath + "/pulse/native"}, &stubFi{mode: 0666}, nil), - call("lookupEnv", stub.ExpectArgs{"PULSE_COOKIE"}, "/proc/nonexistent/cookie", nil), - call("stat", stub.ExpectArgs{"/proc/nonexistent/cookie"}, &stubFi{isDir: false, size: 1 << 8}, nil), - call("verbosef", stub.ExpectArgs{"loading %d bytes from %q", []any{1 << 8, "/proc/nonexistent/cookie"}}, nil, nil), - call("open", stub.ExpectArgs{"/proc/nonexistent/cookie"}, &stubOsFile{Reader: bytes.NewReader(sampleCookie)}, nil), - }, newI(). - // state.ensureRuntimeDir - Ensure(m(wantRunDirPath), 0700). - UpdatePermType(system.User, m(wantRunDirPath), acl.Execute). - Ensure(m(wantRuntimePath), 0700). - UpdatePermType(system.User, m(wantRuntimePath), acl.Execute). - // state.runtime - Ephemeral(system.Process, m(wantRuntimeSharePath), 0700). - UpdatePerm(m(wantRuntimeSharePath), acl.Execute). - // toSystem - Link(m(wantRuntimePath+"/pulse/native"), m(wantRuntimeSharePath+"/pulse")), sysUsesRuntime(nil), nil, insertsOps(afterSpRuntimeOp(nil)), []stub.Call{ - // this op configures the container state and does not make calls during toContainer - }, nil, nil, &hst.AppError{ - Step: "finalise", - Err: os.ErrInvalid, - Msg: "unexpected PulseAudio cookie size", - }}, - - {"success cookie short", func(isShim, _ bool) outcomeOp { - if !isShim { - return new(spPulseOp) - } - sampleCookieTrunc := make([]byte, pulseCookieSizeMax) - copy(sampleCookieTrunc, sampleCookie[:len(sampleCookie)-0xe]) - return &spPulseOp{Cookie: (*[pulseCookieSizeMax]byte)(sampleCookieTrunc), CookieSize: pulseCookieSizeMax - 0xe} - }, hst.Template, nil, []stub.Call{ - call("stat", stub.ExpectArgs{wantRuntimePath + "/pulse"}, (*stubFi)(nil), nil), - call("stat", stub.ExpectArgs{wantRuntimePath + "/pulse/native"}, &stubFi{mode: 0666}, nil), - call("lookupEnv", stub.ExpectArgs{"PULSE_COOKIE"}, "/proc/nonexistent/cookie", nil), - call("stat", stub.ExpectArgs{"/proc/nonexistent/cookie"}, &stubFi{isDir: false, size: pulseCookieSizeMax - 0xe}, nil), - call("verbosef", stub.ExpectArgs{"%s at %q is %d bytes shorter than expected", []any{"PulseAudio cookie", "/proc/nonexistent/cookie", int64(0xe)}}, nil, nil), - call("open", stub.ExpectArgs{"/proc/nonexistent/cookie"}, &stubOsFile{Reader: bytes.NewReader(sampleCookie[:len(sampleCookie)-0xe])}, nil), - }, newI(). - // state.ensureRuntimeDir - Ensure(m(wantRunDirPath), 0700). - UpdatePermType(system.User, m(wantRunDirPath), acl.Execute). - Ensure(m(wantRuntimePath), 0700). - UpdatePermType(system.User, m(wantRuntimePath), acl.Execute). - // state.runtime - Ephemeral(system.Process, m(wantRuntimeSharePath), 0700). - UpdatePerm(m(wantRuntimeSharePath), acl.Execute). - // toSystem - Link(m(wantRuntimePath+"/pulse/native"), m(wantRuntimeSharePath+"/pulse")), sysUsesRuntime(nil), nil, insertsOps(afterSpRuntimeOp(nil)), []stub.Call{ - // this op configures the container state and does not make calls during toContainer - }, &container.Params{ - Ops: new(container.Ops). - Bind(m(wantRuntimeSharePath+"/pulse"), m("/run/user/1000/pulse/native"), 0). - Place(m("/.hakurei/pulse-cookie"), sampleCookie[:len(sampleCookie)-0xe]), - }, paramsWantEnv(config, map[string]string{ - "PULSE_SERVER": "unix:/run/user/1000/pulse/native", - "PULSE_COOKIE": "/.hakurei/pulse-cookie", - }, nil), nil}, - - {"success cookie", func(isShim, _ bool) outcomeOp { - if !isShim { - return new(spPulseOp) - } - return &spPulseOp{Cookie: (*[pulseCookieSizeMax]byte)(sampleCookie), CookieSize: pulseCookieSizeMax} - }, hst.Template, nil, []stub.Call{ - call("stat", stub.ExpectArgs{wantRuntimePath + "/pulse"}, (*stubFi)(nil), nil), - call("stat", stub.ExpectArgs{wantRuntimePath + "/pulse/native"}, &stubFi{mode: 0666}, nil), - call("lookupEnv", stub.ExpectArgs{"PULSE_COOKIE"}, "/proc/nonexistent/cookie", nil), - call("stat", stub.ExpectArgs{"/proc/nonexistent/cookie"}, &stubFi{isDir: false, size: 1 << 8}, nil), - call("verbosef", stub.ExpectArgs{"loading %d bytes from %q", []any{1 << 8, "/proc/nonexistent/cookie"}}, nil, nil), - call("open", stub.ExpectArgs{"/proc/nonexistent/cookie"}, &stubOsFile{Reader: bytes.NewReader(sampleCookie)}, nil), - }, newI(). - // state.ensureRuntimeDir - Ensure(m(wantRunDirPath), 0700). - UpdatePermType(system.User, m(wantRunDirPath), acl.Execute). - Ensure(m(wantRuntimePath), 0700). - UpdatePermType(system.User, m(wantRuntimePath), acl.Execute). - // state.runtime - Ephemeral(system.Process, m(wantRuntimeSharePath), 0700). - UpdatePerm(m(wantRuntimeSharePath), acl.Execute). - // toSystem - Link(m(wantRuntimePath+"/pulse/native"), m(wantRuntimeSharePath+"/pulse")), sysUsesRuntime(nil), nil, insertsOps(afterSpRuntimeOp(nil)), []stub.Call{ - // this op configures the container state and does not make calls during toContainer - }, &container.Params{ - Ops: new(container.Ops). - Bind(m(wantRuntimeSharePath+"/pulse"), m("/run/user/1000/pulse/native"), 0). - Place(m("/.hakurei/pulse-cookie"), sampleCookie), - }, paramsWantEnv(config, map[string]string{ - "PULSE_SERVER": "unix:/run/user/1000/pulse/native", - "PULSE_COOKIE": "/.hakurei/pulse-cookie", - }, nil), nil}, - - {"success", func(bool, bool) outcomeOp { - return new(spPulseOp) - }, hst.Template, nil, []stub.Call{ - call("stat", stub.ExpectArgs{wantRuntimePath + "/pulse"}, (*stubFi)(nil), nil), - call("stat", stub.ExpectArgs{wantRuntimePath + "/pulse/native"}, &stubFi{mode: 0666}, nil), - call("lookupEnv", stub.ExpectArgs{"PULSE_COOKIE"}, nil, nil), - call("lookupEnv", stub.ExpectArgs{"HOME"}, nil, nil), - call("lookupEnv", stub.ExpectArgs{"XDG_CONFIG_HOME"}, nil, nil), - call("verbose", stub.ExpectArgs{[]any{"cannot locate PulseAudio cookie (tried $PULSE_COOKIE, $XDG_CONFIG_HOME/pulse/cookie, $HOME/.pulse-cookie)"}}, nil, nil), - }, newI(). - // state.ensureRuntimeDir - Ensure(m(wantRunDirPath), 0700). - UpdatePermType(system.User, m(wantRunDirPath), acl.Execute). - Ensure(m(wantRuntimePath), 0700). - UpdatePermType(system.User, m(wantRuntimePath), acl.Execute). - // state.runtime - Ephemeral(system.Process, m(wantRuntimeSharePath), 0700). - UpdatePerm(m(wantRuntimeSharePath), acl.Execute). - // toSystem - Link(m(wantRuntimePath+"/pulse/native"), m(wantRuntimeSharePath+"/pulse")), sysUsesRuntime(nil), nil, insertsOps(afterSpRuntimeOp(nil)), []stub.Call{ - // this op configures the container state and does not make calls during toContainer - }, &container.Params{ - Ops: new(container.Ops). - Bind(m(wantRuntimeSharePath+"/pulse"), m("/run/user/1000/pulse/native"), 0), - }, paramsWantEnv(config, map[string]string{ - "PULSE_SERVER": "unix:/run/user/1000/pulse/native", - }, nil), nil}, - }) -} - -func TestDiscoverPulseCookie(t *testing.T) { - t.Parallel() - - fCheckPathname := func(k *kstub) error { - a, err := discoverPulseCookie(k) - k.Verbose(a) - return err - } - - checkSimple(t, "discoverPulseCookie", []simpleTestCase{ - {"override notAbs", fCheckPathname, stub.Expect{Calls: []stub.Call{ - call("lookupEnv", stub.ExpectArgs{"PULSE_COOKIE"}, "proc/nonexistent/pulse-cookie", nil), - call("verbose", stub.ExpectArgs{[]any{(*check.Absolute)(nil)}}, nil, nil), - }}, &hst.AppError{ - Step: "locate PulseAudio cookie", - Err: &check.AbsoluteError{Pathname: "proc/nonexistent/pulse-cookie"}, - }}, - - {"success override", fCheckPathname, stub.Expect{Calls: []stub.Call{ - call("lookupEnv", stub.ExpectArgs{"PULSE_COOKIE"}, "/proc/nonexistent/pulse-cookie", nil), - call("verbose", stub.ExpectArgs{[]any{m("/proc/nonexistent/pulse-cookie")}}, nil, nil), - }}, nil}, - - {"home notAbs", fCheckPathname, stub.Expect{Calls: []stub.Call{ - call("lookupEnv", stub.ExpectArgs{"PULSE_COOKIE"}, nil, nil), - call("lookupEnv", stub.ExpectArgs{"HOME"}, "proc/nonexistent/home", nil), - call("verbose", stub.ExpectArgs{[]any{(*check.Absolute)(nil)}}, nil, nil), - }}, &hst.AppError{ - Step: "locate PulseAudio cookie", - Err: &check.AbsoluteError{Pathname: "proc/nonexistent/home"}, - }}, - - {"home stat", fCheckPathname, stub.Expect{Calls: []stub.Call{ - call("lookupEnv", stub.ExpectArgs{"PULSE_COOKIE"}, nil, nil), - call("lookupEnv", stub.ExpectArgs{"HOME"}, "/proc/nonexistent/home", nil), - call("stat", stub.ExpectArgs{"/proc/nonexistent/home/.pulse-cookie"}, (*stubFi)(nil), stub.UniqueError(1)), - call("verbose", stub.ExpectArgs{[]any{(*check.Absolute)(nil)}}, nil, nil), - }}, &hst.AppError{ - Step: "access PulseAudio cookie", - Err: stub.UniqueError(1), - }}, - - {"home nonexistent", fCheckPathname, stub.Expect{Calls: []stub.Call{ - call("lookupEnv", stub.ExpectArgs{"PULSE_COOKIE"}, nil, nil), - call("lookupEnv", stub.ExpectArgs{"HOME"}, "/proc/nonexistent/home", nil), - call("stat", stub.ExpectArgs{"/proc/nonexistent/home/.pulse-cookie"}, (*stubFi)(nil), os.ErrNotExist), - call("lookupEnv", stub.ExpectArgs{"XDG_CONFIG_HOME"}, nil, nil), - call("verbose", stub.ExpectArgs{[]any{(*check.Absolute)(nil)}}, nil, nil), - }}, nil}, - - {"success home", fCheckPathname, stub.Expect{Calls: []stub.Call{ - call("lookupEnv", stub.ExpectArgs{"PULSE_COOKIE"}, nil, nil), - call("lookupEnv", stub.ExpectArgs{"HOME"}, "/proc/nonexistent/home", nil), - call("stat", stub.ExpectArgs{"/proc/nonexistent/home/.pulse-cookie"}, &stubFi{}, nil), - call("verbose", stub.ExpectArgs{[]any{m("/proc/nonexistent/home/.pulse-cookie")}}, nil, nil), - }}, nil}, - - {"xdg notAbs", fCheckPathname, stub.Expect{Calls: []stub.Call{ - call("lookupEnv", stub.ExpectArgs{"PULSE_COOKIE"}, nil, nil), - call("lookupEnv", stub.ExpectArgs{"HOME"}, nil, nil), - call("lookupEnv", stub.ExpectArgs{"XDG_CONFIG_HOME"}, "proc/nonexistent/xdg", nil), - call("verbose", stub.ExpectArgs{[]any{(*check.Absolute)(nil)}}, nil, nil), - }}, &hst.AppError{ - Step: "locate PulseAudio cookie", - Err: &check.AbsoluteError{Pathname: "proc/nonexistent/xdg"}, - }}, - - {"xdg stat", fCheckPathname, stub.Expect{Calls: []stub.Call{ - call("lookupEnv", stub.ExpectArgs{"PULSE_COOKIE"}, nil, nil), - call("lookupEnv", stub.ExpectArgs{"HOME"}, nil, nil), - call("lookupEnv", stub.ExpectArgs{"XDG_CONFIG_HOME"}, "/proc/nonexistent/xdg", nil), - call("stat", stub.ExpectArgs{"/proc/nonexistent/xdg/pulse/cookie"}, (*stubFi)(nil), stub.UniqueError(0)), - call("verbose", stub.ExpectArgs{[]any{(*check.Absolute)(nil)}}, nil, nil), - }}, &hst.AppError{ - Step: "access PulseAudio cookie", - Err: stub.UniqueError(0), - }}, - - {"xdg dir", fCheckPathname, stub.Expect{Calls: []stub.Call{ - call("lookupEnv", stub.ExpectArgs{"PULSE_COOKIE"}, nil, nil), - call("lookupEnv", stub.ExpectArgs{"HOME"}, nil, nil), - call("lookupEnv", stub.ExpectArgs{"XDG_CONFIG_HOME"}, "/proc/nonexistent/xdg", nil), - call("stat", stub.ExpectArgs{"/proc/nonexistent/xdg/pulse/cookie"}, &stubFi{isDir: true}, nil), - call("verbose", stub.ExpectArgs{[]any{(*check.Absolute)(nil)}}, nil, nil), - }}, nil}, - - {"success home dir xdg nonexistent", fCheckPathname, stub.Expect{Calls: []stub.Call{ - call("lookupEnv", stub.ExpectArgs{"PULSE_COOKIE"}, nil, nil), - call("lookupEnv", stub.ExpectArgs{"HOME"}, "/proc/nonexistent/home", nil), - call("stat", stub.ExpectArgs{"/proc/nonexistent/home/.pulse-cookie"}, &stubFi{isDir: true}, nil), - call("lookupEnv", stub.ExpectArgs{"XDG_CONFIG_HOME"}, "/proc/nonexistent/xdg", nil), - call("stat", stub.ExpectArgs{"/proc/nonexistent/xdg/pulse/cookie"}, (*stubFi)(nil), os.ErrNotExist), - call("verbose", stub.ExpectArgs{[]any{(*check.Absolute)(nil)}}, nil, nil), - }}, nil}, - - {"success home nonexistent xdg", fCheckPathname, stub.Expect{Calls: []stub.Call{ - call("lookupEnv", stub.ExpectArgs{"PULSE_COOKIE"}, nil, nil), - call("lookupEnv", stub.ExpectArgs{"HOME"}, "/proc/nonexistent/home", nil), - call("stat", stub.ExpectArgs{"/proc/nonexistent/home/.pulse-cookie"}, (*stubFi)(nil), os.ErrNotExist), - call("lookupEnv", stub.ExpectArgs{"XDG_CONFIG_HOME"}, "/proc/nonexistent/xdg", nil), - call("stat", stub.ExpectArgs{"/proc/nonexistent/xdg/pulse/cookie"}, &stubFi{}, nil), - call("verbose", stub.ExpectArgs{[]any{m("/proc/nonexistent/xdg/pulse/cookie")}}, nil, nil), - }}, nil}, - - {"success empty environ", fCheckPathname, stub.Expect{Calls: []stub.Call{ - call("lookupEnv", stub.ExpectArgs{"PULSE_COOKIE"}, nil, nil), - call("lookupEnv", stub.ExpectArgs{"HOME"}, nil, nil), - call("lookupEnv", stub.ExpectArgs{"XDG_CONFIG_HOME"}, nil, nil), - call("verbose", stub.ExpectArgs{[]any{(*check.Absolute)(nil)}}, nil, nil), - }}, nil}, - }) -} - -func TestLoadFile(t *testing.T) { - t.Parallel() - - fAfterWriteExact := func(k *kstub) error { - buf := make([]byte, 1<<8) - n, err := loadFile(k, k, - "simulated PulseAudio cookie", - "/home/ophestra/xdg/config/pulse/cookie", - buf) - k.Verbose(buf[:n]) - return err - } - - fAfterWrite := func(k *kstub) error { - buf := make([]byte, 1<<8+0xfd) - n, err := loadFile(k, k, - "simulated PulseAudio cookie", - "/home/ophestra/xdg/config/pulse/cookie", - buf) - k.Verbose(buf[:n]) - return err - } - - fBeforeWrite := func(k *kstub) error { - buf := make([]byte, 1<<8+0xfd) - n, err := loadFile(k, k, - "simulated PulseAudio cookie", - "/home/ophestra/xdg/config/pulse/cookie", - buf) - k.Verbose(n) - - if !bytes.Equal(buf, make([]byte, len(buf))) { - t.Errorf("loadFile: buf = %#v", buf) - } - return err - } - - sampleCookie := bytes.Repeat([]byte{0xfc}, pulseCookieSizeMax) - checkSimple(t, "loadFile", []simpleTestCase{ - {"buf", func(k *kstub) error { - n, err := loadFile(k, k, - "simulated PulseAudio cookie", - "/home/ophestra/xdg/config/pulse/cookie", - nil) - k.Verbose(n) - return err - }, stub.Expect{Calls: []stub.Call{ - call("verbose", stub.ExpectArgs{[]any{-1}}, nil, nil), - }}, errors.New("invalid buffer")}, - - {"stat", fBeforeWrite, stub.Expect{Calls: []stub.Call{ - call("stat", stub.ExpectArgs{"/home/ophestra/xdg/config/pulse/cookie"}, (*stubFi)(nil), stub.UniqueError(3)), - call("verbose", stub.ExpectArgs{[]any{-1}}, nil, nil), - }}, &hst.AppError{ - Step: "access simulated PulseAudio cookie", - Err: stub.UniqueError(3), - }}, - - {"dir", fBeforeWrite, stub.Expect{Calls: []stub.Call{ - call("stat", stub.ExpectArgs{"/home/ophestra/xdg/config/pulse/cookie"}, &stubFi{isDir: true}, nil), - call("verbose", stub.ExpectArgs{[]any{-1}}, nil, nil), - }}, &hst.AppError{ - Step: "read simulated PulseAudio cookie", - Err: &os.PathError{Op: "stat", Path: "/home/ophestra/xdg/config/pulse/cookie", Err: syscall.EISDIR}, - }}, - - {"oob", fBeforeWrite, stub.Expect{Calls: []stub.Call{ - call("stat", stub.ExpectArgs{"/home/ophestra/xdg/config/pulse/cookie"}, &stubFi{size: 1<<8 + 0xff}, nil), - call("verbose", stub.ExpectArgs{[]any{-1}}, nil, nil), - }}, &hst.AppError{ - Step: "finalise", - Err: &os.PathError{Op: "stat", Path: "/home/ophestra/xdg/config/pulse/cookie", Err: syscall.ENOMEM}, - Msg: `simulated PulseAudio cookie at "/home/ophestra/xdg/config/pulse/cookie" exceeds expected size`, - }}, - - {"open", fBeforeWrite, stub.Expect{Calls: []stub.Call{ - call("stat", stub.ExpectArgs{"/home/ophestra/xdg/config/pulse/cookie"}, &stubFi{size: 1 << 8}, nil), - call("verbosef", stub.ExpectArgs{"%s at %q is %d bytes shorter than expected", []any{"simulated PulseAudio cookie", "/home/ophestra/xdg/config/pulse/cookie", int64(0xfd)}}, nil, nil), - call("open", stub.ExpectArgs{"/home/ophestra/xdg/config/pulse/cookie"}, (*stubOsFile)(nil), stub.UniqueError(2)), - call("verbose", stub.ExpectArgs{[]any{-1}}, nil, nil), - }}, &hst.AppError{Step: "open simulated PulseAudio cookie", Err: stub.UniqueError(2)}}, - - {"read", fBeforeWrite, stub.Expect{Calls: []stub.Call{ - call("stat", stub.ExpectArgs{"/home/ophestra/xdg/config/pulse/cookie"}, &stubFi{size: 1 << 8}, nil), - call("verbosef", stub.ExpectArgs{"%s at %q is %d bytes shorter than expected", []any{"simulated PulseAudio cookie", "/home/ophestra/xdg/config/pulse/cookie", int64(0xfd)}}, nil, nil), - call("open", stub.ExpectArgs{"/home/ophestra/xdg/config/pulse/cookie"}, &stubOsFile{Reader: errorReader{stub.UniqueError(1)}}, nil), - call("verbose", stub.ExpectArgs{[]any{-1}}, nil, nil), - }}, &hst.AppError{Step: "read simulated PulseAudio cookie", Err: stub.UniqueError(1)}}, - - {"short close", fAfterWrite, stub.Expect{Calls: []stub.Call{ - call("stat", stub.ExpectArgs{"/home/ophestra/xdg/config/pulse/cookie"}, &stubFi{size: 1 << 8}, nil), - call("verbosef", stub.ExpectArgs{"%s at %q is %d bytes shorter than expected", []any{"simulated PulseAudio cookie", "/home/ophestra/xdg/config/pulse/cookie", int64(0xfd)}}, nil, nil), - call("open", stub.ExpectArgs{"/home/ophestra/xdg/config/pulse/cookie"}, &stubOsFile{closeErr: stub.UniqueError(0), Reader: bytes.NewReader(sampleCookie)}, nil), - call("verbose", stub.ExpectArgs{[]any{sampleCookie}}, nil, nil), - }}, &hst.AppError{Step: "close simulated PulseAudio cookie", Err: stub.UniqueError(0)}}, - - {"success", fAfterWriteExact, stub.Expect{Calls: []stub.Call{ - call("stat", stub.ExpectArgs{"/home/ophestra/xdg/config/pulse/cookie"}, &stubFi{size: 1 << 8}, nil), - call("verbosef", stub.ExpectArgs{"loading %d bytes from %q", []any{1 << 8, "/home/ophestra/xdg/config/pulse/cookie"}}, nil, nil), - call("open", stub.ExpectArgs{"/home/ophestra/xdg/config/pulse/cookie"}, &stubOsFile{Reader: bytes.NewReader(sampleCookie)}, nil), - call("verbose", stub.ExpectArgs{[]any{sampleCookie}}, nil, nil), - }}, nil}, - }) -} diff --git a/internal/app/spruntime.go b/internal/app/spruntime.go deleted file mode 100644 index 25c04b83..00000000 --- a/internal/app/spruntime.go +++ /dev/null @@ -1,125 +0,0 @@ -package app - -import ( - "encoding/gob" - - "hakurei.app/container/check" - "hakurei.app/container/comp" - "hakurei.app/container/fhs" - "hakurei.app/hst" - "hakurei.app/system" - "hakurei.app/system/acl" -) - -const ( - /* - Path to a user-private user-writable directory that is bound - to the user login time on the machine. It is automatically - created the first time a user logs in and removed on the - user's final logout. If a user logs in twice at the same time, - both sessions will see the same $XDG_RUNTIME_DIR and the same - contents. If a user logs in once, then logs out again, and - logs in again, the directory contents will have been lost in - between, but applications should not rely on this behavior and - must be able to deal with stale files. To store - session-private data in this directory, the user should - include the value of $XDG_SESSION_ID in the filename. This - directory shall be used for runtime file system objects such - as AF_UNIX sockets, FIFOs, PID files and similar. It is - guaranteed that this directory is local and offers the - greatest possible file system feature set the operating system - provides. For further details, see the XDG Base Directory - Specification[3]. $XDG_RUNTIME_DIR is not set if the current - user is not the original user of the session. - */ - envXDGRuntimeDir = "XDG_RUNTIME_DIR" - - /* - The session class. This may be used instead of class= on the - module parameter line, and is usually preferred. - */ - envXDGSessionClass = "XDG_SESSION_CLASS" - - /* - A regular interactive user session. This is the default class - for sessions for which a TTY or X display is known at session - registration time. - */ - xdgSessionClassUser = "user" - - /* - The session type. This may be used instead of type= on the - module parameter line, and is usually preferred. - - One of "unspecified", "tty", "x11", "wayland", "mir", or "web". - */ - envXDGSessionType = "XDG_SESSION_TYPE" -) - -func init() { gob.Register(new(spRuntimeOp)) } - -const ( - sessionTypeUnspec = iota - sessionTypeTTY - sessionTypeX11 - sessionTypeWayland -) - -// spRuntimeOp sets up XDG_RUNTIME_DIR inside the container. -type spRuntimeOp struct { - // SessionType determines the value of envXDGSessionType. Populated during toSystem. - SessionType uintptr -} - -func (s *spRuntimeOp) toSystem(state *outcomeStateSys) error { - if state.Container.Flags&hst.FShareRuntime != 0 { - runtimeDir, runtimeDirInst := s.commonPaths(state.outcomeState) - state.sys.Ensure(runtimeDir, 0700) - state.sys.UpdatePermType(system.User, runtimeDir, acl.Execute) - state.sys.Ensure(runtimeDirInst, 0700) - state.sys.UpdatePermType(system.User, runtimeDirInst, acl.Read, acl.Write, acl.Execute) - } - - if state.et&hst.EWayland != 0 { - s.SessionType = sessionTypeWayland - } else if state.et&hst.EX11 != 0 { - s.SessionType = sessionTypeX11 - } else { - s.SessionType = sessionTypeTTY - } - - return nil -} - -func (s *spRuntimeOp) toContainer(state *outcomeStateParams) error { - state.runtimeDir = fhs.AbsRunUser.Append(state.mapuid.String()) - state.env[envXDGRuntimeDir] = state.runtimeDir.String() - state.env[envXDGSessionClass] = xdgSessionClassUser - - switch s.SessionType { - case sessionTypeUnspec: - state.env[envXDGSessionType] = "unspecified" - case sessionTypeTTY: - state.env[envXDGSessionType] = "tty" - case sessionTypeX11: - state.env[envXDGSessionType] = "x11" - case sessionTypeWayland: - state.env[envXDGSessionType] = "wayland" - - } - - state.params.Tmpfs(fhs.AbsRunUser, 1<<12, 0755) - if state.Container.Flags&hst.FShareRuntime != 0 { - _, runtimeDirInst := s.commonPaths(state.outcomeState) - state.params.Bind(runtimeDirInst, state.runtimeDir, comp.BindWritable) - } else { - state.params.Mkdir(state.runtimeDir, 0700) - } - return nil -} - -func (s *spRuntimeOp) commonPaths(state *outcomeState) (runtimeDir, runtimeDirInst *check.Absolute) { - runtimeDir = state.sc.SharePath.Append("runtime") - runtimeDirInst = runtimeDir.Append(state.identity.String()) - return -} diff --git a/internal/app/spruntime_test.go b/internal/app/spruntime_test.go deleted file mode 100644 index 1675a7e5..00000000 --- a/internal/app/spruntime_test.go +++ /dev/null @@ -1,128 +0,0 @@ -package app - -import ( - "testing" - - "hakurei.app/container" - "hakurei.app/container/comp" - "hakurei.app/container/fhs" - "hakurei.app/container/stub" - "hakurei.app/hst" - "hakurei.app/system" - "hakurei.app/system/acl" -) - -func TestSpRuntimeOp(t *testing.T) { - t.Parallel() - config := hst.Template() - - checkOpBehaviour(t, []opBehaviourTestCase{ - {"success zero", func(isShim bool, clearUnexported bool) outcomeOp { - if !isShim { - return new(spRuntimeOp) - } - op := &spRuntimeOp{sessionTypeTTY} - if clearUnexported { - op.SessionType = sessionTypeUnspec - } - return op - }, func() *hst.Config { - c := hst.Template() - *c.Enablements = 0 - return c - }, nil, []stub.Call{ - // this op configures the system state and does not make calls during toSystem - }, newI(). - Ensure(m("/proc/nonexistent/tmp/hakurei.0/runtime"), 0700). - UpdatePermType(system.User, m("/proc/nonexistent/tmp/hakurei.0/runtime"), acl.Execute). - Ensure(m("/proc/nonexistent/tmp/hakurei.0/runtime/9"), 0700). - UpdatePermType(system.User, m("/proc/nonexistent/tmp/hakurei.0/runtime/9"), acl.Read, acl.Write, acl.Execute), nil, nil, insertsOps(nil), []stub.Call{ - // this op configures the container state and does not make calls during toContainer - }, &container.Params{ - Ops: new(container.Ops). - Tmpfs(fhs.AbsRunUser, 1<<12, 0755). - Bind(m("/proc/nonexistent/tmp/hakurei.0/runtime/9"), m("/run/user/1000"), comp.BindWritable), - }, paramsWantEnv(config, map[string]string{ - "XDG_RUNTIME_DIR": "/run/user/1000", - "XDG_SESSION_CLASS": "user", - "XDG_SESSION_TYPE": "unspecified", - }, nil), nil}, - - {"success tty", func(isShim, _ bool) outcomeOp { - if !isShim { - return new(spRuntimeOp) - } - return &spRuntimeOp{sessionTypeTTY} - }, func() *hst.Config { - c := hst.Template() - *c.Enablements = 0 - return c - }, nil, []stub.Call{ - // this op configures the system state and does not make calls during toSystem - }, newI(). - Ensure(m("/proc/nonexistent/tmp/hakurei.0/runtime"), 0700). - UpdatePermType(system.User, m("/proc/nonexistent/tmp/hakurei.0/runtime"), acl.Execute). - Ensure(m("/proc/nonexistent/tmp/hakurei.0/runtime/9"), 0700). - UpdatePermType(system.User, m("/proc/nonexistent/tmp/hakurei.0/runtime/9"), acl.Read, acl.Write, acl.Execute), nil, nil, insertsOps(nil), []stub.Call{ - // this op configures the container state and does not make calls during toContainer - }, &container.Params{ - Ops: new(container.Ops). - Tmpfs(fhs.AbsRunUser, 1<<12, 0755). - Bind(m("/proc/nonexistent/tmp/hakurei.0/runtime/9"), m("/run/user/1000"), comp.BindWritable), - }, paramsWantEnv(config, map[string]string{ - "XDG_RUNTIME_DIR": "/run/user/1000", - "XDG_SESSION_CLASS": "user", - "XDG_SESSION_TYPE": "tty", - }, nil), nil}, - - {"success x11", func(isShim, _ bool) outcomeOp { - if !isShim { - return new(spRuntimeOp) - } - return &spRuntimeOp{sessionTypeX11} - }, func() *hst.Config { - c := hst.Template() - *c.Enablements = hst.Enablements(hst.EX11) - return c - }, nil, []stub.Call{ - // this op configures the system state and does not make calls during toSystem - }, newI(). - Ensure(m("/proc/nonexistent/tmp/hakurei.0/runtime"), 0700). - UpdatePermType(system.User, m("/proc/nonexistent/tmp/hakurei.0/runtime"), acl.Execute). - Ensure(m("/proc/nonexistent/tmp/hakurei.0/runtime/9"), 0700). - UpdatePermType(system.User, m("/proc/nonexistent/tmp/hakurei.0/runtime/9"), acl.Read, acl.Write, acl.Execute), nil, nil, insertsOps(nil), []stub.Call{ - // this op configures the container state and does not make calls during toContainer - }, &container.Params{ - Ops: new(container.Ops). - Tmpfs(fhs.AbsRunUser, 1<<12, 0755). - Bind(m("/proc/nonexistent/tmp/hakurei.0/runtime/9"), m("/run/user/1000"), comp.BindWritable), - }, paramsWantEnv(config, map[string]string{ - "XDG_RUNTIME_DIR": "/run/user/1000", - "XDG_SESSION_CLASS": "user", - "XDG_SESSION_TYPE": "x11", - }, nil), nil}, - - {"success", func(isShim, _ bool) outcomeOp { - if !isShim { - return new(spRuntimeOp) - } - return &spRuntimeOp{sessionTypeWayland} - }, hst.Template, nil, []stub.Call{ - // this op configures the system state and does not make calls during toSystem - }, newI(). - Ensure(m("/proc/nonexistent/tmp/hakurei.0/runtime"), 0700). - UpdatePermType(system.User, m("/proc/nonexistent/tmp/hakurei.0/runtime"), acl.Execute). - Ensure(m("/proc/nonexistent/tmp/hakurei.0/runtime/9"), 0700). - UpdatePermType(system.User, m("/proc/nonexistent/tmp/hakurei.0/runtime/9"), acl.Read, acl.Write, acl.Execute), nil, nil, insertsOps(nil), []stub.Call{ - // this op configures the container state and does not make calls during toContainer - }, &container.Params{ - Ops: new(container.Ops). - Tmpfs(fhs.AbsRunUser, 1<<12, 0755). - Bind(m("/proc/nonexistent/tmp/hakurei.0/runtime/9"), m("/run/user/1000"), comp.BindWritable), - }, paramsWantEnv(config, map[string]string{ - "XDG_RUNTIME_DIR": "/run/user/1000", - "XDG_SESSION_CLASS": "user", - "XDG_SESSION_TYPE": "wayland", - }, nil), nil}, - }) -} diff --git a/internal/app/sptmpdir.go b/internal/app/sptmpdir.go deleted file mode 100644 index 5b7b54b9..00000000 --- a/internal/app/sptmpdir.go +++ /dev/null @@ -1,44 +0,0 @@ -package app - -import ( - "encoding/gob" - - "hakurei.app/container/check" - "hakurei.app/container/comp" - "hakurei.app/container/fhs" - "hakurei.app/hst" - "hakurei.app/system" - "hakurei.app/system/acl" -) - -func init() { gob.Register(spTmpdirOp{}) } - -// spTmpdirOp sets up TMPDIR inside the container. -type spTmpdirOp struct{} - -func (s spTmpdirOp) toSystem(state *outcomeStateSys) error { - if state.Container.Flags&hst.FShareTmpdir != 0 { - tmpdir, tmpdirInst := s.commonPaths(state.outcomeState) - state.sys.Ensure(tmpdir, 0700) - state.sys.UpdatePermType(system.User, tmpdir, acl.Execute) - state.sys.Ensure(tmpdirInst, 01700) - state.sys.UpdatePermType(system.User, tmpdirInst, acl.Read, acl.Write, acl.Execute) - } - return nil -} - -func (s spTmpdirOp) toContainer(state *outcomeStateParams) error { - if state.Container.Flags&hst.FShareTmpdir != 0 { - _, tmpdirInst := s.commonPaths(state.outcomeState) - state.params.Bind(tmpdirInst, fhs.AbsTmp, comp.BindWritable) - } else { - state.params.Tmpfs(fhs.AbsTmp, 0, 01777) - } - return nil -} - -func (s spTmpdirOp) commonPaths(state *outcomeState) (tmpdir, tmpdirInst *check.Absolute) { - tmpdir = state.sc.SharePath.Append("tmpdir") - tmpdirInst = tmpdir.Append(state.identity.String()) - return -} diff --git a/internal/app/sptmpdir_test.go b/internal/app/sptmpdir_test.go deleted file mode 100644 index 53b44fbe..00000000 --- a/internal/app/sptmpdir_test.go +++ /dev/null @@ -1,34 +0,0 @@ -package app - -import ( - "testing" - - "hakurei.app/container" - "hakurei.app/container/comp" - "hakurei.app/container/fhs" - "hakurei.app/container/stub" - "hakurei.app/hst" - "hakurei.app/system" - "hakurei.app/system/acl" -) - -func TestSpTmpdirOp(t *testing.T) { - t.Parallel() - - checkOpBehaviour(t, []opBehaviourTestCase{ - {"success", func(bool, bool) outcomeOp { - return spTmpdirOp{} - }, hst.Template, nil, []stub.Call{ - // this op configures the system state and does not make calls during toSystem - }, newI(). - Ensure(m("/proc/nonexistent/tmp/hakurei.0/tmpdir"), 0700). - UpdatePermType(system.User, m("/proc/nonexistent/tmp/hakurei.0/tmpdir"), acl.Execute). - Ensure(m("/proc/nonexistent/tmp/hakurei.0/tmpdir/9"), 01700). - UpdatePermType(system.User, m("/proc/nonexistent/tmp/hakurei.0/tmpdir/9"), acl.Read, acl.Write, acl.Execute), nil, nil, insertsOps(nil), []stub.Call{ - // this op configures the container state and does not make calls during toContainer - }, &container.Params{ - Ops: new(container.Ops). - Bind(m("/proc/nonexistent/tmp/hakurei.0/tmpdir/9"), fhs.AbsTmp, comp.BindWritable), - }, nil, nil}, - }) -} diff --git a/internal/app/spwayland.go b/internal/app/spwayland.go deleted file mode 100644 index d8c18386..00000000 --- a/internal/app/spwayland.go +++ /dev/null @@ -1,63 +0,0 @@ -package app - -import ( - "encoding/gob" - - "hakurei.app/container/check" - "hakurei.app/hst" - "hakurei.app/system/acl" - "hakurei.app/system/wayland" -) - -func init() { gob.Register(new(spWaylandOp)) } - -// spWaylandOp exports the Wayland display server to the container. -// Runs after spRuntimeOp. -type spWaylandOp struct { - // Path to host wayland socket. Populated during toSystem if DirectWayland is true. - SocketPath *check.Absolute -} - -func (s *spWaylandOp) toSystem(state *outcomeStateSys) error { - if state.et&hst.EWayland == 0 { - return errNotEnabled - } - - // outer wayland socket (usually `/run/user/%d/wayland-%d`) - var socketPath *check.Absolute - if name, ok := state.k.lookupEnv(wayland.WaylandDisplay); !ok { - state.msg.Verbose(wayland.WaylandDisplay + " is not set, assuming " + wayland.FallbackName) - socketPath = state.sc.RuntimePath.Append(wayland.FallbackName) - } else if a, err := check.NewAbs(name); err != nil { - socketPath = state.sc.RuntimePath.Append(name) - } else { - socketPath = a - } - - if !state.directWayland { // set up security-context-v1 - appId := state.appId - if appId == "" { - // use instance ID in case app id is not set - appId = "app.hakurei." + state.id.String() - } - // downstream socket paths - state.sys.Wayland(state.instance().Append("wayland"), socketPath, appId, state.id.String()) - } else { // bind mount wayland socket (insecure) - state.msg.Verbose("direct wayland access, PROCEED WITH CAUTION") - state.ensureRuntimeDir() - s.SocketPath = socketPath - state.sys.UpdatePermType(hst.EWayland, socketPath, acl.Read, acl.Write, acl.Execute) - } - return nil -} - -func (s *spWaylandOp) toContainer(state *outcomeStateParams) error { - innerPath := state.runtimeDir.Append(wayland.FallbackName) - state.env[wayland.WaylandDisplay] = wayland.FallbackName - if s.SocketPath == nil { - state.params.Bind(state.instancePath().Append("wayland"), innerPath, 0) - } else { - state.params.Bind(s.SocketPath, innerPath, 0) - } - return nil -} diff --git a/internal/app/spwayland_test.go b/internal/app/spwayland_test.go deleted file mode 100644 index 89c81473..00000000 --- a/internal/app/spwayland_test.go +++ /dev/null @@ -1,104 +0,0 @@ -package app - -import ( - "testing" - - "hakurei.app/container" - "hakurei.app/container/stub" - "hakurei.app/hst" - "hakurei.app/system" - "hakurei.app/system/acl" - "hakurei.app/system/wayland" -) - -func TestSpWaylandOp(t *testing.T) { - t.Parallel() - config := hst.Template() - - checkOpBehaviour(t, []opBehaviourTestCase{ - {"not enabled", func(bool, bool) outcomeOp { - return new(spWaylandOp) - }, func() *hst.Config { - c := hst.Template() - *c.Enablements = 0 - return c - }, nil, nil, nil, nil, errNotEnabled, nil, nil, nil, nil, nil}, - - {"success notAbs defaultAppId", func(bool, bool) outcomeOp { - return new(spWaylandOp) - }, func() *hst.Config { - c := hst.Template() - c.ID = "" - return c - }, nil, []stub.Call{ - call("lookupEnv", stub.ExpectArgs{"WAYLAND_DISPLAY"}, "wayland-1", nil), - }, newI(). - // state.instance - Ephemeral(system.Process, m(wantInstancePrefix), 0711). - // toSystem - Wayland( - m(wantInstancePrefix+"/wayland"), - m(wantRuntimePath+"/wayland-1"), - "app.hakurei."+wantAutoEtcPrefix, - wantAutoEtcPrefix, - ), sysUsesInstance(nil), nil, insertsOps(afterSpRuntimeOp(nil)), []stub.Call{ - // this op configures the container state and does not make calls during toContainer - }, &container.Params{ - Ops: new(container.Ops). - Bind(m(wantInstancePrefix+"/wayland"), m("/run/user/1000/wayland-0"), 0), - }, paramsWantEnv(config, map[string]string{ - wayland.WaylandDisplay: wayland.FallbackName, - }, nil), nil}, - - {"success direct", func(isShim, _ bool) outcomeOp { - if !isShim { - return new(spWaylandOp) - } - return &spWaylandOp{SocketPath: m("/proc/nonexistent/wayland")} - }, func() *hst.Config { - c := hst.Template() - c.DirectWayland = true - return c - }, nil, []stub.Call{ - call("lookupEnv", stub.ExpectArgs{"WAYLAND_DISPLAY"}, "/proc/nonexistent/wayland", nil), - call("verbose", stub.ExpectArgs{[]any{"direct wayland access, PROCEED WITH CAUTION"}}, nil, nil), - }, newI(). - // state.ensureRuntimeDir - Ensure(m(wantRunDirPath), 0700). - UpdatePermType(system.User, m(wantRunDirPath), acl.Execute). - Ensure(m(wantRuntimePath), 0700). - UpdatePermType(system.User, m(wantRuntimePath), acl.Execute). - // toSystem - UpdatePermType(hst.EWayland, m("/proc/nonexistent/wayland"), acl.Read, acl.Write, acl.Execute), nil, nil, insertsOps(afterSpRuntimeOp(nil)), []stub.Call{ - // this op configures the container state and does not make calls during toContainer - }, &container.Params{ - Ops: new(container.Ops). - Bind(m("/proc/nonexistent/wayland"), m("/run/user/1000/wayland-0"), 0), - }, paramsWantEnv(config, map[string]string{ - wayland.WaylandDisplay: wayland.FallbackName, - }, nil), nil}, - - {"success", func(bool, bool) outcomeOp { - return new(spWaylandOp) - }, hst.Template, nil, []stub.Call{ - call("lookupEnv", stub.ExpectArgs{"WAYLAND_DISPLAY"}, nil, nil), - call("verbose", stub.ExpectArgs{[]any{"WAYLAND_DISPLAY is not set, assuming wayland-0"}}, nil, nil), - }, newI(). - // state.instance - Ephemeral(system.Process, m(wantInstancePrefix), 0711). - // toSystem - Wayland( - m(wantInstancePrefix+"/wayland"), - m(wantRuntimePath+"/"+wayland.FallbackName), - "org.chromium.Chromium", - wantAutoEtcPrefix, - ), sysUsesInstance(nil), nil, insertsOps(afterSpRuntimeOp(nil)), []stub.Call{ - // this op configures the container state and does not make calls during toContainer - }, &container.Params{ - Ops: new(container.Ops). - Bind(m(wantInstancePrefix+"/wayland"), m("/run/user/1000/wayland-0"), 0), - }, paramsWantEnv(config, map[string]string{ - wayland.WaylandDisplay: wayland.FallbackName, - }, nil), nil}, - }) -} diff --git a/internal/app/spx11.go b/internal/app/spx11.go deleted file mode 100644 index 109cf0c0..00000000 --- a/internal/app/spx11.go +++ /dev/null @@ -1,71 +0,0 @@ -package app - -import ( - "encoding/gob" - "errors" - "fmt" - "io/fs" - "strconv" - "strings" - - "hakurei.app/container/check" - "hakurei.app/container/fhs" - "hakurei.app/hst" - "hakurei.app/system/acl" -) - -var absX11SocketDir = fhs.AbsTmp.Append(".X11-unix") - -func init() { gob.Register(new(spX11Op)) } - -// spX11Op exports the X11 display server to the container. -type spX11Op struct { - // Value of $DISPLAY, stored during toSystem - Display string -} - -func (s *spX11Op) toSystem(state *outcomeStateSys) error { - if state.et&hst.EX11 == 0 { - return errNotEnabled - } - - if d, ok := state.k.lookupEnv("DISPLAY"); !ok { - return newWithMessage("DISPLAY is not set") - } else { - s.Display = d - } - - // the socket file at `/tmp/.X11-unix/X%d` is typically owned by the priv user - // and not accessible by the target user - var socketPath *check.Absolute - if len(s.Display) > 1 && s.Display[0] == ':' { // `:%d` - if n, err := strconv.Atoi(s.Display[1:]); err == nil && n >= 0 { - socketPath = absX11SocketDir.Append("X" + strconv.Itoa(n)) - } - } else if len(s.Display) > 5 && strings.HasPrefix(s.Display, "unix:") { // `unix:%s` - if a, err := check.NewAbs(s.Display[5:]); err == nil { - socketPath = a - } - } - if socketPath != nil { - if _, err := state.k.stat(socketPath.String()); err != nil { - if !errors.Is(err, fs.ErrNotExist) { - return &hst.AppError{Step: fmt.Sprintf("access X11 socket %q", socketPath), Err: err} - } - } else { - state.sys.UpdatePermType(hst.EX11, socketPath, acl.Read, acl.Write, acl.Execute) - if state.Container.Flags&hst.FHostAbstract == 0 { - s.Display = "unix:" + socketPath.String() - } - } - } - - state.sys.ChangeHosts("#" + state.uid.String()) - return nil -} - -func (s *spX11Op) toContainer(state *outcomeStateParams) error { - state.env["DISPLAY"] = s.Display - state.params.Bind(absX11SocketDir, absX11SocketDir, 0) - return nil -} diff --git a/internal/app/spx11_test.go b/internal/app/spx11_test.go deleted file mode 100644 index 0486a1e0..00000000 --- a/internal/app/spx11_test.go +++ /dev/null @@ -1,119 +0,0 @@ -package app - -import ( - "os" - "testing" - - "hakurei.app/container" - "hakurei.app/container/stub" - "hakurei.app/hst" - "hakurei.app/system/acl" -) - -func TestSpX11Op(t *testing.T) { - t.Parallel() - config := hst.Template() - - checkOpBehaviour(t, []opBehaviourTestCase{ - {"not enabled", func(bool, bool) outcomeOp { - return new(spX11Op) - }, hst.Template, nil, nil, nil, nil, errNotEnabled, nil, nil, nil, nil, nil}, - - {"lookupEnv", func(bool, bool) outcomeOp { - return new(spX11Op) - }, func() *hst.Config { - c := hst.Template() - *c.Enablements |= hst.Enablements(hst.EX11) - return c - }, nil, []stub.Call{ - call("lookupEnv", stub.ExpectArgs{"DISPLAY"}, nil, nil), - }, nil, nil, &hst.AppError{ - Step: "finalise", - Err: os.ErrInvalid, - Msg: "DISPLAY is not set", - }, nil, nil, nil, nil, nil}, - - {"abs stat", func(bool, bool) outcomeOp { - return new(spX11Op) - }, func() *hst.Config { - c := hst.Template() - *c.Enablements |= hst.Enablements(hst.EX11) - return c - }, nil, []stub.Call{ - call("lookupEnv", stub.ExpectArgs{"DISPLAY"}, "unix:/tmp/.X11-unix/X0", nil), - call("stat", stub.ExpectArgs{"/tmp/.X11-unix/X0"}, (*stubFi)(nil), stub.UniqueError(0)), - }, nil, nil, &hst.AppError{ - Step: `access X11 socket "/tmp/.X11-unix/X0"`, - Err: stub.UniqueError(0), - }, nil, nil, nil, nil, nil}, - - {"success abs nonexistent", func(isShim, _ bool) outcomeOp { - if !isShim { - return new(spX11Op) - } - return &spX11Op{Display: "unix:/tmp/.X11-unix/X0"} - }, func() *hst.Config { - c := hst.Template() - *c.Enablements |= hst.Enablements(hst.EX11) - return c - }, nil, []stub.Call{ - call("lookupEnv", stub.ExpectArgs{"DISPLAY"}, "unix:/tmp/.X11-unix/X0", nil), - call("stat", stub.ExpectArgs{"/tmp/.X11-unix/X0"}, (*stubFi)(nil), os.ErrNotExist), - }, newI(). - ChangeHosts("#1000009"), nil, nil, insertsOps(nil), []stub.Call{ - // this op configures the container state and does not make calls during toContainer - }, &container.Params{ - Ops: new(container.Ops). - Bind(absX11SocketDir, absX11SocketDir, 0), - }, paramsWantEnv(config, map[string]string{ - "DISPLAY": "unix:/tmp/.X11-unix/X0", - }, nil), nil}, - - {"success abs abstract", func(isShim, _ bool) outcomeOp { - if !isShim { - return new(spX11Op) - } - return &spX11Op{Display: "unix:/tmp/.X11-unix/X0"} - }, func() *hst.Config { - c := hst.Template() - *c.Enablements |= hst.Enablements(hst.EX11) - c.Container.Flags &= ^hst.FHostAbstract - return c - }, nil, []stub.Call{ - call("lookupEnv", stub.ExpectArgs{"DISPLAY"}, "unix:/tmp/.X11-unix/X0", nil), - call("stat", stub.ExpectArgs{"/tmp/.X11-unix/X0"}, (*stubFi)(nil), nil), - }, newI(). - UpdatePermType(hst.EX11, m("/tmp/.X11-unix/X0"), acl.Read, acl.Write, acl.Execute). - ChangeHosts("#1000009"), nil, nil, insertsOps(nil), []stub.Call{ - // this op configures the container state and does not make calls during toContainer - }, &container.Params{ - Ops: new(container.Ops). - Bind(absX11SocketDir, absX11SocketDir, 0), - }, paramsWantEnv(config, map[string]string{ - "DISPLAY": "unix:/tmp/.X11-unix/X0", - }, nil), nil}, - - {"success", func(isShim, _ bool) outcomeOp { - if !isShim { - return new(spX11Op) - } - return &spX11Op{Display: ":0"} - }, func() *hst.Config { - c := hst.Template() - *c.Enablements |= hst.Enablements(hst.EX11) - return c - }, nil, []stub.Call{ - call("lookupEnv", stub.ExpectArgs{"DISPLAY"}, ":0", nil), - call("stat", stub.ExpectArgs{"/tmp/.X11-unix/X0"}, (*stubFi)(nil), nil), - }, newI(). - UpdatePermType(hst.EX11, m("/tmp/.X11-unix/X0"), acl.Read, acl.Write, acl.Execute). - ChangeHosts("#1000009"), nil, nil, insertsOps(nil), []stub.Call{ - // this op configures the container state and does not make calls during toContainer - }, &container.Params{ - Ops: new(container.Ops). - Bind(absX11SocketDir, absX11SocketDir, 0), - }, paramsWantEnv(config, map[string]string{ - "DISPLAY": ":0", - }, nil), nil}, - }) -} diff --git a/internal/app/state/data.go b/internal/app/state/data.go deleted file mode 100644 index a680c96b..00000000 --- a/internal/app/state/data.go +++ /dev/null @@ -1,52 +0,0 @@ -package state - -import ( - "encoding/gob" - "fmt" - "io" - "os" - - "hakurei.app/hst" -) - -// entryEncode encodes [hst.State] into [io.Writer] with the state entry header. -// entryEncode does not validate the embedded [hst.Config] value. -// -// A non-nil error returned by entryEncode is of type [hst.AppError]. -func entryEncode(w io.Writer, s *hst.State) error { - if err := entryWriteHeader(w, s.Enablements.Unwrap()); err != nil { - return &hst.AppError{Step: "encode state header", Err: err} - } else if err = gob.NewEncoder(w).Encode(s); err != nil { - return &hst.AppError{Step: "encode state body", Err: err} - } else { - return nil - } -} - -// entryDecodeHeader calls entryReadHeader, returning [hst.AppError] for a non-nil error. -func entryDecodeHeader(r io.Reader) (hst.Enablement, error) { - if et, err := entryReadHeader(r); err != nil { - return 0, &hst.AppError{Step: "decode state header", Err: err} - } else { - return et, nil - } -} - -// entryDecode decodes [hst.State] from [io.Reader] and stores the result in the value pointed to by p. -// entryDecode validates the embedded [hst.Config] value. -// -// A non-nil error returned by entryDecode is of type [hst.AppError]. -func entryDecode(r io.Reader, p *hst.State) (hst.Enablement, error) { - if et, err := entryDecodeHeader(r); err != nil { - return et, err - } else if err = gob.NewDecoder(r).Decode(&p); err != nil { - return et, &hst.AppError{Step: "decode state body", Err: err} - } else if err = p.Config.Validate(); err != nil { - return et, err - } else if p.Enablements.Unwrap() != et { - return et, &hst.AppError{Step: "validate state enablement", Err: os.ErrInvalid, - Msg: fmt.Sprintf("state entry %s has unexpected enablement byte %#x, %#x", p.ID.String(), byte(p.Enablements.Unwrap()), byte(et))} - } else { - return et, nil - } -} diff --git a/internal/app/state/data_test.go b/internal/app/state/data_test.go deleted file mode 100644 index 6ad91e53..00000000 --- a/internal/app/state/data_test.go +++ /dev/null @@ -1,145 +0,0 @@ -package state - -import ( - "bytes" - "encoding/gob" - "errors" - "io" - "os" - "reflect" - "strings" - "testing" - "time" - - "hakurei.app/container/stub" - "hakurei.app/hst" -) - -func TestEntryData(t *testing.T) { - t.Parallel() - - mustEncodeGob := func(e any) string { - var buf bytes.Buffer - if err := gob.NewEncoder(&buf).Encode(e); err != nil { - t.Fatalf("cannot encode invalid state: %v", err) - return "\x00" // not reached - } else { - return buf.String() - } - } - templateStateGob := mustEncodeGob(newTemplateState()) - - testCases := []struct { - name string - data string - s *hst.State - err error - }{ - {"invalid header", "\x00\xff\xca\xfe\xff\xff\xff\x00", nil, &hst.AppError{ - Step: "decode state header", Err: errors.New("unexpected revision ffff")}}, - - {"invalid gob", "\x00\xff\xca\xfe\x00\x00\xff\x00", nil, &hst.AppError{ - Step: "decode state body", Err: io.EOF}}, - - {"invalid config", "\x00\xff\xca\xfe\x00\x00\xff\x00" + mustEncodeGob(new(hst.State)), new(hst.State), &hst.AppError{ - Step: "validate configuration", Err: hst.ErrConfigNull, - Msg: "invalid configuration"}}, - - {"inconsistent enablement", "\x00\xff\xca\xfe\x00\x00\xff\x00" + templateStateGob, newTemplateState(), &hst.AppError{ - Step: "validate state enablement", Err: os.ErrInvalid, - Msg: "state entry aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa has unexpected enablement byte 0xd, 0xff"}}, - - {"template", "\x00\xff\xca\xfe\x00\x00\x0d\xf2" + templateStateGob, newTemplateState(), nil}, - } - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - t.Run("encode", func(t *testing.T) { - if tc.s == nil || tc.s.Config == nil { - return - } - t.Parallel() - - var buf bytes.Buffer - if err := entryEncode(&buf, tc.s); err != nil { - t.Fatalf("entryEncode: error = %v", err) - } - - if tc.err == nil { - // Gob encoding is not guaranteed to be deterministic. - // While the current implementation mostly is, it has randomised order - // for iterating over maps, and hst.Config holds a map for environ. - var got hst.State - if et, err := entryDecode(&buf, &got); err != nil { - t.Fatalf("entryDecode: error = %v", err) - } else if stateEt := got.Enablements.Unwrap(); et != stateEt { - t.Fatalf("entryDecode: et = %x, state %x", et, stateEt) - } - if !reflect.DeepEqual(&got, tc.s) { - t.Errorf("entryEncode: %x", buf.Bytes()) - } - } else if testing.Verbose() { - t.Logf("%x", buf.String()) - } - }) - - t.Run("decode", func(t *testing.T) { - t.Parallel() - - var got hst.State - if et, err := entryDecode(strings.NewReader(tc.data), &got); !reflect.DeepEqual(err, tc.err) { - t.Fatalf("entryDecode: error = %#v, want %#v", err, tc.err) - } else if err != nil { - return - } else if stateEt := got.Enablements.Unwrap(); et != stateEt { - t.Fatalf("entryDecode: et = %x, state %x", et, stateEt) - } - - if !reflect.DeepEqual(&got, tc.s) { - t.Errorf("entryDecode: %#v, want %#v", &got, tc.s) - } - }) - }) - } - - t.Run("encode fault", func(t *testing.T) { - t.Parallel() - s := newTemplateState() - - t.Run("gob", func(t *testing.T) { - var want = &hst.AppError{Step: "encode state body", Err: stub.UniqueError(0xcafe)} - if err := entryEncode(stubNErrorWriter(entryHeaderSize), s); !reflect.DeepEqual(err, want) { - t.Errorf("entryEncode: error = %#v, want %#v", err, want) - } - }) - - t.Run("header", func(t *testing.T) { - var want = &hst.AppError{Step: "encode state header", Err: stub.UniqueError(0xcafe)} - if err := entryEncode(stubNErrorWriter(entryHeaderSize-1), s); !reflect.DeepEqual(err, want) { - t.Errorf("entryEncode: error = %#v, want %#v", err, want) - } - }) - }) -} - -// newTemplateState returns the address of a new template [hst.State] struct. -func newTemplateState() *hst.State { - return &hst.State{ - ID: hst.ID(bytes.Repeat([]byte{0xaa}, len(hst.ID{}))), - PID: 0xcafebabe, - ShimPID: 0xdeadbeef, - Config: hst.Template(), - Time: time.Unix(0, 0), - } -} - -// stubNErrorWriter returns an error for writes above a certain size. -type stubNErrorWriter int - -func (w stubNErrorWriter) Write(p []byte) (n int, err error) { - if len(p) > int(w) { - return int(w), stub.UniqueError(0xcafe) - } - return io.Discard.Write(p) -} diff --git a/internal/app/state/header.go b/internal/app/state/header.go deleted file mode 100644 index 25b1e6af..00000000 --- a/internal/app/state/header.go +++ /dev/null @@ -1,86 +0,0 @@ -package state - -import ( - "encoding/hex" - "errors" - "io" - "os" - "strconv" - "syscall" - - "hakurei.app/hst" -) - -const ( - // entryHeaderMagic are magic bytes at the beginning of the state entry file. - entryHeaderMagic = "\x00\xff\xca\xfe" - // entryHeaderRevision follows entryHeaderMagic and is incremented for revisions of the format. - entryHeaderRevision = "\x00\x00" - // entryHeaderSize is the fixed size of the header in bytes, including the enablement byte and its complement. - entryHeaderSize = len(entryHeaderMagic+entryHeaderRevision) + 2 -) - -// entryHeaderEncode encodes a state entry header for a [hst.Enablement] byte. -func entryHeaderEncode(et hst.Enablement) *[entryHeaderSize]byte { - data := [entryHeaderSize]byte([]byte( - entryHeaderMagic + entryHeaderRevision + string([]hst.Enablement{et, ^et}), - )) - return &data -} - -// entryHeaderDecode validates a state entry header and returns the [hst.Enablement] byte. -func entryHeaderDecode(data *[entryHeaderSize]byte) (hst.Enablement, error) { - if magic := data[:len(entryHeaderMagic)]; string(magic) != entryHeaderMagic { - return 0, errors.New("invalid header " + hex.EncodeToString(magic)) - } - if revision := data[len(entryHeaderMagic):len(entryHeaderMagic+entryHeaderRevision)]; string(revision) != entryHeaderRevision { - return 0, errors.New("unexpected revision " + hex.EncodeToString(revision)) - } - - et := data[len(entryHeaderMagic+entryHeaderRevision)] - if et != ^data[len(entryHeaderMagic+entryHeaderRevision)+1] { - return 0, errors.New("header enablement value is inconsistent") - } - return hst.Enablement(et), nil -} - -// EntrySizeError is returned for a file too small to hold a state entry header. -type EntrySizeError struct { - Name string - Size int64 -} - -func (e *EntrySizeError) Error() string { - if e.Name == "" { - return "state entry file is too short" - } - return "state entry file " + strconv.Quote(e.Name) + " is too short" -} - -// entryCheckFile checks whether [os.FileInfo] refers to a file that might hold [hst.State]. -func entryCheckFile(fi os.FileInfo) error { - if fi.IsDir() { - return syscall.EISDIR - } - if s := fi.Size(); s <= int64(entryHeaderSize) { - return &EntrySizeError{Name: fi.Name(), Size: s} - } - return nil -} - -// entryReadHeader reads [hst.Enablement] from an [io.Reader]. -func entryReadHeader(r io.Reader) (hst.Enablement, error) { - var data [entryHeaderSize]byte - if n, err := r.Read(data[:]); err != nil { - return 0, err - } else if n != entryHeaderSize { - return 0, &EntrySizeError{Size: int64(n)} - } - return entryHeaderDecode(&data) -} - -// entryWriteHeader writes [hst.Enablement] header to an [io.Writer]. -func entryWriteHeader(w io.Writer, et hst.Enablement) error { - _, err := w.Write(entryHeaderEncode(et)[:]) - return err -} diff --git a/internal/app/state/header_test.go b/internal/app/state/header_test.go deleted file mode 100644 index 18a620c2..00000000 --- a/internal/app/state/header_test.go +++ /dev/null @@ -1,184 +0,0 @@ -package state - -import ( - "bytes" - "errors" - "io" - "io/fs" - "os" - "reflect" - "syscall" - "testing" - "time" - - "hakurei.app/hst" -) - -func TestEntryHeader(t *testing.T) { - t.Parallel() - - testCases := []struct { - name string - data [entryHeaderSize]byte - et hst.Enablement - err error - }{ - {"complement mismatch", [entryHeaderSize]byte{0x00, 0xff, 0xca, 0xfe, 0x00, 0x00, - 0x0a, 0xf6}, 0, - errors.New("header enablement value is inconsistent")}, - {"unexpected revision", [entryHeaderSize]byte{0x00, 0xff, 0xca, 0xfe, 0xff, 0xff}, 0, - errors.New("unexpected revision ffff")}, - {"invalid header", [entryHeaderSize]byte{0x00, 0xfe, 0xca, 0xfe}, 0, - errors.New("invalid header 00fecafe")}, - - {"success high", [entryHeaderSize]byte{0x00, 0xff, 0xca, 0xfe, 0x00, 0x00, - 0xff, 0x00}, 0xff, nil}, - {"success", [entryHeaderSize]byte{0x00, 0xff, 0xca, 0xfe, 0x00, 0x00, - 0x09, 0xf6}, hst.EWayland | hst.EPulse, nil}, - } - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - t.Run("encode", func(t *testing.T) { - if tc.err != nil { - return - } - t.Parallel() - - if got := entryHeaderEncode(tc.et); *got != tc.data { - t.Errorf("entryHeaderEncode: %x, want %x", *got, tc.data) - } - - t.Run("write", func(t *testing.T) { - var buf bytes.Buffer - if err := entryWriteHeader(&buf, tc.et); err != nil { - t.Fatalf("entryWriteHeader: error = %v", err) - } - if got := ([entryHeaderSize]byte)(buf.Bytes()); got != tc.data { - t.Errorf("entryWriteHeader: %x, want %x", got, tc.data) - } - }) - }) - - t.Run("decode", func(t *testing.T) { - t.Parallel() - - got, err := entryHeaderDecode(&tc.data) - if !reflect.DeepEqual(err, tc.err) { - t.Fatalf("entryHeaderDecode: error = %#v, want %#v", err, tc.err) - } - if err != nil { - return - } - if got != tc.et { - t.Errorf("entryHeaderDecode: et = %q, want %q", got, tc.et) - } - - if got, err = entryReadHeader(bytes.NewReader(tc.data[:])); err != nil { - t.Fatalf("entryReadHeader: error = %#v", err) - } else if got != tc.et { - t.Errorf("entryReadHeader: et = %q, want %q", got, tc.et) - } - }) - }) - } -} - -func TestEntrySizeError(t *testing.T) { - t.Parallel() - - testCases := []struct { - name string - err error - want string - }{ - {"size only", &EntrySizeError{Size: 0xdeadbeef}, - `state entry file is too short`}, - {"full", &EntrySizeError{Name: "nonexistent", Size: 0xdeadbeef}, - `state entry file "nonexistent" is too short`}, - } - 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: %s, want %s", got, tc.want) - } - }) - } -} - -func TestEntryCheckFile(t *testing.T) { - t.Parallel() - - testCases := []struct { - name string - fi os.FileInfo - err error - }{ - {"dir", &stubFi{name: "dir", isDir: true}, - syscall.EISDIR}, - {"short", stubFi{name: "short", size: 8}, - &EntrySizeError{Name: "short", Size: 8}}, - {"success", stubFi{size: 9}, nil}, - } - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - if err := entryCheckFile(tc.fi); !reflect.DeepEqual(err, tc.err) { - t.Errorf("entryCheckFile: error = %#v, want %#v", err, tc.err) - } - }) - } -} - -func TestEntryReadHeader(t *testing.T) { - t.Parallel() - - testCases := []struct { - name string - newR func() io.Reader - err error - }{ - {"eof", func() io.Reader { return bytes.NewReader([]byte{}) }, io.EOF}, - {"short", func() io.Reader { return bytes.NewReader([]byte{0}) }, &EntrySizeError{Size: 1}}, - } - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - if _, err := entryReadHeader(tc.newR()); !reflect.DeepEqual(err, tc.err) { - t.Errorf("entryReadHeader: error = %#v, want %#v", err, tc.err) - } - }) - } -} - -// stubFi partially implements [os.FileInfo] using hardcoded values. -type stubFi struct { - name string - size int64 - isDir bool -} - -func (fi stubFi) Name() string { - if fi.name == "" { - panic("unreachable") - } - return fi.name -} - -func (fi stubFi) Size() int64 { - if fi.size < 0 { - panic("unreachable") - } - return fi.size -} - -func (fi stubFi) IsDir() bool { return fi.isDir } - -func (fi stubFi) Mode() fs.FileMode { panic("unreachable") } -func (fi stubFi) ModTime() time.Time { panic("unreachable") } -func (fi stubFi) Sys() any { panic("unreachable") } diff --git a/internal/app/state/join.go b/internal/app/state/join.go deleted file mode 100644 index 43a19b1d..00000000 --- a/internal/app/state/join.go +++ /dev/null @@ -1,64 +0,0 @@ -package state - -import ( - "errors" - "maps" - - "hakurei.app/hst" -) - -var ( - ErrDuplicate = errors.New("store contains duplicates") -) - -/* -Joiner is the interface that wraps the Join method. - -The Join function uses Joiner if available. -*/ -type Joiner interface { - Join() (map[hst.ID]*hst.State, error) -} - -// Join returns joined state entries of all active identities. -func Join(s Store) (map[hst.ID]*hst.State, error) { - if j, ok := s.(Joiner); ok { - return j.Join() - } - - var ( - aids []int - entries = make(map[hst.ID]*hst.State) - - el int - res map[hst.ID]*hst.State - loadErr error - ) - - if ln, err := s.List(); err != nil { - return nil, err - } else { - aids = ln - } - - for _, aid := range aids { - if _, err := s.Do(aid, func(c Cursor) { - res, loadErr = c.Load() - }); err != nil { - return nil, err - } - - if loadErr != nil { - return nil, loadErr - } - - // save expected length - el = len(entries) + len(res) - maps.Copy(entries, res) - if len(entries) != el { - return nil, ErrDuplicate - } - } - - return entries, nil -} diff --git a/internal/app/state/segment.go b/internal/app/state/segment.go deleted file mode 100644 index c8ab8c13..00000000 --- a/internal/app/state/segment.go +++ /dev/null @@ -1,161 +0,0 @@ -package state - -import ( - "errors" - "fmt" - "iter" - "os" - "strconv" - "sync" - - "hakurei.app/container/check" - "hakurei.app/hst" - "hakurei.app/internal/lockedfile" -) - -// stateEntryHandle is a handle on a state entry retrieved from a storeHandle. -// Must only be used while its parent storeHandle.fileMu is held. -type stateEntryHandle struct { - // Error returned while decoding pathname. - // A non-nil value disables stateEntryHandle. - decodeErr error - - // Checked path to entry file. - pathname *check.Absolute - - hst.ID -} - -// open opens the underlying state entry file, returning [hst.AppError] for a non-nil error. -func (eh *stateEntryHandle) open(flag int, perm os.FileMode) (*os.File, error) { - if eh.decodeErr != nil { - return nil, eh.decodeErr - } - - if f, err := os.OpenFile(eh.pathname.String(), flag, perm); err != nil { - return nil, &hst.AppError{Step: "open state entry", Err: err} - } else { - return f, nil - } -} - -// destroy removes the underlying state entry file, returning [hst.AppError] for a non-nil error. -func (eh *stateEntryHandle) destroy() error { - // destroy does not go through open - if eh.decodeErr != nil { - return eh.decodeErr - } - - if err := os.Remove(eh.pathname.String()); err != nil { - return &hst.AppError{Step: "destroy state entry", Err: err} - } - return nil -} - -// save encodes [hst.State] and writes it to the underlying file. -// An error is returned if a file already exists with the same identifier. -// save does not validate the embedded [hst.Config]. -func (eh *stateEntryHandle) save(state *hst.State) error { - f, err := eh.open(os.O_RDWR|os.O_CREATE|os.O_EXCL, 0600) - if err != nil { - return err - } - - err = entryEncode(f, state) - if closeErr := f.Close(); closeErr != nil && err == nil { - err = &hst.AppError{Step: "close state file", Err: closeErr} - } - return err -} - -// load loads and validates the state entry header, and returns the [hst.Enablement] byte. -// for a non-nil v, the full state payload is decoded and stored in the value pointed to by v. -// load validates the embedded hst.Config value. -func (eh *stateEntryHandle) load(v *hst.State) (hst.Enablement, error) { - f, err := eh.open(os.O_RDONLY, 0) - if err != nil { - return 0, err - } - - var et hst.Enablement - if v != nil { - et, err = entryDecode(f, v) - if err == nil && v.ID != eh.ID { - err = &hst.AppError{Step: "validate state identifier", Err: os.ErrInvalid, - Msg: fmt.Sprintf("state entry %s has unexpected id %s", eh.ID.String(), v.ID.String())} - } - } else { - et, err = entryDecodeHeader(f) - } - - if closeErr := f.Close(); closeErr != nil && err == nil { - err = &hst.AppError{Step: "close state file", Err: closeErr} - } - return et, err -} - -// storeHandle is a handle on a stateStore segment. -// Initialised by stateStore.identityHandle. -type storeHandle struct { - // Identity of instances tracked by this segment. - identity int - // Pathname of directory that the segment referred to by storeHandle is rooted in. - path *check.Absolute - // Inter-process mutex to synchronise operations against resources in this segment. - fileMu *lockedfile.Mutex - - // Must be held alongside fileMu. - mu sync.Mutex -} - -// entries returns an iterator over all stateEntryHandle held in this segment. -// Must be called while holding a lock on mu and fileMu. -// A non-nil error attached to a stateEntryHandle indicates a malformed identifier and is of type [hst.AppError]. -// A non-nil error returned by entries is of type [hst.AppError]. -func (h *storeHandle) entries() (iter.Seq[*stateEntryHandle], int, error) { - // for error reporting - const step = "read store segment entries" - - // read directory contents, should only contain storeMutexName and identifier - var entries []os.DirEntry - if pl, err := os.ReadDir(h.path.String()); err != nil { - return nil, -1, &hst.AppError{Step: step, Err: err} - } else { - entries = pl - } - - // expects lock file - l := len(entries) - if l > 0 { - l-- - } - - return func(yield func(*stateEntryHandle) bool) { - for _, ent := range entries { - var eh = stateEntryHandle{pathname: h.path.Append(ent.Name())} - - // this should never happen - if ent.IsDir() { - eh.decodeErr = &hst.AppError{Step: step, - Err: errors.New("unexpected directory " + strconv.Quote(ent.Name()) + " in store")} - goto out - } - - // silently skip lock file - if ent.Name() == storeMutexName { - continue - } - - // this either indicates a serious bug or external interference - if err := eh.ID.UnmarshalText([]byte(ent.Name())); err != nil { - eh.decodeErr = &hst.AppError{Step: "decode store segment entry", Err: err} - goto out - } - - out: - if !yield(&eh) { - break - } - } - }, l, nil -} diff --git a/internal/app/state/segment_test.go b/internal/app/state/segment_test.go deleted file mode 100644 index 834cb99a..00000000 --- a/internal/app/state/segment_test.go +++ /dev/null @@ -1,259 +0,0 @@ -package state - -import ( - "errors" - "iter" - "os" - "reflect" - "slices" - "strings" - "syscall" - "testing" - - "hakurei.app/container/check" - "hakurei.app/container/stub" - "hakurei.app/hst" - "hakurei.app/internal/lockedfile" -) - -func TestStateEntryHandle(t *testing.T) { - t.Parallel() - - t.Run("lockout", func(t *testing.T) { - t.Parallel() - wantErr := func() error { return stub.UniqueError(0) } - eh := stateEntryHandle{decodeErr: wantErr(), pathname: check.MustAbs("/proc/nonexistent")} - - if _, err := eh.open(-1, 0); !reflect.DeepEqual(err, wantErr()) { - t.Errorf("open: error = %v, want %v", err, wantErr()) - } - if err := eh.destroy(); !reflect.DeepEqual(err, wantErr()) { - t.Errorf("destroy: error = %v, want %v", err, wantErr()) - } - if err := eh.save(nil); !reflect.DeepEqual(err, wantErr()) { - t.Errorf("save: error = %v, want %v", err, wantErr()) - } - if _, err := eh.load(nil); !reflect.DeepEqual(err, wantErr()) { - t.Errorf("load: error = %v, want %v", err, wantErr()) - } - }) - - t.Run("od", func(t *testing.T) { - t.Parallel() - - { - eh := stateEntryHandle{pathname: check.MustAbs(t.TempDir()).Append("entry")} - if f, err := eh.open(os.O_CREATE|syscall.O_EXCL, 0); err != nil { - t.Fatalf("open: error = %v", err) - } else if err = f.Close(); err != nil { - t.Errorf("Close: error = %v", err) - } - if err := eh.destroy(); err != nil { - t.Fatalf("destroy: error = %v", err) - } - } - - t.Run("nonexistent", func(t *testing.T) { - t.Parallel() - eh := stateEntryHandle{pathname: check.MustAbs("/proc/nonexistent")} - - wantErrOpen := &hst.AppError{Step: "open state entry", - Err: &os.PathError{Op: "open", Path: "/proc/nonexistent", Err: syscall.ENOENT}} - if _, err := eh.open(os.O_CREATE|syscall.O_EXCL, 0); !reflect.DeepEqual(err, wantErrOpen) { - t.Errorf("open: error = %#v, want %#v", err, wantErrOpen) - } - - wantErrDestroy := &hst.AppError{Step: "destroy state entry", - Err: &os.PathError{Op: "remove", Path: "/proc/nonexistent", Err: syscall.ENOENT}} - if err := eh.destroy(); !reflect.DeepEqual(err, wantErrDestroy) { - t.Errorf("destroy: error = %#v, want %#v", err, wantErrDestroy) - } - }) - }) - - t.Run("saveload", func(t *testing.T) { - t.Parallel() - eh := stateEntryHandle{pathname: check.MustAbs(t.TempDir()).Append("entry"), - ID: newTemplateState().ID} - - if err := eh.save(newTemplateState()); err != nil { - t.Fatalf("save: error = %v", err) - } - - t.Run("validate", func(t *testing.T) { - t.Parallel() - - t.Run("internal", func(t *testing.T) { - t.Parallel() - - var got hst.State - if f, err := os.Open(eh.pathname.String()); err != nil { - t.Fatal(err.Error()) - } else if _, err = entryDecode(f, &got); err != nil { - t.Fatalf("entryDecode: error = %v", err) - } else if err = f.Close(); err != nil { - t.Fatal(f.Close()) - } - - if want := newTemplateState(); !reflect.DeepEqual(&got, want) { - t.Errorf("entryDecode: %#v, want %#v", &got, want) - } - }) - - t.Run("load header only", func(t *testing.T) { - t.Parallel() - - if et, err := eh.load(nil); err != nil { - t.Fatalf("load: error = %v", err) - } else if want := newTemplateState().Enablements.Unwrap(); et != want { - t.Errorf("load: et = %x, want %x", et, want) - } - }) - - t.Run("load", func(t *testing.T) { - t.Parallel() - - var got hst.State - if _, err := eh.load(&got); err != nil { - t.Fatalf("load: error = %v", err) - } else if want := newTemplateState(); !reflect.DeepEqual(&got, want) { - t.Errorf("load: %#v, want %#v", &got, want) - } - }) - - t.Run("load inconsistent", func(t *testing.T) { - t.Parallel() - wantErr := &hst.AppError{Step: "validate state identifier", Err: os.ErrInvalid, - Msg: "state entry 00000000000000000000000000000000 has unexpected id aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"} - - ehi := stateEntryHandle{pathname: eh.pathname} - if _, err := ehi.load(new(hst.State)); !reflect.DeepEqual(err, wantErr) { - t.Errorf("load: error = %#v, want %#v", err, wantErr) - } - }) - }) - }) -} - -func TestStoreHandle(t *testing.T) { - t.Parallel() - - testCases := []struct { - name string - ents [2][]string - want func(newEh func(err error, name string) *stateEntryHandle) []*stateEntryHandle - ext func(t *testing.T, entries iter.Seq[*stateEntryHandle], n int) - }{ - {"errors", [2][]string{{ - "e81eb203b4190ac5c3842ef44d429945", - "lock", - "f0-invalid", - }, { - "f1-directory", - }}, func(newEh func(err error, name string) *stateEntryHandle) []*stateEntryHandle { - return []*stateEntryHandle{ - newEh(nil, "e81eb203b4190ac5c3842ef44d429945"), - newEh(&hst.AppError{Step: "decode store segment entry", - Err: hst.IdentifierDecodeError{Err: hst.ErrIdentifierLength}}, "f0-invalid"), - newEh(&hst.AppError{Step: "read store segment entries", - Err: errors.New(`unexpected directory "f1-directory" in store`)}, "f1-directory"), - } - }, nil}, - - {"success", [2][]string{{ - "e81eb203b4190ac5c3842ef44d429945", - "7958cfbb9272d9cf9cfd61c85afa13f1", - "d0b5f7446dd5bd3424ff2f7ac9cace1e", - "c8c8e2c4aea5c32fe47240ff8caa874e", - "fa0d30b249d80f155a1f80ceddcc32f2", - "lock", - }}, func(newEh func(err error, name string) *stateEntryHandle) []*stateEntryHandle { - return []*stateEntryHandle{ - newEh(nil, "7958cfbb9272d9cf9cfd61c85afa13f1"), - newEh(nil, "c8c8e2c4aea5c32fe47240ff8caa874e"), - newEh(nil, "d0b5f7446dd5bd3424ff2f7ac9cace1e"), - newEh(nil, "e81eb203b4190ac5c3842ef44d429945"), - newEh(nil, "fa0d30b249d80f155a1f80ceddcc32f2"), - } - }, func(t *testing.T, entries iter.Seq[*stateEntryHandle], n int) { - if n != 5 { - t.Fatalf("entries: n = %d", n) - } - - // check partial drain - for range entries { - break - } - }}, - } - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - p := check.MustAbs(t.TempDir()).Append("segment") - if err := os.Mkdir(p.String(), 0700); err != nil { - t.Fatal(err.Error()) - } - createEntries(t, p, tc.ents) - - var got []*stateEntryHandle - if entries, n, err := (&storeHandle{ - identity: -0xbad, - path: p, - fileMu: lockedfile.MutexAt(p.Append("lock").String()), - }).entries(); err != nil { - t.Fatalf("entries: error = %v", err) - } else { - got = slices.AppendSeq(make([]*stateEntryHandle, 0, n), entries) - if tc.ext != nil { - tc.ext(t, entries, n) - } - } - - slices.SortFunc(got, func(a, b *stateEntryHandle) int { return strings.Compare(a.pathname.String(), b.pathname.String()) }) - want := tc.want(func(err error, name string) *stateEntryHandle { - eh := stateEntryHandle{decodeErr: err, pathname: p.Append(name)} - if err == nil { - if err = eh.UnmarshalText([]byte(name)); err != nil { - t.Fatalf("UnmarshalText: error = %v", err) - } - } - return &eh - }) - - if !reflect.DeepEqual(got, want) { - t.Errorf("entries: %q, want %q", got, want) - } - }) - } - - t.Run("nonexistent", func(t *testing.T) { - var wantErr = &hst.AppError{Step: "read store segment entries", Err: &os.PathError{ - Op: "open", - Path: "/proc/nonexistent", - Err: syscall.ENOENT, - }} - if _, _, err := (&storeHandle{ - identity: -0xbad, - path: check.MustAbs("/proc/nonexistent"), - }).entries(); !reflect.DeepEqual(err, wantErr) { - t.Fatalf("entries: error = %#v, want %#v", err, wantErr) - } - }) -} - -// createEntries creates file and directory entries in the specified prefix. -func createEntries(t *testing.T, prefix *check.Absolute, ents [2][]string) { - for _, s := range ents[0] { - if f, err := os.OpenFile(prefix.Append(s).String(), os.O_CREATE|os.O_EXCL, 0600); err != nil { - t.Fatal(err.Error()) - } else if err = f.Close(); err != nil { - t.Fatal(err.Error()) - } - } - for _, s := range ents[1] { - if err := os.Mkdir(prefix.Append(s).String(), 0700); err != nil { - t.Fatal(err.Error()) - } - } -} diff --git a/internal/app/state/state.go b/internal/app/state/state.go deleted file mode 100644 index 62b31865..00000000 --- a/internal/app/state/state.go +++ /dev/null @@ -1,131 +0,0 @@ -// Package state provides cross-process state tracking for hakurei container instances. -package state - -import ( - "strconv" - - "hakurei.app/container/check" - "hakurei.app/hst" - "hakurei.app/message" -) - -/* this provides an implementation of Store on top of the improved state tracking to ease in the changes */ - -type Store interface { - // Do calls f exactly once and ensures store exclusivity until f returns. - // Returns whether f is called and any errors during the locking process. - // Cursor provided to f becomes invalid as soon as f returns. - Do(identity int, f func(c Cursor)) (ok bool, err error) - - // List queries the store and returns a list of identities known to the store. - // Note that some or all returned identities might not have any active apps. - List() (identities []int, err error) -} - -func (s *stateStore) Do(identity int, f func(c Cursor)) (bool, error) { - if h, err := s.identityHandle(identity); err != nil { - return false, err - } else { - return h.do(f) - } -} - -// storeAdapter satisfies [Store] via stateStore. -type storeAdapter struct { - msg message.Msg - *stateStore -} - -func (s storeAdapter) List() ([]int, error) { - segments, n, err := s.segments() - if err != nil { - return nil, err - } - - identities := make([]int, 0, n) - for si := range segments { - if si.err != nil { - if m, ok := message.GetMessage(err); ok { - s.msg.Verbose(m) - } else { - // unreachable - return nil, err - } - continue - } - identities = append(identities, si.identity) - } - return identities, nil -} - -// NewMulti returns an instance of the multi-file store. -func NewMulti(msg message.Msg, prefix *check.Absolute) Store { - return storeAdapter{msg, newStore(prefix.Append("state"))} -} - -// Cursor provides access to the store of an identity. -type Cursor interface { - Save(state *hst.State) error - Destroy(id hst.ID) error - Load() (map[hst.ID]*hst.State, error) - Len() (int, error) -} - -// do implements stateStore.Do on storeHandle. -func (h *storeHandle) do(f func(c Cursor)) (bool, error) { - if unlock, err := h.fileMu.Lock(); err != nil { - return false, &hst.AppError{Step: "acquire lock on store segment " + strconv.Itoa(h.identity), Err: err} - } else { - defer unlock() - } - - f(h) - return true, nil -} - -/* these compatibility methods must only be called while fileMu is held */ - -func (h *storeHandle) Save(state *hst.State) error { - return (&stateEntryHandle{nil, h.path.Append(state.ID.String()), state.ID}).save(state) -} - -func (h *storeHandle) Destroy(id hst.ID) error { - return (&stateEntryHandle{nil, h.path.Append(id.String()), id}).destroy() -} - -func (h *storeHandle) Load() (map[hst.ID]*hst.State, error) { - entries, n, err := h.entries() - if err != nil { - return nil, err - } - - r := make(map[hst.ID]*hst.State, n) - for eh := range entries { - if eh.decodeErr != nil { - err = eh.decodeErr - break - } - var s hst.State - if _, err = eh.load(&s); err != nil { - break - } - r[eh.ID] = &s - } - return r, err -} - -func (h *storeHandle) Len() (int, error) { - entries, _, err := h.entries() - if err != nil { - return -1, err - } - - var n int - for eh := range entries { - if eh.decodeErr != nil { - err = eh.decodeErr - } - n++ - } - return n, err -} diff --git a/internal/app/state/state_test.go b/internal/app/state/state_test.go deleted file mode 100644 index c387b2ec..00000000 --- a/internal/app/state/state_test.go +++ /dev/null @@ -1,120 +0,0 @@ -package state_test - -import ( - "log" - "math/rand" - "reflect" - "slices" - "testing" - "time" - - "hakurei.app/container/check" - "hakurei.app/hst" - "hakurei.app/internal/app/state" - "hakurei.app/message" -) - -func TestMulti(t *testing.T) { - s := state.NewMulti(message.NewMsg(log.New(log.Writer(), "multi: ", 0)), check.MustAbs(t.TempDir())) - - t.Run("list empty store", func(t *testing.T) { - if identities, err := s.List(); err != nil { - t.Fatalf("List: error = %v", err) - } else if len(identities) != 0 { - t.Fatalf("List: identities = %#v", identities) - } - }) - - const ( - insertEntryChecked = iota - insertEntryNoCheck - insertEntryOtherApp - - tl - ) - - var tc [tl]hst.State - for i := 0; i < tl; i++ { - if err := hst.NewInstanceID(&tc[i].ID); err != nil { - t.Fatalf("cannot create dummy state: %v", err) - } - tc[i].PID = rand.Int() - tc[i].Config = hst.Template() - tc[i].Time = time.Now() - } - - do := func(identity int, f func(c state.Cursor)) { - if ok, err := s.Do(identity, f); err != nil { - t.Fatalf("Do: ok = %v, error = %v", ok, err) - } - } - - insert := func(i, identity int) { - do(identity, func(c state.Cursor) { - if err := c.Save(&tc[i]); err != nil { - t.Fatalf("Save: error = %v", err) - } - }) - } - - check := func(i, identity int) { - do(identity, func(c state.Cursor) { - if entries, err := c.Load(); err != nil { - t.Fatalf("Load: error = %v", err) - } else if got, ok := entries[tc[i].ID]; !ok { - t.Fatalf("Load: entry %s missing", &tc[i].ID) - } else { - got.Time = tc[i].Time - if !reflect.DeepEqual(got, &tc[i]) { - t.Fatalf("Load: entry %s got %#v, want %#v", &tc[i].ID, got, &tc[i]) - } - } - }) - } - - // insert entry checked - insert(insertEntryChecked, 0) - check(insertEntryChecked, 0) - - // insert entry unchecked - insert(insertEntryNoCheck, 0) - - // insert entry different identity - insert(insertEntryOtherApp, 1) - check(insertEntryOtherApp, 1) - - // check previous insertion - check(insertEntryNoCheck, 0) - - // list identities - if identities, err := s.List(); err != nil { - t.Fatalf("List: error = %v", err) - } else { - slices.Sort(identities) - want := []int{0, 1} - if !slices.Equal(identities, want) { - t.Fatalf("List() = %#v, want %#v", identities, want) - } - } - - // join store - if entries, err := state.Join(s); err != nil { - t.Fatalf("Join: error = %v", err) - } else if len(entries) != 3 { - t.Fatalf("Join(s) = %#v", entries) - } - - // clear identity 1 - do(1, func(c state.Cursor) { - if err := c.Destroy(tc[insertEntryOtherApp].ID); err != nil { - t.Fatalf("Destroy: error = %v", err) - } - }) - do(1, func(c state.Cursor) { - if l, err := c.Len(); err != nil { - t.Fatalf("Len: error = %v", err) - } else if l != 0 { - t.Fatalf("Len: %d, want 0", l) - } - }) -} diff --git a/internal/app/state/store.go b/internal/app/state/store.go deleted file mode 100644 index 565e4ee5..00000000 --- a/internal/app/state/store.go +++ /dev/null @@ -1,162 +0,0 @@ -package state - -import ( - "errors" - "io/fs" - "iter" - "os" - "strconv" - "sync" - "syscall" - - "hakurei.app/container/check" - "hakurei.app/hst" - "hakurei.app/internal/lockedfile" -) - -// storeMutexName is the pathname of the file backing [lockedfile.Mutex] of a stateStore and storeHandle. -const storeMutexName = "lock" - -// A stateStore keeps track of [hst.State] via a well-known filesystem accessible to all hakurei priv-side processes. -// Access to store data and related resources are synchronised on a per-segment basis via storeHandle. -type stateStore struct { - // Pathname of directory that the store is rooted in. - base *check.Absolute - - // All currently known instances of storeHandle, keyed by their identity. - handles sync.Map - - // Inter-process mutex to synchronise operations against the entire store. - // Held during List and when initialising previously unknown identities during Do. - // Must not be accessed directly. Callers should use the bigLock method instead. - fileMu *lockedfile.Mutex - - // For creating the base directory. - mkdirOnce sync.Once - // Stored error value via mkdirOnce. - mkdirErr error -} - -// bigLock acquires fileMu on stateStore. -// A non-nil error returned by bigLock is of type [hst.AppError]. -func (s *stateStore) bigLock() (unlock func(), err error) { - s.mkdirOnce.Do(func() { s.mkdirErr = os.MkdirAll(s.base.String(), 0700) }) - if s.mkdirErr != nil { - return nil, &hst.AppError{Step: "create state store directory", Err: s.mkdirErr} - } - - if unlock, err = s.fileMu.Lock(); err != nil { - return nil, &hst.AppError{Step: "acquire lock on the state store", Err: err} - } - return -} - -// identityHandle loads or initialises a storeHandle for identity. -// A non-nil error returned by identityHandle is of type [hst.AppError]. -func (s *stateStore) identityHandle(identity int) (*storeHandle, error) { - h := new(storeHandle) - h.mu.Lock() - - if v, ok := s.handles.LoadOrStore(identity, h); ok { - h = v.(*storeHandle) - } else { - // acquire big lock to initialise previously unknown segment handle - if unlock, err := s.bigLock(); err != nil { - return nil, err - } else { - defer unlock() - } - - h.identity = identity - h.path = s.base.Append(strconv.Itoa(identity)) - h.fileMu = lockedfile.MutexAt(h.path.Append(storeMutexName).String()) - - err := os.MkdirAll(h.path.String(), 0700) - h.mu.Unlock() - if err != nil && !errors.Is(err, fs.ErrExist) { - // handle methods will likely return ENOENT - s.handles.CompareAndDelete(identity, h) - return nil, &hst.AppError{Step: "create store segment directory", Err: err} - } - } - return h, nil -} - -// segmentIdentity is produced by the iterator returned by stateStore.segments. -type segmentIdentity struct { - // Identity of the current segment. - identity int - // Error encountered while processing this segment. - err error -} - -// segments returns an iterator over all segmentIdentity known to the store. -// To obtain a storeHandle on a segment, caller must then call identityHandle. -// A non-nil error returned by segments is of type [hst.AppError]. -func (s *stateStore) segments() (iter.Seq[segmentIdentity], int, error) { - // read directory contents, should only contain storeMutexName and identity - var entries []os.DirEntry - - // acquire big lock to read store segment list - if unlock, err := s.bigLock(); err != nil { - return nil, -1, err - } else { - entries, err = os.ReadDir(s.base.String()) - unlock() - - if err != nil && !errors.Is(err, os.ErrNotExist) { - return nil, -1, &hst.AppError{Step: "read store segments", Err: err} - } - } - - // expects lock file - l := len(entries) - if l > 0 { - l-- - } - - return func(yield func(segmentIdentity) bool) { - // for error reporting - const step = "process store segment" - - for _, ent := range entries { - si := segmentIdentity{identity: -1} - - // should only be the big lock - if !ent.IsDir() { - if ent.Name() == storeMutexName { - continue - } - - // this should never happen - si.err = &hst.AppError{Step: step, Err: syscall.EISDIR, - Msg: "skipped non-directory entry " + strconv.Quote(ent.Name())} - goto out - } - - // failure paths either indicates a serious bug or external interference - if v, err := strconv.Atoi(ent.Name()); err != nil { - si.err = &hst.AppError{Step: step, Err: err, - Msg: "skipped non-identity entry " + strconv.Quote(ent.Name())} - goto out - } else if v < hst.IdentityMin || v > hst.IdentityMax { - si.err = &hst.AppError{Step: step, Err: syscall.ERANGE, - Msg: "skipped out of bounds entry " + strconv.Itoa(v)} - goto out - } else { - si.identity = v - } - - out: - if !yield(si) { - break - } - } - }, l, nil -} - -// newStore returns the address of a new instance of stateStore. -// Multiple instances of stateStore rooted in the same directory is supported, but discouraged. -func newStore(base *check.Absolute) *stateStore { - return &stateStore{base: base, fileMu: lockedfile.MutexAt(base.Append(storeMutexName).String())} -} diff --git a/internal/app/state/store_test.go b/internal/app/state/store_test.go deleted file mode 100644 index f58be18f..00000000 --- a/internal/app/state/store_test.go +++ /dev/null @@ -1,254 +0,0 @@ -package state - -import ( - "cmp" - "iter" - "os" - "reflect" - "slices" - "strconv" - "strings" - "syscall" - "testing" - - "hakurei.app/container/check" - "hakurei.app/hst" -) - -func TestStateStoreBigLock(t *testing.T) { - t.Parallel() - - { - s := newStore(check.MustAbs(t.TempDir()).Append("state")) - for i := 0; i < 2; i++ { // check once behaviour - if unlock, err := s.bigLock(); err != nil { - t.Fatalf("bigLock: error = %v", err) - } else { - unlock() - } - } - } - - t.Run("mkdir", func(t *testing.T) { - t.Parallel() - - wantErr := &hst.AppError{Step: "create state store directory", - Err: &os.PathError{Op: "mkdir", Path: "/proc/nonexistent", Err: syscall.ENOENT}} - for i := 0; i < 2; i++ { // check once behaviour - if _, err := newStore(check.MustAbs("/proc/nonexistent")).bigLock(); !reflect.DeepEqual(err, wantErr) { - t.Errorf("bigLock: error = %#v, want %#v", err, wantErr) - } - } - }) - - t.Run("access", func(t *testing.T) { - t.Parallel() - - base := check.MustAbs(t.TempDir()).Append("inaccessible") - if err := os.MkdirAll(base.String(), 0); err != nil { - t.Fatal(err.Error()) - } - - wantErr := &hst.AppError{Step: "acquire lock on the state store", - Err: &os.PathError{Op: "open", Path: base.Append(storeMutexName).String(), Err: syscall.EACCES}} - if _, err := newStore(base).bigLock(); !reflect.DeepEqual(err, wantErr) { - t.Errorf("bigLock: error = %#v, want %#v", err, wantErr) - } - }) -} - -func TestStateStoreIdentityHandle(t *testing.T) { - t.Parallel() - - t.Run("loadstore", func(t *testing.T) { - t.Parallel() - - s := newStore(check.MustAbs(t.TempDir()).Append("store")) - - var handleAddr [8]*storeHandle - checkHandle := func(identity int, load bool) { - if h, err := s.identityHandle(identity); err != nil { - t.Fatalf("identityHandle: error = %v", err) - } else if load != (handleAddr[identity] != nil) { - t.Fatalf("identityHandle: load = %v, want %v", load, handleAddr[identity] != nil) - } else if !load { - handleAddr[identity] = h - - if h.identity != identity { - t.Errorf("identityHandle: identity = %d, want %d", h.identity, identity) - } - } else if h != handleAddr[identity] { - t.Fatalf("identityHandle: %p, want %p", h, handleAddr[identity]) - } - } - - checkHandle(0, false) - checkHandle(1, false) - checkHandle(2, false) - checkHandle(3, false) - checkHandle(7, false) - checkHandle(7, true) - checkHandle(2, true) - checkHandle(1, true) - checkHandle(2, true) - checkHandle(0, true) - }) - - t.Run("access", func(t *testing.T) { - t.Parallel() - - base := check.MustAbs(t.TempDir()).Append("inaccessible") - if err := os.MkdirAll(base.String(), 0); err != nil { - t.Fatal(err.Error()) - } - - wantErr := &hst.AppError{Step: "acquire lock on the state store", - Err: &os.PathError{Op: "open", Path: base.Append(storeMutexName).String(), Err: syscall.EACCES}} - if _, err := newStore(base).identityHandle(0); !reflect.DeepEqual(err, wantErr) { - t.Errorf("identityHandle: error = %#v, want %#v", err, wantErr) - } - }) - - t.Run("access segment", func(t *testing.T) { - t.Parallel() - - base := check.MustAbs(t.TempDir()).Append("inaccessible") - if err := os.MkdirAll(base.String(), 0700); err != nil { - t.Fatal(err.Error()) - } - if f, err := os.Create(base.Append(storeMutexName).String()); err != nil { - t.Fatal(err.Error()) - } else if err = f.Close(); err != nil { - t.Fatal(err.Error()) - } - if err := os.Chmod(base.String(), 0100); err != nil { - t.Fatal(err.Error()) - } - t.Cleanup(func() { - if err := os.Chmod(base.String(), 0700); err != nil { - t.Fatal(err.Error()) - } - }) - - wantErr := &hst.AppError{Step: "create store segment directory", - Err: &os.PathError{Op: "mkdir", Path: base.Append("0").String(), Err: syscall.EACCES}} - if _, err := newStore(base).identityHandle(0); !reflect.DeepEqual(err, wantErr) { - t.Errorf("identityHandle: error = %#v, want %#v", err, wantErr) - } - }) -} - -func TestStateStoreSegments(t *testing.T) { - t.Parallel() - - testCases := []struct { - name string - ents [2][]string - want []segmentIdentity - ext func(t *testing.T, segments iter.Seq[segmentIdentity], n int) - }{ - {"errors", [2][]string{{ - "f0-invalid-file", - }, { - "f1-invalid-syntax", - "9999", - "16384", - }}, []segmentIdentity{ - {-1, &hst.AppError{Step: "process store segment", Err: syscall.EISDIR, - Msg: `skipped non-directory entry "f0-invalid-file"`}}, - {-1, &hst.AppError{Step: "process store segment", Err: syscall.ERANGE, - Msg: `skipped out of bounds entry 16384`}}, - {-1, &hst.AppError{Step: "process store segment", - Err: &strconv.NumError{Func: "Atoi", Num: "f1-invalid-syntax", Err: strconv.ErrSyntax}, - Msg: `skipped non-identity entry "f1-invalid-syntax"`}}, - {9999, nil}, - }, nil}, - - {"success", [2][]string{{ - "lock", - }, { - "0", - "1", - "2", - "3", - "4", - "5", - "6", - "7", - "9", - "13", - "20", - "31", - "197", - }}, []segmentIdentity{ - {0, nil}, - {1, nil}, - {2, nil}, - {3, nil}, - {4, nil}, - {5, nil}, - {6, nil}, - {7, nil}, - {9, nil}, - {13, nil}, - {20, nil}, - {31, nil}, - {197, nil}, - }, func(t *testing.T, segments iter.Seq[segmentIdentity], n int) { - if n != 13 { - t.Fatalf("segments: n = %d", n) - } - - // check partial drain - for range segments { - break - } - }}, - } - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - base := check.MustAbs(t.TempDir()).Append("store") - if err := os.Mkdir(base.String(), 0700); err != nil { - t.Fatal(err.Error()) - } - createEntries(t, base, tc.ents) - - var got []segmentIdentity - if segments, n, err := newStore(base).segments(); err != nil { - t.Fatalf("segments: error = %v", err) - } else { - got = slices.AppendSeq(make([]segmentIdentity, 0, n), segments) - if tc.ext != nil { - tc.ext(t, segments, n) - } - } - - slices.SortFunc(got, func(a, b segmentIdentity) int { - if a.identity == b.identity { - return strings.Compare(a.err.Error(), b.err.Error()) - } - return cmp.Compare(a.identity, b.identity) - }) - if !reflect.DeepEqual(got, tc.want) { - t.Errorf("segments: %#v, want %#v", got, tc.want) - } - }) - } - - t.Run("access", func(t *testing.T) { - t.Parallel() - - base := check.MustAbs(t.TempDir()).Append("inaccessible") - if err := os.MkdirAll(base.String(), 0); err != nil { - t.Fatal(err.Error()) - } - - wantErr := &hst.AppError{Step: "acquire lock on the state store", - Err: &os.PathError{Op: "open", Path: base.Append(storeMutexName).String(), Err: syscall.EACCES}} - if _, _, err := newStore(base).segments(); !reflect.DeepEqual(err, wantErr) { - t.Errorf("segments: error = %#v, want %#v", err, wantErr) - } - }) -} diff --git a/internal/outcome/dispatcher.go b/internal/outcome/dispatcher.go new file mode 100644 index 00000000..77ef646a --- /dev/null +++ b/internal/outcome/dispatcher.go @@ -0,0 +1,163 @@ +package outcome + +import ( + "context" + "io" + "io/fs" + "os" + "os/exec" + "os/signal" + "os/user" + "path/filepath" + + "hakurei.app/container" + "hakurei.app/container/check" + "hakurei.app/container/seccomp" + "hakurei.app/internal" + "hakurei.app/message" + "hakurei.app/system/dbus" +) + +// osFile represents [os.File]. +type osFile interface { + Name() string + io.Writer + fs.File +} + +// syscallDispatcher provides methods that make state-dependent system calls as part of their behaviour. +type syscallDispatcher interface { + // new starts a goroutine with a new instance of syscallDispatcher. + // A syscallDispatcher must never be used in any goroutine other than the one owning it, + // just synchronising access is not enough, as this is for test instrumentation. + new(f func(k syscallDispatcher, msg message.Msg)) + + // getpid provides [os.Getpid]. + getpid() int + // getuid provides [os.Getuid]. + getuid() int + // getgid provides [os.Getgid]. + getgid() int + // lookupEnv provides [os.LookupEnv]. + lookupEnv(key string) (string, bool) + // pipe provides os.Pipe. + pipe() (r, w *os.File, err error) + // stat provides [os.Stat]. + stat(name string) (os.FileInfo, error) + // open provides [os.Open]. + open(name string) (osFile, error) + // readdir provides [os.ReadDir]. + readdir(name string) ([]os.DirEntry, error) + // tempdir provides [os.TempDir]. + tempdir() string + // exit provides [os.Exit]. + exit(code int) + + // evalSymlinks provides [filepath.EvalSymlinks]. + evalSymlinks(path string) (string, error) + + // lookupGroupId calls [user.LookupGroup] and returns the Gid field of the resulting [user.Group] struct. + lookupGroupId(name string) (string, error) + + // cmdOutput provides the Output method of [exec.Cmd]. + cmdOutput(cmd *exec.Cmd) ([]byte, error) + + // notifyContext provides [signal.NotifyContext]. + notifyContext(parent context.Context, signals ...os.Signal) (ctx context.Context, stop context.CancelFunc) + + // prctl provides [container.Prctl]. + prctl(op, arg2, arg3 uintptr) error + // overflowUid provides [container.OverflowUid]. + overflowUid(msg message.Msg) int + // overflowGid provides [container.OverflowGid]. + overflowGid(msg message.Msg) int + // setDumpable provides [container.SetDumpable]. + setDumpable(dumpable uintptr) error + // receive provides [container.Receive]. + receive(key string, e any, fdp *uintptr) (closeFunc func() error, err error) + + // containerStart provides the Start method of [container.Container]. + containerStart(z *container.Container) error + // containerStart provides the Serve method of [container.Container]. + containerServe(z *container.Container) error + // containerStart provides the Wait method of [container.Container]. + containerWait(z *container.Container) error + + // seccompLoad provides [seccomp.Load]. + seccompLoad(rules []seccomp.NativeRule, flags seccomp.ExportFlag) error + + // mustHsuPath provides [internal.MustHsuPath]. + mustHsuPath() *check.Absolute + + // dbusAddress provides [dbus.Address]. + dbusAddress() (session, system string) + + // setupContSignal provides setupContSignal. + setupContSignal(pid int) (io.ReadCloser, func(), error) + + // getMsg returns the [message.Msg] held by syscallDispatcher. + getMsg() message.Msg + // fatal provides [log.Fatal]. + fatal(v ...any) + // fatalf provides [log.Fatalf]. + fatalf(format string, v ...any) +} + +// direct implements syscallDispatcher on the current kernel. +type direct struct{ msg message.Msg } + +func (k direct) new(f func(k syscallDispatcher, msg message.Msg)) { go f(k, k.msg) } + +func (direct) getpid() int { return os.Getpid() } +func (direct) getuid() int { return os.Getuid() } +func (direct) getgid() int { return os.Getgid() } +func (direct) lookupEnv(key string) (string, bool) { return os.LookupEnv(key) } +func (direct) pipe() (r, w *os.File, err error) { return os.Pipe() } +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) exit(code int) { os.Exit(code) } + +func (direct) evalSymlinks(path string) (string, error) { return filepath.EvalSymlinks(path) } + +func (direct) lookupGroupId(name string) (gid string, err error) { + var group *user.Group + group, err = user.LookupGroup(name) + if group != nil { + gid = group.Gid + } + return +} + +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) { + return signal.NotifyContext(parent, signals...) +} + +func (direct) prctl(op, arg2, arg3 uintptr) error { return container.Prctl(op, arg2, arg3) } +func (direct) overflowUid(msg message.Msg) int { return container.OverflowUid(msg) } +func (direct) overflowGid(msg message.Msg) int { return container.OverflowGid(msg) } +func (direct) setDumpable(dumpable uintptr) error { return container.SetDumpable(dumpable) } +func (direct) receive(key string, e any, fdp *uintptr) (func() error, error) { + return container.Receive(key, e, fdp) +} + +func (direct) containerStart(z *container.Container) error { return z.Start() } +func (direct) containerServe(z *container.Container) error { return z.Serve() } +func (direct) containerWait(z *container.Container) error { return z.Wait() } + +func (direct) seccompLoad(rules []seccomp.NativeRule, flags seccomp.ExportFlag) error { + return seccomp.Load(rules, flags) +} + +func (direct) mustHsuPath() *check.Absolute { return internal.MustHsuPath() } + +func (direct) dbusAddress() (session, system string) { return dbus.Address() } + +func (direct) setupContSignal(pid int) (io.ReadCloser, func(), error) { return setupContSignal(pid) } + +func (k direct) getMsg() message.Msg { return k.msg } +func (k direct) fatal(v ...any) { k.msg.GetLogger().Fatal(v...) } +func (k direct) fatalf(format string, v ...any) { k.msg.GetLogger().Fatalf(format, v...) } diff --git a/internal/outcome/dispatcher_test.go b/internal/outcome/dispatcher_test.go new file mode 100644 index 00000000..e01946c1 --- /dev/null +++ b/internal/outcome/dispatcher_test.go @@ -0,0 +1,662 @@ +package outcome + +import ( + "bytes" + "context" + "encoding/json" + "io" + "io/fs" + "log" + "maps" + "os" + "os/exec" + "reflect" + "slices" + "sync" + "testing" + "time" + "unsafe" + + "hakurei.app/container" + "hakurei.app/container/check" + "hakurei.app/container/seccomp" + "hakurei.app/container/stub" + "hakurei.app/hst" + "hakurei.app/message" + "hakurei.app/system" +) + +// call initialises a [stub.Call]. +// This keeps composites analysis happy without making the test cases too bloated. +func call(name string, args stub.ExpectArgs, ret any, err error) stub.Call { + return stub.Call{Name: name, Args: args, Ret: ret, Err: err} +} + +const ( + // checkExpectUid is the uid value used by checkOpBehaviour to initialise [system.I]. + checkExpectUid = 0xcafebabe + // wantAutoEtcPrefix is the autoetc prefix corresponding to checkExpectInstanceId. + wantAutoEtcPrefix = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + // wantInstancePrefix is the SharePath corresponding to checkExpectInstanceId. + wantInstancePrefix = container.Nonexistent + "/tmp/hakurei.0/" + wantAutoEtcPrefix + + // wantRuntimePath is the XDG_RUNTIME_DIR value returned during testing. + wantRuntimePath = "/proc/nonexistent/xdg_runtime_dir" + // wantRunDirPath is the RunDirPath value resolved during testing. + wantRunDirPath = wantRuntimePath + "/hakurei" + // wantRuntimeSharePath is the runtimeSharePath value resolved during testing. + wantRuntimeSharePath = wantRunDirPath + "/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +) + +// checkExpectInstanceId is the [hst.ID] value used by checkOpBehaviour to initialise outcomeState. +var checkExpectInstanceId = *(*hst.ID)(bytes.Repeat([]byte{0xaa}, len(hst.ID{}))) + +type ( + // pStateSysFunc is called before each test case is run to prepare outcomeStateSys. + pStateSysFunc = func(state *outcomeStateSys) + // pStateContainerFunc is called before each test case is run to prepare outcomeStateParams. + pStateContainerFunc = func(state *outcomeStateParams) + + // extraCheckSysFunc is called to check outcomeStateSys and must not have side effects. + extraCheckSysFunc = func(t *testing.T, state *outcomeStateSys) + // extraCheckParamsFunc is called to check outcomeStateParams and must not have side effects. + extraCheckParamsFunc = func(t *testing.T, state *outcomeStateParams) +) + +// insertsOps prepares outcomeStateParams to allow [container.Op] to be inserted. +func insertsOps(next pStateContainerFunc) pStateContainerFunc { + return func(state *outcomeStateParams) { + state.params.Ops = new(container.Ops) + + if next != nil { + next(state) + } + } +} + +// afterSpRuntimeOp prepares outcomeStateParams for an outcomeOp meant to run after spRuntimeOp. +func afterSpRuntimeOp(next pStateContainerFunc) pStateContainerFunc { + return func(state *outcomeStateParams) { + // emulates spRuntimeOp + state.runtimeDir = m("/run/user/1000") + + if next != nil { + next(state) + } + } +} + +// sysUsesInstance checks for use of the outcomeStateSys.instance method. +func sysUsesInstance(next extraCheckSysFunc) extraCheckSysFunc { + return func(t *testing.T, state *outcomeStateSys) { + if want := m(wantInstancePrefix); !reflect.DeepEqual(state.sharePath, want) { + t.Errorf("outcomeStateSys: sharePath = %v, want %v", state.sharePath, want) + } + + if next != nil { + next(t, state) + } + } +} + +// sysUsesRuntime checks for use of the outcomeStateSys.runtime method. +func sysUsesRuntime(next extraCheckSysFunc) extraCheckSysFunc { + return func(t *testing.T, state *outcomeStateSys) { + if want := m(wantRuntimeSharePath); !reflect.DeepEqual(state.runtimeSharePath, want) { + t.Errorf("outcomeStateSys: runtimeSharePath = %v, want %v", state.runtimeSharePath, want) + } + + if next != nil { + next(t, state) + } + } +} + +// paramsWantEnv checks outcomeStateParams.env for inserted entries on top of [hst.Config]. +func paramsWantEnv(config *hst.Config, wantEnv map[string]string, next extraCheckParamsFunc) extraCheckParamsFunc { + want := make(map[string]string, len(wantEnv)+len(config.Container.Env)) + maps.Copy(want, wantEnv) + maps.Copy(want, config.Container.Env) + return func(t *testing.T, state *outcomeStateParams) { + if !maps.Equal(state.env, want) { + t.Errorf("toContainer: env = %#v, want %#v", state.env, want) + } + + if next != nil { + next(t, state) + } + } +} + +// opBehaviourTestCase checks outcomeOp behaviour against outcomeStateSys and outcomeStateParams. +type opBehaviourTestCase struct { + name string + // newOp returns a new instance of outcomeOp under testing that is safe to clobber. + newOp func(isShim, clearUnexported bool) outcomeOp + // newConfig returns a new instance of [hst.Config] that is checked not to be clobbered by outcomeOp. + newConfig func() *hst.Config + + // pStateSys is called before outcomeOp.toSystem to prepare outcomeStateSys. + pStateSys pStateSysFunc + // toSystem are expected syscallDispatcher calls during outcomeOp.toSystem. + toSystem []stub.Call + // wantSys is the expected [system.I] state after outcomeOp.toSystem. + wantSys *system.I + // extraCheckSys is called after outcomeOp.toSystem to check the state of outcomeStateSys. + extraCheckSys extraCheckSysFunc + // wantErrSystem is the expected error value returned by outcomeOp.toSystem. + // Further testing is skipped if not nil. + wantErrSystem error + + // pStateContainer is called before outcomeOp.toContainer to prepare outcomeStateParams. + pStateContainer pStateContainerFunc + // toContainer are expected syscallDispatcher calls during outcomeOp.toContainer. + toContainer []stub.Call + // wantParams is the expected [container.Params] after outcomeOp.toContainer. + wantParams *container.Params + // extraCheckParams is called after outcomeOp.toContainer to check the state of outcomeStateParams. + extraCheckParams extraCheckParamsFunc + // wantErrContainer is the expected error value returned by outcomeOp.toContainer. + wantErrContainer error +} + +// checkOpBehaviour runs a slice of opBehaviourTestCase. +func checkOpBehaviour(t *testing.T, testCases []opBehaviourTestCase) { + t.Helper() + + wantNewState := []stub.Call{ + // newOutcomeState + call("getpid", stub.ExpectArgs{}, 0xdead, nil), + call("isVerbose", stub.ExpectArgs{}, true, nil), + call("mustHsuPath", stub.ExpectArgs{}, m(container.Nonexistent), nil), + call("cmdOutput", stub.ExpectArgs{container.Nonexistent, os.Stderr, []string{}, "/"}, []byte("0"), nil), + call("tempdir", stub.ExpectArgs{}, container.Nonexistent+"/tmp", nil), + call("lookupEnv", stub.ExpectArgs{"XDG_RUNTIME_DIR"}, wantRuntimePath, nil), + call("getuid", stub.ExpectArgs{}, 1000, nil), + call("getgid", stub.ExpectArgs{}, 100, nil), + + // populateLocal + call("verbosef", stub.ExpectArgs{"process share directory at %q, runtime directory at %q", []any{ + m(container.Nonexistent + "/tmp/hakurei.0"), + m(container.Nonexistent + "/xdg_runtime_dir/hakurei"), + }}, nil, nil), + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Helper() + t.Parallel() + + wantCallsFull := slices.Concat(wantNewState, tc.toSystem, []stub.Call{{Name: stub.CallSeparator}}) + if tc.wantErrSystem == nil { + wantCallsFull = append(wantCallsFull, slices.Concat(wantNewState, tc.toContainer)...) + } + + wantConfig := tc.newConfig() + k := &kstub{panicDispatcher{}, stub.New(t, + func(s *stub.Stub[syscallDispatcher]) syscallDispatcher { return &kstub{panicDispatcher{}, s} }, + stub.Expect{Calls: wantCallsFull}, + )} + defer stub.HandleExit(t) + + { + config := tc.newConfig() + s := newOutcomeState(k, k, &checkExpectInstanceId, config, &Hsu{k: k}) + if err := s.populateLocal(k, k); err != nil { + t.Fatalf("populateLocal: error = %v", err) + } + stateSys := s.newSys(config, system.New(panicMsgContext{}, k, checkExpectUid)) + if tc.pStateSys != nil { + tc.pStateSys(stateSys) + } + op := tc.newOp(false, true) + + if err := op.toSystem(stateSys); !reflect.DeepEqual(err, tc.wantErrSystem) { + t.Fatalf("toSystem: error = %#v, want %#v", err, tc.wantErrSystem) + } + k.Expects(stub.CallSeparator) + if !reflect.DeepEqual(config, wantConfig) { + t.Errorf("toSystem clobbered config: %#v, want %#v", config, wantConfig) + } + + if tc.wantErrSystem != nil { + goto out + } + + if !stateSys.sys.Equal(tc.wantSys) { + t.Errorf("toSystem: %#v, want %#v", stateSys.sys, tc.wantSys) + } + if tc.extraCheckSys != nil { + tc.extraCheckSys(t, stateSys) + } + if wantOpSys := tc.newOp(true, false); !reflect.DeepEqual(op, wantOpSys) { + t.Errorf("toSystem: op = %#v, want %#v", op, wantOpSys) + } + } + + { + config := tc.newConfig() + s := newOutcomeState(k, k, &checkExpectInstanceId, config, &Hsu{k: k}) + stateParams := s.newParams() + if err := s.populateLocal(k, k); err != nil { + t.Fatalf("populateLocal: error = %v", err) + } + if tc.pStateContainer != nil { + tc.pStateContainer(stateParams) + } + op := tc.newOp(true, true) + + if err := op.toContainer(stateParams); !reflect.DeepEqual(err, tc.wantErrContainer) { + t.Fatalf("toContainer: error = %#v, want %#v", err, tc.wantErrContainer) + } + + if tc.wantErrContainer != nil { + goto out + } + + if !reflect.DeepEqual(stateParams.params, tc.wantParams) { + t.Errorf("toContainer:\n%s\nwant\n%s", mustMarshal(stateParams.params), mustMarshal(tc.wantParams)) + } + if tc.extraCheckParams != nil { + tc.extraCheckParams(t, stateParams) + } + } + + out: + k.VisitIncomplete(func(s *stub.Stub[syscallDispatcher]) { + count := k.Pos() - 1 // separator + if count-len(wantNewState) < len(tc.toSystem) { + t.Errorf("toSystem: %d calls, want %d", count-len(wantNewState), len(tc.toSystem)) + } else { + t.Errorf("toContainer: %d calls, want %d", count-len(tc.toSystem)-2*len(wantNewState), len(tc.toContainer)) + } + }) + }) + } +} + +func newI() *system.I { return system.New(panicMsgContext{}, panicMsgContext{}, checkExpectUid) } + +// simpleTestCase is a simple freeform test case utilising kstub. +type simpleTestCase struct { + name string + f func(k *kstub) error + // want are expected syscallDispatcher calls during f. + want stub.Expect + // wantErr is the expected error value returned by f. + wantErr error +} + +// checkSimple runs a slice of simpleTestCase. +func checkSimple(t *testing.T, fname string, testCases []simpleTestCase) { + t.Helper() + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Helper() + t.Parallel() + + defer stub.HandleExit(t) + k := &kstub{panicDispatcher{}, stub.New(t, func(s *stub.Stub[syscallDispatcher]) syscallDispatcher { return &kstub{panicDispatcher{}, s} }, tc.want)} + if err := tc.f(k); !reflect.DeepEqual(err, tc.wantErr) { + t.Errorf("%s: error = %#v, want %#v", fname, err, tc.wantErr) + } + k.VisitIncomplete(func(s *stub.Stub[syscallDispatcher]) { + t.Helper() + + t.Errorf("%s: %d calls, want %d", fname, s.Pos(), s.Len()) + }) + }) + } +} + +// kstub partially implements syscallDispatcher via [stub.Stub]. +type kstub struct { + panicDispatcher + *stub.Stub[syscallDispatcher] +} + +func (k *kstub) new(f func(k syscallDispatcher, msg message.Msg)) { + k.New(func(k syscallDispatcher) { f(k, k.(*kstub)) }) +} + +func (k *kstub) getpid() int { k.Helper(); return k.Expects("getpid").Ret.(int) } +func (k *kstub) getuid() int { k.Helper(); return k.Expects("getuid").Ret.(int) } +func (k *kstub) getgid() int { k.Helper(); return k.Expects("getgid").Ret.(int) } +func (k *kstub) lookupEnv(key string) (string, bool) { + k.Helper() + expect := k.Expects("lookupEnv") + if expect.Error( + stub.CheckArg(k.Stub, "key", key, 0)) != nil { + k.FailNow() + } + if expect.Ret == nil { + return "\x00", false + } + return expect.Ret.(string), true +} +func (k *kstub) stat(name string) (os.FileInfo, error) { + k.Helper() + expect := k.Expects("stat") + return expect.Ret.(os.FileInfo), expect.Error( + stub.CheckArg(k.Stub, "name", name, 0)) +} +func (k *kstub) open(name string) (osFile, error) { + k.Helper() + expect := k.Expects("open") + return expect.Ret.(osFile), expect.Error( + stub.CheckArg(k.Stub, "name", name, 0)) +} +func (k *kstub) readdir(name string) ([]os.DirEntry, error) { + k.Helper() + expect := k.Expects("readdir") + return expect.Ret.([]os.DirEntry), expect.Error( + stub.CheckArg(k.Stub, "name", name, 0)) +} +func (k *kstub) tempdir() string { k.Helper(); return k.Expects("tempdir").Ret.(string) } +func (k *kstub) evalSymlinks(path string) (string, error) { + k.Helper() + expect := k.Expects("evalSymlinks") + return expect.Ret.(string), expect.Error( + stub.CheckArg(k.Stub, "path", path, 0)) +} + +func (k *kstub) prctl(op, arg2, arg3 uintptr) error { + k.Helper() + return k.Expects("prctl").Error( + stub.CheckArg(k.Stub, "op", op, 0), + stub.CheckArg(k.Stub, "arg2", arg2, 1), + stub.CheckArg(k.Stub, "arg3", arg3, 2)) +} + +func (k *kstub) setDumpable(dumpable uintptr) error { + k.Helper() + return k.Expects("setDumpable").Error( + stub.CheckArg(k.Stub, "dumpable", dumpable, 0)) +} + +func (k *kstub) receive(key string, e any, fdp *uintptr) (closeFunc func() error, err error) { + k.Helper() + expect := k.Expects("receive") + reflect.ValueOf(e).Elem().Set(reflect.ValueOf(expect.Args[1])) + if expect.Args[2] != nil { + *fdp = expect.Args[2].(uintptr) + } + return func() error { return k.Expects("closeReceive").Err }, expect.Error( + stub.CheckArg(k.Stub, "key", key, 0)) +} + +func (k *kstub) expectCheckContainer(expect *stub.Call, z *container.Container) error { + k.Helper() + err := expect.Error( + stub.CheckArgReflect(k.Stub, "params", &z.Params, 0)) + if err != nil { + k.Errorf("params:\n%s\n%s", mustMarshal(&z.Params), mustMarshal(expect.Args[0])) + } + return err +} + +func (k *kstub) containerStart(z *container.Container) error { + k.Helper() + return k.expectCheckContainer(k.Expects("containerStart"), z) +} +func (k *kstub) containerServe(z *container.Container) error { + k.Helper() + return k.expectCheckContainer(k.Expects("containerServe"), z) +} +func (k *kstub) containerWait(z *container.Container) error { + k.Helper() + return k.expectCheckContainer(k.Expects("containerWait"), z) +} + +func (k *kstub) seccompLoad(rules []seccomp.NativeRule, flags seccomp.ExportFlag) error { + k.Helper() + return k.Expects("seccompLoad").Error( + stub.CheckArgReflect(k.Stub, "rules", rules, 0), + stub.CheckArg(k.Stub, "flags", flags, 1)) +} + +func (k *kstub) cmdOutput(cmd *exec.Cmd) ([]byte, error) { + k.Helper() + expect := k.Expects("cmdOutput") + return expect.Ret.([]byte), expect.Error( + stub.CheckArg(k.Stub, "cmd.Path", cmd.Path, 0), + stub.CheckArgReflect(k.Stub, "cmd.Stderr", cmd.Stderr, 1), + stub.CheckArgReflect(k.Stub, "cmd.Env", cmd.Env, 2), + stub.CheckArg(k.Stub, "cmd.Dir", cmd.Dir, 3)) +} + +func (k *kstub) notifyContext(parent context.Context, signals ...os.Signal) (ctx context.Context, stop context.CancelFunc) { + k.Helper() + if k.Expects("notifyContext").Error( + stub.CheckArgReflect(k.Stub, "parent", parent, 0), + stub.CheckArgReflect(k.Stub, "signals", signals, 1)) != nil { + k.FailNow() + } + return k.Context(), func() { k.Helper(); k.Expects("notifyContextStop") } +} + +func (k *kstub) mustHsuPath() *check.Absolute { + k.Helper() + return k.Expects("mustHsuPath").Ret.(*check.Absolute) +} + +func (k *kstub) dbusAddress() (session, system string) { + k.Helper() + ret := k.Expects("dbusAddress").Ret.([2]string) + return ret[0], ret[1] +} + +// stubTrackReader embeds kstub but switches the underlying [stub.Stub] index to sub on its first Read. +// The resulting kstub does not share any state with the instance passed to the instrumented goroutine. +// Therefore, any method making use of such must not be called. +type stubTrackReader struct { + sub int + subOnce sync.Once + + *kstub +} + +func (r *stubTrackReader) Read(p []byte) (n int, err error) { + r.subOnce.Do(func() { + subVal := reflect.ValueOf(r.kstub.Stub).Elem().FieldByName("sub") + r.kstub = &kstub{panicDispatcher{}, reflect. + NewAt(subVal.Type(), unsafe.Pointer(subVal.UnsafeAddr())).Elem(). + Interface().([]*stub.Stub[syscallDispatcher])[r.sub]} + }) + + return r.kstub.Read(p) +} + +func (k *kstub) setupContSignal(pid int) (io.ReadCloser, func(), error) { + k.Helper() + expect := k.Expects("setupContSignal") + return &stubTrackReader{sub: expect.Ret.(int), kstub: k}, func() { k.Expects("wKeepAlive") }, expect.Error( + stub.CheckArg(k.Stub, "pid", pid, 0)) +} + +func (k *kstub) getMsg() message.Msg { k.Helper(); k.Expects("getMsg"); return k } + +func (k *kstub) Close() error { k.Helper(); return k.Expects("rcClose").Err } +func (k *kstub) Read(p []byte) (n int, err error) { + k.Helper() + expect := k.Expects("rcRead") + + // special case to terminate exit outcomes goroutine + // to proceed with further testing of the entrypoint + if expect.Ret == nil { + panic(stub.PanicExit) + } + + return copy(p, expect.Ret.([]byte)), expect.Err +} + +func (k *kstub) GetLogger() *log.Logger { k.Helper(); return k.Expects("getLogger").Ret.(*log.Logger) } +func (k *kstub) IsVerbose() bool { k.Helper(); return k.Expects("isVerbose").Ret.(bool) } +func (k *kstub) SwapVerbose(verbose bool) bool { + k.Helper() + expect := k.Expects("swapVerbose") + if expect.Error( + stub.CheckArg(k.Stub, "verbose", verbose, 0)) != nil { + k.FailNow() + } + return expect.Ret.(bool) +} + +// ignoreValue marks a value to be ignored by the test suite. +type ignoreValue struct{} + +func (k *kstub) Verbose(v ...any) { + k.Helper() + expect := k.Expects("verbose") + + // translate ignores in v + if want, ok := expect.Args[0].([]any); ok && len(v) == len(want) { + for i, a := range want { + if _, ok = a.(ignoreValue); ok { + v[i] = ignoreValue{} + } + } + } + + if expect.Error( + stub.CheckArgReflect(k.Stub, "v", v, 0)) != nil { + k.FailNow() + } +} + +func (k *kstub) Verbosef(format string, v ...any) { + k.Helper() + if k.Expects("verbosef").Error( + stub.CheckArg(k.Stub, "format", format, 0), + stub.CheckArgReflect(k.Stub, "v", v, 1)) != nil { + k.FailNow() + } +} + +func (k *kstub) Suspend() bool { k.Helper(); return k.Expects("suspend").Ret.(bool) } +func (k *kstub) Resume() bool { k.Helper(); return k.Expects("resume").Ret.(bool) } +func (k *kstub) BeforeExit() { k.Helper(); k.Expects("beforeExit") } + +// stubOsFile partially implements osFile. +type stubOsFile struct { + closeErr error + + io.Reader + io.Writer +} + +func (f *stubOsFile) Close() error { return f.closeErr } +func (f *stubOsFile) Name() string { panic("unreachable") } +func (f *stubOsFile) Stat() (fs.FileInfo, error) { panic("unreachable") } + +// stubFi partially implements [os.FileInfo]. Can be passed as nil to assert all methods unreachable. +type stubFi struct { + size int64 + mode os.FileMode + isDir bool +} + +func (fi *stubFi) Name() string { panic("unreachable") } +func (fi *stubFi) ModTime() time.Time { panic("unreachable") } +func (fi *stubFi) Sys() any { panic("unreachable") } +func (fi *stubFi) Size() int64 { return fi.size } +func (fi *stubFi) Mode() os.FileMode { return fi.mode } +func (fi *stubFi) IsDir() bool { return fi.isDir } + +// stubDir returns a slice of [os.DirEntry] with only their Name method implemented. +func stubDir(names ...string) []os.DirEntry { + d := make([]os.DirEntry, len(names)) + for i, name := range names { + d[i] = nameDentry(name) + } + return d +} + +// nameDentry implements the Name method on [os.DirEntry]. +type nameDentry string + +func (e nameDentry) Name() string { return string(e) } +func (nameDentry) IsDir() bool { panic("unreachable") } +func (nameDentry) Type() fs.FileMode { panic("unreachable") } +func (nameDentry) Info() (fs.FileInfo, error) { panic("unreachable") } + +// errorReader implements [io.Reader] that unconditionally returns -1, val. +type errorReader struct{ val error } + +func (r errorReader) Read([]byte) (int, error) { return -1, r.val } + +// mustMarshal returns the result of [json.Marshal] as a string and panics on error. +func mustMarshal(v any) string { + if b, err := json.Marshal(v); err != nil { + panic(err.Error()) + } else { + return string(b) + } +} + +// m is a shortcut for [check.MustAbs]. +func m(pathname string) *check.Absolute { return check.MustAbs(pathname) } + +// f returns [hst.FilesystemConfig] wrapped in its [json] adapter. +func f(c hst.FilesystemConfig) hst.FilesystemConfigJSON { + return hst.FilesystemConfigJSON{FilesystemConfig: c} +} + +// panicMsgContext implements [message.Msg] and [context.Context] with methods wrapping panic. +// This should be assigned to test cases to be checked against. +type panicMsgContext struct{} + +func (panicMsgContext) GetLogger() *log.Logger { panic("unreachable") } +func (panicMsgContext) IsVerbose() bool { panic("unreachable") } +func (panicMsgContext) SwapVerbose(bool) bool { panic("unreachable") } +func (panicMsgContext) Verbose(...any) { panic("unreachable") } +func (panicMsgContext) Verbosef(string, ...any) { panic("unreachable") } +func (panicMsgContext) Suspend() bool { panic("unreachable") } +func (panicMsgContext) Resume() bool { panic("unreachable") } +func (panicMsgContext) BeforeExit() { panic("unreachable") } + +func (panicMsgContext) Deadline() (time.Time, bool) { panic("unreachable") } +func (panicMsgContext) Done() <-chan struct{} { panic("unreachable") } +func (panicMsgContext) Err() error { panic("unreachable") } +func (panicMsgContext) Value(any) any { panic("unreachable") } + +// panicDispatcher implements syscallDispatcher with methods wrapping panic. +// This type is meant to be embedded in partial syscallDispatcher implementations. +type panicDispatcher struct{} + +func (panicDispatcher) new(func(k syscallDispatcher, msg message.Msg)) { panic("unreachable") } +func (panicDispatcher) getpid() int { panic("unreachable") } +func (panicDispatcher) getuid() int { panic("unreachable") } +func (panicDispatcher) getgid() int { panic("unreachable") } +func (panicDispatcher) lookupEnv(string) (string, bool) { panic("unreachable") } +func (panicDispatcher) pipe() (*os.File, *os.File, error) { panic("unreachable") } +func (panicDispatcher) stat(string) (os.FileInfo, error) { panic("unreachable") } +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) 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) cmdOutput(*exec.Cmd) ([]byte, error) { panic("unreachable") } +func (panicDispatcher) overflowUid(message.Msg) int { panic("unreachable") } +func (panicDispatcher) overflowGid(message.Msg) int { panic("unreachable") } +func (panicDispatcher) setDumpable(uintptr) error { panic("unreachable") } +func (panicDispatcher) receive(string, any, *uintptr) (func() error, error) { panic("unreachable") } +func (panicDispatcher) containerStart(*container.Container) error { panic("unreachable") } +func (panicDispatcher) containerServe(*container.Container) error { panic("unreachable") } +func (panicDispatcher) containerWait(*container.Container) error { panic("unreachable") } +func (panicDispatcher) mustHsuPath() *check.Absolute { panic("unreachable") } +func (panicDispatcher) dbusAddress() (string, string) { panic("unreachable") } +func (panicDispatcher) setupContSignal(int) (io.ReadCloser, func(), error) { panic("unreachable") } +func (panicDispatcher) getMsg() message.Msg { panic("unreachable") } +func (panicDispatcher) fatal(...any) { panic("unreachable") } +func (panicDispatcher) fatalf(string, ...any) { panic("unreachable") } + +func (panicDispatcher) notifyContext(context.Context, ...os.Signal) (context.Context, context.CancelFunc) { + panic("unreachable") +} +func (panicDispatcher) seccompLoad([]seccomp.NativeRule, seccomp.ExportFlag) error { + panic("unreachable") +} diff --git a/internal/outcome/finalise.go b/internal/outcome/finalise.go new file mode 100644 index 00000000..184bd905 --- /dev/null +++ b/internal/outcome/finalise.go @@ -0,0 +1,85 @@ +package outcome + +import ( + "context" + "errors" + "fmt" + "os" + "os/user" + "sync/atomic" + + "hakurei.app/hst" + "hakurei.app/message" + "hakurei.app/system" +) + +func newWithMessage(msg string) error { return newWithMessageError(msg, os.ErrInvalid) } +func newWithMessageError(msg string, err error) error { + return &hst.AppError{Step: "finalise", Err: err, Msg: msg} +} + +// An outcome is the runnable state of a hakurei container via [hst.Config]. +type outcome struct { + // Supplementary group ids. Populated during finalise. + supp []string + // Resolved priv side operating system interactions. Populated during finalise. + sys *system.I + // Transmitted to shim. Populated during finalise. + state *outcomeState + // Kept for saving to [state]. + config *hst.Config + + // Whether the current process is in outcome.main. + active atomic.Bool + + ctx context.Context + syscallDispatcher +} + +func (k *outcome) finalise(ctx context.Context, msg message.Msg, id *hst.ID, config *hst.Config) error { + if ctx == nil || id == nil { + // unreachable + panic("invalid call to finalise") + } + if k.ctx != nil || k.sys != nil || k.state != nil { + // unreachable + panic("attempting to finalise twice") + } + k.ctx = ctx + + if err := config.Validate(); err != nil { + return err + } + + // hsu expects numerical group ids + supp := make([]string, len(config.Groups)) + for i, name := range config.Groups { + if gid, err := k.lookupGroupId(name); err != nil { + var unknownGroupError user.UnknownGroupError + if errors.As(err, &unknownGroupError) { + return newWithMessageError(fmt.Sprintf("unknown group %q", name), unknownGroupError) + } else { + return &hst.AppError{Step: "look up group by name", Err: err} + } + } else { + supp[i] = gid + } + } + + // early validation complete at this point + s := newOutcomeState(k.syscallDispatcher, msg, id, config, &Hsu{k: k}) + if err := s.populateLocal(k.syscallDispatcher, msg); err != nil { + return err + } + + sys := system.New(k.ctx, msg, s.uid.unwrap()) + if err := s.newSys(config, sys).toSystem(); err != nil { + return err + } + + k.sys = sys + k.supp = supp + k.state = s + k.config = config + return nil +} diff --git a/internal/outcome/hsu.go b/internal/outcome/hsu.go new file mode 100644 index 00000000..b1107331 --- /dev/null +++ b/internal/outcome/hsu.go @@ -0,0 +1,101 @@ +package outcome + +import ( + "errors" + "fmt" + "log" + "os" + "os/exec" + "strconv" + "sync" + + "hakurei.app/container/fhs" + "hakurei.app/hst" + "hakurei.app/message" +) + +// Hsu caches responses from cmd/hsu. +type Hsu struct { + idOnce sync.Once + idErr error + id int + + kOnce sync.Once + + // msg is not populated + k syscallDispatcher +} + +var ErrHsuAccess = errors.New("current user is not in the hsurc file") + +// ensureDispatcher ensures Hsu.k is not nil. +func (h *Hsu) ensureDispatcher() { + h.kOnce.Do(func() { + if h.k == nil { + h.k = direct{} + } + }) +} + +// ID returns the current user hsurc identifier. +// [ErrHsuAccess] is returned if the current user is not in hsurc. +func (h *Hsu) ID() (int, error) { + h.ensureDispatcher() + h.idOnce.Do(func() { + h.id = -1 + hsuPath := h.k.mustHsuPath().String() + + cmd := exec.Command(hsuPath) + cmd.Path = hsuPath + cmd.Stderr = os.Stderr // pass through fatal messages + cmd.Env = make([]string, 0) + cmd.Dir = fhs.Root + var ( + p []byte + exitError *exec.ExitError + ) + + const step = "obtain uid from hsu" + if p, h.idErr = h.k.cmdOutput(cmd); h.idErr == nil { + h.id, h.idErr = strconv.Atoi(string(p)) + if h.idErr != nil { + h.idErr = &hst.AppError{Step: step, Err: h.idErr, Msg: "invalid uid string from hsu"} + } + } else if errors.As(h.idErr, &exitError) && exitError != nil && exitError.ExitCode() == 1 { + // hsu prints an error message in this case + h.idErr = &hst.AppError{Step: step, Err: ErrHsuAccess} + } else if errors.Is(h.idErr, os.ErrNotExist) { + h.idErr = &hst.AppError{Step: step, Err: h.idErr, + Msg: fmt.Sprintf("the setuid helper is missing: %s", hsuPath)} + } + }) + + return h.id, h.idErr +} + +// MustID calls [Hsu.ID] and terminates on error. +func (h *Hsu) MustID(msg message.Msg) int { + id, err := h.ID() + if err == nil { + return id + } + + const fallback = "cannot retrieve user id from setuid wrapper:" + if errors.Is(err, ErrHsuAccess) { + if msg != nil { + msg.Verbose("*"+fallback, err) + } + os.Exit(1) + return -0xdeadbeef // not reached + } else if m, ok := message.GetMessage(err); ok { + log.Fatal(m) + return -0xdeadbeef // not reached + } else { + log.Fatalln(fallback, err) + return -0xdeadbeef // not reached + } +} + +// HsuUid returns target uid for the stable hsu uid format. +// No bounds check is performed, a value retrieved by [Hsu] is expected. +func HsuUid(id, identity int) int { return 1000000 + id*10000 + identity } diff --git a/internal/outcome/hsu_test.go b/internal/outcome/hsu_test.go new file mode 100644 index 00000000..4ee7fa9c --- /dev/null +++ b/internal/outcome/hsu_test.go @@ -0,0 +1,84 @@ +package outcome + +import ( + "os" + "os/exec" + "reflect" + "strconv" + "syscall" + "testing" + "unsafe" + + "hakurei.app/container/stub" + "hakurei.app/hst" +) + +func TestHsu(t *testing.T) { + t.Parallel() + + t.Run("ensure dispatcher", func(t *testing.T) { + hsu := new(Hsu) + hsu.ensureDispatcher() + + k := direct{} + if !reflect.DeepEqual(hsu.k, k) { + t.Errorf("ensureDispatcher: k = %#v, want %#v", hsu.k, k) + } + }) + + fCheckID := func(k *kstub) error { + hsu := &Hsu{k: k} + id, err := hsu.ID() + k.Verbose(id) + if id0, err0 := hsu.ID(); id0 != id || !reflect.DeepEqual(err0, err) { + t.Fatalf("ID: id0 = %d, err0 = %#v, id = %d, err = %#v", id0, err0, id, err) + } + return err + } + + checkSimple(t, "Hsu.ID", []simpleTestCase{ + {"hsu nonexistent", fCheckID, stub.Expect{Calls: []stub.Call{ + call("mustHsuPath", stub.ExpectArgs{}, m("/run/wrappers/bin/hsu"), nil), + call("cmdOutput", stub.ExpectArgs{"/run/wrappers/bin/hsu", os.Stderr, []string{}, "/"}, ([]byte)(nil), os.ErrNotExist), + call("verbose", stub.ExpectArgs{[]any{-1}}, nil, nil), + }}, &hst.AppError{ + Step: "obtain uid from hsu", + Err: os.ErrNotExist, + Msg: "the setuid helper is missing: /run/wrappers/bin/hsu", + }}, + + {"access", fCheckID, stub.Expect{Calls: []stub.Call{ + call("mustHsuPath", stub.ExpectArgs{}, m("/run/wrappers/bin/hsu"), nil), + call("cmdOutput", stub.ExpectArgs{"/run/wrappers/bin/hsu", os.Stderr, []string{}, "/"}, ([]byte)(nil), makeExitError(1<<8)), + call("verbose", stub.ExpectArgs{[]any{-1}}, nil, nil), + }}, &hst.AppError{ + Step: "obtain uid from hsu", + Err: ErrHsuAccess, + }}, + + {"invalid output", fCheckID, stub.Expect{Calls: []stub.Call{ + call("mustHsuPath", stub.ExpectArgs{}, m("/run/wrappers/bin/hsu"), nil), + call("cmdOutput", stub.ExpectArgs{"/run/wrappers/bin/hsu", os.Stderr, []string{}, "/"}, []byte{0}, nil), + call("verbose", stub.ExpectArgs{[]any{0}}, nil, nil), + }}, &hst.AppError{ + Step: "obtain uid from hsu", + Err: &strconv.NumError{Func: "Atoi", Num: "\x00", Err: strconv.ErrSyntax}, + Msg: "invalid uid string from hsu", + }}, + + {"success", fCheckID, stub.Expect{Calls: []stub.Call{ + call("mustHsuPath", stub.ExpectArgs{}, m("/run/wrappers/bin/hsu"), nil), + call("cmdOutput", stub.ExpectArgs{"/run/wrappers/bin/hsu", os.Stderr, []string{}, "/"}, []byte{'0'}, nil), + call("verbose", stub.ExpectArgs{[]any{0}}, nil, nil), + }}, nil}, + }) +} + +// makeExitError populates syscall.WaitStatus in an [exec.ExitError]. +// Do not reuse this function in a cross-platform package. +func makeExitError(status syscall.WaitStatus) error { + ps := new(os.ProcessState) + statusV := reflect.ValueOf(ps).Elem().FieldByName("status") + *reflect.NewAt(statusV.Type(), unsafe.Pointer(statusV.UnsafeAddr())).Interface().(*syscall.WaitStatus) = status + return &exec.ExitError{ProcessState: ps} +} diff --git a/internal/outcome/main.go b/internal/outcome/main.go new file mode 100644 index 00000000..06327963 --- /dev/null +++ b/internal/outcome/main.go @@ -0,0 +1,27 @@ +package outcome + +import ( + "context" + "log" + "os" + + "hakurei.app/hst" + "hakurei.app/message" +) + +// Main runs an app according to [hst.Config] and terminates. Main does not return. +func Main(ctx context.Context, msg message.Msg, config *hst.Config) { + var id hst.ID + if err := hst.NewInstanceID(&id); err != nil { + log.Fatal(err.Error()) + } + + seal := outcome{syscallDispatcher: direct{msg}} + if err := seal.finalise(ctx, msg, &id, config); err != nil { + printMessageError("cannot seal app:", err) + os.Exit(1) + } + + seal.main(msg) + panic("unreachable") +} diff --git a/internal/outcome/main_test.go b/internal/outcome/main_test.go new file mode 100644 index 00000000..ee1c0374 --- /dev/null +++ b/internal/outcome/main_test.go @@ -0,0 +1,923 @@ +package outcome + +import ( + "bytes" + "encoding/gob" + "errors" + "fmt" + "io" + "io/fs" + "log" + "os/exec" + "os/user" + "reflect" + "syscall" + "testing" + "time" + + "hakurei.app/container" + "hakurei.app/container/check" + "hakurei.app/container/comp" + "hakurei.app/container/fhs" + "hakurei.app/container/seccomp" + "hakurei.app/hst" + "hakurei.app/message" + "hakurei.app/system" + "hakurei.app/system/acl" + "hakurei.app/system/dbus" +) + +func TestOutcomeMain(t *testing.T) { + t.Parallel() + msg := message.NewMsg(nil) + msg.SwapVerbose(testing.Verbose()) + + testCases := []struct { + name string + k syscallDispatcher + config *hst.Config + id hst.ID + wantSys *system.I + wantParams *container.Params + }{ + {"template", new(stubNixOS), hst.Template(), checkExpectInstanceId, system.New(panicMsgContext{}, message.NewMsg(nil), 1000009). + // spParamsOp + Ensure(m("/tmp/hakurei.0"), 0711). + + // spRuntimeOp + Ensure(m("/tmp/hakurei.0/runtime"), 0700). + UpdatePermType(system.User, m("/tmp/hakurei.0/runtime"), acl.Execute). + Ensure(m("/tmp/hakurei.0/runtime/9"), 0700). + UpdatePermType(system.User, m("/tmp/hakurei.0/runtime/9"), acl.Read, acl.Write, acl.Execute). + + // spTmpdirOp + Ensure(m("/tmp/hakurei.0/tmpdir"), 0700). + UpdatePermType(system.User, m("/tmp/hakurei.0/tmpdir"), acl.Execute). + Ensure(m("/tmp/hakurei.0/tmpdir/9"), 01700). + UpdatePermType(system.User, m("/tmp/hakurei.0/tmpdir/9"), acl.Read, acl.Write, acl.Execute). + + // instance + Ephemeral(system.Process, m("/tmp/hakurei.0/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), 0711). + + // spWaylandOp + Wayland( + m("/tmp/hakurei.0/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/wayland"), + m("/run/user/1971/wayland-0"), + "org.chromium.Chromium", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ). + + // ensureRuntimeDir + Ensure(m("/run/user/1971/hakurei"), 0700). + UpdatePermType(system.User, m("/run/user/1971/hakurei"), acl.Execute). + Ensure(m("/run/user/1971"), 0700). + UpdatePermType(system.User, m("/run/user/1971"), acl.Execute). + + // runtime + Ephemeral(system.Process, m("/run/user/1971/hakurei/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), 0700). + UpdatePerm(m("/run/user/1971/hakurei/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), acl.Execute). + + // spPulseOp + Link(m("/run/user/1971/pulse/native"), m("/run/user/1971/hakurei/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/pulse")). + + // spDBusOp + MustProxyDBus( + hst.Template().SessionBus, + hst.Template().SystemBus, dbus.ProxyPair{ + "unix:path=/run/user/1971/bus", + "/tmp/hakurei.0/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/bus", + }, dbus.ProxyPair{ + "unix:path=/var/run/dbus/system_bus_socket", + "/tmp/hakurei.0/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/system_bus_socket", + }, + ).UpdatePerm(m("/tmp/hakurei.0/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/bus"), acl.Read, acl.Write). + UpdatePerm(m("/tmp/hakurei.0/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/system_bus_socket"), acl.Read, acl.Write). + + // spFilesystemOp + Ensure(m("/var/lib/hakurei/u0"), 0700). + UpdatePermType(system.User, m("/var/lib/hakurei/u0"), acl.Execute). + UpdatePermType(system.User, m("/var/lib/hakurei/u0/org.chromium.Chromium"), acl.Read, acl.Write, acl.Execute), &container.Params{ + + Dir: m("/data/data/org.chromium.Chromium"), + Env: []string{ + "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1971/bus", + "DBUS_SYSTEM_BUS_ADDRESS=unix:path=/var/run/dbus/system_bus_socket", + "GOOGLE_API_KEY=AIzaSyBHDrl33hwRp4rMQY0ziRbj8K9LPA6vUCY", + "GOOGLE_DEFAULT_CLIENT_ID=77185425430.apps.googleusercontent.com", + "GOOGLE_DEFAULT_CLIENT_SECRET=OTJgUOQcT7lO7GsGZq2G4IlT", + "HOME=/data/data/org.chromium.Chromium", + "PULSE_COOKIE=/.hakurei/pulse-cookie", + "PULSE_SERVER=unix:/run/user/1971/pulse/native", + "SHELL=/run/current-system/sw/bin/zsh", + "TERM=xterm-256color", + "USER=chronos", + "WAYLAND_DISPLAY=wayland-0", + "XDG_RUNTIME_DIR=/run/user/1971", + "XDG_SESSION_CLASS=user", + "XDG_SESSION_TYPE=wayland", + }, + + // spParamsOp + Hostname: "localhost", + RetainSession: true, + HostNet: true, + HostAbstract: true, + Path: m("/run/current-system/sw/bin/chromium"), + Args: []string{ + "chromium", + "--ignore-gpu-blocklist", + "--disable-smooth-scrolling", + "--enable-features=UseOzonePlatform", + "--ozone-platform=wayland", + }, + SeccompFlags: seccomp.AllowMultiarch, + Uid: 1971, + Gid: 100, + + Ops: new(container.Ops). + // resolveRoot + Root(m("/var/lib/hakurei/base/org.debian"), comp.BindWritable). + // spParamsOp + Proc(fhs.AbsProc). + Tmpfs(hst.AbsPrivateTmp, 1<<12, 0755). + Bind(fhs.AbsDev, fhs.AbsDev, comp.BindWritable|comp.BindDevice). + Tmpfs(fhs.AbsDev.Append("shm"), 0, 01777). + + // spRuntimeOp + Tmpfs(fhs.AbsRunUser, 1<<12, 0755). + Bind(m("/tmp/hakurei.0/runtime/9"), m("/run/user/1971"), comp.BindWritable). + + // spTmpdirOp + Bind(m("/tmp/hakurei.0/tmpdir/9"), fhs.AbsTmp, comp.BindWritable). + + // spAccountOp + Place(m("/etc/passwd"), []byte("chronos:x:1971:100:Hakurei:/data/data/org.chromium.Chromium:/run/current-system/sw/bin/zsh\n")). + Place(m("/etc/group"), []byte("hakurei:x:100:\n")). + + // spWaylandOp + Bind(m("/tmp/hakurei.0/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/wayland"), m("/run/user/1971/wayland-0"), 0). + + // spPulseOp + Bind(m("/run/user/1971/hakurei/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/pulse"), m("/run/user/1971/pulse/native"), 0). + Place(m("/.hakurei/pulse-cookie"), bytes.Repeat([]byte{0}, pulseCookieSizeMax)). + + // 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). + + // spFilesystemOp + Etc(fhs.AbsEtc, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"). + Tmpfs(fhs.AbsTmp, 0, 0755). + Overlay(m("/nix/store"), + fhs.AbsVarLib.Append("hakurei/nix/u0/org.chromium.Chromium/rw-store/upper"), + fhs.AbsVarLib.Append("hakurei/nix/u0/org.chromium.Chromium/rw-store/work"), + fhs.AbsVarLib.Append("hakurei/base/org.nixos/ro-store")). + Link(m("/run/current-system"), "/run/current-system", true). + Link(m("/run/opengl-driver"), "/run/opengl-driver", true). + Bind(fhs.AbsVarLib.Append("hakurei/u0/org.chromium.Chromium"), + m("/data/data/org.chromium.Chromium"), + comp.BindWritable|comp.BindEnsure). + Bind(fhs.AbsDev.Append("dri"), fhs.AbsDev.Append("dri"), + comp.BindOptional|comp.BindWritable|comp.BindDevice). + Remount(fhs.AbsRoot, syscall.MS_RDONLY), + }}, + + {"nixos permissive defaults no enablements", new(stubNixOS), &hst.Config{Container: &hst.ContainerConfig{ + Filesystem: []hst.FilesystemConfigJSON{ + {FilesystemConfig: &hst.FSBind{ + Target: fhs.AbsRoot, + Source: fhs.AbsRoot, + Write: true, + Special: true, + }}, + {FilesystemConfig: &hst.FSBind{ + Source: fhs.AbsDev.Append("kvm"), + Device: true, + Optional: true, + }}, + {FilesystemConfig: &hst.FSBind{ + Target: fhs.AbsEtc, + Source: fhs.AbsEtc, + Special: true, + }}, + }, + + Username: "chronos", + Shell: m("/run/current-system/sw/bin/zsh"), + Home: m("/home/chronos"), + + Path: m("/run/current-system/sw/bin/zsh"), + Args: []string{"/run/current-system/sw/bin/zsh"}, + + Flags: hst.FUserns | hst.FHostNet | hst.FHostAbstract | hst.FTty | hst.FShareRuntime | hst.FShareTmpdir, + }}, hst.ID{ + 0x4a, 0x45, 0x0b, 0x65, + 0x96, 0xd7, 0xbc, 0x15, + 0xbd, 0x01, 0x78, 0x0e, + 0xb9, 0xa6, 0x07, 0xac, + }, system.New(t.Context(), msg, 1000000). + Ensure(m("/tmp/hakurei.0"), 0711). + Ensure(m("/tmp/hakurei.0/runtime"), 0700). + UpdatePermType(system.User, m("/tmp/hakurei.0/runtime"), acl.Execute). + Ensure(m("/tmp/hakurei.0/runtime/0"), 0700). + UpdatePermType(system.User, m("/tmp/hakurei.0/runtime/0"), acl.Read, acl.Write, acl.Execute). + Ensure(m("/tmp/hakurei.0/tmpdir"), 0700). + UpdatePermType(system.User, m("/tmp/hakurei.0/tmpdir"), acl.Execute). + Ensure(m("/tmp/hakurei.0/tmpdir/0"), 01700). + UpdatePermType(system.User, m("/tmp/hakurei.0/tmpdir/0"), acl.Read, acl.Write, acl.Execute), &container.Params{ + + Dir: m("/home/chronos"), + Path: m("/run/current-system/sw/bin/zsh"), + Args: []string{"/run/current-system/sw/bin/zsh"}, + Env: []string{ + "HOME=/home/chronos", + "SHELL=/run/current-system/sw/bin/zsh", + "TERM=xterm-256color", + "USER=chronos", + "XDG_RUNTIME_DIR=/run/user/65534", + "XDG_SESSION_CLASS=user", + "XDG_SESSION_TYPE=tty", + }, + Ops: new(container.Ops). + Root(m("/"), comp.BindWritable). + Proc(m("/proc/")). + Tmpfs(hst.AbsPrivateTmp, 4096, 0755). + DevWritable(m("/dev/"), true). + Tmpfs(m("/dev/shm"), 0, 01777). + Tmpfs(m("/run/user/"), 4096, 0755). + Bind(m("/tmp/hakurei.0/runtime/0"), m("/run/user/65534"), comp.BindWritable). + Bind(m("/tmp/hakurei.0/tmpdir/0"), m("/tmp/"), comp.BindWritable). + Place(m("/etc/passwd"), []byte("chronos:x:65534:65534:Hakurei:/home/chronos:/run/current-system/sw/bin/zsh\n")). + Place(m("/etc/group"), []byte("hakurei:x:65534:\n")). + Bind(m("/dev/kvm"), m("/dev/kvm"), comp.BindWritable|comp.BindDevice|comp.BindOptional). + Etc(m("/etc/"), "4a450b6596d7bc15bd01780eb9a607ac"). + Tmpfs(m("/run/user/1971"), 8192, 0755). + Tmpfs(m("/run/nscd"), 8192, 0755). + Tmpfs(m("/run/dbus"), 8192, 0755). + Remount(m("/dev/"), syscall.MS_RDONLY). + Remount(m("/"), syscall.MS_RDONLY), + SeccompPresets: comp.PresetExt | comp.PresetDenyDevel, + HostNet: true, + HostAbstract: true, + RetainSession: true, + ForwardCancel: true, + }}, + + {"nixos permissive defaults chromium", new(stubNixOS), &hst.Config{ + ID: "org.chromium.Chromium", + Identity: 9, + Groups: []string{"video"}, + SessionBus: &hst.BusConfig{ + Talk: []string{ + "org.freedesktop.Notifications", + "org.freedesktop.FileManager1", + "org.freedesktop.ScreenSaver", + "org.freedesktop.secrets", + "org.kde.kwalletd5", + "org.kde.kwalletd6", + "org.gnome.SessionManager", + }, + Own: []string{ + "org.chromium.Chromium.*", + "org.mpris.MediaPlayer2.org.chromium.Chromium.*", + "org.mpris.MediaPlayer2.chromium.*", + }, + Call: map[string]string{ + "org.freedesktop.portal.*": "*", + }, + Broadcast: map[string]string{ + "org.freedesktop.portal.*": "@/org/freedesktop/portal/*", + }, + Filter: true, + }, + SystemBus: &hst.BusConfig{ + Talk: []string{ + "org.bluez", + "org.freedesktop.Avahi", + "org.freedesktop.UPower", + }, + Filter: true, + }, + Enablements: hst.NewEnablements(hst.EWayland | hst.EDBus | hst.EPulse), + + Container: &hst.ContainerConfig{ + Filesystem: []hst.FilesystemConfigJSON{ + {FilesystemConfig: &hst.FSBind{ + Target: fhs.AbsRoot, + Source: fhs.AbsRoot, + Write: true, + Special: true, + }}, + {FilesystemConfig: &hst.FSBind{ + Source: fhs.AbsDev.Append("dri"), + Device: true, + Optional: true, + }}, + {FilesystemConfig: &hst.FSBind{ + Source: fhs.AbsDev.Append("kvm"), + Device: true, + Optional: true, + }}, + {FilesystemConfig: &hst.FSBind{ + Target: fhs.AbsEtc, + Source: fhs.AbsEtc, + Special: true, + }}, + }, + + Username: "chronos", + Shell: m("/run/current-system/sw/bin/zsh"), + Home: m("/home/chronos"), + + Path: m("/run/current-system/sw/bin/zsh"), + Args: []string{"zsh", "-c", "exec chromium "}, + + Flags: hst.FUserns | hst.FHostNet | hst.FHostAbstract | hst.FTty | hst.FShareRuntime | hst.FShareTmpdir, + }, + }, hst.ID{ + 0xeb, 0xf0, 0x83, 0xd1, + 0xb1, 0x75, 0x91, 0x17, + 0x82, 0xd4, 0x13, 0x36, + 0x9b, 0x64, 0xce, 0x7c, + }, system.New(t.Context(), msg, 1000009). + Ensure(m("/tmp/hakurei.0"), 0711). + Ensure(m("/tmp/hakurei.0/runtime"), 0700).UpdatePermType(system.User, m("/tmp/hakurei.0/runtime"), acl.Execute). + Ensure(m("/tmp/hakurei.0/runtime/9"), 0700).UpdatePermType(system.User, m("/tmp/hakurei.0/runtime/9"), acl.Read, acl.Write, acl.Execute). + Ensure(m("/tmp/hakurei.0/tmpdir"), 0700).UpdatePermType(system.User, m("/tmp/hakurei.0/tmpdir"), acl.Execute). + 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"). + Ensure(m("/run/user/1971/hakurei"), 0700).UpdatePermType(system.User, m("/run/user/1971/hakurei"), acl.Execute). + Ensure(m("/run/user/1971"), 0700).UpdatePermType(system.User, m("/run/user/1971"), acl.Execute). // this is ordered as is because the previous Ensure only calls mkdir if XDG_RUNTIME_DIR is unset + Ephemeral(system.Process, m("/run/user/1971/hakurei/ebf083d1b175911782d413369b64ce7c"), 0700).UpdatePermType(system.Process, m("/run/user/1971/hakurei/ebf083d1b175911782d413369b64ce7c"), acl.Execute). + Link(m("/run/user/1971/pulse/native"), m("/run/user/1971/hakurei/ebf083d1b175911782d413369b64ce7c/pulse")). + MustProxyDBus(&hst.BusConfig{ + Talk: []string{ + "org.freedesktop.Notifications", + "org.freedesktop.FileManager1", + "org.freedesktop.ScreenSaver", + "org.freedesktop.secrets", + "org.kde.kwalletd5", + "org.kde.kwalletd6", + "org.gnome.SessionManager", + }, + Own: []string{ + "org.chromium.Chromium.*", + "org.mpris.MediaPlayer2.org.chromium.Chromium.*", + "org.mpris.MediaPlayer2.chromium.*", + }, + Call: map[string]string{ + "org.freedesktop.portal.*": "*", + }, + Broadcast: map[string]string{ + "org.freedesktop.portal.*": "@/org/freedesktop/portal/*", + }, + Filter: true, + }, &hst.BusConfig{ + Talk: []string{ + "org.bluez", + "org.freedesktop.Avahi", + "org.freedesktop.UPower", + }, + Filter: true, + }, dbus.ProxyPair{ + "unix:path=/run/user/1971/bus", + "/tmp/hakurei.0/ebf083d1b175911782d413369b64ce7c/bus", + }, dbus.ProxyPair{ + "unix:path=/var/run/dbus/system_bus_socket", + "/tmp/hakurei.0/ebf083d1b175911782d413369b64ce7c/system_bus_socket", + }). + UpdatePerm(m("/tmp/hakurei.0/ebf083d1b175911782d413369b64ce7c/bus"), acl.Read, acl.Write). + UpdatePerm(m("/tmp/hakurei.0/ebf083d1b175911782d413369b64ce7c/system_bus_socket"), acl.Read, acl.Write), &container.Params{ + + Dir: m("/home/chronos"), + Path: m("/run/current-system/sw/bin/zsh"), + Args: []string{"zsh", "-c", "exec chromium "}, + Env: []string{ + "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/65534/bus", + "DBUS_SYSTEM_BUS_ADDRESS=unix:path=/var/run/dbus/system_bus_socket", + "HOME=/home/chronos", + "PULSE_COOKIE=" + hst.PrivateTmp + "/pulse-cookie", + "PULSE_SERVER=unix:/run/user/65534/pulse/native", + "SHELL=/run/current-system/sw/bin/zsh", + "TERM=xterm-256color", + "USER=chronos", + "WAYLAND_DISPLAY=wayland-0", + "XDG_RUNTIME_DIR=/run/user/65534", + "XDG_SESSION_CLASS=user", + "XDG_SESSION_TYPE=wayland", + }, + Ops: new(container.Ops). + Root(m("/"), comp.BindWritable). + Proc(m("/proc/")). + Tmpfs(hst.AbsPrivateTmp, 4096, 0755). + DevWritable(m("/dev/"), true). + Tmpfs(m("/dev/shm"), 0, 01777). + Tmpfs(m("/run/user/"), 4096, 0755). + Bind(m("/tmp/hakurei.0/runtime/9"), m("/run/user/65534"), comp.BindWritable). + Bind(m("/tmp/hakurei.0/tmpdir/9"), m("/tmp/"), comp.BindWritable). + Place(m("/etc/passwd"), []byte("chronos:x:65534:65534:Hakurei:/home/chronos:/run/current-system/sw/bin/zsh\n")). + Place(m("/etc/group"), []byte("hakurei:x:65534:\n")). + Bind(m("/tmp/hakurei.0/ebf083d1b175911782d413369b64ce7c/wayland"), m("/run/user/65534/wayland-0"), 0). + Bind(m("/run/user/1971/hakurei/ebf083d1b175911782d413369b64ce7c/pulse"), m("/run/user/65534/pulse/native"), 0). + Place(m(hst.PrivateTmp+"/pulse-cookie"), bytes.Repeat([]byte{0}, pulseCookieSizeMax)). + Bind(m("/tmp/hakurei.0/ebf083d1b175911782d413369b64ce7c/bus"), m("/run/user/65534/bus"), 0). + Bind(m("/tmp/hakurei.0/ebf083d1b175911782d413369b64ce7c/system_bus_socket"), m("/var/run/dbus/system_bus_socket"), 0). + Bind(m("/dev/dri"), m("/dev/dri"), comp.BindWritable|comp.BindDevice|comp.BindOptional). + Bind(m("/dev/kvm"), m("/dev/kvm"), comp.BindWritable|comp.BindDevice|comp.BindOptional). + Etc(m("/etc/"), "ebf083d1b175911782d413369b64ce7c"). + Tmpfs(m("/run/user/1971"), 8192, 0755). + Tmpfs(m("/run/nscd"), 8192, 0755). + Tmpfs(m("/run/dbus"), 8192, 0755). + Remount(m("/dev/"), syscall.MS_RDONLY). + Remount(m("/"), syscall.MS_RDONLY), + SeccompPresets: comp.PresetExt | comp.PresetDenyDevel, + HostNet: true, + HostAbstract: true, + RetainSession: true, + ForwardCancel: true, + }}, + + {"nixos chromium direct wayland", new(stubNixOS), &hst.Config{ + ID: "org.chromium.Chromium", + Enablements: hst.NewEnablements(hst.EWayland | hst.EDBus | hst.EPulse), + Container: &hst.ContainerConfig{ + Env: nil, + Filesystem: []hst.FilesystemConfigJSON{ + f(&hst.FSBind{Source: m("/bin")}), + f(&hst.FSBind{Source: m("/usr/bin/")}), + f(&hst.FSBind{Source: m("/nix/store")}), + f(&hst.FSBind{Source: m("/run/current-system")}), + f(&hst.FSBind{Source: m("/sys/block"), Optional: true}), + f(&hst.FSBind{Source: m("/sys/bus"), Optional: true}), + f(&hst.FSBind{Source: m("/sys/class"), Optional: true}), + f(&hst.FSBind{Source: m("/sys/dev"), Optional: true}), + f(&hst.FSBind{Source: m("/sys/devices"), Optional: true}), + f(&hst.FSBind{Source: m("/run/opengl-driver")}), + f(&hst.FSBind{Source: m("/dev/dri"), Device: true, Optional: true}), + f(&hst.FSBind{Source: m("/etc/"), Target: m("/etc/"), Special: true}), + f(&hst.FSBind{Source: m("/var/lib/persist/module/hakurei/0/1"), Write: true, Ensure: true}), + }, + + Username: "u0_a1", + Shell: m("/run/current-system/sw/bin/zsh"), + Home: m("/var/lib/persist/module/hakurei/0/1"), + + Path: m("/nix/store/yqivzpzzn7z5x0lq9hmbzygh45d8rhqd-chromium-start"), + + Flags: hst.FUserns | hst.FHostNet | hst.FMapRealUID | hst.FShareRuntime | hst.FShareTmpdir, + }, + SystemBus: &hst.BusConfig{ + Talk: []string{"org.bluez", "org.freedesktop.Avahi", "org.freedesktop.UPower"}, + Filter: true, + }, + SessionBus: &hst.BusConfig{ + Talk: []string{ + "org.freedesktop.FileManager1", "org.freedesktop.Notifications", + "org.freedesktop.ScreenSaver", "org.freedesktop.secrets", + "org.kde.kwalletd5", "org.kde.kwalletd6", + }, + Own: []string{ + "org.chromium.Chromium.*", + "org.mpris.MediaPlayer2.org.chromium.Chromium.*", + "org.mpris.MediaPlayer2.chromium.*", + }, + Call: map[string]string{}, Broadcast: map[string]string{}, + Filter: true, + }, + DirectWayland: true, + + Identity: 1, Groups: []string{}, + }, hst.ID{ + 0x8e, 0x2c, 0x76, 0xb0, + 0x66, 0xda, 0xbe, 0x57, + 0x4c, 0xf0, 0x73, 0xbd, + 0xb4, 0x6e, 0xb5, 0xc1, + }, system.New(t.Context(), msg, 1000001). + Ensure(m("/tmp/hakurei.0"), 0711). + Ensure(m("/tmp/hakurei.0/runtime"), 0700).UpdatePermType(system.User, m("/tmp/hakurei.0/runtime"), acl.Execute). + Ensure(m("/tmp/hakurei.0/runtime/1"), 0700).UpdatePermType(system.User, m("/tmp/hakurei.0/runtime/1"), acl.Read, acl.Write, acl.Execute). + Ensure(m("/tmp/hakurei.0/tmpdir"), 0700).UpdatePermType(system.User, m("/tmp/hakurei.0/tmpdir"), acl.Execute). + Ensure(m("/tmp/hakurei.0/tmpdir/1"), 01700).UpdatePermType(system.User, m("/tmp/hakurei.0/tmpdir/1"), acl.Read, acl.Write, acl.Execute). + Ensure(m("/run/user/1971/hakurei"), 0700).UpdatePermType(system.User, m("/run/user/1971/hakurei"), acl.Execute). + Ensure(m("/run/user/1971"), 0700).UpdatePermType(system.User, m("/run/user/1971"), acl.Execute). // this is ordered as is because the previous Ensure only calls mkdir if XDG_RUNTIME_DIR is unset + UpdatePermType(hst.EWayland, m("/run/user/1971/wayland-0"), acl.Read, acl.Write, acl.Execute). + Ephemeral(system.Process, m("/run/user/1971/hakurei/8e2c76b066dabe574cf073bdb46eb5c1"), 0700).UpdatePermType(system.Process, m("/run/user/1971/hakurei/8e2c76b066dabe574cf073bdb46eb5c1"), acl.Execute). + Link(m("/run/user/1971/pulse/native"), m("/run/user/1971/hakurei/8e2c76b066dabe574cf073bdb46eb5c1/pulse")). + Ephemeral(system.Process, m("/tmp/hakurei.0/8e2c76b066dabe574cf073bdb46eb5c1"), 0711). + MustProxyDBus(&hst.BusConfig{ + Talk: []string{ + "org.freedesktop.FileManager1", "org.freedesktop.Notifications", + "org.freedesktop.ScreenSaver", "org.freedesktop.secrets", + "org.kde.kwalletd5", "org.kde.kwalletd6", + }, + Own: []string{ + "org.chromium.Chromium.*", + "org.mpris.MediaPlayer2.org.chromium.Chromium.*", + "org.mpris.MediaPlayer2.chromium.*", + }, + Call: map[string]string{}, Broadcast: map[string]string{}, + Filter: true, + }, &hst.BusConfig{ + Talk: []string{ + "org.bluez", + "org.freedesktop.Avahi", + "org.freedesktop.UPower", + }, + Filter: true, + }, dbus.ProxyPair{ + "unix:path=/run/user/1971/bus", + "/tmp/hakurei.0/8e2c76b066dabe574cf073bdb46eb5c1/bus", + }, dbus.ProxyPair{ + "unix:path=/var/run/dbus/system_bus_socket", + "/tmp/hakurei.0/8e2c76b066dabe574cf073bdb46eb5c1/system_bus_socket", + }). + UpdatePerm(m("/tmp/hakurei.0/8e2c76b066dabe574cf073bdb46eb5c1/bus"), acl.Read, acl.Write). + UpdatePerm(m("/tmp/hakurei.0/8e2c76b066dabe574cf073bdb46eb5c1/system_bus_socket"), acl.Read, acl.Write), &container.Params{ + + Uid: 1971, + Gid: 100, + Dir: m("/var/lib/persist/module/hakurei/0/1"), + Path: m("/nix/store/yqivzpzzn7z5x0lq9hmbzygh45d8rhqd-chromium-start"), + Args: []string{"/nix/store/yqivzpzzn7z5x0lq9hmbzygh45d8rhqd-chromium-start"}, + Env: []string{ + "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1971/bus", + "DBUS_SYSTEM_BUS_ADDRESS=unix:path=/var/run/dbus/system_bus_socket", + "HOME=/var/lib/persist/module/hakurei/0/1", + "PULSE_COOKIE=" + hst.PrivateTmp + "/pulse-cookie", + "PULSE_SERVER=unix:/run/user/1971/pulse/native", + "SHELL=/run/current-system/sw/bin/zsh", + "TERM=xterm-256color", + "USER=u0_a1", + "WAYLAND_DISPLAY=wayland-0", + "XDG_RUNTIME_DIR=/run/user/1971", + "XDG_SESSION_CLASS=user", + "XDG_SESSION_TYPE=wayland", + }, + Ops: new(container.Ops). + Proc(m("/proc/")). + Tmpfs(hst.AbsPrivateTmp, 4096, 0755). + DevWritable(m("/dev/"), true). + Tmpfs(m("/dev/shm"), 0, 01777). + Tmpfs(m("/run/user/"), 4096, 0755). + Bind(m("/tmp/hakurei.0/runtime/1"), m("/run/user/1971"), comp.BindWritable). + Bind(m("/tmp/hakurei.0/tmpdir/1"), m("/tmp/"), comp.BindWritable). + Place(m("/etc/passwd"), []byte("u0_a1:x:1971:100:Hakurei:/var/lib/persist/module/hakurei/0/1:/run/current-system/sw/bin/zsh\n")). + Place(m("/etc/group"), []byte("hakurei:x:100:\n")). + Bind(m("/run/user/1971/wayland-0"), m("/run/user/1971/wayland-0"), 0). + Bind(m("/run/user/1971/hakurei/8e2c76b066dabe574cf073bdb46eb5c1/pulse"), m("/run/user/1971/pulse/native"), 0). + Place(m(hst.PrivateTmp+"/pulse-cookie"), bytes.Repeat([]byte{0}, pulseCookieSizeMax)). + Bind(m("/tmp/hakurei.0/8e2c76b066dabe574cf073bdb46eb5c1/bus"), m("/run/user/1971/bus"), 0). + Bind(m("/tmp/hakurei.0/8e2c76b066dabe574cf073bdb46eb5c1/system_bus_socket"), m("/var/run/dbus/system_bus_socket"), 0). + Bind(m("/bin"), m("/bin"), 0). + Bind(m("/usr/bin/"), m("/usr/bin/"), 0). + Bind(m("/nix/store"), m("/nix/store"), 0). + Bind(m("/run/current-system"), m("/run/current-system"), 0). + Bind(m("/sys/block"), m("/sys/block"), comp.BindOptional). + Bind(m("/sys/bus"), m("/sys/bus"), comp.BindOptional). + Bind(m("/sys/class"), m("/sys/class"), comp.BindOptional). + Bind(m("/sys/dev"), m("/sys/dev"), comp.BindOptional). + Bind(m("/sys/devices"), m("/sys/devices"), comp.BindOptional). + Bind(m("/run/opengl-driver"), m("/run/opengl-driver"), 0). + Bind(m("/dev/dri"), m("/dev/dri"), comp.BindDevice|comp.BindWritable|comp.BindOptional). + Etc(m("/etc/"), "8e2c76b066dabe574cf073bdb46eb5c1"). + Bind(m("/var/lib/persist/module/hakurei/0/1"), m("/var/lib/persist/module/hakurei/0/1"), comp.BindWritable|comp.BindEnsure). + Remount(m("/dev/"), syscall.MS_RDONLY). + Remount(m("/"), syscall.MS_RDONLY), + SeccompPresets: comp.PresetExt | comp.PresetDenyTTY | comp.PresetDenyDevel, + HostNet: true, + ForwardCancel: true, + }}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + gr, gw := io.Pipe() + + var gotSys *system.I + { + sPriv := newOutcomeState(tc.k, msg, &tc.id, tc.config, &Hsu{k: tc.k}) + if err := sPriv.populateLocal(tc.k, msg); err != nil { + t.Fatalf("populateLocal: error = %#v", err) + } + + gotSys = system.New(t.Context(), msg, sPriv.uid.unwrap()) + if err := sPriv.newSys(tc.config, gotSys).toSystem(); err != nil { + t.Fatalf("toSystem: error = %#v", err) + } + + go func() { + e := gob.NewEncoder(gw) + if err := errors.Join(e.Encode(&sPriv)); err != nil { + t.Errorf("Encode: error = %v", err) + panic("unexpected encode fault") + } + }() + } + + var gotParams *container.Params + { + var sShim outcomeState + + d := gob.NewDecoder(gr) + if err := errors.Join(d.Decode(&sShim)); err != nil { + t.Fatalf("Decode: error = %v", err) + } + if err := sShim.populateLocal(tc.k, msg); err != nil { + t.Fatalf("populateLocal: error = %#v", err) + } + + stateParams := sShim.newParams() + for _, op := range sShim.Shim.Ops { + if err := op.toContainer(stateParams); err != nil { + t.Fatalf("toContainer: error = %#v", err) + } + } + gotParams = stateParams.params + } + + t.Run("sys", func(t *testing.T) { + if !gotSys.Equal(tc.wantSys) { + t.Errorf("toSystem: sys = %#v, want %#v", gotSys, tc.wantSys) + } + }) + + t.Run("params", func(t *testing.T) { + if !reflect.DeepEqual(gotParams, tc.wantParams) { + t.Errorf("toContainer: params =\n%s\n, want\n%s", mustMarshal(gotParams), mustMarshal(tc.wantParams)) + } + }) + }) + } +} + +func stubDirEntries(names ...string) (e []fs.DirEntry, err error) { + e = make([]fs.DirEntry, len(names)) + for i, name := range names { + e[i] = stubDirEntryPath(name) + } + return +} + +type stubDirEntryPath string + +func (p stubDirEntryPath) Name() string { return string(p) } +func (p stubDirEntryPath) IsDir() bool { panic("attempted to call IsDir") } +func (p stubDirEntryPath) Type() fs.FileMode { panic("attempted to call Type") } +func (p stubDirEntryPath) Info() (fs.FileInfo, error) { panic("attempted to call Info") } + +type stubFileInfoMode fs.FileMode + +func (s stubFileInfoMode) Name() string { panic("attempted to call Name") } +func (s stubFileInfoMode) Size() int64 { panic("attempted to call Size") } +func (s stubFileInfoMode) Mode() fs.FileMode { return fs.FileMode(s) } +func (s stubFileInfoMode) ModTime() time.Time { panic("attempted to call ModTime") } +func (s stubFileInfoMode) IsDir() bool { panic("attempted to call IsDir") } +func (s stubFileInfoMode) Sys() any { panic("attempted to call Sys") } + +type stubFileInfoIsDir bool + +func (s stubFileInfoIsDir) Name() string { panic("attempted to call Name") } +func (s stubFileInfoIsDir) Size() int64 { panic("attempted to call Size") } +func (s stubFileInfoIsDir) Mode() fs.FileMode { panic("attempted to call Mode") } +func (s stubFileInfoIsDir) ModTime() time.Time { panic("attempted to call ModTime") } +func (s stubFileInfoIsDir) IsDir() bool { return bool(s) } +func (s stubFileInfoIsDir) Sys() any { panic("attempted to call Sys") } + +type stubFileInfoPulseCookie struct{ stubFileInfoIsDir } + +func (s stubFileInfoPulseCookie) Size() int64 { return pulseCookieSizeMax } + +type stubOsFileReadCloser struct{ io.ReadCloser } + +func (s stubOsFileReadCloser) Name() string { panic("attempting to call Name") } +func (s stubOsFileReadCloser) Write([]byte) (int, error) { panic("attempting to call Write") } +func (s stubOsFileReadCloser) Stat() (fs.FileInfo, error) { panic("attempting to call Stat") } + +type stubNixOS struct { + usernameErr map[string]error + panicDispatcher +} + +func (k *stubNixOS) getpid() int { return 0xdeadbeef } +func (k *stubNixOS) getuid() int { return 1971 } +func (k *stubNixOS) getgid() int { return 100 } + +func (k *stubNixOS) lookupEnv(key string) (string, bool) { + switch key { + case "SHELL": + return "/run/current-system/sw/bin/zsh", true + case "TERM": + return "xterm-256color", true + case "WAYLAND_DISPLAY": + return "wayland-0", true + case "PULSE_COOKIE": + return "", false + case "HOME": + return "/home/ophestra", true + case "XDG_RUNTIME_DIR": + return "/run/user/1971", true + case "XDG_CONFIG_HOME": + return "/home/ophestra/xdg/config", true + case "DBUS_SYSTEM_BUS_ADDRESS": + return "", false + default: + panic(fmt.Sprintf("attempted to access unexpected environment variable %q", key)) + } +} + +func (k *stubNixOS) stat(name string) (fs.FileInfo, error) { + switch name { + case "/var/run/nscd": + return nil, nil + case "/run/user/1971/pulse": + return nil, nil + case "/run/user/1971/pulse/native": + return stubFileInfoMode(0666), nil + case "/home/ophestra/.pulse-cookie": + return stubFileInfoIsDir(true), nil + case "/home/ophestra/xdg/config/pulse/cookie": + return stubFileInfoPulseCookie{false}, nil + default: + panic(fmt.Sprintf("attempted to stat unexpected path %q", name)) + } +} + +func (k *stubNixOS) open(name string) (osFile, error) { + switch name { + case "/home/ophestra/xdg/config/pulse/cookie": + return stubOsFileReadCloser{io.NopCloser(bytes.NewReader(bytes.Repeat([]byte{0}, pulseCookieSizeMax)))}, nil + default: + panic(fmt.Sprintf("attempted to open unexpected path %q", name)) + } +} + +func (k *stubNixOS) readdir(name string) ([]fs.DirEntry, error) { + switch name { + case "/": + return stubDirEntries("bin", "boot", "dev", "etc", "home", "lib", + "lib64", "nix", "proc", "root", "run", "srv", "sys", "tmp", "usr", "var") + + case "/run": + return stubDirEntries("agetty.reload", "binfmt", "booted-system", + "credentials", "cryptsetup", "current-system", "dbus", "host", "keys", + "libvirt", "libvirtd.pid", "lock", "log", "lvm", "mount", "NetworkManager", + "nginx", "nixos", "nscd", "opengl-driver", "pppd", "resolvconf", "sddm", + "store", "syncoid", "system", "systemd", "tmpfiles.d", "udev", "udisks2", + "user", "utmp", "virtlogd.pid", "wrappers", "zed.pid", "zed.state") + + case "/etc": + return stubDirEntries("alsa", "bashrc", "binfmt.d", "dbus-1", "default", + "ethertypes", "fonts", "fstab", "fuse.conf", "group", "host.conf", "hostid", + "hostname", "hostname.CHECKSUM", "hosts", "inputrc", "ipsec.d", "issue", "kbd", + "libblockdev", "locale.conf", "localtime", "login.defs", "lsb-release", "lvm", + "machine-id", "man_db.conf", "modprobe.d", "modules-load.d", "mtab", "nanorc", + "netgroup", "NetworkManager", "nix", "nixos", "NIXOS", "nscd.conf", "nsswitch.conf", + "opensnitchd", "os-release", "pam", "pam.d", "passwd", "pipewire", "pki", "polkit-1", + "profile", "protocols", "qemu", "resolv.conf", "resolvconf.conf", "rpc", "samba", + "sddm.conf", "secureboot", "services", "set-environment", "shadow", "shells", "ssh", + "ssl", "static", "subgid", "subuid", "sudoers", "sysctl.d", "systemd", "terminfo", + "tmpfiles.d", "udev", "udisks2", "UPower", "vconsole.conf", "X11", "zfs", "zinputrc", + "zoneinfo", "zprofile", "zshenv", "zshrc") + + case "/var/lib/hakurei/base/org.debian": + return stubDirEntries("bin", "dev", "etc", "home", "lib64", "lost+found", + "mnt", "nix", "proc", "root", "run", "srv", "sys", "tmp", "usr", "var") + + default: + panic(fmt.Sprintf("attempted to read unexpected directory %q", name)) + } +} + +func (k *stubNixOS) tempdir() string { return "/tmp/" } + +func (k *stubNixOS) evalSymlinks(path string) (string, error) { + switch path { + case "/var/run/nscd": + return "/run/nscd", nil + case "/run/user/1971": + return "/run/user/1971", nil + case "/tmp/hakurei.0": + return "/tmp/hakurei.0", nil + case "/var/run/dbus": + return "/run/dbus", nil + case "/dev/kvm": + return "/dev/kvm", nil + case "/etc/": + return "/etc/", nil + case "/bin": + return "/bin", nil + case "/boot": + return "/boot", nil + case "/home": + return "/home", nil + case "/lib": + return "/lib", nil + case "/lib64": + return "/lib64", nil + case "/nix": + return "/nix", nil + case "/root": + return "/root", nil + case "/run": + return "/run", nil + case "/srv": + return "/srv", nil + case "/sys": + return "/sys", nil + case "/usr": + return "/usr", nil + case "/var": + return "/var", nil + case "/dev/dri": + return "/dev/dri", nil + case "/usr/bin/": + return "/usr/bin/", nil + case "/nix/store": + return "/nix/store", nil + case "/run/current-system": + return "/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-nixos-system-satori-25.05.99999999.aaaaaaa", nil + case "/sys/block": + return "/sys/block", nil + case "/sys/bus": + return "/sys/bus", nil + case "/sys/class": + return "/sys/class", nil + case "/sys/dev": + return "/sys/dev", nil + case "/sys/devices": + return "/sys/devices", nil + case "/run/opengl-driver": + return "/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-graphics-drivers", nil + case "/var/lib/persist/module/hakurei/0/1": + return "/var/lib/persist/module/hakurei/0/1", nil + + case "/var/lib/hakurei/nix/u0/org.chromium.Chromium/rw-store/upper": + return "/var/lib/hakurei/nix/u0/org.chromium.Chromium/rw-store/upper", nil + case "/var/lib/hakurei/nix/u0/org.chromium.Chromium/rw-store/work": + return "/var/lib/hakurei/nix/u0/org.chromium.Chromium/rw-store/work", nil + case "/var/lib/hakurei/base/org.nixos/ro-store": + return "/var/lib/hakurei/base/org.nixos/ro-store", nil + case "/var/lib/hakurei/u0/org.chromium.Chromium": + return "/var/lib/hakurei/u0/org.chromium.Chromium", nil + case "/var/lib/hakurei/base/org.debian/bin": + return "/var/lib/hakurei/base/org.debian/bin", nil + case "/var/lib/hakurei/base/org.debian/home": + return "/var/lib/hakurei/base/org.debian/home", nil + case "/var/lib/hakurei/base/org.debian/lib64": + return "/var/lib/hakurei/base/org.debian/lib64", nil + case "/var/lib/hakurei/base/org.debian/lost+found": + return "/var/lib/hakurei/base/org.debian/lost+found", nil + case "/var/lib/hakurei/base/org.debian/nix": + return "/var/lib/hakurei/base/org.debian/nix", nil + case "/var/lib/hakurei/base/org.debian/root": + return "/var/lib/hakurei/base/org.debian/root", nil + case "/var/lib/hakurei/base/org.debian/run": + return "/var/lib/hakurei/base/org.debian/run", nil + case "/var/lib/hakurei/base/org.debian/srv": + return "/var/lib/hakurei/base/org.debian/srv", nil + case "/var/lib/hakurei/base/org.debian/sys": + return "/var/lib/hakurei/base/org.debian/sys", nil + case "/var/lib/hakurei/base/org.debian/usr": + return "/var/lib/hakurei/base/org.debian/usr", nil + case "/var/lib/hakurei/base/org.debian/var": + return "/var/lib/hakurei/base/org.debian/var", nil + + default: + panic(fmt.Sprintf("attempted to evaluate unexpected path %q", path)) + } +} + +func (k *stubNixOS) lookupGroupId(name string) (string, error) { + switch name { + case "video": + return "26", nil + default: + return "", user.UnknownGroupError(name) + } +} + +func (k *stubNixOS) cmdOutput(cmd *exec.Cmd) ([]byte, error) { + switch cmd.Path { + case "/proc/nonexistent/hsu": + return []byte{'0'}, nil + default: + panic(fmt.Sprintf("unexpected cmd %#v", cmd)) + } +} + +func (k *stubNixOS) overflowUid(message.Msg) int { return 65534 } +func (k *stubNixOS) overflowGid(message.Msg) int { return 65534 } + +func (k *stubNixOS) mustHsuPath() *check.Absolute { return m("/proc/nonexistent/hsu") } + +func (k *stubNixOS) dbusAddress() (string, string) { + return "unix:path=/run/user/1971/bus", "unix:path=/var/run/dbus/system_bus_socket" +} + +func (k *stubNixOS) fatalf(format string, v ...any) { panic(fmt.Sprintf(format, v...)) } + +func (k *stubNixOS) isVerbose() bool { return true } +func (k *stubNixOS) verbose(v ...any) { log.Print(v...) } +func (k *stubNixOS) verbosef(format string, v ...any) { log.Printf(format, v...) } diff --git a/internal/outcome/outcome.go b/internal/outcome/outcome.go new file mode 100644 index 00000000..2397b725 --- /dev/null +++ b/internal/outcome/outcome.go @@ -0,0 +1,303 @@ +// Package outcome implements the outcome of the privileged and container sides of a hakurei container. +package outcome + +import ( + "errors" + "maps" + "strconv" + + "hakurei.app/container" + "hakurei.app/container/check" + "hakurei.app/hst" + "hakurei.app/internal/env" + "hakurei.app/message" + "hakurei.app/system" + "hakurei.app/system/acl" +) + +// envAllocSize is the initial size of the env map pre-allocated when the configured env map is nil. +// It should be large enough to fit all insertions by outcomeOp.toContainer. +const envAllocSize = 1 << 6 + +func newInt(v int) *stringPair[int] { return &stringPair[int]{v, strconv.Itoa(v)} } + +// stringPair stores a value and its string representation. +type stringPair[T comparable] struct { + v T + s string +} + +func (s *stringPair[T]) unwrap() T { return s.v } +func (s *stringPair[T]) String() string { return s.s } + +// outcomeState is copied to the shim process and available while applying outcomeOp. +// This is transmitted from the priv side to the shim, so exported fields should be kept to a minimum. +type outcomeState struct { + // Params only used by the shim process. Populated by populateEarly. + Shim *shimParams + + // Generated and accounted for by the caller. + ID *hst.ID + // Copied from ID. + id *stringPair[hst.ID] + + // Copied from the [hst.Config] field of the same name. + Identity int + // Copied from Identity. + identity *stringPair[int] + // Returned by [Hsu.MustID]. + UserID int + // Target init namespace uid resolved from UserID and identity. + uid *stringPair[int] + + // Included as part of [hst.Config], transmitted as-is unless permissive defaults. + Container *hst.ContainerConfig + + // Mapped credentials within container user namespace. + Mapuid, Mapgid int + // Copied from their respective exported values. + mapuid, mapgid *stringPair[int] + + // Copied from [EnvPaths] per-process. + sc hst.Paths + *env.Paths + + // Copied via populateLocal. + k syscallDispatcher + // Copied via populateLocal. + msg message.Msg +} + +// valid checks outcomeState to be safe for use with outcomeOp. +func (s *outcomeState) valid() bool { + return s != nil && + s.Shim.valid() && + s.ID != nil && + s.Container != nil && + s.Paths != nil +} + +// newOutcomeState returns the address of a new outcomeState with its exported fields populated via syscallDispatcher. +func newOutcomeState(k syscallDispatcher, msg message.Msg, id *hst.ID, config *hst.Config, hsu *Hsu) *outcomeState { + s := outcomeState{ + Shim: &shimParams{PrivPID: k.getpid(), Verbose: msg.IsVerbose()}, + ID: id, + Identity: config.Identity, + UserID: hsu.MustID(msg), + Paths: env.CopyPathsFunc(k.fatalf, k.tempdir, func(key string) string { v, _ := k.lookupEnv(key); return v }), + Container: config.Container, + } + + // enforce bounds and default early + if s.Container.WaitDelay < 0 { + s.Shim.WaitDelay = 0 + } else if s.Container.WaitDelay == 0 { + s.Shim.WaitDelay = hst.WaitDelayDefault + } else if s.Container.WaitDelay > hst.WaitDelayMax { + s.Shim.WaitDelay = hst.WaitDelayMax + } else { + s.Shim.WaitDelay = s.Container.WaitDelay + } + + if s.Container.Flags&hst.FMapRealUID != 0 { + s.Mapuid, s.Mapgid = k.getuid(), k.getgid() + } else { + s.Mapuid, s.Mapgid = k.overflowUid(msg), k.overflowGid(msg) + } + + return &s +} + +// populateLocal populates unexported fields from transmitted exported fields. +// These fields are cheaper to recompute per-process. +func (s *outcomeState) populateLocal(k syscallDispatcher, msg message.Msg) error { + if !s.valid() || k == nil || msg == nil { + return newWithMessage("impossible outcome state reached") + } + + if s.k != nil || s.msg != nil { + panic("attempting to call populateLocal twice") + } + s.k = k + s.msg = msg + + s.id = &stringPair[hst.ID]{*s.ID, s.ID.String()} + + s.Copy(&s.sc, s.UserID) + msg.Verbosef("process share directory at %q, runtime directory at %q", s.sc.SharePath, s.sc.RunDirPath) + + s.identity = newInt(s.Identity) + s.mapuid, s.mapgid = newInt(s.Mapuid), newInt(s.Mapgid) + s.uid = newInt(HsuUid(s.UserID, s.identity.unwrap())) + + return nil +} + +// instancePath returns a path formatted for outcomeStateSys.instance. +// This method must only be called from outcomeOp.toContainer if +// outcomeOp.toSystem has already called outcomeStateSys.instance. +func (s *outcomeState) instancePath() *check.Absolute { return s.sc.SharePath.Append(s.id.String()) } + +// runtimePath returns a path formatted for outcomeStateSys.runtime. +// This method must only be called from outcomeOp.toContainer if +// outcomeOp.toSystem has already called outcomeStateSys.runtime. +func (s *outcomeState) runtimePath() *check.Absolute { return s.sc.RunDirPath.Append(s.id.String()) } + +// outcomeStateSys wraps outcomeState and [system.I]. Used on the priv side only. +// Implementations of outcomeOp must not access fields other than sys unless explicitly stated. +type outcomeStateSys struct { + // Whether XDG_RUNTIME_DIR is used post hsu. + useRuntimeDir bool + // Process-specific directory in TMPDIR, nil if unused. + sharePath *check.Absolute + // Process-specific directory in XDG_RUNTIME_DIR, nil if unused. + runtimeSharePath *check.Absolute + + // Copied from [hst.Config]. Safe for read by outcomeOp.toSystem. + appId string + // Copied from [hst.Config]. Safe for read by outcomeOp.toSystem. + et hst.Enablement + + // Copied from [hst.Config]. Safe for read by spWaylandOp.toSystem only. + directWayland bool + // Copied header from [hst.Config]. Safe for read by spFilesystemOp.toSystem only. + extraPerms []hst.ExtraPermConfig + // Copied address from [hst.Config]. Safe for read by spDBusOp.toSystem only. + sessionBus, systemBus *hst.BusConfig + + sys *system.I + *outcomeState +} + +// newSys returns the address of a new outcomeStateSys embedding the current outcomeState. +func (s *outcomeState) newSys(config *hst.Config, sys *system.I) *outcomeStateSys { + return &outcomeStateSys{ + appId: config.ID, et: config.Enablements.Unwrap(), + directWayland: config.DirectWayland, extraPerms: config.ExtraPerms, + sessionBus: config.SessionBus, systemBus: config.SystemBus, + sys: sys, outcomeState: s, + } +} + +// newParams returns the address of a new outcomeStateParams embedding the current outcomeState. +func (s *outcomeState) newParams() *outcomeStateParams { + stateParams := outcomeStateParams{params: new(container.Params), outcomeState: s} + if s.Container.Env == nil { + stateParams.env = make(map[string]string, envAllocSize) + } else { + stateParams.env = maps.Clone(s.Container.Env) + } + return &stateParams +} + +// ensureRuntimeDir must be called if access to paths within XDG_RUNTIME_DIR is required. +func (state *outcomeStateSys) ensureRuntimeDir() { + if state.useRuntimeDir { + return + } + state.useRuntimeDir = true + state.sys.Ensure(state.sc.RunDirPath, 0700) + state.sys.UpdatePermType(system.User, state.sc.RunDirPath, acl.Execute) + state.sys.Ensure(state.sc.RuntimePath, 0700) // ensure this dir in case XDG_RUNTIME_DIR is unset + state.sys.UpdatePermType(system.User, state.sc.RuntimePath, acl.Execute) +} + +// instance returns the pathname to a process-specific directory within TMPDIR. +// This directory must only hold entries bound to [system.Process]. +func (state *outcomeStateSys) instance() *check.Absolute { + if state.sharePath != nil { + return state.sharePath + } + state.sharePath = state.instancePath() + state.sys.Ephemeral(system.Process, state.sharePath, 0711) + return state.sharePath +} + +// runtime returns the pathname to a process-specific directory within XDG_RUNTIME_DIR. +// This directory must only hold entries bound to [system.Process]. +func (state *outcomeStateSys) runtime() *check.Absolute { + if state.runtimeSharePath != nil { + return state.runtimeSharePath + } + state.ensureRuntimeDir() + state.runtimeSharePath = state.runtimePath() + state.sys.Ephemeral(system.Process, state.runtimeSharePath, 0700) + state.sys.UpdatePerm(state.runtimeSharePath, acl.Execute) + return state.runtimeSharePath +} + +// outcomeStateParams wraps outcomeState and [container.Params]. Used on the shim side only. +type outcomeStateParams struct { + // Overrides the embedded [container.Params] in [container.Container]. The Env field must not be used. + params *container.Params + // Collapsed into the Env slice in [container.Params] by the final outcomeOp. + env map[string]string + + // Filesystems with the optional root sliced off if present. Populated by spParamsOp. + // Safe for use by spFilesystemOp. + filesystem []hst.FilesystemConfigJSON + + // Inner XDG_RUNTIME_DIR default formatting of `/run/user/%d` via mapped uid. + // Populated by spRuntimeOp. + runtimeDir *check.Absolute + + as hst.ApplyState + *outcomeState +} + +// errNotEnabled is returned by outcomeOp.toSystem and used internally to exclude an outcomeOp from transmission. +var errNotEnabled = errors.New("op not enabled in the configuration") + +// An outcomeOp inflicts an outcome on [system.I] and contains enough information to +// inflict it on [container.Params] in a separate process. +// An implementation of outcomeOp must store cross-process states in exported fields only. +type outcomeOp interface { + // toSystem inflicts the current outcome on [system.I] in the priv side process. + toSystem(state *outcomeStateSys) error + + // toContainer inflicts the current outcome on [container.Params] in the shim process. + // The implementation must not write to the Env field of [container.Params] as it will be overwritten + // by flattened env map. + toContainer(state *outcomeStateParams) error +} + +// toSystem calls the outcomeOp.toSystem method on all outcomeOp implementations and populates shimParams.Ops. +// This function assumes the caller has already called the Validate method on [hst.Config] +// and checked that it returns nil. +func (state *outcomeStateSys) toSystem() error { + if state.Shim == nil || state.Shim.Ops != nil { + return newWithMessage("invalid ops state reached") + } + + ops := [...]outcomeOp{ + // must run first + &spParamsOp{}, + + &spRuntimeOp{}, + spTmpdirOp{}, + spAccountOp{}, + + // optional via enablements + &spWaylandOp{}, + &spX11Op{}, + &spPulseOp{}, + &spDBusOp{}, + + // must run last + &spFilesystemOp{}, + } + + state.Shim.Ops = make([]outcomeOp, 0, len(ops)) + for _, op := range ops { + if err := op.toSystem(state); err != nil { + // this error is used internally to exclude this outcomeOp from transmission + if errors.Is(err, errNotEnabled) { + continue + } + + return err + } + state.Shim.Ops = append(state.Shim.Ops, op) + } + return nil +} diff --git a/internal/outcome/outcome_test.go b/internal/outcome/outcome_test.go new file mode 100644 index 00000000..a03e2501 --- /dev/null +++ b/internal/outcome/outcome_test.go @@ -0,0 +1,34 @@ +package outcome + +import ( + "testing" + + "hakurei.app/hst" + "hakurei.app/internal/env" +) + +func TestOutcomeStateValid(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + s *outcomeState + want bool + }{ + {"nil", nil, false}, + {"zero", new(outcomeState), false}, + {"shim", &outcomeState{Shim: &shimParams{PrivPID: -1, Ops: []outcomeOp{}}, Container: new(hst.ContainerConfig), Paths: new(env.Paths)}, false}, + {"id", &outcomeState{Shim: &shimParams{PrivPID: 1, Ops: []outcomeOp{}}, Container: new(hst.ContainerConfig), Paths: new(env.Paths)}, false}, + {"container", &outcomeState{Shim: &shimParams{PrivPID: 1, Ops: []outcomeOp{}}, ID: new(hst.ID), Paths: new(env.Paths)}, false}, + {"envpaths", &outcomeState{Shim: &shimParams{PrivPID: 1, Ops: []outcomeOp{}}, ID: new(hst.ID), Container: new(hst.ContainerConfig)}, false}, + {"valid", &outcomeState{Shim: &shimParams{PrivPID: 1, Ops: []outcomeOp{}}, ID: new(hst.ID), Container: new(hst.ContainerConfig), Paths: new(env.Paths)}, true}, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := tc.s.valid(); got != tc.want { + t.Errorf("valid: %v, want %v", got, tc.want) + } + }) + } +} diff --git a/internal/outcome/process.go b/internal/outcome/process.go new file mode 100644 index 00000000..d96c7efa --- /dev/null +++ b/internal/outcome/process.go @@ -0,0 +1,321 @@ +package outcome + +import ( + "context" + "encoding/gob" + "errors" + "log" + "os" + "os/exec" + "strconv" + "strings" + "syscall" + "time" + + "hakurei.app/container" + "hakurei.app/container/fhs" + "hakurei.app/hst" + "hakurei.app/internal" + "hakurei.app/internal/state" + "hakurei.app/message" + "hakurei.app/system" +) + +// Duration to wait for shim to exit on top of container WaitDelay. +const shimWaitTimeout = 5 * time.Second + +// mainState holds persistent state bound to outcome.main. +type mainState struct { + // done is whether beforeExit has been called already. + done bool + + // Time is the exact point in time where the process was created. + // Location must be set to UTC. + // + // Time is nil if no process was ever created. + Time *time.Time + + store state.Store + cancel context.CancelFunc + cmd *exec.Cmd + cmdWait chan error + + k *outcome + message.Msg + uintptr +} + +const ( + // mainNeedsRevert indicates the call to Commit has succeeded. + mainNeedsRevert uintptr = 1 << iota + // mainNeedsDestroy indicates the instance state entry is present in the store. + mainNeedsDestroy +) + +// beforeExit must be called immediately before a call to [os.Exit]. +func (ms mainState) beforeExit(isFault bool) { + if ms.done { + panic("attempting to call beforeExit twice") + } + ms.done = true + defer ms.BeforeExit() + + if isFault && ms.cancel != nil { + ms.cancel() + } + + var hasErr bool + // updates hasErr but does not terminate + perror := func(err error, message string) { + hasErr = true + printMessageError("cannot "+message+":", err) + } + exitCode := 1 + defer func() { + if hasErr { + os.Exit(exitCode) + } + }() + + // this also handles wait for a non-fault termination + if ms.cmd != nil && ms.cmdWait != nil { + waitDone := make(chan struct{}) + + // this ties waitDone to ctx with the additional compensated timeout duration + go func() { <-ms.k.ctx.Done(); time.Sleep(ms.k.state.Shim.WaitDelay + shimWaitTimeout); close(waitDone) }() + + select { + case err := <-ms.cmdWait: + wstatus, ok := ms.cmd.ProcessState.Sys().(syscall.WaitStatus) + if ok { + if v := wstatus.ExitStatus(); v != 0 { + hasErr = true + exitCode = v + } + } + + if ms.IsVerbose() { + if !ok { + if err != nil { + ms.Verbosef("wait: %v", err) + } + } else { + switch { + case wstatus.Exited(): + ms.Verbosef("process %d exited with code %d", ms.cmd.Process.Pid, wstatus.ExitStatus()) + + case wstatus.CoreDump(): + ms.Verbosef("process %d dumped core", ms.cmd.Process.Pid) + + case wstatus.Signaled(): + ms.Verbosef("process %d got %s", ms.cmd.Process.Pid, wstatus.Signal()) + + default: + ms.Verbosef("process %d exited with status %#x", ms.cmd.Process.Pid, wstatus) + } + } + } + + case <-waitDone: + ms.Resume() + // this is only reachable when shim did not exit within shimWaitTimeout, after its WaitDelay has elapsed. + // This is different from the container failing to terminate within its timeout period, as that is enforced + // by the shim. This path is instead reached when there is a lockup in shim preventing it from completing. + log.Printf("process %d did not terminate", ms.cmd.Process.Pid) + } + + ms.Resume() + } + + if ms.uintptr&mainNeedsRevert != 0 { + if ok, err := ms.store.Do(ms.k.state.identity.unwrap(), func(c state.Cursor) { + if ms.uintptr&mainNeedsDestroy != 0 { + if err := c.Destroy(ms.k.state.id.unwrap()); err != nil { + perror(err, "destroy state entry") + } + } + + var rt hst.Enablement + if states, err := c.Load(); err != nil { + // it is impossible to continue from this point; + // revert per-process state here to limit damage + ec := system.Process + if revertErr := ms.k.sys.Revert((*system.Criteria)(&ec)); revertErr != nil { + var joinError interface { + Unwrap() []error + error + } + if !errors.As(revertErr, &joinError) || joinError == nil { + perror(revertErr, "revert system setup") + } else { + for _, v := range joinError.Unwrap() { + perror(v, "revert system setup step") + } + } + } + perror(err, "load instance states") + } else { + ec := system.Process + if l := len(states); l == 0 { + ec |= system.User + } else { + ms.Verbosef("found %d instances, cleaning up without user-scoped operations", l) + } + + // accumulate enablements of remaining launchers + for i, s := range states { + if s.Config != nil { + rt |= s.Config.Enablements.Unwrap() + } else { + log.Printf("state entry %d does not contain config", i) + } + } + + ec |= rt ^ (hst.EWayland | hst.EX11 | hst.EDBus | hst.EPulse) + if ms.IsVerbose() { + if ec > 0 { + ms.Verbose("reverting operations scope", system.TypeString(ec)) + } + } + + if err = ms.k.sys.Revert((*system.Criteria)(&ec)); err != nil { + perror(err, "revert system setup") + } + } + }); err != nil { + if ok { + perror(err, "unlock state store") + } else { + perror(err, "open state store") + } + } + } else if ms.uintptr&mainNeedsDestroy != 0 { + panic("unreachable") + } +} + +// fatal calls printMessageError, performs necessary cleanup, followed by a call to [os.Exit](1). +func (ms mainState) fatal(fallback string, ferr error) { + printMessageError(fallback, ferr) + ms.beforeExit(true) + os.Exit(1) +} + +// main carries out outcome and terminates. main does not return. +func (k *outcome) main(msg message.Msg) { + if !k.active.CompareAndSwap(false, true) { + panic("outcome: attempted to run twice") + } + + if k.ctx == nil || k.sys == nil || k.state == nil { + panic("outcome: did not finalise") + } + + // read comp value early for early failure + hsuPath := internal.MustHsuPath() + + // ms.beforeExit required beyond this point + ms := &mainState{Msg: msg, k: k} + + if err := k.sys.Commit(); err != nil { + ms.fatal("cannot commit system setup:", err) + } + ms.uintptr |= mainNeedsRevert + ms.store = state.NewMulti(msg, k.state.sc.RunDirPath) + + ctx, cancel := context.WithCancel(k.ctx) + defer cancel() + ms.cancel = cancel + + ms.cmd = exec.CommandContext(ctx, hsuPath.String()) + ms.cmd.Stdin, ms.cmd.Stdout, ms.cmd.Stderr = os.Stdin, os.Stdout, os.Stderr + ms.cmd.Dir = fhs.Root // container init enters final working directory + // shim runs in the same session as monitor; see shim.go for behaviour + ms.cmd.Cancel = func() error { return ms.cmd.Process.Signal(syscall.SIGCONT) } + + var e *gob.Encoder + if fd, encoder, err := container.Setup(&ms.cmd.ExtraFiles); err != nil { + ms.fatal("cannot create shim setup pipe:", err) + } else { + e = encoder + ms.cmd.Env = []string{ + // passed through to shim by hsu + shimEnv + "=" + strconv.Itoa(fd), + // interpreted by hsu + "HAKUREI_IDENTITY=" + k.state.identity.String(), + } + } + + if len(k.supp) > 0 { + msg.Verbosef("attaching supplementary group ids %s", k.supp) + // interpreted by hsu + ms.cmd.Env = append(ms.cmd.Env, "HAKUREI_GROUPS="+strings.Join(k.supp, " ")) + } + + msg.Verbosef("setuid helper at %s", hsuPath) + msg.Suspend() + if err := ms.cmd.Start(); err != nil { + ms.fatal("cannot start setuid wrapper:", err) + } + startTime := time.Now().UTC() + ms.cmdWait = make(chan error, 1) + // this ties context back to the life of the process + go func() { ms.cmdWait <- ms.cmd.Wait(); cancel() }() + ms.Time = &startTime + + // unfortunately the I/O here cannot be directly canceled; + // the cancellation path leads to fatal in this case so that is fine + select { + case err := <-func() (setupErr chan error) { + setupErr = make(chan error, 1) + go func() { setupErr <- e.Encode(k.state) }() + return + }(): + if err != nil { + msg.Resume() + ms.fatal("cannot transmit shim config:", err) + } + + case <-ctx.Done(): + msg.Resume() + ms.fatal("shim context canceled:", newWithMessageError("shim setup canceled", ctx.Err())) + } + + // shim accepted setup payload, create process state + if ok, err := ms.store.Do(k.state.identity.unwrap(), func(c state.Cursor) { + if err := c.Save(&hst.State{ + ID: k.state.id.unwrap(), + PID: os.Getpid(), + ShimPID: ms.cmd.Process.Pid, + Config: k.config, + Time: *ms.Time, + }); err != nil { + ms.fatal("cannot save state entry:", err) + } + }); err != nil { + if ok { + ms.uintptr |= mainNeedsDestroy + ms.fatal("cannot unlock state store:", err) + } else { + ms.fatal("cannot open state store:", err) + } + } + // state in store at this point, destroy defunct state entry on termination + ms.uintptr |= mainNeedsDestroy + + // beforeExit ties shim process to context + ms.beforeExit(false) + os.Exit(0) +} + +// printMessageError prints the error message according to [message.GetMessage], +// or fallback prepended to err if an error message is not available. +func printMessageError(fallback string, err error) { + m, ok := message.GetMessage(err) + if !ok { + log.Println(fallback, err) + return + } + + log.Print(m) +} diff --git a/internal/outcome/shim-signal.c b/internal/outcome/shim-signal.c new file mode 100644 index 00000000..fc005347 --- /dev/null +++ b/internal/outcome/shim-signal.c @@ -0,0 +1,65 @@ +#include "shim-signal.h" +#include +#include +#include +#include +#include + +static pid_t hakurei_shim_param_ppid = -1; +static int hakurei_shim_fd = -1; + +static ssize_t hakurei_shim_write(const void *buf, size_t count) { + int savedErrno = errno; + ssize_t ret = write(hakurei_shim_fd, buf, count); + if (ret == -1 && errno != EAGAIN) + exit(EXIT_FAILURE); + errno = savedErrno; + return ret; +} + +/* see shim_linux.go for handling of the value */ +static void hakurei_shim_sigaction(int sig, siginfo_t *si, void *ucontext) { + if (sig != SIGCONT || si == NULL) { + /* unreachable */ + hakurei_shim_write("\2", 1); + return; + } + + if (si->si_pid == hakurei_shim_param_ppid) { + /* monitor requests shim exit */ + hakurei_shim_write("\0", 1); + return; + } + + /* unexpected si_pid */ + hakurei_shim_write("\3", 1); + + if (getppid() != hakurei_shim_param_ppid) + /* shim orphaned before monitor delivers a signal */ + hakurei_shim_write("\1", 1); +} + +void hakurei_shim_setup_cont_signal(pid_t ppid, int fd) { + if (hakurei_shim_param_ppid != -1 || hakurei_shim_fd != -1) + *(int *)NULL = 0; /* unreachable */ + + struct sigaction new_action = {0}, old_action = {0}; + if (sigaction(SIGCONT, NULL, &old_action) != 0) + return; + if (old_action.sa_handler != SIG_DFL) { + errno = ENOTRECOVERABLE; + return; + } + + new_action.sa_sigaction = hakurei_shim_sigaction; + if (sigemptyset(&new_action.sa_mask) != 0) + return; + new_action.sa_flags = SA_ONSTACK | SA_SIGINFO; + + if (sigaction(SIGCONT, &new_action, NULL) != 0) + return; + + errno = 0; + hakurei_shim_param_ppid = ppid; + hakurei_shim_fd = fd; +} diff --git a/internal/outcome/shim-signal.h b/internal/outcome/shim-signal.h new file mode 100644 index 00000000..bdeae3b0 --- /dev/null +++ b/internal/outcome/shim-signal.h @@ -0,0 +1,3 @@ +#include + +void hakurei_shim_setup_cont_signal(pid_t ppid, int fd); diff --git a/internal/outcome/shim.go b/internal/outcome/shim.go new file mode 100644 index 00000000..53d04ea2 --- /dev/null +++ b/internal/outcome/shim.go @@ -0,0 +1,235 @@ +package outcome + +import ( + "context" + "errors" + "io" + "log" + "os" + "os/exec" + "runtime" + "sync/atomic" + "syscall" + "time" + + "hakurei.app/container" + "hakurei.app/container/comp" + "hakurei.app/container/seccomp" + "hakurei.app/hst" + "hakurei.app/message" +) + +//#include "shim-signal.h" +import "C" + +// setupContSignal sets up the SIGCONT signal handler for the cross-uid shim exit hack. +// The signal handler is implemented in C, signals can be processed by reading from the returned reader. +// The returned function must be called after all signal processing concludes. +func setupContSignal(pid int) (io.ReadCloser, func(), error) { + if r, w, err := os.Pipe(); err != nil { + return nil, nil, err + } else if _, err = C.hakurei_shim_setup_cont_signal(C.pid_t(pid), C.int(w.Fd())); err != nil { + _, _ = r.Close(), w.Close() + return nil, nil, err + } else { + return r, func() { runtime.KeepAlive(w) }, nil + } +} + +// shimEnv is the name of the environment variable storing decimal representation of +// setup pipe fd for [container.Receive]. +const shimEnv = "HAKUREI_SHIM" + +// shimParams is embedded in outcomeState and transmitted from priv side to shim. +type shimParams struct { + // Priv side pid, checked against ppid in signal handler for the syscall.SIGCONT hack. + PrivPID int + + // Duration to wait for after the initial process receives os.Interrupt before the container is killed. + // Limits are enforced on the priv side. + WaitDelay time.Duration + + // Verbosity pass through from [message.Msg]. + Verbose bool + + // Outcome setup ops, contains setup state. Populated by outcome.finalise. + Ops []outcomeOp +} + +// valid checks shimParams to be safe for use. +func (p *shimParams) valid() bool { return p != nil && p.PrivPID > 0 } + +// shimName is the prefix used by log.std in the shim process. +const shimName = "shim" + +// Shim is called by the main function of the shim process and runs as the unconstrained target user. +// Shim does not return. +func Shim(msg message.Msg) { + if msg == nil { + msg = message.NewMsg(log.Default()) + } + shimEntrypoint(direct{msg}) +} + +func shimEntrypoint(k syscallDispatcher) { + msg := k.getMsg() + if msg == nil { + panic("attempting to call shimEntrypoint with nil msg") + } else if logger := msg.GetLogger(); logger != nil { + logger.SetPrefix(shimName + ": ") + logger.SetFlags(0) + } + + if err := k.setDumpable(container.SUID_DUMP_DISABLE); err != nil { + k.fatalf("cannot set SUID_DUMP_DISABLE: %s", err) + } + + var ( + state outcomeState + closeSetup func() error + ) + if f, err := k.receive(shimEnv, &state, nil); err != nil { + if errors.Is(err, syscall.EBADF) { + k.fatal("invalid config descriptor") + } + if errors.Is(err, container.ErrReceiveEnv) { + k.fatal(shimEnv + " not set") + } + + k.fatalf("cannot receive shim setup params: %v", err) + } else { + msg.SwapVerbose(state.Shim.Verbose) + closeSetup = f + + if err = state.populateLocal(k, msg); err != nil { + if m, ok := message.GetMessage(err); ok { + k.fatal(m) + } else { + k.fatalf("cannot populate local state: %v", err) + } + } + } + + // the Go runtime does not expose siginfo_t so SIGCONT is handled in C to check si_pid + var signalPipe io.ReadCloser + if r, wKeepAlive, err := k.setupContSignal(state.Shim.PrivPID); err != nil { + switch { + case errors.As(err, new(*os.SyscallError)): // returned by os.Pipe + k.fatal(err.Error()) + return + + case errors.As(err, new(syscall.Errno)): // returned by hakurei_shim_setup_cont_signal + k.fatalf("cannot install SIGCONT handler: %v", err) + return + + default: // unreachable + k.fatalf("cannot set up exit request: %v", err) + return + } + + } else { + defer wKeepAlive() + signalPipe = r + } + + // pdeath_signal delivery is checked as if the dying process called kill(2), see kernel/exit.c + if err := k.prctl(syscall.PR_SET_PDEATHSIG, uintptr(syscall.SIGCONT), 0); err != nil { + k.fatalf("cannot set parent-death signal: %v", err) + } + + stateParams := state.newParams() + for _, op := range state.Shim.Ops { + if err := op.toContainer(stateParams); err != nil { + if m, ok := message.GetMessage(err); ok { + k.fatal(m) + } else { + k.fatalf("cannot create container state: %v", err) + } + } + } + + // shim exit outcomes + var cancelContainer atomic.Pointer[context.CancelFunc] + k.new(func(k syscallDispatcher, msg message.Msg) { + buf := make([]byte, 1) + for { + if _, err := signalPipe.Read(buf); err != nil { + k.fatalf("cannot read from signal pipe: %v", err) + } + + switch buf[0] { + case 0: // got SIGCONT from monitor: shim exit requested + if fp := cancelContainer.Load(); stateParams.params.ForwardCancel && fp != nil && *fp != nil { + (*fp)() + // shim now bound by ShimWaitDelay, implemented below + continue + } + + // setup has not completed, terminate immediately + msg.Resume() + k.exit(hst.ExitRequest) + return + + case 1: // got SIGCONT after adoption: monitor died before delivering signal + msg.BeforeExit() + k.exit(hst.ExitOrphan) + return + + case 2: // unreachable + msg.Verbose("sa_sigaction got invalid siginfo") + + case 3: // got SIGCONT from unexpected process: hopefully the terminal driver + msg.Verbose("got SIGCONT from unexpected process") + + default: // unreachable + k.fatalf("got invalid message %d from signal handler", buf[0]) + } + } + }) + + if stateParams.params.Ops == nil { + k.fatal("invalid container params") + } + + // close setup socket + if err := closeSetup(); err != nil { + msg.Verbosef("cannot close setup pipe: %v", err) + // not fatal + } + + ctx, stop := k.notifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + cancelContainer.Store(&stop) + z := container.New(ctx, msg) + z.Params = *stateParams.params + z.Stdin, z.Stdout, z.Stderr = os.Stdin, os.Stdout, os.Stderr + + // bounds and default enforced in finalise.go + z.WaitDelay = state.Shim.WaitDelay + + if err := k.containerStart(z); err != nil { + printMessageError("cannot start container:", err) + k.exit(hst.ExitFailure) + } + if err := k.containerServe(z); err != nil { + printMessageError("cannot configure container:", err) + } + + if err := k.seccompLoad( + seccomp.Preset(comp.PresetStrict, seccomp.AllowMultiarch), + seccomp.AllowMultiarch, + ); err != nil { + k.fatalf("cannot load syscall filter: %v", err) + } + + if err := k.containerWait(z); err != nil { + var exitError *exec.ExitError + if !errors.As(err, &exitError) { + if errors.Is(err, context.Canceled) { + k.exit(hst.ExitCancel) + } + msg.Verbosef("cannot wait: %v", err) + k.exit(127) + } + k.exit(exitError.ExitCode()) + } +} diff --git a/internal/outcome/shim_test.go b/internal/outcome/shim_test.go new file mode 100644 index 00000000..e4de9c37 --- /dev/null +++ b/internal/outcome/shim_test.go @@ -0,0 +1,156 @@ +package outcome + +import ( + "bytes" + "context" + "log" + "os" + "syscall" + "testing" + + "hakurei.app/container" + "hakurei.app/container/comp" + "hakurei.app/container/fhs" + "hakurei.app/container/seccomp" + "hakurei.app/container/stub" + "hakurei.app/hst" + "hakurei.app/internal/env" +) + +func TestShimEntrypoint(t *testing.T) { + t.Parallel() + shimPreset := seccomp.Preset(comp.PresetStrict, seccomp.AllowMultiarch) + templateParams := &container.Params{ + Dir: m("/data/data/org.chromium.Chromium"), + Env: []string{ + "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus", + "DBUS_SYSTEM_BUS_ADDRESS=unix:path=/var/run/dbus/system_bus_socket", + "GOOGLE_API_KEY=AIzaSyBHDrl33hwRp4rMQY0ziRbj8K9LPA6vUCY", + "GOOGLE_DEFAULT_CLIENT_ID=77185425430.apps.googleusercontent.com", + "GOOGLE_DEFAULT_CLIENT_SECRET=OTJgUOQcT7lO7GsGZq2G4IlT", + "HOME=/data/data/org.chromium.Chromium", + "PULSE_COOKIE=/.hakurei/pulse-cookie", + "PULSE_SERVER=unix:/run/user/1000/pulse/native", + "SHELL=/run/current-system/sw/bin/zsh", + "TERM=xterm-256color", + "USER=chronos", + "WAYLAND_DISPLAY=wayland-0", + "XDG_RUNTIME_DIR=/run/user/1000", + "XDG_SESSION_CLASS=user", + "XDG_SESSION_TYPE=wayland", + }, + + // spParamsOp + Hostname: "localhost", + RetainSession: true, + HostNet: true, + HostAbstract: true, + ForwardCancel: true, + Path: m("/run/current-system/sw/bin/chromium"), + Args: []string{ + "chromium", + "--ignore-gpu-blocklist", + "--disable-smooth-scrolling", + "--enable-features=UseOzonePlatform", + "--ozone-platform=wayland", + }, + SeccompFlags: seccomp.AllowMultiarch, + Uid: 1000, + Gid: 100, + + Ops: new(container.Ops). + // resolveRoot + Root(m("/var/lib/hakurei/base/org.debian"), comp.BindWritable). + // spParamsOp + Proc(fhs.AbsProc). + Tmpfs(hst.AbsPrivateTmp, 1<<12, 0755). + Bind(fhs.AbsDev, fhs.AbsDev, comp.BindWritable|comp.BindDevice). + Tmpfs(fhs.AbsDev.Append("shm"), 0, 01777). + + // spRuntimeOp + Tmpfs(fhs.AbsRunUser, 1<<12, 0755). + Bind(m("/tmp/hakurei.10/runtime/9999"), m("/run/user/1000"), comp.BindWritable). + + // spTmpdirOp + Bind(m("/tmp/hakurei.10/tmpdir/9999"), fhs.AbsTmp, comp.BindWritable). + + // spAccountOp + Place(m("/etc/passwd"), []byte("chronos:x:1000:100:Hakurei:/data/data/org.chromium.Chromium:/run/current-system/sw/bin/zsh\n")). + Place(m("/etc/group"), []byte("hakurei:x:100:\n")). + + // spWaylandOp + Bind(m("/tmp/hakurei.10/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/wayland"), m("/run/user/1000/wayland-0"), 0). + + // spPulseOp + Bind(m("/run/user/1000/hakurei/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/pulse"), m("/run/user/1000/pulse/native"), 0). + Place(m("/.hakurei/pulse-cookie"), bytes.Repeat([]byte{0}, pulseCookieSizeMax)). + + // spDBusOp + Bind(m("/tmp/hakurei.10/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/bus"), m("/run/user/1000/bus"), 0). + Bind(m("/tmp/hakurei.10/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/system_bus_socket"), m("/var/run/dbus/system_bus_socket"), 0). + + // spFilesystemOp + Etc(fhs.AbsEtc, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"). + Tmpfs(fhs.AbsTmp, 0, 0755). + Overlay(m("/nix/store"), + fhs.AbsVarLib.Append("hakurei/nix/u0/org.chromium.Chromium/rw-store/upper"), + fhs.AbsVarLib.Append("hakurei/nix/u0/org.chromium.Chromium/rw-store/work"), + fhs.AbsVarLib.Append("hakurei/base/org.nixos/ro-store")). + Link(m("/run/current-system"), "/run/current-system", true). + Link(m("/run/opengl-driver"), "/run/opengl-driver", true). + Bind(fhs.AbsVarLib.Append("hakurei/u0/org.chromium.Chromium"), + m("/data/data/org.chromium.Chromium"), + comp.BindWritable|comp.BindEnsure). + Bind(fhs.AbsDev.Append("dri"), fhs.AbsDev.Append("dri"), + comp.BindOptional|comp.BindWritable|comp.BindDevice). + Remount(fhs.AbsRoot, syscall.MS_RDONLY), + } + + checkSimple(t, "shimEntrypoint", []simpleTestCase{ + {"success", func(k *kstub) error { shimEntrypoint(k); return nil }, stub.Expect{Calls: []stub.Call{ + call("getMsg", stub.ExpectArgs{}, nil, nil), + call("getLogger", stub.ExpectArgs{}, (*log.Logger)(nil), nil), + call("setDumpable", stub.ExpectArgs{uintptr(container.SUID_DUMP_DISABLE)}, nil, nil), + call("receive", stub.ExpectArgs{"HAKUREI_SHIM", outcomeState{ + Shim: &shimParams{PrivPID: 0xbad, WaitDelay: 0xf, Verbose: true, Ops: []outcomeOp{ + &spParamsOp{"xterm-256color", true}, + &spRuntimeOp{sessionTypeWayland}, + spTmpdirOp{}, + spAccountOp{}, + &spWaylandOp{}, + &spPulseOp{(*[pulseCookieSizeMax]byte)(bytes.Repeat([]byte{0}, pulseCookieSizeMax)), pulseCookieSizeMax}, + &spDBusOp{true}, + &spFilesystemOp{}, + }}, + + ID: &checkExpectInstanceId, + Identity: hst.IdentityMax, + UserID: 10, + Container: hst.Template().Container, + Mapuid: 1000, + Mapgid: 100, + Paths: &env.Paths{TempDir: fhs.AbsTmp, RuntimePath: fhs.AbsRunUser.Append("1000")}, + }, nil}, nil, nil), + call("swapVerbose", stub.ExpectArgs{true}, false, nil), + call("verbosef", stub.ExpectArgs{"process share directory at %q, runtime directory at %q", []any{m("/tmp/hakurei.10"), m("/run/user/1000/hakurei")}}, nil, nil), + call("setupContSignal", stub.ExpectArgs{0xbad}, 0, nil), + call("prctl", stub.ExpectArgs{uintptr(syscall.PR_SET_PDEATHSIG), uintptr(syscall.SIGCONT), uintptr(0)}, nil, nil), + call("New", stub.ExpectArgs{}, nil, nil), + call("closeReceive", stub.ExpectArgs{}, nil, nil), + call("notifyContext", stub.ExpectArgs{context.Background(), []os.Signal{os.Interrupt, syscall.SIGTERM}}, nil, nil), + call("containerStart", stub.ExpectArgs{templateParams}, nil, nil), + call("containerServe", stub.ExpectArgs{templateParams}, nil, nil), + call("seccompLoad", stub.ExpectArgs{shimPreset, seccomp.AllowMultiarch}, nil, nil), + call("containerWait", stub.ExpectArgs{templateParams}, nil, nil), + + // deferred + call("wKeepAlive", stub.ExpectArgs{}, nil, nil), + }, Tracks: []stub.Expect{{Calls: []stub.Call{ + call("rcRead", stub.ExpectArgs{}, []byte{2}, nil), + call("verbose", stub.ExpectArgs{[]any{"sa_sigaction got invalid siginfo"}}, nil, nil), + call("rcRead", stub.ExpectArgs{}, []byte{3}, nil), + call("verbose", stub.ExpectArgs{[]any{"got SIGCONT from unexpected process"}}, nil, nil), + call("rcRead", stub.ExpectArgs{}, nil, nil), // stub terminates this goroutine + }}}}, nil}, + }) +} diff --git a/internal/outcome/spaccount.go b/internal/outcome/spaccount.go new file mode 100644 index 00000000..0e9c602a --- /dev/null +++ b/internal/outcome/spaccount.go @@ -0,0 +1,56 @@ +package outcome + +import ( + "encoding/gob" + "fmt" + "syscall" + + "hakurei.app/container/fhs" + "hakurei.app/internal/validate" +) + +func init() { gob.Register(spAccountOp{}) } + +// spAccountOp sets up user account emulation inside the container. +type spAccountOp struct{} + +func (s spAccountOp) toSystem(state *outcomeStateSys) error { + // do checks here to fail before fork/exec + if state.Container == nil || state.Container.Home == nil || state.Container.Shell == nil { + // unreachable + return syscall.ENOTRECOVERABLE + } + + // default is applied in toContainer + if state.Container.Username != "" && !validate.IsValidUsername(state.Container.Username) { + return newWithMessage(fmt.Sprintf("invalid user name %q", state.Container.Username)) + } + return nil +} + +func (s spAccountOp) toContainer(state *outcomeStateParams) error { + const fallbackUsername = "chronos" + + username := state.Container.Username + if username == "" { + username = fallbackUsername + } + + state.params.Dir = state.Container.Home + state.env["HOME"] = state.Container.Home.String() + state.env["USER"] = username + state.env["SHELL"] = state.Container.Shell.String() + + state.params. + Place(fhs.AbsEtc.Append("passwd"), + []byte(username+":x:"+ + state.mapuid.String()+":"+ + state.mapgid.String()+ + ":Hakurei:"+ + state.Container.Home.String()+":"+ + state.Container.Shell.String()+"\n")). + Place(fhs.AbsEtc.Append("group"), + []byte("hakurei:x:"+state.mapgid.String()+":\n")) + + return nil +} diff --git a/internal/outcome/spaccount_test.go b/internal/outcome/spaccount_test.go new file mode 100644 index 00000000..c1693dd8 --- /dev/null +++ b/internal/outcome/spaccount_test.go @@ -0,0 +1,72 @@ +package outcome + +import ( + "os" + "syscall" + "testing" + + "hakurei.app/container" + "hakurei.app/container/stub" + "hakurei.app/hst" +) + +func TestSpAccountOp(t *testing.T) { + t.Parallel() + config := hst.Template() + + checkOpBehaviour(t, []opBehaviourTestCase{ + {"invalid state", func(bool, bool) outcomeOp { return spAccountOp{} }, func() *hst.Config { + c := hst.Template() + c.Container.Shell = nil + return c + }, nil, []stub.Call{ + // this op performs basic validation and does not make calls during toSystem + }, nil, nil, syscall.ENOTRECOVERABLE, nil, nil, nil, nil, nil}, + + {"invalid user name", func(bool, bool) outcomeOp { return spAccountOp{} }, func() *hst.Config { + c := hst.Template() + c.Container.Username = "9" + return c + }, nil, []stub.Call{ + // this op performs basic validation and does not make calls during toSystem + }, nil, nil, &hst.AppError{ + Step: "finalise", + Err: os.ErrInvalid, + Msg: `invalid user name "9"`, + }, nil, nil, nil, nil, nil}, + + {"success fallback username", func(bool, bool) outcomeOp { return spAccountOp{} }, func() *hst.Config { + c := hst.Template() + c.Container.Username = "" + return c + }, nil, []stub.Call{ + // this op performs basic validation and does not make calls during toSystem + }, newI(), nil, nil, insertsOps(nil), []stub.Call{ + // this op configures the container state and does not make calls during toContainer + }, &container.Params{ + Dir: config.Container.Home, + Ops: new(container.Ops). + Place(m("/etc/passwd"), []byte("chronos:x:1000:100:Hakurei:/data/data/org.chromium.Chromium:/run/current-system/sw/bin/zsh\n")). + Place(m("/etc/group"), []byte("hakurei:x:100:\n")), + }, paramsWantEnv(config, map[string]string{ + "HOME": config.Container.Home.String(), + "USER": config.Container.Username, + "SHELL": config.Container.Shell.String(), + }, nil), nil}, + + {"success", func(bool, bool) outcomeOp { return spAccountOp{} }, hst.Template, nil, []stub.Call{ + // this op performs basic validation and does not make calls during toSystem + }, newI(), nil, nil, insertsOps(nil), []stub.Call{ + // this op configures the container state and does not make calls during toContainer + }, &container.Params{ + Dir: config.Container.Home, + Ops: new(container.Ops). + Place(m("/etc/passwd"), []byte("chronos:x:1000:100:Hakurei:/data/data/org.chromium.Chromium:/run/current-system/sw/bin/zsh\n")). + Place(m("/etc/group"), []byte("hakurei:x:100:\n")), + }, paramsWantEnv(config, map[string]string{ + "HOME": config.Container.Home.String(), + "USER": config.Container.Username, + "SHELL": config.Container.Shell.String(), + }, nil), nil}, + }) +} diff --git a/internal/outcome/spcontainer.go b/internal/outcome/spcontainer.go new file mode 100644 index 00000000..13808ca1 --- /dev/null +++ b/internal/outcome/spcontainer.go @@ -0,0 +1,391 @@ +package outcome + +import ( + "encoding/gob" + "errors" + "io/fs" + "os" + "path" + "slices" + "strconv" + "syscall" + + "hakurei.app/container" + "hakurei.app/container/check" + "hakurei.app/container/comp" + "hakurei.app/container/fhs" + "hakurei.app/container/seccomp" + "hakurei.app/hst" + "hakurei.app/internal/validate" + "hakurei.app/message" + "hakurei.app/system" + "hakurei.app/system/acl" + "hakurei.app/system/dbus" +) + +const varRunNscd = fhs.Var + "run/nscd" + +func init() { gob.Register(new(spParamsOp)) } + +// spParamsOp initialises unordered fields of [container.Params] and the optional root filesystem. +// This outcomeOp is hardcoded to always run first. +type spParamsOp struct { + // Value of $TERM, stored during toSystem. + Term string + // Whether $TERM is set, stored during toSystem. + TermSet bool +} + +func (s *spParamsOp) toSystem(state *outcomeStateSys) error { + s.Term, s.TermSet = state.k.lookupEnv("TERM") + state.sys.Ensure(state.sc.SharePath, 0711) + return nil +} + +func (s *spParamsOp) toContainer(state *outcomeStateParams) error { + // pass $TERM for proper terminal I/O in initial process + if s.TermSet { + state.env["TERM"] = s.Term + } + + // in practice there should be less than 30 system mount points + const preallocateOpsCount = 1 << 5 + + state.params.Hostname = state.Container.Hostname + state.params.RetainSession = state.Container.Flags&hst.FTty != 0 + state.params.HostNet = state.Container.Flags&hst.FHostNet != 0 + state.params.HostAbstract = state.Container.Flags&hst.FHostAbstract != 0 + + if state.Container.Path == nil { + return newWithMessage("invalid program path") + } + state.params.Path = state.Container.Path + + if len(state.Container.Args) == 0 { + state.params.Args = []string{state.Container.Path.String()} + } else { + state.params.Args = state.Container.Args + } + + // the container is canceled when shim is requested to exit or receives an interrupt or termination signal; + // this behaviour is implemented in the shim + state.params.ForwardCancel = state.Shim.WaitDelay > 0 + + if state.Container.Flags&hst.FMultiarch != 0 { + state.params.SeccompFlags |= seccomp.AllowMultiarch + } + + if state.Container.Flags&hst.FSeccompCompat == 0 { + state.params.SeccompPresets |= comp.PresetExt + } + if state.Container.Flags&hst.FDevel == 0 { + state.params.SeccompPresets |= comp.PresetDenyDevel + } + if state.Container.Flags&hst.FUserns == 0 { + state.params.SeccompPresets |= comp.PresetDenyNS + } + if state.Container.Flags&hst.FTty == 0 { + state.params.SeccompPresets |= comp.PresetDenyTTY + } + + if state.Container.Flags&hst.FMapRealUID != 0 { + state.params.Uid = state.Mapuid + state.params.Gid = state.Mapgid + } + + { + state.as.AutoEtcPrefix = state.id.String() + ops := make(container.Ops, 0, preallocateOpsCount+len(state.Container.Filesystem)) + state.params.Ops = &ops + state.as.Ops = opsAdapter{&ops} + } + + rootfs, filesystem, _ := resolveRoot(state.Container) + state.filesystem = filesystem + if rootfs != nil { + rootfs.Apply(&state.as) + } + + // early mount points + state.params. + Proc(fhs.AbsProc). + Tmpfs(hst.AbsPrivateTmp, 1<<12, 0755) + if state.Container.Flags&hst.FDevice == 0 { + state.params.DevWritable(fhs.AbsDev, true) + } else { + state.params.Bind(fhs.AbsDev, fhs.AbsDev, comp.BindWritable|comp.BindDevice) + } + // /dev is mounted readonly later on, this prevents /dev/shm from going readonly with it + state.params.Tmpfs(fhs.AbsDev.Append("shm"), 0, 01777) + + return nil +} + +func init() { gob.Register(new(spFilesystemOp)) } + +// spFilesystemOp applies configured filesystems to [container.Params], excluding the optional root filesystem. +// This outcomeOp is hardcoded to always run last. +type spFilesystemOp struct { + // Matched paths to cover. Stored during toSystem. + HidePaths []*check.Absolute +} + +func (s *spFilesystemOp) toSystem(state *outcomeStateSys) error { + /* retrieve paths and hide them if they're made available in the sandbox; + + this feature tries to improve user experience of permissive defaults, and + to warn about issues in custom configuration; it is NOT a security feature + and should not be treated as such, ALWAYS be careful with what you bind */ + hidePaths := []string{ + state.sc.RuntimePath.String(), + state.sc.SharePath.String(), + + // this causes emulated passwd database to be bypassed on some /etc/ setups + varRunNscd, + } + + // dbus.Address does not go through syscallDispatcher + systemBusAddr := dbus.FallbackSystemBusAddress + if addr, ok := state.k.lookupEnv(dbus.SystemBusAddress); ok { + systemBusAddr = addr + } + + if entries, err := dbus.Parse([]byte(systemBusAddr)); err != nil { + return &hst.AppError{Step: "parse dbus address", Err: err} + } else { + // there is usually only one, do not preallocate + for _, entry := range entries { + if entry.Method != "unix" { + continue + } + for _, pair := range entry.Values { + if pair[0] == "path" { + if path.IsAbs(pair[1]) { + // get parent dir of socket + dir := path.Dir(pair[1]) + if dir == "." || dir == fhs.Root { + state.msg.Verbosef("dbus socket %q is in an unusual location", pair[1]) + } + hidePaths = append(hidePaths, dir) + } else { + state.msg.Verbosef("dbus socket %q is not absolute", pair[1]) + } + } + } + } + } + hidePathMatch := make([]bool, len(hidePaths)) + for i := range hidePaths { + if err := evalSymlinks(state.msg, state.k, &hidePaths[i]); err != nil { + return &hst.AppError{Step: "evaluate path hiding target", Err: err} + } + } + + _, filesystem, autoroot := resolveRoot(state.Container) + + var hidePathSourceCount int + for i, c := range filesystem { + if !c.Valid() { + return newWithMessage("invalid filesystem at index " + strconv.Itoa(i)) + } + + // fs counter + hidePathSourceCount += len(c.Host()) + } + + // AutoRootOp is a collection of many BindMountOp internally + var autoRootEntries []fs.DirEntry + if autoroot != nil { + if d, err := state.k.readdir(autoroot.Source.String()); err != nil { + return &hst.AppError{Step: "access autoroot source", Err: err} + } else { + // autoroot counter + hidePathSourceCount += len(d) + autoRootEntries = d + } + } + + hidePathSource := make([]*check.Absolute, 0, hidePathSourceCount) + + // fs append + for _, c := range filesystem { + // all entries already checked above + hidePathSource = append(hidePathSource, c.Host()...) + } + + // autoroot append + if autoroot != nil { + for _, ent := range autoRootEntries { + name := ent.Name() + if container.IsAutoRootBindable(state.msg, name) { + hidePathSource = append(hidePathSource, autoroot.Source.Append(name)) + } + } + } + + // evaluated path, input path + hidePathSourceEval := make([][2]string, len(hidePathSource)) + for i, a := range hidePathSource { + if a == nil { + // unreachable + return newWithMessage("impossible path hiding state reached") + } + + hidePathSourceEval[i] = [2]string{a.String(), a.String()} + if err := evalSymlinks(state.msg, state.k, &hidePathSourceEval[i][0]); err != nil { + return &hst.AppError{Step: "evaluate path hiding source", Err: err} + } + } + + for _, p := range hidePathSourceEval { + for i := range hidePaths { + // skip matched entries + if hidePathMatch[i] { + continue + } + + if ok, err := validate.DeepContainsH(p[0], hidePaths[i]); err != nil { + return &hst.AppError{Step: "determine path hiding outcome", Err: err} + } else if ok { + hidePathMatch[i] = true + state.msg.Verbosef("hiding path %q from %q", hidePaths[i], p[1]) + } + } + } + + // copy matched paths for shim + for i, ok := range hidePathMatch { + if ok { + if a, err := check.NewAbs(hidePaths[i]); err != nil { + return newWithMessage("invalid path hiding candidate " + strconv.Quote(hidePaths[i])) + } else { + s.HidePaths = append(s.HidePaths, a) + } + } + } + + // append ExtraPerms last + flattenExtraPerms(state.sys, state.extraPerms) + return nil +} + +func (s *spFilesystemOp) toContainer(state *outcomeStateParams) error { + for i, c := range state.filesystem { + if !c.Valid() { + return newWithMessage("invalid filesystem at index " + strconv.Itoa(i)) + } + c.Apply(&state.as) + } + + for _, a := range s.HidePaths { + state.params.Tmpfs(a, 1<<13, 0755) + } + + // no more configured paths beyond this point + if state.Container.Flags&hst.FDevice == 0 { + state.params.Remount(fhs.AbsDev, syscall.MS_RDONLY) + } + state.params.Remount(fhs.AbsRoot, syscall.MS_RDONLY) + + state.params.Env = make([]string, 0, len(state.env)) + for key, value := range state.env { + // key validated early via hst + state.params.Env = append(state.params.Env, key+"="+value) + } + slices.Sort(state.params.Env) + + return nil +} + +// resolveRoot handles the root filesystem special case for [hst.FilesystemConfig] and additionally resolves autoroot +// as it requires special handling during path hiding. +func resolveRoot(c *hst.ContainerConfig) (rootfs hst.FilesystemConfig, filesystem []hst.FilesystemConfigJSON, autoroot *hst.FSBind) { + // root filesystem special case + filesystem = c.Filesystem + // valid happens late, so root gets it here + if len(filesystem) > 0 && filesystem[0].Valid() && filesystem[0].Path().String() == fhs.Root { + // if the first element targets /, it is inserted early and excluded from path hiding + rootfs = filesystem[0].FilesystemConfig + filesystem = filesystem[1:] + + // autoroot requires special handling during path hiding + if b, ok := rootfs.(*hst.FSBind); ok && b.IsAutoRoot() { + autoroot = b + } + } + return +} + +// evalSymlinks calls syscallDispatcher.evalSymlinks but discards errors unwrapping to [fs.ErrNotExist]. +func evalSymlinks(msg message.Msg, k syscallDispatcher, v *string) error { + if p, err := k.evalSymlinks(*v); err != nil { + if !errors.Is(err, fs.ErrNotExist) { + return err + } + msg.Verbosef("path %q does not yet exist", *v) + } else { + *v = p + } + return nil +} + +// flattenExtraPerms expands a slice of [hst.ExtraPermConfig] into [system.I]. +func flattenExtraPerms(sys *system.I, extraPerms []hst.ExtraPermConfig) { + for i := range extraPerms { + p := &extraPerms[i] + if p.Path == nil { + continue + } + + if p.Ensure { + sys.Ensure(p.Path, 0700) + } + + perms := make(acl.Perms, 0, 3) + if p.Read { + perms = append(perms, acl.Read) + } + if p.Write { + perms = append(perms, acl.Write) + } + if p.Execute { + perms = append(perms, acl.Execute) + } + sys.UpdatePermType(system.User, p.Path, perms...) + } +} + +// opsAdapter implements [hst.Ops] on [container.Ops]. +type opsAdapter struct{ *container.Ops } + +func (p opsAdapter) Tmpfs(target *check.Absolute, size int, perm os.FileMode) hst.Ops { + return opsAdapter{p.Ops.Tmpfs(target, size, perm)} +} + +func (p opsAdapter) Readonly(target *check.Absolute, perm os.FileMode) hst.Ops { + return opsAdapter{p.Ops.Readonly(target, perm)} +} + +func (p opsAdapter) Bind(source, target *check.Absolute, flags int) hst.Ops { + return opsAdapter{p.Ops.Bind(source, target, flags)} +} + +func (p opsAdapter) Overlay(target, state, work *check.Absolute, layers ...*check.Absolute) hst.Ops { + return opsAdapter{p.Ops.Overlay(target, state, work, layers...)} +} + +func (p opsAdapter) OverlayReadonly(target *check.Absolute, layers ...*check.Absolute) hst.Ops { + return opsAdapter{p.Ops.OverlayReadonly(target, layers...)} +} + +func (p opsAdapter) Link(target *check.Absolute, linkName string, dereference bool) hst.Ops { + return opsAdapter{p.Ops.Link(target, linkName, dereference)} +} + +func (p opsAdapter) Root(host *check.Absolute, flags int) hst.Ops { + return opsAdapter{p.Ops.Root(host, flags)} +} + +func (p opsAdapter) Etc(host *check.Absolute, prefix string) hst.Ops { + return opsAdapter{p.Ops.Etc(host, prefix)} +} diff --git a/internal/outcome/spcontainer_test.go b/internal/outcome/spcontainer_test.go new file mode 100644 index 00000000..3b003054 --- /dev/null +++ b/internal/outcome/spcontainer_test.go @@ -0,0 +1,477 @@ +package outcome + +import ( + "errors" + "os" + "reflect" + "syscall" + "testing" + + "hakurei.app/container" + "hakurei.app/container/check" + "hakurei.app/container/comp" + "hakurei.app/container/fhs" + "hakurei.app/container/seccomp" + "hakurei.app/container/stub" + "hakurei.app/hst" + "hakurei.app/system" + "hakurei.app/system/acl" + "hakurei.app/system/dbus" +) + +func TestSpParamsOp(t *testing.T) { + t.Parallel() + config := hst.Template() + + checkOpBehaviour(t, []opBehaviourTestCase{ + {"invalid program path", func(isShim, _ bool) outcomeOp { + if !isShim { + return new(spParamsOp) + } + return &spParamsOp{Term: "xterm", TermSet: true} + }, func() *hst.Config { + c := hst.Template() + c.Container.Path = nil + return c + }, nil, []stub.Call{ + call("lookupEnv", stub.ExpectArgs{"TERM"}, "xterm", nil), + }, newI(). + Ensure(m(container.Nonexistent+"/tmp/hakurei.0"), 0711), nil, nil, nil, []stub.Call{ + // this op configures the container state and does not make calls during toContainer + }, nil, nil, &hst.AppError{ + Step: "finalise", + Err: os.ErrInvalid, + Msg: "invalid program path", + }}, + + {"success defaultargs secure", func(isShim, _ bool) outcomeOp { + if !isShim { + return new(spParamsOp) + } + return &spParamsOp{Term: "xterm", TermSet: true} + }, func() *hst.Config { + c := hst.Template() + c.Container.Args = nil + c.Container.Flags = hst.FHostNet | hst.FHostAbstract | hst.FMapRealUID + return c + }, nil, []stub.Call{ + call("lookupEnv", stub.ExpectArgs{"TERM"}, "xterm", nil), + }, newI(). + Ensure(m(container.Nonexistent+"/tmp/hakurei.0"), 0711), nil, nil, nil, []stub.Call{ + // this op configures the container state and does not make calls during toContainer + }, &container.Params{ + Hostname: config.Container.Hostname, + HostNet: true, + HostAbstract: true, + Path: config.Container.Path, + Args: []string{config.Container.Path.String()}, + SeccompPresets: comp.PresetExt | comp.PresetDenyDevel | comp.PresetDenyNS | comp.PresetDenyTTY, + Uid: 1000, + Gid: 100, + Ops: new(container.Ops). + Root(m("/var/lib/hakurei/base/org.debian"), comp.BindWritable). + Proc(fhs.AbsProc).Tmpfs(hst.AbsPrivateTmp, 1<<12, 0755). + DevWritable(fhs.AbsDev, true). + Tmpfs(fhs.AbsDev.Append("shm"), 0, 01777), + }, paramsWantEnv(config, map[string]string{ + "TERM": "xterm", + }, func(t *testing.T, state *outcomeStateParams) { + if state.as.AutoEtcPrefix != wantAutoEtcPrefix { + t.Errorf("toContainer: as.AutoEtcPrefix = %q, want %q", state.as.AutoEtcPrefix, wantAutoEtcPrefix) + } + + wantFilesystems := config.Container.Filesystem[1:] + if !reflect.DeepEqual(state.filesystem, wantFilesystems) { + t.Errorf("toContainer: filesystem = %#v, want %#v", state.filesystem, wantFilesystems) + } + }), nil}, + + {"success", func(isShim, _ bool) outcomeOp { + if !isShim { + return new(spParamsOp) + } + return &spParamsOp{Term: "xterm", TermSet: true} + }, hst.Template, nil, []stub.Call{ + call("lookupEnv", stub.ExpectArgs{"TERM"}, "xterm", nil), + }, newI(). + Ensure(m(container.Nonexistent+"/tmp/hakurei.0"), 0711), nil, nil, nil, []stub.Call{ + // this op configures the container state and does not make calls during toContainer + }, &container.Params{ + Hostname: config.Container.Hostname, + RetainSession: true, + HostNet: true, + HostAbstract: true, + Path: config.Container.Path, + Args: config.Container.Args, + SeccompFlags: seccomp.AllowMultiarch, + Uid: 1000, + Gid: 100, + Ops: new(container.Ops). + Root(m("/var/lib/hakurei/base/org.debian"), comp.BindWritable). + Proc(fhs.AbsProc).Tmpfs(hst.AbsPrivateTmp, 1<<12, 0755). + Bind(fhs.AbsDev, fhs.AbsDev, comp.BindWritable|comp.BindDevice). + Tmpfs(fhs.AbsDev.Append("shm"), 0, 01777), + }, paramsWantEnv(config, map[string]string{ + "TERM": "xterm", + }, func(t *testing.T, state *outcomeStateParams) { + if state.as.AutoEtcPrefix != wantAutoEtcPrefix { + t.Errorf("toContainer: as.AutoEtcPrefix = %q, want %q", state.as.AutoEtcPrefix, wantAutoEtcPrefix) + } + + wantFilesystems := config.Container.Filesystem[1:] + if !reflect.DeepEqual(state.filesystem, wantFilesystems) { + t.Errorf("toContainer: filesystem = %#v, want %#v", state.filesystem, wantFilesystems) + } + }), nil}, + }) +} + +func TestSpFilesystemOp(t *testing.T) { + const nePrefix = container.Nonexistent + "/eval" + var stubDebianRoot = stubDir("bin", "dev", "etc", "home", "lib64", "lost+found", + "mnt", "nix", "proc", "root", "run", "srv", "sys", "tmp", "usr", "var") + config := hst.Template() + + newConfigSmall := func() *hst.Config { + c := hst.Template() + c.Container.Filesystem = []hst.FilesystemConfigJSON{ + {FilesystemConfig: &hst.FSBind{Target: fhs.AbsEtc, Source: fhs.AbsEtc, Special: true}}, + {FilesystemConfig: &hst.FSOverlay{Target: m("/nix/store"), Lower: []*check.Absolute{ + fhs.AbsVarLib.Append("hakurei/base/org.nixos/.ro-store"), + fhs.AbsVarLib.Append("hakurei/base/org.nixos/org.chromium.Chromium"), + }}}, + {FilesystemConfig: &hst.FSEphemeral{Target: hst.AbsPrivateTmp}}, + } + c.Container.Flags &= ^hst.FDevice + return c + } + configSmall := newConfigSmall() + + needsApplyState := func(next pStateContainerFunc) pStateContainerFunc { + return func(state *outcomeStateParams) { + state.as = hst.ApplyState{AutoEtcPrefix: wantAutoEtcPrefix, Ops: opsAdapter{state.params.Ops}} + + if next != nil { + next(state) + } + } + } + + checkOpBehaviour(t, []opBehaviourTestCase{ + {"readdir", func(bool, bool) outcomeOp { + return new(spFilesystemOp) + }, hst.Template, nil, []stub.Call{ + call("lookupEnv", stub.ExpectArgs{dbus.SystemBusAddress}, nil, nil), + call("evalSymlinks", stub.ExpectArgs{container.Nonexistent + "/xdg_runtime_dir"}, nePrefix+"/xdg_runtime_dir", nil), + call("evalSymlinks", stub.ExpectArgs{container.Nonexistent + "/tmp/hakurei.0"}, nePrefix+"/tmp/hakurei.0", nil), + call("evalSymlinks", stub.ExpectArgs{"/var/run/nscd"}, "", &os.PathError{Op: "lstat", Path: "/var/run/nscd", Err: os.ErrNotExist}), + call("verbosef", stub.ExpectArgs{"path %q does not yet exist", []any{"/var/run/nscd"}}, nil, nil), + call("evalSymlinks", stub.ExpectArgs{"/var/run/dbus"}, nePrefix+"/run/dbus", nil), + call("readdir", stub.ExpectArgs{"/var/lib/hakurei/base/org.debian"}, []os.DirEntry{}, stub.UniqueError(2)), + }, nil, nil, &hst.AppError{ + Step: "access autoroot source", + Err: stub.UniqueError(2), + }, nil, nil, nil, nil, nil}, + + {"invalid dbus address", func(bool, bool) outcomeOp { return new(spFilesystemOp) }, func() *hst.Config { + c := newConfigSmall() + c.Container.Filesystem = append(c.Container.Filesystem, hst.FilesystemConfigJSON{FilesystemConfig: invalidFSHost(false)}) + return c + }, nil, []stub.Call{ + call("lookupEnv", stub.ExpectArgs{dbus.SystemBusAddress}, "invalid", nil), + }, nil, nil, &hst.AppError{ + Step: "parse dbus address", + Err: &dbus.BadAddressError{ + Type: dbus.ErrNoColon, + EntryVal: []byte("invalid"), + PairPos: -1, + }, + }, nil, nil, nil, nil, nil}, + + {"invalid fs early", func(bool, bool) outcomeOp { return new(spFilesystemOp) }, func() *hst.Config { + c := newConfigSmall() + c.Container.Filesystem = append(c.Container.Filesystem, hst.FilesystemConfigJSON{FilesystemConfig: invalidFSHost(false)}) + return c + }, nil, []stub.Call{ + call("lookupEnv", stub.ExpectArgs{dbus.SystemBusAddress}, "invalid:meow=0;unix:path=/system_bus_socket;unix:path=system_bus_socket", nil), + call("verbosef", stub.ExpectArgs{"dbus socket %q is in an unusual location", []any{"/system_bus_socket"}}, nil, nil), + call("verbosef", stub.ExpectArgs{"dbus socket %q is not absolute", []any{"system_bus_socket"}}, nil, nil), + call("evalSymlinks", stub.ExpectArgs{container.Nonexistent + "/xdg_runtime_dir"}, nePrefix+"/xdg_runtime_dir", nil), + call("evalSymlinks", stub.ExpectArgs{container.Nonexistent + "/tmp/hakurei.0"}, nePrefix+"/tmp/hakurei.0", nil), + call("evalSymlinks", stub.ExpectArgs{"/var/run/nscd"}, "", &os.PathError{Op: "lstat", Path: "/var/run/nscd", Err: os.ErrNotExist}), + call("verbosef", stub.ExpectArgs{"path %q does not yet exist", []any{"/var/run/nscd"}}, nil, nil), + call("evalSymlinks", stub.ExpectArgs{"/"}, nePrefix+"/etc/dbus", nil), // to match hidePaths + }, nil, nil, &hst.AppError{ + Step: "finalise", + Err: os.ErrInvalid, + Msg: "invalid filesystem at index 3", + }, nil, nil, nil, nil, nil}, + + {"evalSymlinks early", func(bool, bool) outcomeOp { return new(spFilesystemOp) }, newConfigSmall, nil, []stub.Call{ + call("lookupEnv", stub.ExpectArgs{dbus.SystemBusAddress}, "invalid:meow=0;unix:path=/system_bus_socket;unix:path=system_bus_socket", nil), + call("verbosef", stub.ExpectArgs{"dbus socket %q is in an unusual location", []any{"/system_bus_socket"}}, nil, nil), + call("verbosef", stub.ExpectArgs{"dbus socket %q is not absolute", []any{"system_bus_socket"}}, nil, nil), + call("evalSymlinks", stub.ExpectArgs{container.Nonexistent + "/xdg_runtime_dir"}, "", stub.UniqueError(0)), + }, nil, nil, &hst.AppError{ + Step: "evaluate path hiding target", + Err: stub.UniqueError(0), + }, nil, nil, nil, nil, nil}, + + {"host nil abs", func(bool, bool) outcomeOp { return new(spFilesystemOp) }, func() *hst.Config { + c := newConfigSmall() + c.Container.Filesystem = append(c.Container.Filesystem, hst.FilesystemConfigJSON{FilesystemConfig: invalidFSHost(true)}) + return c + }, nil, []stub.Call{ + call("lookupEnv", stub.ExpectArgs{dbus.SystemBusAddress}, "invalid:meow=0;unix:path=/system_bus_socket;unix:path=system_bus_socket", nil), + call("verbosef", stub.ExpectArgs{"dbus socket %q is in an unusual location", []any{"/system_bus_socket"}}, nil, nil), + call("verbosef", stub.ExpectArgs{"dbus socket %q is not absolute", []any{"system_bus_socket"}}, nil, nil), + call("evalSymlinks", stub.ExpectArgs{container.Nonexistent + "/xdg_runtime_dir"}, nePrefix+"/xdg_runtime_dir", nil), + call("evalSymlinks", stub.ExpectArgs{container.Nonexistent + "/tmp/hakurei.0"}, nePrefix+"/tmp/hakurei.0", nil), + call("evalSymlinks", stub.ExpectArgs{"/var/run/nscd"}, "", &os.PathError{Op: "lstat", Path: "/var/run/nscd", Err: os.ErrNotExist}), + call("verbosef", stub.ExpectArgs{"path %q does not yet exist", []any{"/var/run/nscd"}}, nil, nil), + call("evalSymlinks", stub.ExpectArgs{"/"}, nePrefix+"/etc/dbus", nil), // to match hidePaths + call("evalSymlinks", stub.ExpectArgs{"/etc/"}, nePrefix+"/etc", nil), + call("evalSymlinks", stub.ExpectArgs{"/var/lib/hakurei/base/org.nixos/.ro-store"}, nePrefix+"/var/lib/hakurei/base/org.nixos/.ro-store", nil), + call("evalSymlinks", stub.ExpectArgs{"/var/lib/hakurei/base/org.nixos/org.chromium.Chromium"}, "var/lib/hakurei/base/org.nixos/org.chromium.Chromium", nil), + }, nil, nil, &hst.AppError{ + Step: "finalise", + Err: os.ErrInvalid, + Msg: "impossible path hiding state reached", + }, nil, nil, nil, nil, nil}, + + {"evalSymlinks late", func(bool, bool) outcomeOp { return new(spFilesystemOp) }, newConfigSmall, nil, []stub.Call{ + call("lookupEnv", stub.ExpectArgs{dbus.SystemBusAddress}, "invalid:meow=0;unix:path=/system_bus_socket;unix:path=system_bus_socket", nil), + call("verbosef", stub.ExpectArgs{"dbus socket %q is in an unusual location", []any{"/system_bus_socket"}}, nil, nil), + call("verbosef", stub.ExpectArgs{"dbus socket %q is not absolute", []any{"system_bus_socket"}}, nil, nil), + call("evalSymlinks", stub.ExpectArgs{container.Nonexistent + "/xdg_runtime_dir"}, nePrefix+"/xdg_runtime_dir", nil), + call("evalSymlinks", stub.ExpectArgs{container.Nonexistent + "/tmp/hakurei.0"}, nePrefix+"/tmp/hakurei.0", nil), + call("evalSymlinks", stub.ExpectArgs{"/var/run/nscd"}, "", &os.PathError{Op: "lstat", Path: "/var/run/nscd", Err: os.ErrNotExist}), + call("verbosef", stub.ExpectArgs{"path %q does not yet exist", []any{"/var/run/nscd"}}, nil, nil), + call("evalSymlinks", stub.ExpectArgs{"/"}, nePrefix+"/etc/dbus", nil), // to match hidePaths + call("evalSymlinks", stub.ExpectArgs{"/etc/"}, nePrefix+"/etc", stub.UniqueError(1)), + }, nil, nil, &hst.AppError{ + Step: "evaluate path hiding source", + Err: stub.UniqueError(1), + }, nil, nil, nil, nil, nil}, + + {"invalid contains", func(bool, bool) outcomeOp { return new(spFilesystemOp) }, newConfigSmall, nil, []stub.Call{ + call("lookupEnv", stub.ExpectArgs{dbus.SystemBusAddress}, "invalid:meow=0;unix:path=/system_bus_socket;unix:path=system_bus_socket", nil), + call("verbosef", stub.ExpectArgs{"dbus socket %q is in an unusual location", []any{"/system_bus_socket"}}, nil, nil), + call("verbosef", stub.ExpectArgs{"dbus socket %q is not absolute", []any{"system_bus_socket"}}, nil, nil), + call("evalSymlinks", stub.ExpectArgs{container.Nonexistent + "/xdg_runtime_dir"}, nePrefix+"/xdg_runtime_dir", nil), + call("evalSymlinks", stub.ExpectArgs{container.Nonexistent + "/tmp/hakurei.0"}, nePrefix+"/tmp/hakurei.0", nil), + call("evalSymlinks", stub.ExpectArgs{"/var/run/nscd"}, "", &os.PathError{Op: "lstat", Path: "/var/run/nscd", Err: os.ErrNotExist}), + call("verbosef", stub.ExpectArgs{"path %q does not yet exist", []any{"/var/run/nscd"}}, nil, nil), + call("evalSymlinks", stub.ExpectArgs{"/"}, nePrefix+"/etc/dbus", nil), // to match hidePaths + call("evalSymlinks", stub.ExpectArgs{"/etc/"}, nePrefix+"/etc", nil), + call("evalSymlinks", stub.ExpectArgs{"/var/lib/hakurei/base/org.nixos/.ro-store"}, nePrefix+"/var/lib/hakurei/base/org.nixos/.ro-store", nil), + call("evalSymlinks", stub.ExpectArgs{"/var/lib/hakurei/base/org.nixos/org.chromium.Chromium"}, "var/lib/hakurei/base/org.nixos/org.chromium.Chromium", nil), + call("verbosef", stub.ExpectArgs{"hiding path %q from %q", []any{"/proc/nonexistent/eval/etc/dbus", "/etc/"}}, nil, nil), + }, nil, nil, &hst.AppError{ + Step: "determine path hiding outcome", + Err: errors.New("Rel: can't make /proc/nonexistent/eval/xdg_runtime_dir relative to var/lib/hakurei/base/org.nixos/org.chromium.Chromium"), + }, nil, nil, nil, nil, nil}, + + {"invalid hide", func(bool, bool) outcomeOp { return new(spFilesystemOp) }, newConfigSmall, nil, []stub.Call{ + call("lookupEnv", stub.ExpectArgs{dbus.SystemBusAddress}, "invalid:meow=0;unix:path=/system_bus_socket;unix:path=system_bus_socket", nil), + call("verbosef", stub.ExpectArgs{"dbus socket %q is in an unusual location", []any{"/system_bus_socket"}}, nil, nil), + call("verbosef", stub.ExpectArgs{"dbus socket %q is not absolute", []any{"system_bus_socket"}}, nil, nil), + call("evalSymlinks", stub.ExpectArgs{container.Nonexistent + "/xdg_runtime_dir"}, "xdg_runtime_dir", nil), + call("evalSymlinks", stub.ExpectArgs{container.Nonexistent + "/tmp/hakurei.0"}, "tmp/hakurei.0", nil), + call("evalSymlinks", stub.ExpectArgs{"/var/run/nscd"}, "nscd", nil), + call("evalSymlinks", stub.ExpectArgs{"/"}, "nonexistent/dbus", nil), + call("evalSymlinks", stub.ExpectArgs{"/etc/"}, "nonexistent", nil), + call("evalSymlinks", stub.ExpectArgs{"/var/lib/hakurei/base/org.nixos/.ro-store"}, ".ro-store", nil), + call("evalSymlinks", stub.ExpectArgs{"/var/lib/hakurei/base/org.nixos/org.chromium.Chromium"}, "org.chromium.Chromium", nil), + call("verbosef", stub.ExpectArgs{"hiding path %q from %q", []any{"nonexistent/dbus", "/etc/"}}, nil, nil), + }, nil, nil, &hst.AppError{ + Step: "finalise", + Err: os.ErrInvalid, + Msg: `invalid path hiding candidate "nonexistent/dbus"`, + }, nil, nil, nil, nil, nil}, + + {"invalid fs", func(isShim, clearUnexported bool) outcomeOp { + if !isShim { + return new(spFilesystemOp) + } + return &spFilesystemOp{HidePaths: []*check.Absolute{m("/proc/nonexistent/eval/etc/dbus")}} + }, newConfigSmall, nil, []stub.Call{ + call("lookupEnv", stub.ExpectArgs{dbus.SystemBusAddress}, "invalid:meow=0;unix:path=/system_bus_socket;unix:path=system_bus_socket", nil), + call("verbosef", stub.ExpectArgs{"dbus socket %q is in an unusual location", []any{"/system_bus_socket"}}, nil, nil), + call("verbosef", stub.ExpectArgs{"dbus socket %q is not absolute", []any{"system_bus_socket"}}, nil, nil), + call("evalSymlinks", stub.ExpectArgs{container.Nonexistent + "/xdg_runtime_dir"}, nePrefix+"/xdg_runtime_dir", nil), + call("evalSymlinks", stub.ExpectArgs{container.Nonexistent + "/tmp/hakurei.0"}, nePrefix+"/tmp/hakurei.0", nil), + call("evalSymlinks", stub.ExpectArgs{"/var/run/nscd"}, "", &os.PathError{Op: "lstat", Path: "/var/run/nscd", Err: os.ErrNotExist}), + call("verbosef", stub.ExpectArgs{"path %q does not yet exist", []any{"/var/run/nscd"}}, nil, nil), + call("evalSymlinks", stub.ExpectArgs{"/"}, nePrefix+"/etc/dbus", nil), // to match hidePaths + call("evalSymlinks", stub.ExpectArgs{"/etc/"}, nePrefix+"/etc", nil), + call("evalSymlinks", stub.ExpectArgs{"/var/lib/hakurei/base/org.nixos/.ro-store"}, nePrefix+"/var/lib/hakurei/base/org.nixos/.ro-store", nil), + call("evalSymlinks", stub.ExpectArgs{"/var/lib/hakurei/base/org.nixos/org.chromium.Chromium"}, nePrefix+"/var/lib/hakurei/base/org.nixos/org.chromium.Chromium", nil), + call("verbosef", stub.ExpectArgs{"hiding path %q from %q", []any{"/proc/nonexistent/eval/etc/dbus", "/etc/"}}, nil, nil), + }, newI(). + Ensure(m("/var/lib/hakurei/u0"), 0700). + UpdatePermType(system.User, m("/var/lib/hakurei/u0"), + acl.Execute). + UpdatePermType(system.User, m("/var/lib/hakurei/u0/org.chromium.Chromium"), + acl.Read, acl.Write, acl.Execute), nil, nil, insertsOps(needsApplyState(func(state *outcomeStateParams) { + state.filesystem = append(configSmall.Container.Filesystem, hst.FilesystemConfigJSON{}) + })), []stub.Call{ + // this op configures the container state and does not make calls during toContainer + }, nil, nil, &hst.AppError{ + Step: "finalise", + Err: os.ErrInvalid, + Msg: "invalid filesystem at index 3", + }}, + + {"success noroot nodev envdbus strangedbus dbusnotabs hide", func(isShim, clearUnexported bool) outcomeOp { + if !isShim { + return new(spFilesystemOp) + } + return &spFilesystemOp{HidePaths: []*check.Absolute{m("/proc/nonexistent/eval/etc/dbus")}} + }, newConfigSmall, nil, []stub.Call{ + call("lookupEnv", stub.ExpectArgs{dbus.SystemBusAddress}, "invalid:meow=0;unix:path=/system_bus_socket;unix:path=system_bus_socket", nil), + call("verbosef", stub.ExpectArgs{"dbus socket %q is in an unusual location", []any{"/system_bus_socket"}}, nil, nil), + call("verbosef", stub.ExpectArgs{"dbus socket %q is not absolute", []any{"system_bus_socket"}}, nil, nil), + call("evalSymlinks", stub.ExpectArgs{container.Nonexistent + "/xdg_runtime_dir"}, nePrefix+"/xdg_runtime_dir", nil), + call("evalSymlinks", stub.ExpectArgs{container.Nonexistent + "/tmp/hakurei.0"}, nePrefix+"/tmp/hakurei.0", nil), + call("evalSymlinks", stub.ExpectArgs{"/var/run/nscd"}, "", &os.PathError{Op: "lstat", Path: "/var/run/nscd", Err: os.ErrNotExist}), + call("verbosef", stub.ExpectArgs{"path %q does not yet exist", []any{"/var/run/nscd"}}, nil, nil), + call("evalSymlinks", stub.ExpectArgs{"/"}, nePrefix+"/etc/dbus", nil), // to match hidePaths + call("evalSymlinks", stub.ExpectArgs{"/etc/"}, nePrefix+"/etc", nil), + call("evalSymlinks", stub.ExpectArgs{"/var/lib/hakurei/base/org.nixos/.ro-store"}, nePrefix+"/var/lib/hakurei/base/org.nixos/.ro-store", nil), + call("evalSymlinks", stub.ExpectArgs{"/var/lib/hakurei/base/org.nixos/org.chromium.Chromium"}, nePrefix+"/var/lib/hakurei/base/org.nixos/org.chromium.Chromium", nil), + call("verbosef", stub.ExpectArgs{"hiding path %q from %q", []any{"/proc/nonexistent/eval/etc/dbus", "/etc/"}}, nil, nil), + }, newI(). + Ensure(m("/var/lib/hakurei/u0"), 0700). + UpdatePermType(system.User, m("/var/lib/hakurei/u0"), + acl.Execute). + UpdatePermType(system.User, m("/var/lib/hakurei/u0/org.chromium.Chromium"), + acl.Read, acl.Write, acl.Execute), nil, nil, insertsOps(needsApplyState(func(state *outcomeStateParams) { + state.filesystem = configSmall.Container.Filesystem + })), []stub.Call{ + // this op configures the container state and does not make calls during toContainer + }, &container.Params{ + Env: []string{ + "GOOGLE_API_KEY=AIzaSyBHDrl33hwRp4rMQY0ziRbj8K9LPA6vUCY", + "GOOGLE_DEFAULT_CLIENT_ID=77185425430.apps.googleusercontent.com", + "GOOGLE_DEFAULT_CLIENT_SECRET=OTJgUOQcT7lO7GsGZq2G4IlT", + }, + + Ops: new(container.Ops). + Etc(fhs.AbsEtc, wantAutoEtcPrefix). + OverlayReadonly( + check.MustAbs("/nix/store"), + fhs.AbsVarLib.Append("hakurei/base/org.nixos/.ro-store"), + fhs.AbsVarLib.Append("hakurei/base/org.nixos/org.chromium.Chromium")). + Readonly(hst.AbsPrivateTmp, 0755). + Tmpfs(m("/proc/nonexistent/eval/etc/dbus"), 1<<13, 0755). + Remount(fhs.AbsDev, syscall.MS_RDONLY). + Remount(fhs.AbsRoot, syscall.MS_RDONLY), + }, nil, nil}, + + {"success", func(bool, bool) outcomeOp { + return new(spFilesystemOp) + }, hst.Template, nil, []stub.Call{ + call("lookupEnv", stub.ExpectArgs{dbus.SystemBusAddress}, nil, nil), + call("evalSymlinks", stub.ExpectArgs{container.Nonexistent + "/xdg_runtime_dir"}, nePrefix+"/xdg_runtime_dir", nil), + call("evalSymlinks", stub.ExpectArgs{container.Nonexistent + "/tmp/hakurei.0"}, nePrefix+"/tmp/hakurei.0", nil), + call("evalSymlinks", stub.ExpectArgs{"/var/run/nscd"}, "", &os.PathError{Op: "lstat", Path: "/var/run/nscd", Err: os.ErrNotExist}), + call("verbosef", stub.ExpectArgs{"path %q does not yet exist", []any{"/var/run/nscd"}}, nil, nil), + call("evalSymlinks", stub.ExpectArgs{"/var/run/dbus"}, nePrefix+"/run/dbus", nil), + call("readdir", stub.ExpectArgs{"/var/lib/hakurei/base/org.debian"}, stubDebianRoot, nil), + call("evalSymlinks", stub.ExpectArgs{"/etc/"}, nePrefix+"/etc", nil), + call("evalSymlinks", stub.ExpectArgs{"/var/lib/hakurei/nix/u0/org.chromium.Chromium/rw-store/upper"}, nePrefix+"/var/lib/hakurei/nix/u0/org.chromium.Chromium/rw-store/upper", nil), + call("evalSymlinks", stub.ExpectArgs{"/var/lib/hakurei/nix/u0/org.chromium.Chromium/rw-store/work"}, nePrefix+"/var/lib/hakurei/nix/u0/org.chromium.Chromium/rw-store/work", nil), + call("evalSymlinks", stub.ExpectArgs{"/var/lib/hakurei/base/org.nixos/ro-store"}, nePrefix+"/var/lib/hakurei/base/org.nixos/ro-store", nil), + call("evalSymlinks", stub.ExpectArgs{"/var/lib/hakurei/u0/org.chromium.Chromium"}, nePrefix+"/var/lib/hakurei/u0/org.chromium.Chromium", nil), + call("evalSymlinks", stub.ExpectArgs{"/dev/dri"}, nePrefix+"/dev/dri", nil), + call("evalSymlinks", stub.ExpectArgs{"/var/lib/hakurei/base/org.debian/bin"}, nePrefix+"/var/lib/hakurei/base/org.debian/bin", nil), + call("evalSymlinks", stub.ExpectArgs{"/var/lib/hakurei/base/org.debian/home"}, nePrefix+"/var/lib/hakurei/base/org.debian/home", nil), + call("evalSymlinks", stub.ExpectArgs{"/var/lib/hakurei/base/org.debian/lib64"}, nePrefix+"/var/lib/hakurei/base/org.debian/lib64", nil), + call("evalSymlinks", stub.ExpectArgs{"/var/lib/hakurei/base/org.debian/lost+found"}, nePrefix+"/var/lib/hakurei/base/org.debian/lost+found", nil), + call("evalSymlinks", stub.ExpectArgs{"/var/lib/hakurei/base/org.debian/nix"}, nePrefix+"/var/lib/hakurei/base/org.debian/nix", nil), + call("evalSymlinks", stub.ExpectArgs{"/var/lib/hakurei/base/org.debian/root"}, nePrefix+"/var/lib/hakurei/base/org.debian/root", nil), + call("evalSymlinks", stub.ExpectArgs{"/var/lib/hakurei/base/org.debian/run"}, nePrefix+"/var/lib/hakurei/base/org.debian/run", nil), + call("evalSymlinks", stub.ExpectArgs{"/var/lib/hakurei/base/org.debian/srv"}, nePrefix+"/var/lib/hakurei/base/org.debian/srv", nil), + call("evalSymlinks", stub.ExpectArgs{"/var/lib/hakurei/base/org.debian/sys"}, nePrefix+"/var/lib/hakurei/base/org.debian/sys", nil), + call("evalSymlinks", stub.ExpectArgs{"/var/lib/hakurei/base/org.debian/usr"}, nePrefix+"/var/lib/hakurei/base/org.debian/usr", nil), + call("evalSymlinks", stub.ExpectArgs{"/var/lib/hakurei/base/org.debian/var"}, nePrefix+"/var/lib/hakurei/base/org.debian/var", nil), + }, newI(). + Ensure(m("/var/lib/hakurei/u0"), 0700). + UpdatePermType(system.User, m("/var/lib/hakurei/u0"), + acl.Execute). + UpdatePermType(system.User, m("/var/lib/hakurei/u0/org.chromium.Chromium"), + acl.Read, acl.Write, acl.Execute), nil, nil, insertsOps(needsApplyState(func(state *outcomeStateParams) { + state.filesystem = config.Container.Filesystem[1:] + })), []stub.Call{ + // this op configures the container state and does not make calls during toContainer + }, &container.Params{ + Env: []string{ + "GOOGLE_API_KEY=AIzaSyBHDrl33hwRp4rMQY0ziRbj8K9LPA6vUCY", + "GOOGLE_DEFAULT_CLIENT_ID=77185425430.apps.googleusercontent.com", + "GOOGLE_DEFAULT_CLIENT_SECRET=OTJgUOQcT7lO7GsGZq2G4IlT", + }, + + Ops: new(container.Ops). + Etc(fhs.AbsEtc, wantAutoEtcPrefix). + Tmpfs(fhs.AbsTmp, 0, 0755). + Overlay( + check.MustAbs("/nix/store"), + fhs.AbsVarLib.Append("hakurei/nix/u0/org.chromium.Chromium/rw-store/upper"), + fhs.AbsVarLib.Append("hakurei/nix/u0/org.chromium.Chromium/rw-store/work"), + fhs.AbsVarLib.Append("hakurei/base/org.nixos/ro-store")). + Link(fhs.AbsRun.Append("current-system"), "/run/current-system", true). + Link(fhs.AbsRun.Append("opengl-driver"), "/run/opengl-driver", true). + Bind( + fhs.AbsVarLib.Append("hakurei/u0/org.chromium.Chromium"), + check.MustAbs("/data/data/org.chromium.Chromium"), + comp.BindWritable|comp.BindEnsure). + Bind(fhs.AbsDev.Append("dri"), fhs.AbsDev.Append("dri"), comp.BindDevice|comp.BindWritable|comp.BindOptional). + Remount(fhs.AbsRoot, syscall.MS_RDONLY), + }, nil, nil}, + }) +} + +func TestFlattenExtraPerms(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + perms []hst.ExtraPermConfig + want *system.I + }{ + {"path nil check", append(hst.Template().ExtraPerms, hst.ExtraPermConfig{}), newI(). + Ensure(m("/var/lib/hakurei/u0"), 0700). + UpdatePermType(system.User, m("/var/lib/hakurei/u0"), + acl.Execute). + UpdatePermType(system.User, m("/var/lib/hakurei/u0/org.chromium.Chromium"), + acl.Read, acl.Write, acl.Execute)}, + + {"template", hst.Template().ExtraPerms, newI(). + Ensure(m("/var/lib/hakurei/u0"), 0700). + UpdatePermType(system.User, m("/var/lib/hakurei/u0"), + acl.Execute). + UpdatePermType(system.User, m("/var/lib/hakurei/u0/org.chromium.Chromium"), + acl.Read, acl.Write, acl.Execute)}, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got := newI() + flattenExtraPerms(got, tc.perms) + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("flattenExtraPerms: sys = %#v, want %#v", got, tc.want) + } + }) + } +} + +// invalidFSHost implements the Host method of [hst.FilesystemConfig] with an invalid response. +type invalidFSHost bool + +func (f invalidFSHost) Valid() bool { return bool(f) } +func (invalidFSHost) Path() *check.Absolute { panic("unreachable") } +func (invalidFSHost) Host() []*check.Absolute { return []*check.Absolute{nil} } +func (invalidFSHost) Apply(*hst.ApplyState) { panic("unreachable") } +func (invalidFSHost) String() string { panic("unreachable") } diff --git a/internal/outcome/spdbus.go b/internal/outcome/spdbus.go new file mode 100644 index 00000000..afb62ca0 --- /dev/null +++ b/internal/outcome/spdbus.go @@ -0,0 +1,58 @@ +package outcome + +import ( + "encoding/gob" + + "hakurei.app/container/fhs" + "hakurei.app/hst" + "hakurei.app/system/acl" + "hakurei.app/system/dbus" +) + +func init() { gob.Register(new(spDBusOp)) } + +// spDBusOp maintains an xdg-dbus-proxy instance for the container. +// Runs after spRuntimeOp. +type spDBusOp struct { + // Whether to bind the system bus socket. Populated during toSystem. + ProxySystem bool +} + +func (s *spDBusOp) toSystem(state *outcomeStateSys) error { + if state.et&hst.EDBus == 0 { + return errNotEnabled + } + + if state.sessionBus == nil { + state.sessionBus = dbus.NewConfig(state.appId, true, true) + } + + // downstream socket paths + sessionPath, systemPath := state.instance().Append("bus"), state.instance().Append("system_bus_socket") + + var sessionBus, systemBus dbus.ProxyPair + sessionBus[0], systemBus[0] = state.k.dbusAddress() + sessionBus[1], systemBus[1] = sessionPath.String(), systemPath.String() + if err := state.sys.ProxyDBus(state.sessionBus, state.systemBus, sessionBus, systemBus); err != nil { + return err + } + + state.sys.UpdatePerm(sessionPath, acl.Read, acl.Write) + if state.systemBus != nil { + s.ProxySystem = true + state.sys.UpdatePerm(systemPath, acl.Read, acl.Write) + } + return nil +} + +func (s *spDBusOp) toContainer(state *outcomeStateParams) error { + sessionInner := state.runtimeDir.Append("bus") + state.env["DBUS_SESSION_BUS_ADDRESS"] = "unix:path=" + sessionInner.String() + state.params.Bind(state.instancePath().Append("bus"), sessionInner, 0) + if s.ProxySystem { + systemInner := fhs.AbsVar.Append("run/dbus/system_bus_socket") + state.env["DBUS_SYSTEM_BUS_ADDRESS"] = "unix:path=" + systemInner.String() + state.params.Bind(state.instancePath().Append("system_bus_socket"), systemInner, 0) + } + return nil +} diff --git a/internal/outcome/spdbus_test.go b/internal/outcome/spdbus_test.go new file mode 100644 index 00000000..26f1fdcc --- /dev/null +++ b/internal/outcome/spdbus_test.go @@ -0,0 +1,190 @@ +package outcome + +import ( + "syscall" + "testing" + + "hakurei.app/container" + "hakurei.app/container/stub" + "hakurei.app/helper" + "hakurei.app/hst" + "hakurei.app/message" + "hakurei.app/system" + "hakurei.app/system/acl" + "hakurei.app/system/dbus" +) + +func TestSpDBusOp(t *testing.T) { + config := hst.Template() + + checkOpBehaviour(t, []opBehaviourTestCase{ + {"not enabled", func(bool, bool) outcomeOp { + return new(spDBusOp) + }, func() *hst.Config { + c := hst.Template() + *c.Enablements = 0 + return c + }, nil, nil, nil, nil, errNotEnabled, nil, nil, nil, nil, nil}, + + {"invalid", func(bool, bool) outcomeOp { + return new(spDBusOp) + }, func() *hst.Config { + c := hst.Template() + c.SessionBus.Talk[0] += "\x00" + c.SystemBus = nil + return c + }, nil, []stub.Call{ + call("dbusAddress", stub.ExpectArgs{}, [2]string{ + "unix:path=/run/user/1000/bus", + "unix:path=/var/run/dbus/system_bus_socket", + }, nil), + }, nil, sysUsesInstance(nil), &system.OpError{ + Op: "dbus", + Err: syscall.EINVAL, + Msg: "message bus proxy configuration contains NUL byte", + Revert: false, + }, nil, nil, nil, nil, nil}, + + {"success default", func(bool, bool) outcomeOp { + return new(spDBusOp) + }, func() *hst.Config { + c := hst.Template() + c.SessionBus, c.SystemBus = nil, nil + return c + }, nil, []stub.Call{ + call("dbusAddress", stub.ExpectArgs{}, [2]string{ + "unix:path=/run/user/1000/bus", + "unix:path=/var/run/dbus/system_bus_socket", + }, nil), + call("isVerbose", stub.ExpectArgs{}, true, nil), + call("verbose", stub.ExpectArgs{[]any{"session bus proxy:", []string{ + "unix:path=/run/user/1000/bus", + wantInstancePrefix + "/bus", + "--filter", + "--talk=org.freedesktop.DBus", + "--talk=org.freedesktop.Notifications", + "--own=org.chromium.Chromium.*", + "--own=org.mpris.MediaPlayer2.org.chromium.Chromium.*", + "--call=org.freedesktop.portal.*=*", + "--broadcast=org.freedesktop.portal.*=@/org/freedesktop/portal/*", + }}}, nil, nil), + call("verbose", stub.ExpectArgs{[]any{"message bus proxy final args:", helper.MustNewCheckedArgs( + "unix:path=/run/user/1000/bus", + wantInstancePrefix+"/bus", + "--filter", + "--talk=org.freedesktop.DBus", + "--talk=org.freedesktop.Notifications", + "--own=org.chromium.Chromium.*", + "--own=org.mpris.MediaPlayer2.org.chromium.Chromium.*", + "--call=org.freedesktop.portal.*=*", + "--broadcast=org.freedesktop.portal.*=@/org/freedesktop/portal/*", + )}}, nil, nil), + }, func() *system.I { + sys := system.New(panicMsgContext{}, message.NewMsg(nil), checkExpectUid) + sys.Ephemeral(system.Process, m(wantInstancePrefix), 0711) + if err := sys.ProxyDBus( + dbus.NewConfig(config.ID, true, true), nil, + dbus.ProxyPair{"unix:path=/run/user/1000/bus", wantInstancePrefix + "/bus"}, + dbus.ProxyPair{"unix:path=/var/run/dbus/system_bus_socket", wantInstancePrefix + "/system_bus_socket"}, + ); err != nil { + t.Fatalf("cannot prepare sys: %v", err) + } + sys.UpdatePerm(m(wantInstancePrefix+"/bus"), acl.Read, acl.Write) + return sys + }(), sysUsesInstance(nil), nil, insertsOps(afterSpRuntimeOp(nil)), []stub.Call{ + // this op configures the container state and does not make calls during toContainer + }, &container.Params{ + Ops: new(container.Ops). + Bind(m(wantInstancePrefix+"/bus"), + m("/run/user/1000/bus"), 0), + }, paramsWantEnv(config, map[string]string{ + "DBUS_SESSION_BUS_ADDRESS": "unix:path=/run/user/1000/bus", + }, nil), nil}, + + {"success", func(isShim, _ bool) outcomeOp { + if !isShim { + return new(spDBusOp) + } + return &spDBusOp{ProxySystem: true} + }, hst.Template, nil, []stub.Call{ + call("dbusAddress", stub.ExpectArgs{}, [2]string{ + "unix:path=/run/user/1000/bus", + "unix:path=/var/run/dbus/system_bus_socket", + }, nil), + call("isVerbose", stub.ExpectArgs{}, true, nil), + call("verbose", stub.ExpectArgs{[]any{"session bus proxy:", []string{ + "unix:path=/run/user/1000/bus", + wantInstancePrefix + "/bus", + "--filter", + "--talk=org.freedesktop.Notifications", + "--talk=org.freedesktop.FileManager1", + "--talk=org.freedesktop.ScreenSaver", + "--talk=org.freedesktop.secrets", + "--talk=org.kde.kwalletd5", + "--talk=org.kde.kwalletd6", + "--talk=org.gnome.SessionManager", + "--own=org.chromium.Chromium.*", + "--own=org.mpris.MediaPlayer2.org.chromium.Chromium.*", + "--own=org.mpris.MediaPlayer2.chromium.*", + "--call=org.freedesktop.portal.*=*", + "--broadcast=org.freedesktop.portal.*=@/org/freedesktop/portal/*", + }}}, nil, nil), + call("verbose", stub.ExpectArgs{[]any{"system bus proxy:", []string{ + "unix:path=/var/run/dbus/system_bus_socket", + wantInstancePrefix + "/system_bus_socket", + "--filter", + "--talk=org.bluez", + "--talk=org.freedesktop.Avahi", + "--talk=org.freedesktop.UPower", + }}}, nil, nil), + call("verbose", stub.ExpectArgs{[]any{"message bus proxy final args:", helper.MustNewCheckedArgs( + "unix:path=/run/user/1000/bus", + wantInstancePrefix+"/bus", + "--filter", + "--talk=org.freedesktop.Notifications", + "--talk=org.freedesktop.FileManager1", + "--talk=org.freedesktop.ScreenSaver", + "--talk=org.freedesktop.secrets", + "--talk=org.kde.kwalletd5", + "--talk=org.kde.kwalletd6", + "--talk=org.gnome.SessionManager", + "--own=org.chromium.Chromium.*", + "--own=org.mpris.MediaPlayer2.org.chromium.Chromium.*", + "--own=org.mpris.MediaPlayer2.chromium.*", + "--call=org.freedesktop.portal.*=*", + "--broadcast=org.freedesktop.portal.*=@/org/freedesktop/portal/*", + + "unix:path=/var/run/dbus/system_bus_socket", + wantInstancePrefix+"/system_bus_socket", + "--filter", + "--talk=org.bluez", + "--talk=org.freedesktop.Avahi", + "--talk=org.freedesktop.UPower", + )}}, nil, nil), + }, func() *system.I { + sys := system.New(panicMsgContext{}, message.NewMsg(nil), checkExpectUid) + sys.Ephemeral(system.Process, m(wantInstancePrefix), 0711) + if err := sys.ProxyDBus( + config.SessionBus, config.SystemBus, + dbus.ProxyPair{"unix:path=/run/user/1000/bus", wantInstancePrefix + "/bus"}, + dbus.ProxyPair{"unix:path=/var/run/dbus/system_bus_socket", wantInstancePrefix + "/system_bus_socket"}, + ); err != nil { + t.Fatalf("cannot prepare sys: %v", err) + } + sys.UpdatePerm(m(wantInstancePrefix+"/bus"), acl.Read, acl.Write). + UpdatePerm(m(wantInstancePrefix+"/system_bus_socket"), acl.Read, acl.Write) + return sys + }(), sysUsesInstance(nil), nil, insertsOps(afterSpRuntimeOp(nil)), []stub.Call{ + // this op configures the container state and does not make calls during toContainer + }, &container.Params{ + Ops: new(container.Ops). + Bind(m(wantInstancePrefix+"/bus"), + m("/run/user/1000/bus"), 0). + Bind(m(wantInstancePrefix+"/system_bus_socket"), + m("/var/run/dbus/system_bus_socket"), 0), + }, paramsWantEnv(config, map[string]string{ + "DBUS_SESSION_BUS_ADDRESS": "unix:path=/run/user/1000/bus", + "DBUS_SYSTEM_BUS_ADDRESS": "unix:path=/var/run/dbus/system_bus_socket", + }, nil), nil}, + }) +} diff --git a/internal/outcome/sppulse.go b/internal/outcome/sppulse.go new file mode 100644 index 00000000..a2dc2401 --- /dev/null +++ b/internal/outcome/sppulse.go @@ -0,0 +1,210 @@ +package outcome + +import ( + "encoding/gob" + "errors" + "fmt" + "io" + "io/fs" + "os" + "strconv" + "syscall" + + "hakurei.app/container/check" + "hakurei.app/hst" + "hakurei.app/message" +) + +const pulseCookieSizeMax = 1 << 8 + +func init() { gob.Register(new(spPulseOp)) } + +// spPulseOp exports the PulseAudio server to the container. +// Runs after spRuntimeOp. +type spPulseOp struct { + // PulseAudio cookie data, populated during toSystem if a cookie is present. + Cookie *[pulseCookieSizeMax]byte + // PulseAudio cookie size, populated during toSystem if a cookie is present. + CookieSize int +} + +func (s *spPulseOp) toSystem(state *outcomeStateSys) error { + if state.et&hst.EPulse == 0 { + return errNotEnabled + } + + pulseRuntimeDir, pulseSocket := s.commonPaths(state.outcomeState) + + if _, err := state.k.stat(pulseRuntimeDir.String()); err != nil { + if !errors.Is(err, fs.ErrNotExist) { + return &hst.AppError{Step: fmt.Sprintf("access PulseAudio directory %q", pulseRuntimeDir), Err: err} + } + return newWithMessageError(fmt.Sprintf("PulseAudio directory %q not found", pulseRuntimeDir), err) + } + + if fi, err := state.k.stat(pulseSocket.String()); err != nil { + if !errors.Is(err, fs.ErrNotExist) { + return &hst.AppError{Step: fmt.Sprintf("access PulseAudio socket %q", pulseSocket), Err: err} + } + return newWithMessageError(fmt.Sprintf("PulseAudio directory %q found but socket does not exist", pulseRuntimeDir), err) + } else { + if m := fi.Mode(); m&0o006 != 0o006 { + return newWithMessage(fmt.Sprintf("unexpected permissions on %q: %s", pulseSocket, m)) + } + } + + // pulse socket is world writable and its parent directory DAC permissions prevents access; + // hard link to target-executable share directory to grant access + state.sys.Link(pulseSocket, state.runtime().Append("pulse")) + + // load up to pulseCookieSizeMax bytes of pulse cookie for transmission to shim + if a, err := discoverPulseCookie(state.k); err != nil { + return err + } else if a != nil { + s.Cookie = new([pulseCookieSizeMax]byte) + if s.CookieSize, err = loadFile(state.msg, state.k, "PulseAudio cookie", a.String(), s.Cookie[:]); err != nil { + return err + } + } else { + state.msg.Verbose("cannot locate PulseAudio cookie (tried " + + "$PULSE_COOKIE, " + + "$XDG_CONFIG_HOME/pulse/cookie, " + + "$HOME/.pulse-cookie)") + } + + return nil +} + +func (s *spPulseOp) toContainer(state *outcomeStateParams) error { + innerPulseSocket := state.runtimeDir.Append("pulse", "native") + state.params.Bind(state.runtimePath().Append("pulse"), innerPulseSocket, 0) + state.env["PULSE_SERVER"] = "unix:" + innerPulseSocket.String() + + if s.Cookie != nil { + innerDst := hst.AbsPrivateTmp.Append("/pulse-cookie") + + if s.CookieSize < 0 || s.CookieSize > pulseCookieSizeMax { + return newWithMessage("unexpected PulseAudio cookie size") + } + state.env["PULSE_COOKIE"] = innerDst.String() + state.params.Place(innerDst, s.Cookie[:s.CookieSize]) + } + + return nil +} + +func (s *spPulseOp) commonPaths(state *outcomeState) (pulseRuntimeDir, pulseSocket *check.Absolute) { + // PulseAudio runtime directory (usually `/run/user/%d/pulse`) + pulseRuntimeDir = state.sc.RuntimePath.Append("pulse") + // PulseAudio socket (usually `/run/user/%d/pulse/native`) + pulseSocket = pulseRuntimeDir.Append("native") + return +} + +// discoverPulseCookie attempts to discover the pathname of the PulseAudio cookie of the current user. +// If both returned pathname and error are nil, the cookie is likely unavailable and can be silently skipped. +func discoverPulseCookie(k syscallDispatcher) (*check.Absolute, error) { + const paLocateStep = "locate PulseAudio cookie" + + // from environment + if p, ok := k.lookupEnv("PULSE_COOKIE"); ok { + if a, err := check.NewAbs(p); err != nil { + return nil, &hst.AppError{Step: paLocateStep, Err: err} + } else { + // this takes precedence, do not verify whether the file is accessible + return a, nil + } + } + + // $HOME/.pulse-cookie + if p, ok := k.lookupEnv("HOME"); ok { + var pulseCookiePath *check.Absolute + if a, err := check.NewAbs(p); err != nil { + return nil, &hst.AppError{Step: paLocateStep, Err: err} + } else { + pulseCookiePath = a.Append(".pulse-cookie") + } + + if fi, err := k.stat(pulseCookiePath.String()); err != nil { + if !errors.Is(err, fs.ErrNotExist) { + return nil, &hst.AppError{Step: "access PulseAudio cookie", Err: err} + } + // fallthrough + } else if fi.IsDir() { + // fallthrough + } else { + return pulseCookiePath, nil + } + } + + // $XDG_CONFIG_HOME/pulse/cookie + if p, ok := k.lookupEnv("XDG_CONFIG_HOME"); ok { + var pulseCookiePath *check.Absolute + if a, err := check.NewAbs(p); err != nil { + return nil, &hst.AppError{Step: paLocateStep, Err: err} + } else { + pulseCookiePath = a.Append("pulse", "cookie") + } + + if fi, err := k.stat(pulseCookiePath.String()); err != nil { + if !errors.Is(err, fs.ErrNotExist) { + return nil, &hst.AppError{Step: "access PulseAudio cookie", Err: err} + } + // fallthrough + } else if fi.IsDir() { + // fallthrough + } else { + return pulseCookiePath, nil + } + } + + // cookie not present + // not fatal: authentication is disabled + return nil, nil +} + +// loadFile reads up to len(buf) bytes from the file at pathname. +func loadFile( + msg message.Msg, k syscallDispatcher, + description, pathname string, buf []byte, +) (int, error) { + n := len(buf) + if n == 0 { + return -1, errors.New("invalid buffer") + } + + if fi, err := k.stat(pathname); err != nil { + return -1, &hst.AppError{Step: "access " + description, Err: err} + } else { + if fi.IsDir() { + return -1, &hst.AppError{Step: "read " + description, + Err: &os.PathError{Op: "stat", Path: pathname, Err: syscall.EISDIR}} + } + if s := fi.Size(); s > int64(n) { + return -1, newWithMessageError( + description+" at "+strconv.Quote(pathname)+" exceeds expected size", + &os.PathError{Op: "stat", Path: pathname, Err: syscall.ENOMEM}, + ) + } else if s < int64(n) { + msg.Verbosef("%s at %q is %d bytes shorter than expected", description, pathname, int64(n)-s) + } else { + msg.Verbosef("loading %d bytes from %q", n, pathname) + } + } + + if f, err := k.open(pathname); err != nil { + return -1, &hst.AppError{Step: "open " + description, Err: err} + } else { + if n, err = f.Read(buf); err != nil { + if !errors.Is(err, io.EOF) { + _ = f.Close() + return n, &hst.AppError{Step: "read " + description, Err: err} + } + } + + if err = f.Close(); err != nil { + return n, &hst.AppError{Step: "close " + description, Err: err} + } + return n, nil + } +} diff --git a/internal/outcome/sppulse_test.go b/internal/outcome/sppulse_test.go new file mode 100644 index 00000000..9aecb198 --- /dev/null +++ b/internal/outcome/sppulse_test.go @@ -0,0 +1,460 @@ +package outcome + +import ( + "bytes" + "errors" + "os" + "syscall" + "testing" + + "hakurei.app/container" + "hakurei.app/container/check" + "hakurei.app/container/stub" + "hakurei.app/hst" + "hakurei.app/system" + "hakurei.app/system/acl" +) + +func TestSpPulseOp(t *testing.T) { + t.Parallel() + + config := hst.Template() + sampleCookie := bytes.Repeat([]byte{0xfc}, pulseCookieSizeMax) + + checkOpBehaviour(t, []opBehaviourTestCase{ + {"not enabled", func(bool, bool) outcomeOp { + return new(spPulseOp) + }, func() *hst.Config { + c := hst.Template() + *c.Enablements = 0 + return c + }, nil, nil, nil, nil, errNotEnabled, nil, nil, nil, nil, nil}, + + {"socketDir stat", func(isShim, _ bool) outcomeOp { + if !isShim { + return new(spPulseOp) + } + return &spPulseOp{Cookie: (*[256]byte)(sampleCookie)} + }, hst.Template, nil, []stub.Call{ + call("stat", stub.ExpectArgs{wantRuntimePath + "/pulse"}, (*stubFi)(nil), stub.UniqueError(2)), + }, nil, nil, &hst.AppError{ + Step: `access PulseAudio directory "/proc/nonexistent/xdg_runtime_dir/pulse"`, + Err: stub.UniqueError(2), + }, nil, nil, nil, nil, nil}, + + {"socketDir nonexistent", func(bool, bool) outcomeOp { + return new(spPulseOp) + }, hst.Template, nil, []stub.Call{ + call("stat", stub.ExpectArgs{wantRuntimePath + "/pulse"}, (*stubFi)(nil), os.ErrNotExist), + }, nil, nil, &hst.AppError{ + Step: "finalise", + Err: os.ErrNotExist, + Msg: `PulseAudio directory "/proc/nonexistent/xdg_runtime_dir/pulse" not found`, + }, nil, nil, nil, nil, nil}, + + {"socket stat", func(bool, bool) outcomeOp { + return new(spPulseOp) + }, hst.Template, nil, []stub.Call{ + call("stat", stub.ExpectArgs{wantRuntimePath + "/pulse"}, (*stubFi)(nil), nil), + call("stat", stub.ExpectArgs{wantRuntimePath + "/pulse/native"}, (*stubFi)(nil), stub.UniqueError(1)), + }, nil, nil, &hst.AppError{ + Step: `access PulseAudio socket "/proc/nonexistent/xdg_runtime_dir/pulse/native"`, + Err: stub.UniqueError(1), + }, nil, nil, nil, nil, nil}, + + {"socket nonexistent", func(bool, bool) outcomeOp { + return new(spPulseOp) + }, hst.Template, nil, []stub.Call{ + call("stat", stub.ExpectArgs{wantRuntimePath + "/pulse"}, (*stubFi)(nil), nil), + call("stat", stub.ExpectArgs{wantRuntimePath + "/pulse/native"}, (*stubFi)(nil), os.ErrNotExist), + }, nil, nil, &hst.AppError{ + Step: "finalise", + Err: os.ErrNotExist, + Msg: `PulseAudio directory "/proc/nonexistent/xdg_runtime_dir/pulse" found but socket does not exist`, + }, nil, nil, nil, nil, nil}, + + {"socket mode", func(bool, bool) outcomeOp { + return new(spPulseOp) + }, hst.Template, nil, []stub.Call{ + call("stat", stub.ExpectArgs{wantRuntimePath + "/pulse"}, (*stubFi)(nil), nil), + call("stat", stub.ExpectArgs{wantRuntimePath + "/pulse/native"}, &stubFi{mode: 0660}, nil), + }, nil, nil, &hst.AppError{ + Step: "finalise", + Err: os.ErrInvalid, + Msg: `unexpected permissions on "/proc/nonexistent/xdg_runtime_dir/pulse/native": -rw-rw----`, + }, nil, nil, nil, nil, nil}, + + {"cookie notAbs", func(bool, bool) outcomeOp { + return new(spPulseOp) + }, hst.Template, nil, []stub.Call{ + call("stat", stub.ExpectArgs{wantRuntimePath + "/pulse"}, (*stubFi)(nil), nil), + call("stat", stub.ExpectArgs{wantRuntimePath + "/pulse/native"}, &stubFi{mode: 0666}, nil), + call("lookupEnv", stub.ExpectArgs{"PULSE_COOKIE"}, "proc/nonexistent/cookie", nil), + }, nil, nil, &hst.AppError{ + Step: "locate PulseAudio cookie", + Err: &check.AbsoluteError{Pathname: "proc/nonexistent/cookie"}, + }, nil, nil, nil, nil, nil}, + + {"cookie loadFile", func(bool, bool) outcomeOp { + return new(spPulseOp) + }, hst.Template, nil, []stub.Call{ + call("stat", stub.ExpectArgs{wantRuntimePath + "/pulse"}, (*stubFi)(nil), nil), + call("stat", stub.ExpectArgs{wantRuntimePath + "/pulse/native"}, &stubFi{mode: 0666}, nil), + call("lookupEnv", stub.ExpectArgs{"PULSE_COOKIE"}, "/proc/nonexistent/cookie", nil), + call("stat", stub.ExpectArgs{"/proc/nonexistent/cookie"}, &stubFi{isDir: false, size: 1 << 8}, nil), + call("verbosef", stub.ExpectArgs{"loading %d bytes from %q", []any{1 << 8, "/proc/nonexistent/cookie"}}, nil, nil), + call("open", stub.ExpectArgs{"/proc/nonexistent/cookie"}, (*stubOsFile)(nil), stub.UniqueError(0)), + }, nil, nil, &hst.AppError{ + Step: "open PulseAudio cookie", + Err: stub.UniqueError(0), + }, nil, nil, nil, nil, nil}, + + {"cookie bad shim size", func(isShim, clearUnexported bool) outcomeOp { + if !isShim { + return new(spPulseOp) + } + op := &spPulseOp{Cookie: (*[pulseCookieSizeMax]byte)(sampleCookie), CookieSize: pulseCookieSizeMax} + if clearUnexported { + op.CookieSize += +0xfd + } + return op + }, hst.Template, nil, []stub.Call{ + call("stat", stub.ExpectArgs{wantRuntimePath + "/pulse"}, (*stubFi)(nil), nil), + call("stat", stub.ExpectArgs{wantRuntimePath + "/pulse/native"}, &stubFi{mode: 0666}, nil), + call("lookupEnv", stub.ExpectArgs{"PULSE_COOKIE"}, "/proc/nonexistent/cookie", nil), + call("stat", stub.ExpectArgs{"/proc/nonexistent/cookie"}, &stubFi{isDir: false, size: 1 << 8}, nil), + call("verbosef", stub.ExpectArgs{"loading %d bytes from %q", []any{1 << 8, "/proc/nonexistent/cookie"}}, nil, nil), + call("open", stub.ExpectArgs{"/proc/nonexistent/cookie"}, &stubOsFile{Reader: bytes.NewReader(sampleCookie)}, nil), + }, newI(). + // state.ensureRuntimeDir + Ensure(m(wantRunDirPath), 0700). + UpdatePermType(system.User, m(wantRunDirPath), acl.Execute). + Ensure(m(wantRuntimePath), 0700). + UpdatePermType(system.User, m(wantRuntimePath), acl.Execute). + // state.runtime + Ephemeral(system.Process, m(wantRuntimeSharePath), 0700). + UpdatePerm(m(wantRuntimeSharePath), acl.Execute). + // toSystem + Link(m(wantRuntimePath+"/pulse/native"), m(wantRuntimeSharePath+"/pulse")), sysUsesRuntime(nil), nil, insertsOps(afterSpRuntimeOp(nil)), []stub.Call{ + // this op configures the container state and does not make calls during toContainer + }, nil, nil, &hst.AppError{ + Step: "finalise", + Err: os.ErrInvalid, + Msg: "unexpected PulseAudio cookie size", + }}, + + {"success cookie short", func(isShim, _ bool) outcomeOp { + if !isShim { + return new(spPulseOp) + } + sampleCookieTrunc := make([]byte, pulseCookieSizeMax) + copy(sampleCookieTrunc, sampleCookie[:len(sampleCookie)-0xe]) + return &spPulseOp{Cookie: (*[pulseCookieSizeMax]byte)(sampleCookieTrunc), CookieSize: pulseCookieSizeMax - 0xe} + }, hst.Template, nil, []stub.Call{ + call("stat", stub.ExpectArgs{wantRuntimePath + "/pulse"}, (*stubFi)(nil), nil), + call("stat", stub.ExpectArgs{wantRuntimePath + "/pulse/native"}, &stubFi{mode: 0666}, nil), + call("lookupEnv", stub.ExpectArgs{"PULSE_COOKIE"}, "/proc/nonexistent/cookie", nil), + call("stat", stub.ExpectArgs{"/proc/nonexistent/cookie"}, &stubFi{isDir: false, size: pulseCookieSizeMax - 0xe}, nil), + call("verbosef", stub.ExpectArgs{"%s at %q is %d bytes shorter than expected", []any{"PulseAudio cookie", "/proc/nonexistent/cookie", int64(0xe)}}, nil, nil), + call("open", stub.ExpectArgs{"/proc/nonexistent/cookie"}, &stubOsFile{Reader: bytes.NewReader(sampleCookie[:len(sampleCookie)-0xe])}, nil), + }, newI(). + // state.ensureRuntimeDir + Ensure(m(wantRunDirPath), 0700). + UpdatePermType(system.User, m(wantRunDirPath), acl.Execute). + Ensure(m(wantRuntimePath), 0700). + UpdatePermType(system.User, m(wantRuntimePath), acl.Execute). + // state.runtime + Ephemeral(system.Process, m(wantRuntimeSharePath), 0700). + UpdatePerm(m(wantRuntimeSharePath), acl.Execute). + // toSystem + Link(m(wantRuntimePath+"/pulse/native"), m(wantRuntimeSharePath+"/pulse")), sysUsesRuntime(nil), nil, insertsOps(afterSpRuntimeOp(nil)), []stub.Call{ + // this op configures the container state and does not make calls during toContainer + }, &container.Params{ + Ops: new(container.Ops). + Bind(m(wantRuntimeSharePath+"/pulse"), m("/run/user/1000/pulse/native"), 0). + Place(m("/.hakurei/pulse-cookie"), sampleCookie[:len(sampleCookie)-0xe]), + }, paramsWantEnv(config, map[string]string{ + "PULSE_SERVER": "unix:/run/user/1000/pulse/native", + "PULSE_COOKIE": "/.hakurei/pulse-cookie", + }, nil), nil}, + + {"success cookie", func(isShim, _ bool) outcomeOp { + if !isShim { + return new(spPulseOp) + } + return &spPulseOp{Cookie: (*[pulseCookieSizeMax]byte)(sampleCookie), CookieSize: pulseCookieSizeMax} + }, hst.Template, nil, []stub.Call{ + call("stat", stub.ExpectArgs{wantRuntimePath + "/pulse"}, (*stubFi)(nil), nil), + call("stat", stub.ExpectArgs{wantRuntimePath + "/pulse/native"}, &stubFi{mode: 0666}, nil), + call("lookupEnv", stub.ExpectArgs{"PULSE_COOKIE"}, "/proc/nonexistent/cookie", nil), + call("stat", stub.ExpectArgs{"/proc/nonexistent/cookie"}, &stubFi{isDir: false, size: 1 << 8}, nil), + call("verbosef", stub.ExpectArgs{"loading %d bytes from %q", []any{1 << 8, "/proc/nonexistent/cookie"}}, nil, nil), + call("open", stub.ExpectArgs{"/proc/nonexistent/cookie"}, &stubOsFile{Reader: bytes.NewReader(sampleCookie)}, nil), + }, newI(). + // state.ensureRuntimeDir + Ensure(m(wantRunDirPath), 0700). + UpdatePermType(system.User, m(wantRunDirPath), acl.Execute). + Ensure(m(wantRuntimePath), 0700). + UpdatePermType(system.User, m(wantRuntimePath), acl.Execute). + // state.runtime + Ephemeral(system.Process, m(wantRuntimeSharePath), 0700). + UpdatePerm(m(wantRuntimeSharePath), acl.Execute). + // toSystem + Link(m(wantRuntimePath+"/pulse/native"), m(wantRuntimeSharePath+"/pulse")), sysUsesRuntime(nil), nil, insertsOps(afterSpRuntimeOp(nil)), []stub.Call{ + // this op configures the container state and does not make calls during toContainer + }, &container.Params{ + Ops: new(container.Ops). + Bind(m(wantRuntimeSharePath+"/pulse"), m("/run/user/1000/pulse/native"), 0). + Place(m("/.hakurei/pulse-cookie"), sampleCookie), + }, paramsWantEnv(config, map[string]string{ + "PULSE_SERVER": "unix:/run/user/1000/pulse/native", + "PULSE_COOKIE": "/.hakurei/pulse-cookie", + }, nil), nil}, + + {"success", func(bool, bool) outcomeOp { + return new(spPulseOp) + }, hst.Template, nil, []stub.Call{ + call("stat", stub.ExpectArgs{wantRuntimePath + "/pulse"}, (*stubFi)(nil), nil), + call("stat", stub.ExpectArgs{wantRuntimePath + "/pulse/native"}, &stubFi{mode: 0666}, nil), + call("lookupEnv", stub.ExpectArgs{"PULSE_COOKIE"}, nil, nil), + call("lookupEnv", stub.ExpectArgs{"HOME"}, nil, nil), + call("lookupEnv", stub.ExpectArgs{"XDG_CONFIG_HOME"}, nil, nil), + call("verbose", stub.ExpectArgs{[]any{"cannot locate PulseAudio cookie (tried $PULSE_COOKIE, $XDG_CONFIG_HOME/pulse/cookie, $HOME/.pulse-cookie)"}}, nil, nil), + }, newI(). + // state.ensureRuntimeDir + Ensure(m(wantRunDirPath), 0700). + UpdatePermType(system.User, m(wantRunDirPath), acl.Execute). + Ensure(m(wantRuntimePath), 0700). + UpdatePermType(system.User, m(wantRuntimePath), acl.Execute). + // state.runtime + Ephemeral(system.Process, m(wantRuntimeSharePath), 0700). + UpdatePerm(m(wantRuntimeSharePath), acl.Execute). + // toSystem + Link(m(wantRuntimePath+"/pulse/native"), m(wantRuntimeSharePath+"/pulse")), sysUsesRuntime(nil), nil, insertsOps(afterSpRuntimeOp(nil)), []stub.Call{ + // this op configures the container state and does not make calls during toContainer + }, &container.Params{ + Ops: new(container.Ops). + Bind(m(wantRuntimeSharePath+"/pulse"), m("/run/user/1000/pulse/native"), 0), + }, paramsWantEnv(config, map[string]string{ + "PULSE_SERVER": "unix:/run/user/1000/pulse/native", + }, nil), nil}, + }) +} + +func TestDiscoverPulseCookie(t *testing.T) { + t.Parallel() + + fCheckPathname := func(k *kstub) error { + a, err := discoverPulseCookie(k) + k.Verbose(a) + return err + } + + checkSimple(t, "discoverPulseCookie", []simpleTestCase{ + {"override notAbs", fCheckPathname, stub.Expect{Calls: []stub.Call{ + call("lookupEnv", stub.ExpectArgs{"PULSE_COOKIE"}, "proc/nonexistent/pulse-cookie", nil), + call("verbose", stub.ExpectArgs{[]any{(*check.Absolute)(nil)}}, nil, nil), + }}, &hst.AppError{ + Step: "locate PulseAudio cookie", + Err: &check.AbsoluteError{Pathname: "proc/nonexistent/pulse-cookie"}, + }}, + + {"success override", fCheckPathname, stub.Expect{Calls: []stub.Call{ + call("lookupEnv", stub.ExpectArgs{"PULSE_COOKIE"}, "/proc/nonexistent/pulse-cookie", nil), + call("verbose", stub.ExpectArgs{[]any{m("/proc/nonexistent/pulse-cookie")}}, nil, nil), + }}, nil}, + + {"home notAbs", fCheckPathname, stub.Expect{Calls: []stub.Call{ + call("lookupEnv", stub.ExpectArgs{"PULSE_COOKIE"}, nil, nil), + call("lookupEnv", stub.ExpectArgs{"HOME"}, "proc/nonexistent/home", nil), + call("verbose", stub.ExpectArgs{[]any{(*check.Absolute)(nil)}}, nil, nil), + }}, &hst.AppError{ + Step: "locate PulseAudio cookie", + Err: &check.AbsoluteError{Pathname: "proc/nonexistent/home"}, + }}, + + {"home stat", fCheckPathname, stub.Expect{Calls: []stub.Call{ + call("lookupEnv", stub.ExpectArgs{"PULSE_COOKIE"}, nil, nil), + call("lookupEnv", stub.ExpectArgs{"HOME"}, "/proc/nonexistent/home", nil), + call("stat", stub.ExpectArgs{"/proc/nonexistent/home/.pulse-cookie"}, (*stubFi)(nil), stub.UniqueError(1)), + call("verbose", stub.ExpectArgs{[]any{(*check.Absolute)(nil)}}, nil, nil), + }}, &hst.AppError{ + Step: "access PulseAudio cookie", + Err: stub.UniqueError(1), + }}, + + {"home nonexistent", fCheckPathname, stub.Expect{Calls: []stub.Call{ + call("lookupEnv", stub.ExpectArgs{"PULSE_COOKIE"}, nil, nil), + call("lookupEnv", stub.ExpectArgs{"HOME"}, "/proc/nonexistent/home", nil), + call("stat", stub.ExpectArgs{"/proc/nonexistent/home/.pulse-cookie"}, (*stubFi)(nil), os.ErrNotExist), + call("lookupEnv", stub.ExpectArgs{"XDG_CONFIG_HOME"}, nil, nil), + call("verbose", stub.ExpectArgs{[]any{(*check.Absolute)(nil)}}, nil, nil), + }}, nil}, + + {"success home", fCheckPathname, stub.Expect{Calls: []stub.Call{ + call("lookupEnv", stub.ExpectArgs{"PULSE_COOKIE"}, nil, nil), + call("lookupEnv", stub.ExpectArgs{"HOME"}, "/proc/nonexistent/home", nil), + call("stat", stub.ExpectArgs{"/proc/nonexistent/home/.pulse-cookie"}, &stubFi{}, nil), + call("verbose", stub.ExpectArgs{[]any{m("/proc/nonexistent/home/.pulse-cookie")}}, nil, nil), + }}, nil}, + + {"xdg notAbs", fCheckPathname, stub.Expect{Calls: []stub.Call{ + call("lookupEnv", stub.ExpectArgs{"PULSE_COOKIE"}, nil, nil), + call("lookupEnv", stub.ExpectArgs{"HOME"}, nil, nil), + call("lookupEnv", stub.ExpectArgs{"XDG_CONFIG_HOME"}, "proc/nonexistent/xdg", nil), + call("verbose", stub.ExpectArgs{[]any{(*check.Absolute)(nil)}}, nil, nil), + }}, &hst.AppError{ + Step: "locate PulseAudio cookie", + Err: &check.AbsoluteError{Pathname: "proc/nonexistent/xdg"}, + }}, + + {"xdg stat", fCheckPathname, stub.Expect{Calls: []stub.Call{ + call("lookupEnv", stub.ExpectArgs{"PULSE_COOKIE"}, nil, nil), + call("lookupEnv", stub.ExpectArgs{"HOME"}, nil, nil), + call("lookupEnv", stub.ExpectArgs{"XDG_CONFIG_HOME"}, "/proc/nonexistent/xdg", nil), + call("stat", stub.ExpectArgs{"/proc/nonexistent/xdg/pulse/cookie"}, (*stubFi)(nil), stub.UniqueError(0)), + call("verbose", stub.ExpectArgs{[]any{(*check.Absolute)(nil)}}, nil, nil), + }}, &hst.AppError{ + Step: "access PulseAudio cookie", + Err: stub.UniqueError(0), + }}, + + {"xdg dir", fCheckPathname, stub.Expect{Calls: []stub.Call{ + call("lookupEnv", stub.ExpectArgs{"PULSE_COOKIE"}, nil, nil), + call("lookupEnv", stub.ExpectArgs{"HOME"}, nil, nil), + call("lookupEnv", stub.ExpectArgs{"XDG_CONFIG_HOME"}, "/proc/nonexistent/xdg", nil), + call("stat", stub.ExpectArgs{"/proc/nonexistent/xdg/pulse/cookie"}, &stubFi{isDir: true}, nil), + call("verbose", stub.ExpectArgs{[]any{(*check.Absolute)(nil)}}, nil, nil), + }}, nil}, + + {"success home dir xdg nonexistent", fCheckPathname, stub.Expect{Calls: []stub.Call{ + call("lookupEnv", stub.ExpectArgs{"PULSE_COOKIE"}, nil, nil), + call("lookupEnv", stub.ExpectArgs{"HOME"}, "/proc/nonexistent/home", nil), + call("stat", stub.ExpectArgs{"/proc/nonexistent/home/.pulse-cookie"}, &stubFi{isDir: true}, nil), + call("lookupEnv", stub.ExpectArgs{"XDG_CONFIG_HOME"}, "/proc/nonexistent/xdg", nil), + call("stat", stub.ExpectArgs{"/proc/nonexistent/xdg/pulse/cookie"}, (*stubFi)(nil), os.ErrNotExist), + call("verbose", stub.ExpectArgs{[]any{(*check.Absolute)(nil)}}, nil, nil), + }}, nil}, + + {"success home nonexistent xdg", fCheckPathname, stub.Expect{Calls: []stub.Call{ + call("lookupEnv", stub.ExpectArgs{"PULSE_COOKIE"}, nil, nil), + call("lookupEnv", stub.ExpectArgs{"HOME"}, "/proc/nonexistent/home", nil), + call("stat", stub.ExpectArgs{"/proc/nonexistent/home/.pulse-cookie"}, (*stubFi)(nil), os.ErrNotExist), + call("lookupEnv", stub.ExpectArgs{"XDG_CONFIG_HOME"}, "/proc/nonexistent/xdg", nil), + call("stat", stub.ExpectArgs{"/proc/nonexistent/xdg/pulse/cookie"}, &stubFi{}, nil), + call("verbose", stub.ExpectArgs{[]any{m("/proc/nonexistent/xdg/pulse/cookie")}}, nil, nil), + }}, nil}, + + {"success empty environ", fCheckPathname, stub.Expect{Calls: []stub.Call{ + call("lookupEnv", stub.ExpectArgs{"PULSE_COOKIE"}, nil, nil), + call("lookupEnv", stub.ExpectArgs{"HOME"}, nil, nil), + call("lookupEnv", stub.ExpectArgs{"XDG_CONFIG_HOME"}, nil, nil), + call("verbose", stub.ExpectArgs{[]any{(*check.Absolute)(nil)}}, nil, nil), + }}, nil}, + }) +} + +func TestLoadFile(t *testing.T) { + t.Parallel() + + fAfterWriteExact := func(k *kstub) error { + buf := make([]byte, 1<<8) + n, err := loadFile(k, k, + "simulated PulseAudio cookie", + "/home/ophestra/xdg/config/pulse/cookie", + buf) + k.Verbose(buf[:n]) + return err + } + + fAfterWrite := func(k *kstub) error { + buf := make([]byte, 1<<8+0xfd) + n, err := loadFile(k, k, + "simulated PulseAudio cookie", + "/home/ophestra/xdg/config/pulse/cookie", + buf) + k.Verbose(buf[:n]) + return err + } + + fBeforeWrite := func(k *kstub) error { + buf := make([]byte, 1<<8+0xfd) + n, err := loadFile(k, k, + "simulated PulseAudio cookie", + "/home/ophestra/xdg/config/pulse/cookie", + buf) + k.Verbose(n) + + if !bytes.Equal(buf, make([]byte, len(buf))) { + t.Errorf("loadFile: buf = %#v", buf) + } + return err + } + + sampleCookie := bytes.Repeat([]byte{0xfc}, pulseCookieSizeMax) + checkSimple(t, "loadFile", []simpleTestCase{ + {"buf", func(k *kstub) error { + n, err := loadFile(k, k, + "simulated PulseAudio cookie", + "/home/ophestra/xdg/config/pulse/cookie", + nil) + k.Verbose(n) + return err + }, stub.Expect{Calls: []stub.Call{ + call("verbose", stub.ExpectArgs{[]any{-1}}, nil, nil), + }}, errors.New("invalid buffer")}, + + {"stat", fBeforeWrite, stub.Expect{Calls: []stub.Call{ + call("stat", stub.ExpectArgs{"/home/ophestra/xdg/config/pulse/cookie"}, (*stubFi)(nil), stub.UniqueError(3)), + call("verbose", stub.ExpectArgs{[]any{-1}}, nil, nil), + }}, &hst.AppError{ + Step: "access simulated PulseAudio cookie", + Err: stub.UniqueError(3), + }}, + + {"dir", fBeforeWrite, stub.Expect{Calls: []stub.Call{ + call("stat", stub.ExpectArgs{"/home/ophestra/xdg/config/pulse/cookie"}, &stubFi{isDir: true}, nil), + call("verbose", stub.ExpectArgs{[]any{-1}}, nil, nil), + }}, &hst.AppError{ + Step: "read simulated PulseAudio cookie", + Err: &os.PathError{Op: "stat", Path: "/home/ophestra/xdg/config/pulse/cookie", Err: syscall.EISDIR}, + }}, + + {"oob", fBeforeWrite, stub.Expect{Calls: []stub.Call{ + call("stat", stub.ExpectArgs{"/home/ophestra/xdg/config/pulse/cookie"}, &stubFi{size: 1<<8 + 0xff}, nil), + call("verbose", stub.ExpectArgs{[]any{-1}}, nil, nil), + }}, &hst.AppError{ + Step: "finalise", + Err: &os.PathError{Op: "stat", Path: "/home/ophestra/xdg/config/pulse/cookie", Err: syscall.ENOMEM}, + Msg: `simulated PulseAudio cookie at "/home/ophestra/xdg/config/pulse/cookie" exceeds expected size`, + }}, + + {"open", fBeforeWrite, stub.Expect{Calls: []stub.Call{ + call("stat", stub.ExpectArgs{"/home/ophestra/xdg/config/pulse/cookie"}, &stubFi{size: 1 << 8}, nil), + call("verbosef", stub.ExpectArgs{"%s at %q is %d bytes shorter than expected", []any{"simulated PulseAudio cookie", "/home/ophestra/xdg/config/pulse/cookie", int64(0xfd)}}, nil, nil), + call("open", stub.ExpectArgs{"/home/ophestra/xdg/config/pulse/cookie"}, (*stubOsFile)(nil), stub.UniqueError(2)), + call("verbose", stub.ExpectArgs{[]any{-1}}, nil, nil), + }}, &hst.AppError{Step: "open simulated PulseAudio cookie", Err: stub.UniqueError(2)}}, + + {"read", fBeforeWrite, stub.Expect{Calls: []stub.Call{ + call("stat", stub.ExpectArgs{"/home/ophestra/xdg/config/pulse/cookie"}, &stubFi{size: 1 << 8}, nil), + call("verbosef", stub.ExpectArgs{"%s at %q is %d bytes shorter than expected", []any{"simulated PulseAudio cookie", "/home/ophestra/xdg/config/pulse/cookie", int64(0xfd)}}, nil, nil), + call("open", stub.ExpectArgs{"/home/ophestra/xdg/config/pulse/cookie"}, &stubOsFile{Reader: errorReader{stub.UniqueError(1)}}, nil), + call("verbose", stub.ExpectArgs{[]any{-1}}, nil, nil), + }}, &hst.AppError{Step: "read simulated PulseAudio cookie", Err: stub.UniqueError(1)}}, + + {"short close", fAfterWrite, stub.Expect{Calls: []stub.Call{ + call("stat", stub.ExpectArgs{"/home/ophestra/xdg/config/pulse/cookie"}, &stubFi{size: 1 << 8}, nil), + call("verbosef", stub.ExpectArgs{"%s at %q is %d bytes shorter than expected", []any{"simulated PulseAudio cookie", "/home/ophestra/xdg/config/pulse/cookie", int64(0xfd)}}, nil, nil), + call("open", stub.ExpectArgs{"/home/ophestra/xdg/config/pulse/cookie"}, &stubOsFile{closeErr: stub.UniqueError(0), Reader: bytes.NewReader(sampleCookie)}, nil), + call("verbose", stub.ExpectArgs{[]any{sampleCookie}}, nil, nil), + }}, &hst.AppError{Step: "close simulated PulseAudio cookie", Err: stub.UniqueError(0)}}, + + {"success", fAfterWriteExact, stub.Expect{Calls: []stub.Call{ + call("stat", stub.ExpectArgs{"/home/ophestra/xdg/config/pulse/cookie"}, &stubFi{size: 1 << 8}, nil), + call("verbosef", stub.ExpectArgs{"loading %d bytes from %q", []any{1 << 8, "/home/ophestra/xdg/config/pulse/cookie"}}, nil, nil), + call("open", stub.ExpectArgs{"/home/ophestra/xdg/config/pulse/cookie"}, &stubOsFile{Reader: bytes.NewReader(sampleCookie)}, nil), + call("verbose", stub.ExpectArgs{[]any{sampleCookie}}, nil, nil), + }}, nil}, + }) +} diff --git a/internal/outcome/spruntime.go b/internal/outcome/spruntime.go new file mode 100644 index 00000000..b458826a --- /dev/null +++ b/internal/outcome/spruntime.go @@ -0,0 +1,125 @@ +package outcome + +import ( + "encoding/gob" + + "hakurei.app/container/check" + "hakurei.app/container/comp" + "hakurei.app/container/fhs" + "hakurei.app/hst" + "hakurei.app/system" + "hakurei.app/system/acl" +) + +const ( + /* + Path to a user-private user-writable directory that is bound + to the user login time on the machine. It is automatically + created the first time a user logs in and removed on the + user's final logout. If a user logs in twice at the same time, + both sessions will see the same $XDG_RUNTIME_DIR and the same + contents. If a user logs in once, then logs out again, and + logs in again, the directory contents will have been lost in + between, but applications should not rely on this behavior and + must be able to deal with stale files. To store + session-private data in this directory, the user should + include the value of $XDG_SESSION_ID in the filename. This + directory shall be used for runtime file system objects such + as AF_UNIX sockets, FIFOs, PID files and similar. It is + guaranteed that this directory is local and offers the + greatest possible file system feature set the operating system + provides. For further details, see the XDG Base Directory + Specification[3]. $XDG_RUNTIME_DIR is not set if the current + user is not the original user of the session. + */ + envXDGRuntimeDir = "XDG_RUNTIME_DIR" + + /* + The session class. This may be used instead of class= on the + module parameter line, and is usually preferred. + */ + envXDGSessionClass = "XDG_SESSION_CLASS" + + /* + A regular interactive user session. This is the default class + for sessions for which a TTY or X display is known at session + registration time. + */ + xdgSessionClassUser = "user" + + /* + The session type. This may be used instead of type= on the + module parameter line, and is usually preferred. + + One of "unspecified", "tty", "x11", "wayland", "mir", or "web". + */ + envXDGSessionType = "XDG_SESSION_TYPE" +) + +func init() { gob.Register(new(spRuntimeOp)) } + +const ( + sessionTypeUnspec = iota + sessionTypeTTY + sessionTypeX11 + sessionTypeWayland +) + +// spRuntimeOp sets up XDG_RUNTIME_DIR inside the container. +type spRuntimeOp struct { + // SessionType determines the value of envXDGSessionType. Populated during toSystem. + SessionType uintptr +} + +func (s *spRuntimeOp) toSystem(state *outcomeStateSys) error { + if state.Container.Flags&hst.FShareRuntime != 0 { + runtimeDir, runtimeDirInst := s.commonPaths(state.outcomeState) + state.sys.Ensure(runtimeDir, 0700) + state.sys.UpdatePermType(system.User, runtimeDir, acl.Execute) + state.sys.Ensure(runtimeDirInst, 0700) + state.sys.UpdatePermType(system.User, runtimeDirInst, acl.Read, acl.Write, acl.Execute) + } + + if state.et&hst.EWayland != 0 { + s.SessionType = sessionTypeWayland + } else if state.et&hst.EX11 != 0 { + s.SessionType = sessionTypeX11 + } else { + s.SessionType = sessionTypeTTY + } + + return nil +} + +func (s *spRuntimeOp) toContainer(state *outcomeStateParams) error { + state.runtimeDir = fhs.AbsRunUser.Append(state.mapuid.String()) + state.env[envXDGRuntimeDir] = state.runtimeDir.String() + state.env[envXDGSessionClass] = xdgSessionClassUser + + switch s.SessionType { + case sessionTypeUnspec: + state.env[envXDGSessionType] = "unspecified" + case sessionTypeTTY: + state.env[envXDGSessionType] = "tty" + case sessionTypeX11: + state.env[envXDGSessionType] = "x11" + case sessionTypeWayland: + state.env[envXDGSessionType] = "wayland" + + } + + state.params.Tmpfs(fhs.AbsRunUser, 1<<12, 0755) + if state.Container.Flags&hst.FShareRuntime != 0 { + _, runtimeDirInst := s.commonPaths(state.outcomeState) + state.params.Bind(runtimeDirInst, state.runtimeDir, comp.BindWritable) + } else { + state.params.Mkdir(state.runtimeDir, 0700) + } + return nil +} + +func (s *spRuntimeOp) commonPaths(state *outcomeState) (runtimeDir, runtimeDirInst *check.Absolute) { + runtimeDir = state.sc.SharePath.Append("runtime") + runtimeDirInst = runtimeDir.Append(state.identity.String()) + return +} diff --git a/internal/outcome/spruntime_test.go b/internal/outcome/spruntime_test.go new file mode 100644 index 00000000..00056eda --- /dev/null +++ b/internal/outcome/spruntime_test.go @@ -0,0 +1,128 @@ +package outcome + +import ( + "testing" + + "hakurei.app/container" + "hakurei.app/container/comp" + "hakurei.app/container/fhs" + "hakurei.app/container/stub" + "hakurei.app/hst" + "hakurei.app/system" + "hakurei.app/system/acl" +) + +func TestSpRuntimeOp(t *testing.T) { + t.Parallel() + config := hst.Template() + + checkOpBehaviour(t, []opBehaviourTestCase{ + {"success zero", func(isShim bool, clearUnexported bool) outcomeOp { + if !isShim { + return new(spRuntimeOp) + } + op := &spRuntimeOp{sessionTypeTTY} + if clearUnexported { + op.SessionType = sessionTypeUnspec + } + return op + }, func() *hst.Config { + c := hst.Template() + *c.Enablements = 0 + return c + }, nil, []stub.Call{ + // this op configures the system state and does not make calls during toSystem + }, newI(). + Ensure(m("/proc/nonexistent/tmp/hakurei.0/runtime"), 0700). + UpdatePermType(system.User, m("/proc/nonexistent/tmp/hakurei.0/runtime"), acl.Execute). + Ensure(m("/proc/nonexistent/tmp/hakurei.0/runtime/9"), 0700). + UpdatePermType(system.User, m("/proc/nonexistent/tmp/hakurei.0/runtime/9"), acl.Read, acl.Write, acl.Execute), nil, nil, insertsOps(nil), []stub.Call{ + // this op configures the container state and does not make calls during toContainer + }, &container.Params{ + Ops: new(container.Ops). + Tmpfs(fhs.AbsRunUser, 1<<12, 0755). + Bind(m("/proc/nonexistent/tmp/hakurei.0/runtime/9"), m("/run/user/1000"), comp.BindWritable), + }, paramsWantEnv(config, map[string]string{ + "XDG_RUNTIME_DIR": "/run/user/1000", + "XDG_SESSION_CLASS": "user", + "XDG_SESSION_TYPE": "unspecified", + }, nil), nil}, + + {"success tty", func(isShim, _ bool) outcomeOp { + if !isShim { + return new(spRuntimeOp) + } + return &spRuntimeOp{sessionTypeTTY} + }, func() *hst.Config { + c := hst.Template() + *c.Enablements = 0 + return c + }, nil, []stub.Call{ + // this op configures the system state and does not make calls during toSystem + }, newI(). + Ensure(m("/proc/nonexistent/tmp/hakurei.0/runtime"), 0700). + UpdatePermType(system.User, m("/proc/nonexistent/tmp/hakurei.0/runtime"), acl.Execute). + Ensure(m("/proc/nonexistent/tmp/hakurei.0/runtime/9"), 0700). + UpdatePermType(system.User, m("/proc/nonexistent/tmp/hakurei.0/runtime/9"), acl.Read, acl.Write, acl.Execute), nil, nil, insertsOps(nil), []stub.Call{ + // this op configures the container state and does not make calls during toContainer + }, &container.Params{ + Ops: new(container.Ops). + Tmpfs(fhs.AbsRunUser, 1<<12, 0755). + Bind(m("/proc/nonexistent/tmp/hakurei.0/runtime/9"), m("/run/user/1000"), comp.BindWritable), + }, paramsWantEnv(config, map[string]string{ + "XDG_RUNTIME_DIR": "/run/user/1000", + "XDG_SESSION_CLASS": "user", + "XDG_SESSION_TYPE": "tty", + }, nil), nil}, + + {"success x11", func(isShim, _ bool) outcomeOp { + if !isShim { + return new(spRuntimeOp) + } + return &spRuntimeOp{sessionTypeX11} + }, func() *hst.Config { + c := hst.Template() + *c.Enablements = hst.Enablements(hst.EX11) + return c + }, nil, []stub.Call{ + // this op configures the system state and does not make calls during toSystem + }, newI(). + Ensure(m("/proc/nonexistent/tmp/hakurei.0/runtime"), 0700). + UpdatePermType(system.User, m("/proc/nonexistent/tmp/hakurei.0/runtime"), acl.Execute). + Ensure(m("/proc/nonexistent/tmp/hakurei.0/runtime/9"), 0700). + UpdatePermType(system.User, m("/proc/nonexistent/tmp/hakurei.0/runtime/9"), acl.Read, acl.Write, acl.Execute), nil, nil, insertsOps(nil), []stub.Call{ + // this op configures the container state and does not make calls during toContainer + }, &container.Params{ + Ops: new(container.Ops). + Tmpfs(fhs.AbsRunUser, 1<<12, 0755). + Bind(m("/proc/nonexistent/tmp/hakurei.0/runtime/9"), m("/run/user/1000"), comp.BindWritable), + }, paramsWantEnv(config, map[string]string{ + "XDG_RUNTIME_DIR": "/run/user/1000", + "XDG_SESSION_CLASS": "user", + "XDG_SESSION_TYPE": "x11", + }, nil), nil}, + + {"success", func(isShim, _ bool) outcomeOp { + if !isShim { + return new(spRuntimeOp) + } + return &spRuntimeOp{sessionTypeWayland} + }, hst.Template, nil, []stub.Call{ + // this op configures the system state and does not make calls during toSystem + }, newI(). + Ensure(m("/proc/nonexistent/tmp/hakurei.0/runtime"), 0700). + UpdatePermType(system.User, m("/proc/nonexistent/tmp/hakurei.0/runtime"), acl.Execute). + Ensure(m("/proc/nonexistent/tmp/hakurei.0/runtime/9"), 0700). + UpdatePermType(system.User, m("/proc/nonexistent/tmp/hakurei.0/runtime/9"), acl.Read, acl.Write, acl.Execute), nil, nil, insertsOps(nil), []stub.Call{ + // this op configures the container state and does not make calls during toContainer + }, &container.Params{ + Ops: new(container.Ops). + Tmpfs(fhs.AbsRunUser, 1<<12, 0755). + Bind(m("/proc/nonexistent/tmp/hakurei.0/runtime/9"), m("/run/user/1000"), comp.BindWritable), + }, paramsWantEnv(config, map[string]string{ + "XDG_RUNTIME_DIR": "/run/user/1000", + "XDG_SESSION_CLASS": "user", + "XDG_SESSION_TYPE": "wayland", + }, nil), nil}, + }) +} diff --git a/internal/outcome/sptmpdir.go b/internal/outcome/sptmpdir.go new file mode 100644 index 00000000..4776dad7 --- /dev/null +++ b/internal/outcome/sptmpdir.go @@ -0,0 +1,44 @@ +package outcome + +import ( + "encoding/gob" + + "hakurei.app/container/check" + "hakurei.app/container/comp" + "hakurei.app/container/fhs" + "hakurei.app/hst" + "hakurei.app/system" + "hakurei.app/system/acl" +) + +func init() { gob.Register(spTmpdirOp{}) } + +// spTmpdirOp sets up TMPDIR inside the container. +type spTmpdirOp struct{} + +func (s spTmpdirOp) toSystem(state *outcomeStateSys) error { + if state.Container.Flags&hst.FShareTmpdir != 0 { + tmpdir, tmpdirInst := s.commonPaths(state.outcomeState) + state.sys.Ensure(tmpdir, 0700) + state.sys.UpdatePermType(system.User, tmpdir, acl.Execute) + state.sys.Ensure(tmpdirInst, 01700) + state.sys.UpdatePermType(system.User, tmpdirInst, acl.Read, acl.Write, acl.Execute) + } + return nil +} + +func (s spTmpdirOp) toContainer(state *outcomeStateParams) error { + if state.Container.Flags&hst.FShareTmpdir != 0 { + _, tmpdirInst := s.commonPaths(state.outcomeState) + state.params.Bind(tmpdirInst, fhs.AbsTmp, comp.BindWritable) + } else { + state.params.Tmpfs(fhs.AbsTmp, 0, 01777) + } + return nil +} + +func (s spTmpdirOp) commonPaths(state *outcomeState) (tmpdir, tmpdirInst *check.Absolute) { + tmpdir = state.sc.SharePath.Append("tmpdir") + tmpdirInst = tmpdir.Append(state.identity.String()) + return +} diff --git a/internal/outcome/sptmpdir_test.go b/internal/outcome/sptmpdir_test.go new file mode 100644 index 00000000..afa641f6 --- /dev/null +++ b/internal/outcome/sptmpdir_test.go @@ -0,0 +1,34 @@ +package outcome + +import ( + "testing" + + "hakurei.app/container" + "hakurei.app/container/comp" + "hakurei.app/container/fhs" + "hakurei.app/container/stub" + "hakurei.app/hst" + "hakurei.app/system" + "hakurei.app/system/acl" +) + +func TestSpTmpdirOp(t *testing.T) { + t.Parallel() + + checkOpBehaviour(t, []opBehaviourTestCase{ + {"success", func(bool, bool) outcomeOp { + return spTmpdirOp{} + }, hst.Template, nil, []stub.Call{ + // this op configures the system state and does not make calls during toSystem + }, newI(). + Ensure(m("/proc/nonexistent/tmp/hakurei.0/tmpdir"), 0700). + UpdatePermType(system.User, m("/proc/nonexistent/tmp/hakurei.0/tmpdir"), acl.Execute). + Ensure(m("/proc/nonexistent/tmp/hakurei.0/tmpdir/9"), 01700). + UpdatePermType(system.User, m("/proc/nonexistent/tmp/hakurei.0/tmpdir/9"), acl.Read, acl.Write, acl.Execute), nil, nil, insertsOps(nil), []stub.Call{ + // this op configures the container state and does not make calls during toContainer + }, &container.Params{ + Ops: new(container.Ops). + Bind(m("/proc/nonexistent/tmp/hakurei.0/tmpdir/9"), fhs.AbsTmp, comp.BindWritable), + }, nil, nil}, + }) +} diff --git a/internal/outcome/spwayland.go b/internal/outcome/spwayland.go new file mode 100644 index 00000000..54119de6 --- /dev/null +++ b/internal/outcome/spwayland.go @@ -0,0 +1,63 @@ +package outcome + +import ( + "encoding/gob" + + "hakurei.app/container/check" + "hakurei.app/hst" + "hakurei.app/system/acl" + "hakurei.app/system/wayland" +) + +func init() { gob.Register(new(spWaylandOp)) } + +// spWaylandOp exports the Wayland display server to the container. +// Runs after spRuntimeOp. +type spWaylandOp struct { + // Path to host wayland socket. Populated during toSystem if DirectWayland is true. + SocketPath *check.Absolute +} + +func (s *spWaylandOp) toSystem(state *outcomeStateSys) error { + if state.et&hst.EWayland == 0 { + return errNotEnabled + } + + // outer wayland socket (usually `/run/user/%d/wayland-%d`) + var socketPath *check.Absolute + if name, ok := state.k.lookupEnv(wayland.WaylandDisplay); !ok { + state.msg.Verbose(wayland.WaylandDisplay + " is not set, assuming " + wayland.FallbackName) + socketPath = state.sc.RuntimePath.Append(wayland.FallbackName) + } else if a, err := check.NewAbs(name); err != nil { + socketPath = state.sc.RuntimePath.Append(name) + } else { + socketPath = a + } + + if !state.directWayland { // set up security-context-v1 + appId := state.appId + if appId == "" { + // use instance ID in case app id is not set + appId = "app.hakurei." + state.id.String() + } + // downstream socket paths + state.sys.Wayland(state.instance().Append("wayland"), socketPath, appId, state.id.String()) + } else { // bind mount wayland socket (insecure) + state.msg.Verbose("direct wayland access, PROCEED WITH CAUTION") + state.ensureRuntimeDir() + s.SocketPath = socketPath + state.sys.UpdatePermType(hst.EWayland, socketPath, acl.Read, acl.Write, acl.Execute) + } + return nil +} + +func (s *spWaylandOp) toContainer(state *outcomeStateParams) error { + innerPath := state.runtimeDir.Append(wayland.FallbackName) + state.env[wayland.WaylandDisplay] = wayland.FallbackName + if s.SocketPath == nil { + state.params.Bind(state.instancePath().Append("wayland"), innerPath, 0) + } else { + state.params.Bind(s.SocketPath, innerPath, 0) + } + return nil +} diff --git a/internal/outcome/spwayland_test.go b/internal/outcome/spwayland_test.go new file mode 100644 index 00000000..5cee8de7 --- /dev/null +++ b/internal/outcome/spwayland_test.go @@ -0,0 +1,104 @@ +package outcome + +import ( + "testing" + + "hakurei.app/container" + "hakurei.app/container/stub" + "hakurei.app/hst" + "hakurei.app/system" + "hakurei.app/system/acl" + "hakurei.app/system/wayland" +) + +func TestSpWaylandOp(t *testing.T) { + t.Parallel() + config := hst.Template() + + checkOpBehaviour(t, []opBehaviourTestCase{ + {"not enabled", func(bool, bool) outcomeOp { + return new(spWaylandOp) + }, func() *hst.Config { + c := hst.Template() + *c.Enablements = 0 + return c + }, nil, nil, nil, nil, errNotEnabled, nil, nil, nil, nil, nil}, + + {"success notAbs defaultAppId", func(bool, bool) outcomeOp { + return new(spWaylandOp) + }, func() *hst.Config { + c := hst.Template() + c.ID = "" + return c + }, nil, []stub.Call{ + call("lookupEnv", stub.ExpectArgs{"WAYLAND_DISPLAY"}, "wayland-1", nil), + }, newI(). + // state.instance + Ephemeral(system.Process, m(wantInstancePrefix), 0711). + // toSystem + Wayland( + m(wantInstancePrefix+"/wayland"), + m(wantRuntimePath+"/wayland-1"), + "app.hakurei."+wantAutoEtcPrefix, + wantAutoEtcPrefix, + ), sysUsesInstance(nil), nil, insertsOps(afterSpRuntimeOp(nil)), []stub.Call{ + // this op configures the container state and does not make calls during toContainer + }, &container.Params{ + Ops: new(container.Ops). + Bind(m(wantInstancePrefix+"/wayland"), m("/run/user/1000/wayland-0"), 0), + }, paramsWantEnv(config, map[string]string{ + wayland.WaylandDisplay: wayland.FallbackName, + }, nil), nil}, + + {"success direct", func(isShim, _ bool) outcomeOp { + if !isShim { + return new(spWaylandOp) + } + return &spWaylandOp{SocketPath: m("/proc/nonexistent/wayland")} + }, func() *hst.Config { + c := hst.Template() + c.DirectWayland = true + return c + }, nil, []stub.Call{ + call("lookupEnv", stub.ExpectArgs{"WAYLAND_DISPLAY"}, "/proc/nonexistent/wayland", nil), + call("verbose", stub.ExpectArgs{[]any{"direct wayland access, PROCEED WITH CAUTION"}}, nil, nil), + }, newI(). + // state.ensureRuntimeDir + Ensure(m(wantRunDirPath), 0700). + UpdatePermType(system.User, m(wantRunDirPath), acl.Execute). + Ensure(m(wantRuntimePath), 0700). + UpdatePermType(system.User, m(wantRuntimePath), acl.Execute). + // toSystem + UpdatePermType(hst.EWayland, m("/proc/nonexistent/wayland"), acl.Read, acl.Write, acl.Execute), nil, nil, insertsOps(afterSpRuntimeOp(nil)), []stub.Call{ + // this op configures the container state and does not make calls during toContainer + }, &container.Params{ + Ops: new(container.Ops). + Bind(m("/proc/nonexistent/wayland"), m("/run/user/1000/wayland-0"), 0), + }, paramsWantEnv(config, map[string]string{ + wayland.WaylandDisplay: wayland.FallbackName, + }, nil), nil}, + + {"success", func(bool, bool) outcomeOp { + return new(spWaylandOp) + }, hst.Template, nil, []stub.Call{ + call("lookupEnv", stub.ExpectArgs{"WAYLAND_DISPLAY"}, nil, nil), + call("verbose", stub.ExpectArgs{[]any{"WAYLAND_DISPLAY is not set, assuming wayland-0"}}, nil, nil), + }, newI(). + // state.instance + Ephemeral(system.Process, m(wantInstancePrefix), 0711). + // toSystem + Wayland( + m(wantInstancePrefix+"/wayland"), + m(wantRuntimePath+"/"+wayland.FallbackName), + "org.chromium.Chromium", + wantAutoEtcPrefix, + ), sysUsesInstance(nil), nil, insertsOps(afterSpRuntimeOp(nil)), []stub.Call{ + // this op configures the container state and does not make calls during toContainer + }, &container.Params{ + Ops: new(container.Ops). + Bind(m(wantInstancePrefix+"/wayland"), m("/run/user/1000/wayland-0"), 0), + }, paramsWantEnv(config, map[string]string{ + wayland.WaylandDisplay: wayland.FallbackName, + }, nil), nil}, + }) +} diff --git a/internal/outcome/spx11.go b/internal/outcome/spx11.go new file mode 100644 index 00000000..506fa9ac --- /dev/null +++ b/internal/outcome/spx11.go @@ -0,0 +1,71 @@ +package outcome + +import ( + "encoding/gob" + "errors" + "fmt" + "io/fs" + "strconv" + "strings" + + "hakurei.app/container/check" + "hakurei.app/container/fhs" + "hakurei.app/hst" + "hakurei.app/system/acl" +) + +var absX11SocketDir = fhs.AbsTmp.Append(".X11-unix") + +func init() { gob.Register(new(spX11Op)) } + +// spX11Op exports the X11 display server to the container. +type spX11Op struct { + // Value of $DISPLAY, stored during toSystem + Display string +} + +func (s *spX11Op) toSystem(state *outcomeStateSys) error { + if state.et&hst.EX11 == 0 { + return errNotEnabled + } + + if d, ok := state.k.lookupEnv("DISPLAY"); !ok { + return newWithMessage("DISPLAY is not set") + } else { + s.Display = d + } + + // the socket file at `/tmp/.X11-unix/X%d` is typically owned by the priv user + // and not accessible by the target user + var socketPath *check.Absolute + if len(s.Display) > 1 && s.Display[0] == ':' { // `:%d` + if n, err := strconv.Atoi(s.Display[1:]); err == nil && n >= 0 { + socketPath = absX11SocketDir.Append("X" + strconv.Itoa(n)) + } + } else if len(s.Display) > 5 && strings.HasPrefix(s.Display, "unix:") { // `unix:%s` + if a, err := check.NewAbs(s.Display[5:]); err == nil { + socketPath = a + } + } + if socketPath != nil { + if _, err := state.k.stat(socketPath.String()); err != nil { + if !errors.Is(err, fs.ErrNotExist) { + return &hst.AppError{Step: fmt.Sprintf("access X11 socket %q", socketPath), Err: err} + } + } else { + state.sys.UpdatePermType(hst.EX11, socketPath, acl.Read, acl.Write, acl.Execute) + if state.Container.Flags&hst.FHostAbstract == 0 { + s.Display = "unix:" + socketPath.String() + } + } + } + + state.sys.ChangeHosts("#" + state.uid.String()) + return nil +} + +func (s *spX11Op) toContainer(state *outcomeStateParams) error { + state.env["DISPLAY"] = s.Display + state.params.Bind(absX11SocketDir, absX11SocketDir, 0) + return nil +} diff --git a/internal/outcome/spx11_test.go b/internal/outcome/spx11_test.go new file mode 100644 index 00000000..c958e946 --- /dev/null +++ b/internal/outcome/spx11_test.go @@ -0,0 +1,119 @@ +package outcome + +import ( + "os" + "testing" + + "hakurei.app/container" + "hakurei.app/container/stub" + "hakurei.app/hst" + "hakurei.app/system/acl" +) + +func TestSpX11Op(t *testing.T) { + t.Parallel() + config := hst.Template() + + checkOpBehaviour(t, []opBehaviourTestCase{ + {"not enabled", func(bool, bool) outcomeOp { + return new(spX11Op) + }, hst.Template, nil, nil, nil, nil, errNotEnabled, nil, nil, nil, nil, nil}, + + {"lookupEnv", func(bool, bool) outcomeOp { + return new(spX11Op) + }, func() *hst.Config { + c := hst.Template() + *c.Enablements |= hst.Enablements(hst.EX11) + return c + }, nil, []stub.Call{ + call("lookupEnv", stub.ExpectArgs{"DISPLAY"}, nil, nil), + }, nil, nil, &hst.AppError{ + Step: "finalise", + Err: os.ErrInvalid, + Msg: "DISPLAY is not set", + }, nil, nil, nil, nil, nil}, + + {"abs stat", func(bool, bool) outcomeOp { + return new(spX11Op) + }, func() *hst.Config { + c := hst.Template() + *c.Enablements |= hst.Enablements(hst.EX11) + return c + }, nil, []stub.Call{ + call("lookupEnv", stub.ExpectArgs{"DISPLAY"}, "unix:/tmp/.X11-unix/X0", nil), + call("stat", stub.ExpectArgs{"/tmp/.X11-unix/X0"}, (*stubFi)(nil), stub.UniqueError(0)), + }, nil, nil, &hst.AppError{ + Step: `access X11 socket "/tmp/.X11-unix/X0"`, + Err: stub.UniqueError(0), + }, nil, nil, nil, nil, nil}, + + {"success abs nonexistent", func(isShim, _ bool) outcomeOp { + if !isShim { + return new(spX11Op) + } + return &spX11Op{Display: "unix:/tmp/.X11-unix/X0"} + }, func() *hst.Config { + c := hst.Template() + *c.Enablements |= hst.Enablements(hst.EX11) + return c + }, nil, []stub.Call{ + call("lookupEnv", stub.ExpectArgs{"DISPLAY"}, "unix:/tmp/.X11-unix/X0", nil), + call("stat", stub.ExpectArgs{"/tmp/.X11-unix/X0"}, (*stubFi)(nil), os.ErrNotExist), + }, newI(). + ChangeHosts("#1000009"), nil, nil, insertsOps(nil), []stub.Call{ + // this op configures the container state and does not make calls during toContainer + }, &container.Params{ + Ops: new(container.Ops). + Bind(absX11SocketDir, absX11SocketDir, 0), + }, paramsWantEnv(config, map[string]string{ + "DISPLAY": "unix:/tmp/.X11-unix/X0", + }, nil), nil}, + + {"success abs abstract", func(isShim, _ bool) outcomeOp { + if !isShim { + return new(spX11Op) + } + return &spX11Op{Display: "unix:/tmp/.X11-unix/X0"} + }, func() *hst.Config { + c := hst.Template() + *c.Enablements |= hst.Enablements(hst.EX11) + c.Container.Flags &= ^hst.FHostAbstract + return c + }, nil, []stub.Call{ + call("lookupEnv", stub.ExpectArgs{"DISPLAY"}, "unix:/tmp/.X11-unix/X0", nil), + call("stat", stub.ExpectArgs{"/tmp/.X11-unix/X0"}, (*stubFi)(nil), nil), + }, newI(). + UpdatePermType(hst.EX11, m("/tmp/.X11-unix/X0"), acl.Read, acl.Write, acl.Execute). + ChangeHosts("#1000009"), nil, nil, insertsOps(nil), []stub.Call{ + // this op configures the container state and does not make calls during toContainer + }, &container.Params{ + Ops: new(container.Ops). + Bind(absX11SocketDir, absX11SocketDir, 0), + }, paramsWantEnv(config, map[string]string{ + "DISPLAY": "unix:/tmp/.X11-unix/X0", + }, nil), nil}, + + {"success", func(isShim, _ bool) outcomeOp { + if !isShim { + return new(spX11Op) + } + return &spX11Op{Display: ":0"} + }, func() *hst.Config { + c := hst.Template() + *c.Enablements |= hst.Enablements(hst.EX11) + return c + }, nil, []stub.Call{ + call("lookupEnv", stub.ExpectArgs{"DISPLAY"}, ":0", nil), + call("stat", stub.ExpectArgs{"/tmp/.X11-unix/X0"}, (*stubFi)(nil), nil), + }, newI(). + UpdatePermType(hst.EX11, m("/tmp/.X11-unix/X0"), acl.Read, acl.Write, acl.Execute). + ChangeHosts("#1000009"), nil, nil, insertsOps(nil), []stub.Call{ + // this op configures the container state and does not make calls during toContainer + }, &container.Params{ + Ops: new(container.Ops). + Bind(absX11SocketDir, absX11SocketDir, 0), + }, paramsWantEnv(config, map[string]string{ + "DISPLAY": ":0", + }, nil), nil}, + }) +} diff --git a/internal/state/data.go b/internal/state/data.go new file mode 100644 index 00000000..a680c96b --- /dev/null +++ b/internal/state/data.go @@ -0,0 +1,52 @@ +package state + +import ( + "encoding/gob" + "fmt" + "io" + "os" + + "hakurei.app/hst" +) + +// entryEncode encodes [hst.State] into [io.Writer] with the state entry header. +// entryEncode does not validate the embedded [hst.Config] value. +// +// A non-nil error returned by entryEncode is of type [hst.AppError]. +func entryEncode(w io.Writer, s *hst.State) error { + if err := entryWriteHeader(w, s.Enablements.Unwrap()); err != nil { + return &hst.AppError{Step: "encode state header", Err: err} + } else if err = gob.NewEncoder(w).Encode(s); err != nil { + return &hst.AppError{Step: "encode state body", Err: err} + } else { + return nil + } +} + +// entryDecodeHeader calls entryReadHeader, returning [hst.AppError] for a non-nil error. +func entryDecodeHeader(r io.Reader) (hst.Enablement, error) { + if et, err := entryReadHeader(r); err != nil { + return 0, &hst.AppError{Step: "decode state header", Err: err} + } else { + return et, nil + } +} + +// entryDecode decodes [hst.State] from [io.Reader] and stores the result in the value pointed to by p. +// entryDecode validates the embedded [hst.Config] value. +// +// A non-nil error returned by entryDecode is of type [hst.AppError]. +func entryDecode(r io.Reader, p *hst.State) (hst.Enablement, error) { + if et, err := entryDecodeHeader(r); err != nil { + return et, err + } else if err = gob.NewDecoder(r).Decode(&p); err != nil { + return et, &hst.AppError{Step: "decode state body", Err: err} + } else if err = p.Config.Validate(); err != nil { + return et, err + } else if p.Enablements.Unwrap() != et { + return et, &hst.AppError{Step: "validate state enablement", Err: os.ErrInvalid, + Msg: fmt.Sprintf("state entry %s has unexpected enablement byte %#x, %#x", p.ID.String(), byte(p.Enablements.Unwrap()), byte(et))} + } else { + return et, nil + } +} diff --git a/internal/state/data_test.go b/internal/state/data_test.go new file mode 100644 index 00000000..6ad91e53 --- /dev/null +++ b/internal/state/data_test.go @@ -0,0 +1,145 @@ +package state + +import ( + "bytes" + "encoding/gob" + "errors" + "io" + "os" + "reflect" + "strings" + "testing" + "time" + + "hakurei.app/container/stub" + "hakurei.app/hst" +) + +func TestEntryData(t *testing.T) { + t.Parallel() + + mustEncodeGob := func(e any) string { + var buf bytes.Buffer + if err := gob.NewEncoder(&buf).Encode(e); err != nil { + t.Fatalf("cannot encode invalid state: %v", err) + return "\x00" // not reached + } else { + return buf.String() + } + } + templateStateGob := mustEncodeGob(newTemplateState()) + + testCases := []struct { + name string + data string + s *hst.State + err error + }{ + {"invalid header", "\x00\xff\xca\xfe\xff\xff\xff\x00", nil, &hst.AppError{ + Step: "decode state header", Err: errors.New("unexpected revision ffff")}}, + + {"invalid gob", "\x00\xff\xca\xfe\x00\x00\xff\x00", nil, &hst.AppError{ + Step: "decode state body", Err: io.EOF}}, + + {"invalid config", "\x00\xff\xca\xfe\x00\x00\xff\x00" + mustEncodeGob(new(hst.State)), new(hst.State), &hst.AppError{ + Step: "validate configuration", Err: hst.ErrConfigNull, + Msg: "invalid configuration"}}, + + {"inconsistent enablement", "\x00\xff\xca\xfe\x00\x00\xff\x00" + templateStateGob, newTemplateState(), &hst.AppError{ + Step: "validate state enablement", Err: os.ErrInvalid, + Msg: "state entry aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa has unexpected enablement byte 0xd, 0xff"}}, + + {"template", "\x00\xff\xca\xfe\x00\x00\x0d\xf2" + templateStateGob, newTemplateState(), nil}, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + t.Run("encode", func(t *testing.T) { + if tc.s == nil || tc.s.Config == nil { + return + } + t.Parallel() + + var buf bytes.Buffer + if err := entryEncode(&buf, tc.s); err != nil { + t.Fatalf("entryEncode: error = %v", err) + } + + if tc.err == nil { + // Gob encoding is not guaranteed to be deterministic. + // While the current implementation mostly is, it has randomised order + // for iterating over maps, and hst.Config holds a map for environ. + var got hst.State + if et, err := entryDecode(&buf, &got); err != nil { + t.Fatalf("entryDecode: error = %v", err) + } else if stateEt := got.Enablements.Unwrap(); et != stateEt { + t.Fatalf("entryDecode: et = %x, state %x", et, stateEt) + } + if !reflect.DeepEqual(&got, tc.s) { + t.Errorf("entryEncode: %x", buf.Bytes()) + } + } else if testing.Verbose() { + t.Logf("%x", buf.String()) + } + }) + + t.Run("decode", func(t *testing.T) { + t.Parallel() + + var got hst.State + if et, err := entryDecode(strings.NewReader(tc.data), &got); !reflect.DeepEqual(err, tc.err) { + t.Fatalf("entryDecode: error = %#v, want %#v", err, tc.err) + } else if err != nil { + return + } else if stateEt := got.Enablements.Unwrap(); et != stateEt { + t.Fatalf("entryDecode: et = %x, state %x", et, stateEt) + } + + if !reflect.DeepEqual(&got, tc.s) { + t.Errorf("entryDecode: %#v, want %#v", &got, tc.s) + } + }) + }) + } + + t.Run("encode fault", func(t *testing.T) { + t.Parallel() + s := newTemplateState() + + t.Run("gob", func(t *testing.T) { + var want = &hst.AppError{Step: "encode state body", Err: stub.UniqueError(0xcafe)} + if err := entryEncode(stubNErrorWriter(entryHeaderSize), s); !reflect.DeepEqual(err, want) { + t.Errorf("entryEncode: error = %#v, want %#v", err, want) + } + }) + + t.Run("header", func(t *testing.T) { + var want = &hst.AppError{Step: "encode state header", Err: stub.UniqueError(0xcafe)} + if err := entryEncode(stubNErrorWriter(entryHeaderSize-1), s); !reflect.DeepEqual(err, want) { + t.Errorf("entryEncode: error = %#v, want %#v", err, want) + } + }) + }) +} + +// newTemplateState returns the address of a new template [hst.State] struct. +func newTemplateState() *hst.State { + return &hst.State{ + ID: hst.ID(bytes.Repeat([]byte{0xaa}, len(hst.ID{}))), + PID: 0xcafebabe, + ShimPID: 0xdeadbeef, + Config: hst.Template(), + Time: time.Unix(0, 0), + } +} + +// stubNErrorWriter returns an error for writes above a certain size. +type stubNErrorWriter int + +func (w stubNErrorWriter) Write(p []byte) (n int, err error) { + if len(p) > int(w) { + return int(w), stub.UniqueError(0xcafe) + } + return io.Discard.Write(p) +} diff --git a/internal/state/header.go b/internal/state/header.go new file mode 100644 index 00000000..25b1e6af --- /dev/null +++ b/internal/state/header.go @@ -0,0 +1,86 @@ +package state + +import ( + "encoding/hex" + "errors" + "io" + "os" + "strconv" + "syscall" + + "hakurei.app/hst" +) + +const ( + // entryHeaderMagic are magic bytes at the beginning of the state entry file. + entryHeaderMagic = "\x00\xff\xca\xfe" + // entryHeaderRevision follows entryHeaderMagic and is incremented for revisions of the format. + entryHeaderRevision = "\x00\x00" + // entryHeaderSize is the fixed size of the header in bytes, including the enablement byte and its complement. + entryHeaderSize = len(entryHeaderMagic+entryHeaderRevision) + 2 +) + +// entryHeaderEncode encodes a state entry header for a [hst.Enablement] byte. +func entryHeaderEncode(et hst.Enablement) *[entryHeaderSize]byte { + data := [entryHeaderSize]byte([]byte( + entryHeaderMagic + entryHeaderRevision + string([]hst.Enablement{et, ^et}), + )) + return &data +} + +// entryHeaderDecode validates a state entry header and returns the [hst.Enablement] byte. +func entryHeaderDecode(data *[entryHeaderSize]byte) (hst.Enablement, error) { + if magic := data[:len(entryHeaderMagic)]; string(magic) != entryHeaderMagic { + return 0, errors.New("invalid header " + hex.EncodeToString(magic)) + } + if revision := data[len(entryHeaderMagic):len(entryHeaderMagic+entryHeaderRevision)]; string(revision) != entryHeaderRevision { + return 0, errors.New("unexpected revision " + hex.EncodeToString(revision)) + } + + et := data[len(entryHeaderMagic+entryHeaderRevision)] + if et != ^data[len(entryHeaderMagic+entryHeaderRevision)+1] { + return 0, errors.New("header enablement value is inconsistent") + } + return hst.Enablement(et), nil +} + +// EntrySizeError is returned for a file too small to hold a state entry header. +type EntrySizeError struct { + Name string + Size int64 +} + +func (e *EntrySizeError) Error() string { + if e.Name == "" { + return "state entry file is too short" + } + return "state entry file " + strconv.Quote(e.Name) + " is too short" +} + +// entryCheckFile checks whether [os.FileInfo] refers to a file that might hold [hst.State]. +func entryCheckFile(fi os.FileInfo) error { + if fi.IsDir() { + return syscall.EISDIR + } + if s := fi.Size(); s <= int64(entryHeaderSize) { + return &EntrySizeError{Name: fi.Name(), Size: s} + } + return nil +} + +// entryReadHeader reads [hst.Enablement] from an [io.Reader]. +func entryReadHeader(r io.Reader) (hst.Enablement, error) { + var data [entryHeaderSize]byte + if n, err := r.Read(data[:]); err != nil { + return 0, err + } else if n != entryHeaderSize { + return 0, &EntrySizeError{Size: int64(n)} + } + return entryHeaderDecode(&data) +} + +// entryWriteHeader writes [hst.Enablement] header to an [io.Writer]. +func entryWriteHeader(w io.Writer, et hst.Enablement) error { + _, err := w.Write(entryHeaderEncode(et)[:]) + return err +} diff --git a/internal/state/header_test.go b/internal/state/header_test.go new file mode 100644 index 00000000..18a620c2 --- /dev/null +++ b/internal/state/header_test.go @@ -0,0 +1,184 @@ +package state + +import ( + "bytes" + "errors" + "io" + "io/fs" + "os" + "reflect" + "syscall" + "testing" + "time" + + "hakurei.app/hst" +) + +func TestEntryHeader(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + data [entryHeaderSize]byte + et hst.Enablement + err error + }{ + {"complement mismatch", [entryHeaderSize]byte{0x00, 0xff, 0xca, 0xfe, 0x00, 0x00, + 0x0a, 0xf6}, 0, + errors.New("header enablement value is inconsistent")}, + {"unexpected revision", [entryHeaderSize]byte{0x00, 0xff, 0xca, 0xfe, 0xff, 0xff}, 0, + errors.New("unexpected revision ffff")}, + {"invalid header", [entryHeaderSize]byte{0x00, 0xfe, 0xca, 0xfe}, 0, + errors.New("invalid header 00fecafe")}, + + {"success high", [entryHeaderSize]byte{0x00, 0xff, 0xca, 0xfe, 0x00, 0x00, + 0xff, 0x00}, 0xff, nil}, + {"success", [entryHeaderSize]byte{0x00, 0xff, 0xca, 0xfe, 0x00, 0x00, + 0x09, 0xf6}, hst.EWayland | hst.EPulse, nil}, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + t.Run("encode", func(t *testing.T) { + if tc.err != nil { + return + } + t.Parallel() + + if got := entryHeaderEncode(tc.et); *got != tc.data { + t.Errorf("entryHeaderEncode: %x, want %x", *got, tc.data) + } + + t.Run("write", func(t *testing.T) { + var buf bytes.Buffer + if err := entryWriteHeader(&buf, tc.et); err != nil { + t.Fatalf("entryWriteHeader: error = %v", err) + } + if got := ([entryHeaderSize]byte)(buf.Bytes()); got != tc.data { + t.Errorf("entryWriteHeader: %x, want %x", got, tc.data) + } + }) + }) + + t.Run("decode", func(t *testing.T) { + t.Parallel() + + got, err := entryHeaderDecode(&tc.data) + if !reflect.DeepEqual(err, tc.err) { + t.Fatalf("entryHeaderDecode: error = %#v, want %#v", err, tc.err) + } + if err != nil { + return + } + if got != tc.et { + t.Errorf("entryHeaderDecode: et = %q, want %q", got, tc.et) + } + + if got, err = entryReadHeader(bytes.NewReader(tc.data[:])); err != nil { + t.Fatalf("entryReadHeader: error = %#v", err) + } else if got != tc.et { + t.Errorf("entryReadHeader: et = %q, want %q", got, tc.et) + } + }) + }) + } +} + +func TestEntrySizeError(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + err error + want string + }{ + {"size only", &EntrySizeError{Size: 0xdeadbeef}, + `state entry file is too short`}, + {"full", &EntrySizeError{Name: "nonexistent", Size: 0xdeadbeef}, + `state entry file "nonexistent" is too short`}, + } + 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: %s, want %s", got, tc.want) + } + }) + } +} + +func TestEntryCheckFile(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + fi os.FileInfo + err error + }{ + {"dir", &stubFi{name: "dir", isDir: true}, + syscall.EISDIR}, + {"short", stubFi{name: "short", size: 8}, + &EntrySizeError{Name: "short", Size: 8}}, + {"success", stubFi{size: 9}, nil}, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + if err := entryCheckFile(tc.fi); !reflect.DeepEqual(err, tc.err) { + t.Errorf("entryCheckFile: error = %#v, want %#v", err, tc.err) + } + }) + } +} + +func TestEntryReadHeader(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + newR func() io.Reader + err error + }{ + {"eof", func() io.Reader { return bytes.NewReader([]byte{}) }, io.EOF}, + {"short", func() io.Reader { return bytes.NewReader([]byte{0}) }, &EntrySizeError{Size: 1}}, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + if _, err := entryReadHeader(tc.newR()); !reflect.DeepEqual(err, tc.err) { + t.Errorf("entryReadHeader: error = %#v, want %#v", err, tc.err) + } + }) + } +} + +// stubFi partially implements [os.FileInfo] using hardcoded values. +type stubFi struct { + name string + size int64 + isDir bool +} + +func (fi stubFi) Name() string { + if fi.name == "" { + panic("unreachable") + } + return fi.name +} + +func (fi stubFi) Size() int64 { + if fi.size < 0 { + panic("unreachable") + } + return fi.size +} + +func (fi stubFi) IsDir() bool { return fi.isDir } + +func (fi stubFi) Mode() fs.FileMode { panic("unreachable") } +func (fi stubFi) ModTime() time.Time { panic("unreachable") } +func (fi stubFi) Sys() any { panic("unreachable") } diff --git a/internal/state/join.go b/internal/state/join.go new file mode 100644 index 00000000..43a19b1d --- /dev/null +++ b/internal/state/join.go @@ -0,0 +1,64 @@ +package state + +import ( + "errors" + "maps" + + "hakurei.app/hst" +) + +var ( + ErrDuplicate = errors.New("store contains duplicates") +) + +/* +Joiner is the interface that wraps the Join method. + +The Join function uses Joiner if available. +*/ +type Joiner interface { + Join() (map[hst.ID]*hst.State, error) +} + +// Join returns joined state entries of all active identities. +func Join(s Store) (map[hst.ID]*hst.State, error) { + if j, ok := s.(Joiner); ok { + return j.Join() + } + + var ( + aids []int + entries = make(map[hst.ID]*hst.State) + + el int + res map[hst.ID]*hst.State + loadErr error + ) + + if ln, err := s.List(); err != nil { + return nil, err + } else { + aids = ln + } + + for _, aid := range aids { + if _, err := s.Do(aid, func(c Cursor) { + res, loadErr = c.Load() + }); err != nil { + return nil, err + } + + if loadErr != nil { + return nil, loadErr + } + + // save expected length + el = len(entries) + len(res) + maps.Copy(entries, res) + if len(entries) != el { + return nil, ErrDuplicate + } + } + + return entries, nil +} diff --git a/internal/state/segment.go b/internal/state/segment.go new file mode 100644 index 00000000..c8ab8c13 --- /dev/null +++ b/internal/state/segment.go @@ -0,0 +1,161 @@ +package state + +import ( + "errors" + "fmt" + "iter" + "os" + "strconv" + "sync" + + "hakurei.app/container/check" + "hakurei.app/hst" + "hakurei.app/internal/lockedfile" +) + +// stateEntryHandle is a handle on a state entry retrieved from a storeHandle. +// Must only be used while its parent storeHandle.fileMu is held. +type stateEntryHandle struct { + // Error returned while decoding pathname. + // A non-nil value disables stateEntryHandle. + decodeErr error + + // Checked path to entry file. + pathname *check.Absolute + + hst.ID +} + +// open opens the underlying state entry file, returning [hst.AppError] for a non-nil error. +func (eh *stateEntryHandle) open(flag int, perm os.FileMode) (*os.File, error) { + if eh.decodeErr != nil { + return nil, eh.decodeErr + } + + if f, err := os.OpenFile(eh.pathname.String(), flag, perm); err != nil { + return nil, &hst.AppError{Step: "open state entry", Err: err} + } else { + return f, nil + } +} + +// destroy removes the underlying state entry file, returning [hst.AppError] for a non-nil error. +func (eh *stateEntryHandle) destroy() error { + // destroy does not go through open + if eh.decodeErr != nil { + return eh.decodeErr + } + + if err := os.Remove(eh.pathname.String()); err != nil { + return &hst.AppError{Step: "destroy state entry", Err: err} + } + return nil +} + +// save encodes [hst.State] and writes it to the underlying file. +// An error is returned if a file already exists with the same identifier. +// save does not validate the embedded [hst.Config]. +func (eh *stateEntryHandle) save(state *hst.State) error { + f, err := eh.open(os.O_RDWR|os.O_CREATE|os.O_EXCL, 0600) + if err != nil { + return err + } + + err = entryEncode(f, state) + if closeErr := f.Close(); closeErr != nil && err == nil { + err = &hst.AppError{Step: "close state file", Err: closeErr} + } + return err +} + +// load loads and validates the state entry header, and returns the [hst.Enablement] byte. +// for a non-nil v, the full state payload is decoded and stored in the value pointed to by v. +// load validates the embedded hst.Config value. +func (eh *stateEntryHandle) load(v *hst.State) (hst.Enablement, error) { + f, err := eh.open(os.O_RDONLY, 0) + if err != nil { + return 0, err + } + + var et hst.Enablement + if v != nil { + et, err = entryDecode(f, v) + if err == nil && v.ID != eh.ID { + err = &hst.AppError{Step: "validate state identifier", Err: os.ErrInvalid, + Msg: fmt.Sprintf("state entry %s has unexpected id %s", eh.ID.String(), v.ID.String())} + } + } else { + et, err = entryDecodeHeader(f) + } + + if closeErr := f.Close(); closeErr != nil && err == nil { + err = &hst.AppError{Step: "close state file", Err: closeErr} + } + return et, err +} + +// storeHandle is a handle on a stateStore segment. +// Initialised by stateStore.identityHandle. +type storeHandle struct { + // Identity of instances tracked by this segment. + identity int + // Pathname of directory that the segment referred to by storeHandle is rooted in. + path *check.Absolute + // Inter-process mutex to synchronise operations against resources in this segment. + fileMu *lockedfile.Mutex + + // Must be held alongside fileMu. + mu sync.Mutex +} + +// entries returns an iterator over all stateEntryHandle held in this segment. +// Must be called while holding a lock on mu and fileMu. +// A non-nil error attached to a stateEntryHandle indicates a malformed identifier and is of type [hst.AppError]. +// A non-nil error returned by entries is of type [hst.AppError]. +func (h *storeHandle) entries() (iter.Seq[*stateEntryHandle], int, error) { + // for error reporting + const step = "read store segment entries" + + // read directory contents, should only contain storeMutexName and identifier + var entries []os.DirEntry + if pl, err := os.ReadDir(h.path.String()); err != nil { + return nil, -1, &hst.AppError{Step: step, Err: err} + } else { + entries = pl + } + + // expects lock file + l := len(entries) + if l > 0 { + l-- + } + + return func(yield func(*stateEntryHandle) bool) { + for _, ent := range entries { + var eh = stateEntryHandle{pathname: h.path.Append(ent.Name())} + + // this should never happen + if ent.IsDir() { + eh.decodeErr = &hst.AppError{Step: step, + Err: errors.New("unexpected directory " + strconv.Quote(ent.Name()) + " in store")} + goto out + } + + // silently skip lock file + if ent.Name() == storeMutexName { + continue + } + + // this either indicates a serious bug or external interference + if err := eh.ID.UnmarshalText([]byte(ent.Name())); err != nil { + eh.decodeErr = &hst.AppError{Step: "decode store segment entry", Err: err} + goto out + } + + out: + if !yield(&eh) { + break + } + } + }, l, nil +} diff --git a/internal/state/segment_test.go b/internal/state/segment_test.go new file mode 100644 index 00000000..834cb99a --- /dev/null +++ b/internal/state/segment_test.go @@ -0,0 +1,259 @@ +package state + +import ( + "errors" + "iter" + "os" + "reflect" + "slices" + "strings" + "syscall" + "testing" + + "hakurei.app/container/check" + "hakurei.app/container/stub" + "hakurei.app/hst" + "hakurei.app/internal/lockedfile" +) + +func TestStateEntryHandle(t *testing.T) { + t.Parallel() + + t.Run("lockout", func(t *testing.T) { + t.Parallel() + wantErr := func() error { return stub.UniqueError(0) } + eh := stateEntryHandle{decodeErr: wantErr(), pathname: check.MustAbs("/proc/nonexistent")} + + if _, err := eh.open(-1, 0); !reflect.DeepEqual(err, wantErr()) { + t.Errorf("open: error = %v, want %v", err, wantErr()) + } + if err := eh.destroy(); !reflect.DeepEqual(err, wantErr()) { + t.Errorf("destroy: error = %v, want %v", err, wantErr()) + } + if err := eh.save(nil); !reflect.DeepEqual(err, wantErr()) { + t.Errorf("save: error = %v, want %v", err, wantErr()) + } + if _, err := eh.load(nil); !reflect.DeepEqual(err, wantErr()) { + t.Errorf("load: error = %v, want %v", err, wantErr()) + } + }) + + t.Run("od", func(t *testing.T) { + t.Parallel() + + { + eh := stateEntryHandle{pathname: check.MustAbs(t.TempDir()).Append("entry")} + if f, err := eh.open(os.O_CREATE|syscall.O_EXCL, 0); err != nil { + t.Fatalf("open: error = %v", err) + } else if err = f.Close(); err != nil { + t.Errorf("Close: error = %v", err) + } + if err := eh.destroy(); err != nil { + t.Fatalf("destroy: error = %v", err) + } + } + + t.Run("nonexistent", func(t *testing.T) { + t.Parallel() + eh := stateEntryHandle{pathname: check.MustAbs("/proc/nonexistent")} + + wantErrOpen := &hst.AppError{Step: "open state entry", + Err: &os.PathError{Op: "open", Path: "/proc/nonexistent", Err: syscall.ENOENT}} + if _, err := eh.open(os.O_CREATE|syscall.O_EXCL, 0); !reflect.DeepEqual(err, wantErrOpen) { + t.Errorf("open: error = %#v, want %#v", err, wantErrOpen) + } + + wantErrDestroy := &hst.AppError{Step: "destroy state entry", + Err: &os.PathError{Op: "remove", Path: "/proc/nonexistent", Err: syscall.ENOENT}} + if err := eh.destroy(); !reflect.DeepEqual(err, wantErrDestroy) { + t.Errorf("destroy: error = %#v, want %#v", err, wantErrDestroy) + } + }) + }) + + t.Run("saveload", func(t *testing.T) { + t.Parallel() + eh := stateEntryHandle{pathname: check.MustAbs(t.TempDir()).Append("entry"), + ID: newTemplateState().ID} + + if err := eh.save(newTemplateState()); err != nil { + t.Fatalf("save: error = %v", err) + } + + t.Run("validate", func(t *testing.T) { + t.Parallel() + + t.Run("internal", func(t *testing.T) { + t.Parallel() + + var got hst.State + if f, err := os.Open(eh.pathname.String()); err != nil { + t.Fatal(err.Error()) + } else if _, err = entryDecode(f, &got); err != nil { + t.Fatalf("entryDecode: error = %v", err) + } else if err = f.Close(); err != nil { + t.Fatal(f.Close()) + } + + if want := newTemplateState(); !reflect.DeepEqual(&got, want) { + t.Errorf("entryDecode: %#v, want %#v", &got, want) + } + }) + + t.Run("load header only", func(t *testing.T) { + t.Parallel() + + if et, err := eh.load(nil); err != nil { + t.Fatalf("load: error = %v", err) + } else if want := newTemplateState().Enablements.Unwrap(); et != want { + t.Errorf("load: et = %x, want %x", et, want) + } + }) + + t.Run("load", func(t *testing.T) { + t.Parallel() + + var got hst.State + if _, err := eh.load(&got); err != nil { + t.Fatalf("load: error = %v", err) + } else if want := newTemplateState(); !reflect.DeepEqual(&got, want) { + t.Errorf("load: %#v, want %#v", &got, want) + } + }) + + t.Run("load inconsistent", func(t *testing.T) { + t.Parallel() + wantErr := &hst.AppError{Step: "validate state identifier", Err: os.ErrInvalid, + Msg: "state entry 00000000000000000000000000000000 has unexpected id aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"} + + ehi := stateEntryHandle{pathname: eh.pathname} + if _, err := ehi.load(new(hst.State)); !reflect.DeepEqual(err, wantErr) { + t.Errorf("load: error = %#v, want %#v", err, wantErr) + } + }) + }) + }) +} + +func TestStoreHandle(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + ents [2][]string + want func(newEh func(err error, name string) *stateEntryHandle) []*stateEntryHandle + ext func(t *testing.T, entries iter.Seq[*stateEntryHandle], n int) + }{ + {"errors", [2][]string{{ + "e81eb203b4190ac5c3842ef44d429945", + "lock", + "f0-invalid", + }, { + "f1-directory", + }}, func(newEh func(err error, name string) *stateEntryHandle) []*stateEntryHandle { + return []*stateEntryHandle{ + newEh(nil, "e81eb203b4190ac5c3842ef44d429945"), + newEh(&hst.AppError{Step: "decode store segment entry", + Err: hst.IdentifierDecodeError{Err: hst.ErrIdentifierLength}}, "f0-invalid"), + newEh(&hst.AppError{Step: "read store segment entries", + Err: errors.New(`unexpected directory "f1-directory" in store`)}, "f1-directory"), + } + }, nil}, + + {"success", [2][]string{{ + "e81eb203b4190ac5c3842ef44d429945", + "7958cfbb9272d9cf9cfd61c85afa13f1", + "d0b5f7446dd5bd3424ff2f7ac9cace1e", + "c8c8e2c4aea5c32fe47240ff8caa874e", + "fa0d30b249d80f155a1f80ceddcc32f2", + "lock", + }}, func(newEh func(err error, name string) *stateEntryHandle) []*stateEntryHandle { + return []*stateEntryHandle{ + newEh(nil, "7958cfbb9272d9cf9cfd61c85afa13f1"), + newEh(nil, "c8c8e2c4aea5c32fe47240ff8caa874e"), + newEh(nil, "d0b5f7446dd5bd3424ff2f7ac9cace1e"), + newEh(nil, "e81eb203b4190ac5c3842ef44d429945"), + newEh(nil, "fa0d30b249d80f155a1f80ceddcc32f2"), + } + }, func(t *testing.T, entries iter.Seq[*stateEntryHandle], n int) { + if n != 5 { + t.Fatalf("entries: n = %d", n) + } + + // check partial drain + for range entries { + break + } + }}, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + p := check.MustAbs(t.TempDir()).Append("segment") + if err := os.Mkdir(p.String(), 0700); err != nil { + t.Fatal(err.Error()) + } + createEntries(t, p, tc.ents) + + var got []*stateEntryHandle + if entries, n, err := (&storeHandle{ + identity: -0xbad, + path: p, + fileMu: lockedfile.MutexAt(p.Append("lock").String()), + }).entries(); err != nil { + t.Fatalf("entries: error = %v", err) + } else { + got = slices.AppendSeq(make([]*stateEntryHandle, 0, n), entries) + if tc.ext != nil { + tc.ext(t, entries, n) + } + } + + slices.SortFunc(got, func(a, b *stateEntryHandle) int { return strings.Compare(a.pathname.String(), b.pathname.String()) }) + want := tc.want(func(err error, name string) *stateEntryHandle { + eh := stateEntryHandle{decodeErr: err, pathname: p.Append(name)} + if err == nil { + if err = eh.UnmarshalText([]byte(name)); err != nil { + t.Fatalf("UnmarshalText: error = %v", err) + } + } + return &eh + }) + + if !reflect.DeepEqual(got, want) { + t.Errorf("entries: %q, want %q", got, want) + } + }) + } + + t.Run("nonexistent", func(t *testing.T) { + var wantErr = &hst.AppError{Step: "read store segment entries", Err: &os.PathError{ + Op: "open", + Path: "/proc/nonexistent", + Err: syscall.ENOENT, + }} + if _, _, err := (&storeHandle{ + identity: -0xbad, + path: check.MustAbs("/proc/nonexistent"), + }).entries(); !reflect.DeepEqual(err, wantErr) { + t.Fatalf("entries: error = %#v, want %#v", err, wantErr) + } + }) +} + +// createEntries creates file and directory entries in the specified prefix. +func createEntries(t *testing.T, prefix *check.Absolute, ents [2][]string) { + for _, s := range ents[0] { + if f, err := os.OpenFile(prefix.Append(s).String(), os.O_CREATE|os.O_EXCL, 0600); err != nil { + t.Fatal(err.Error()) + } else if err = f.Close(); err != nil { + t.Fatal(err.Error()) + } + } + for _, s := range ents[1] { + if err := os.Mkdir(prefix.Append(s).String(), 0700); err != nil { + t.Fatal(err.Error()) + } + } +} diff --git a/internal/state/state.go b/internal/state/state.go new file mode 100644 index 00000000..62b31865 --- /dev/null +++ b/internal/state/state.go @@ -0,0 +1,131 @@ +// Package state provides cross-process state tracking for hakurei container instances. +package state + +import ( + "strconv" + + "hakurei.app/container/check" + "hakurei.app/hst" + "hakurei.app/message" +) + +/* this provides an implementation of Store on top of the improved state tracking to ease in the changes */ + +type Store interface { + // Do calls f exactly once and ensures store exclusivity until f returns. + // Returns whether f is called and any errors during the locking process. + // Cursor provided to f becomes invalid as soon as f returns. + Do(identity int, f func(c Cursor)) (ok bool, err error) + + // List queries the store and returns a list of identities known to the store. + // Note that some or all returned identities might not have any active apps. + List() (identities []int, err error) +} + +func (s *stateStore) Do(identity int, f func(c Cursor)) (bool, error) { + if h, err := s.identityHandle(identity); err != nil { + return false, err + } else { + return h.do(f) + } +} + +// storeAdapter satisfies [Store] via stateStore. +type storeAdapter struct { + msg message.Msg + *stateStore +} + +func (s storeAdapter) List() ([]int, error) { + segments, n, err := s.segments() + if err != nil { + return nil, err + } + + identities := make([]int, 0, n) + for si := range segments { + if si.err != nil { + if m, ok := message.GetMessage(err); ok { + s.msg.Verbose(m) + } else { + // unreachable + return nil, err + } + continue + } + identities = append(identities, si.identity) + } + return identities, nil +} + +// NewMulti returns an instance of the multi-file store. +func NewMulti(msg message.Msg, prefix *check.Absolute) Store { + return storeAdapter{msg, newStore(prefix.Append("state"))} +} + +// Cursor provides access to the store of an identity. +type Cursor interface { + Save(state *hst.State) error + Destroy(id hst.ID) error + Load() (map[hst.ID]*hst.State, error) + Len() (int, error) +} + +// do implements stateStore.Do on storeHandle. +func (h *storeHandle) do(f func(c Cursor)) (bool, error) { + if unlock, err := h.fileMu.Lock(); err != nil { + return false, &hst.AppError{Step: "acquire lock on store segment " + strconv.Itoa(h.identity), Err: err} + } else { + defer unlock() + } + + f(h) + return true, nil +} + +/* these compatibility methods must only be called while fileMu is held */ + +func (h *storeHandle) Save(state *hst.State) error { + return (&stateEntryHandle{nil, h.path.Append(state.ID.String()), state.ID}).save(state) +} + +func (h *storeHandle) Destroy(id hst.ID) error { + return (&stateEntryHandle{nil, h.path.Append(id.String()), id}).destroy() +} + +func (h *storeHandle) Load() (map[hst.ID]*hst.State, error) { + entries, n, err := h.entries() + if err != nil { + return nil, err + } + + r := make(map[hst.ID]*hst.State, n) + for eh := range entries { + if eh.decodeErr != nil { + err = eh.decodeErr + break + } + var s hst.State + if _, err = eh.load(&s); err != nil { + break + } + r[eh.ID] = &s + } + return r, err +} + +func (h *storeHandle) Len() (int, error) { + entries, _, err := h.entries() + if err != nil { + return -1, err + } + + var n int + for eh := range entries { + if eh.decodeErr != nil { + err = eh.decodeErr + } + n++ + } + return n, err +} diff --git a/internal/state/state_test.go b/internal/state/state_test.go new file mode 100644 index 00000000..eed2c890 --- /dev/null +++ b/internal/state/state_test.go @@ -0,0 +1,120 @@ +package state_test + +import ( + "log" + "math/rand" + "reflect" + "slices" + "testing" + "time" + + "hakurei.app/container/check" + "hakurei.app/hst" + "hakurei.app/internal/state" + "hakurei.app/message" +) + +func TestMulti(t *testing.T) { + s := state.NewMulti(message.NewMsg(log.New(log.Writer(), "multi: ", 0)), check.MustAbs(t.TempDir())) + + t.Run("list empty store", func(t *testing.T) { + if identities, err := s.List(); err != nil { + t.Fatalf("List: error = %v", err) + } else if len(identities) != 0 { + t.Fatalf("List: identities = %#v", identities) + } + }) + + const ( + insertEntryChecked = iota + insertEntryNoCheck + insertEntryOtherApp + + tl + ) + + var tc [tl]hst.State + for i := 0; i < tl; i++ { + if err := hst.NewInstanceID(&tc[i].ID); err != nil { + t.Fatalf("cannot create dummy state: %v", err) + } + tc[i].PID = rand.Int() + tc[i].Config = hst.Template() + tc[i].Time = time.Now() + } + + do := func(identity int, f func(c state.Cursor)) { + if ok, err := s.Do(identity, f); err != nil { + t.Fatalf("Do: ok = %v, error = %v", ok, err) + } + } + + insert := func(i, identity int) { + do(identity, func(c state.Cursor) { + if err := c.Save(&tc[i]); err != nil { + t.Fatalf("Save: error = %v", err) + } + }) + } + + check := func(i, identity int) { + do(identity, func(c state.Cursor) { + if entries, err := c.Load(); err != nil { + t.Fatalf("Load: error = %v", err) + } else if got, ok := entries[tc[i].ID]; !ok { + t.Fatalf("Load: entry %s missing", &tc[i].ID) + } else { + got.Time = tc[i].Time + if !reflect.DeepEqual(got, &tc[i]) { + t.Fatalf("Load: entry %s got %#v, want %#v", &tc[i].ID, got, &tc[i]) + } + } + }) + } + + // insert entry checked + insert(insertEntryChecked, 0) + check(insertEntryChecked, 0) + + // insert entry unchecked + insert(insertEntryNoCheck, 0) + + // insert entry different identity + insert(insertEntryOtherApp, 1) + check(insertEntryOtherApp, 1) + + // check previous insertion + check(insertEntryNoCheck, 0) + + // list identities + if identities, err := s.List(); err != nil { + t.Fatalf("List: error = %v", err) + } else { + slices.Sort(identities) + want := []int{0, 1} + if !slices.Equal(identities, want) { + t.Fatalf("List() = %#v, want %#v", identities, want) + } + } + + // join store + if entries, err := state.Join(s); err != nil { + t.Fatalf("Join: error = %v", err) + } else if len(entries) != 3 { + t.Fatalf("Join(s) = %#v", entries) + } + + // clear identity 1 + do(1, func(c state.Cursor) { + if err := c.Destroy(tc[insertEntryOtherApp].ID); err != nil { + t.Fatalf("Destroy: error = %v", err) + } + }) + do(1, func(c state.Cursor) { + if l, err := c.Len(); err != nil { + t.Fatalf("Len: error = %v", err) + } else if l != 0 { + t.Fatalf("Len: %d, want 0", l) + } + }) +} diff --git a/internal/state/store.go b/internal/state/store.go new file mode 100644 index 00000000..565e4ee5 --- /dev/null +++ b/internal/state/store.go @@ -0,0 +1,162 @@ +package state + +import ( + "errors" + "io/fs" + "iter" + "os" + "strconv" + "sync" + "syscall" + + "hakurei.app/container/check" + "hakurei.app/hst" + "hakurei.app/internal/lockedfile" +) + +// storeMutexName is the pathname of the file backing [lockedfile.Mutex] of a stateStore and storeHandle. +const storeMutexName = "lock" + +// A stateStore keeps track of [hst.State] via a well-known filesystem accessible to all hakurei priv-side processes. +// Access to store data and related resources are synchronised on a per-segment basis via storeHandle. +type stateStore struct { + // Pathname of directory that the store is rooted in. + base *check.Absolute + + // All currently known instances of storeHandle, keyed by their identity. + handles sync.Map + + // Inter-process mutex to synchronise operations against the entire store. + // Held during List and when initialising previously unknown identities during Do. + // Must not be accessed directly. Callers should use the bigLock method instead. + fileMu *lockedfile.Mutex + + // For creating the base directory. + mkdirOnce sync.Once + // Stored error value via mkdirOnce. + mkdirErr error +} + +// bigLock acquires fileMu on stateStore. +// A non-nil error returned by bigLock is of type [hst.AppError]. +func (s *stateStore) bigLock() (unlock func(), err error) { + s.mkdirOnce.Do(func() { s.mkdirErr = os.MkdirAll(s.base.String(), 0700) }) + if s.mkdirErr != nil { + return nil, &hst.AppError{Step: "create state store directory", Err: s.mkdirErr} + } + + if unlock, err = s.fileMu.Lock(); err != nil { + return nil, &hst.AppError{Step: "acquire lock on the state store", Err: err} + } + return +} + +// identityHandle loads or initialises a storeHandle for identity. +// A non-nil error returned by identityHandle is of type [hst.AppError]. +func (s *stateStore) identityHandle(identity int) (*storeHandle, error) { + h := new(storeHandle) + h.mu.Lock() + + if v, ok := s.handles.LoadOrStore(identity, h); ok { + h = v.(*storeHandle) + } else { + // acquire big lock to initialise previously unknown segment handle + if unlock, err := s.bigLock(); err != nil { + return nil, err + } else { + defer unlock() + } + + h.identity = identity + h.path = s.base.Append(strconv.Itoa(identity)) + h.fileMu = lockedfile.MutexAt(h.path.Append(storeMutexName).String()) + + err := os.MkdirAll(h.path.String(), 0700) + h.mu.Unlock() + if err != nil && !errors.Is(err, fs.ErrExist) { + // handle methods will likely return ENOENT + s.handles.CompareAndDelete(identity, h) + return nil, &hst.AppError{Step: "create store segment directory", Err: err} + } + } + return h, nil +} + +// segmentIdentity is produced by the iterator returned by stateStore.segments. +type segmentIdentity struct { + // Identity of the current segment. + identity int + // Error encountered while processing this segment. + err error +} + +// segments returns an iterator over all segmentIdentity known to the store. +// To obtain a storeHandle on a segment, caller must then call identityHandle. +// A non-nil error returned by segments is of type [hst.AppError]. +func (s *stateStore) segments() (iter.Seq[segmentIdentity], int, error) { + // read directory contents, should only contain storeMutexName and identity + var entries []os.DirEntry + + // acquire big lock to read store segment list + if unlock, err := s.bigLock(); err != nil { + return nil, -1, err + } else { + entries, err = os.ReadDir(s.base.String()) + unlock() + + if err != nil && !errors.Is(err, os.ErrNotExist) { + return nil, -1, &hst.AppError{Step: "read store segments", Err: err} + } + } + + // expects lock file + l := len(entries) + if l > 0 { + l-- + } + + return func(yield func(segmentIdentity) bool) { + // for error reporting + const step = "process store segment" + + for _, ent := range entries { + si := segmentIdentity{identity: -1} + + // should only be the big lock + if !ent.IsDir() { + if ent.Name() == storeMutexName { + continue + } + + // this should never happen + si.err = &hst.AppError{Step: step, Err: syscall.EISDIR, + Msg: "skipped non-directory entry " + strconv.Quote(ent.Name())} + goto out + } + + // failure paths either indicates a serious bug or external interference + if v, err := strconv.Atoi(ent.Name()); err != nil { + si.err = &hst.AppError{Step: step, Err: err, + Msg: "skipped non-identity entry " + strconv.Quote(ent.Name())} + goto out + } else if v < hst.IdentityMin || v > hst.IdentityMax { + si.err = &hst.AppError{Step: step, Err: syscall.ERANGE, + Msg: "skipped out of bounds entry " + strconv.Itoa(v)} + goto out + } else { + si.identity = v + } + + out: + if !yield(si) { + break + } + } + }, l, nil +} + +// newStore returns the address of a new instance of stateStore. +// Multiple instances of stateStore rooted in the same directory is supported, but discouraged. +func newStore(base *check.Absolute) *stateStore { + return &stateStore{base: base, fileMu: lockedfile.MutexAt(base.Append(storeMutexName).String())} +} diff --git a/internal/state/store_test.go b/internal/state/store_test.go new file mode 100644 index 00000000..f58be18f --- /dev/null +++ b/internal/state/store_test.go @@ -0,0 +1,254 @@ +package state + +import ( + "cmp" + "iter" + "os" + "reflect" + "slices" + "strconv" + "strings" + "syscall" + "testing" + + "hakurei.app/container/check" + "hakurei.app/hst" +) + +func TestStateStoreBigLock(t *testing.T) { + t.Parallel() + + { + s := newStore(check.MustAbs(t.TempDir()).Append("state")) + for i := 0; i < 2; i++ { // check once behaviour + if unlock, err := s.bigLock(); err != nil { + t.Fatalf("bigLock: error = %v", err) + } else { + unlock() + } + } + } + + t.Run("mkdir", func(t *testing.T) { + t.Parallel() + + wantErr := &hst.AppError{Step: "create state store directory", + Err: &os.PathError{Op: "mkdir", Path: "/proc/nonexistent", Err: syscall.ENOENT}} + for i := 0; i < 2; i++ { // check once behaviour + if _, err := newStore(check.MustAbs("/proc/nonexistent")).bigLock(); !reflect.DeepEqual(err, wantErr) { + t.Errorf("bigLock: error = %#v, want %#v", err, wantErr) + } + } + }) + + t.Run("access", func(t *testing.T) { + t.Parallel() + + base := check.MustAbs(t.TempDir()).Append("inaccessible") + if err := os.MkdirAll(base.String(), 0); err != nil { + t.Fatal(err.Error()) + } + + wantErr := &hst.AppError{Step: "acquire lock on the state store", + Err: &os.PathError{Op: "open", Path: base.Append(storeMutexName).String(), Err: syscall.EACCES}} + if _, err := newStore(base).bigLock(); !reflect.DeepEqual(err, wantErr) { + t.Errorf("bigLock: error = %#v, want %#v", err, wantErr) + } + }) +} + +func TestStateStoreIdentityHandle(t *testing.T) { + t.Parallel() + + t.Run("loadstore", func(t *testing.T) { + t.Parallel() + + s := newStore(check.MustAbs(t.TempDir()).Append("store")) + + var handleAddr [8]*storeHandle + checkHandle := func(identity int, load bool) { + if h, err := s.identityHandle(identity); err != nil { + t.Fatalf("identityHandle: error = %v", err) + } else if load != (handleAddr[identity] != nil) { + t.Fatalf("identityHandle: load = %v, want %v", load, handleAddr[identity] != nil) + } else if !load { + handleAddr[identity] = h + + if h.identity != identity { + t.Errorf("identityHandle: identity = %d, want %d", h.identity, identity) + } + } else if h != handleAddr[identity] { + t.Fatalf("identityHandle: %p, want %p", h, handleAddr[identity]) + } + } + + checkHandle(0, false) + checkHandle(1, false) + checkHandle(2, false) + checkHandle(3, false) + checkHandle(7, false) + checkHandle(7, true) + checkHandle(2, true) + checkHandle(1, true) + checkHandle(2, true) + checkHandle(0, true) + }) + + t.Run("access", func(t *testing.T) { + t.Parallel() + + base := check.MustAbs(t.TempDir()).Append("inaccessible") + if err := os.MkdirAll(base.String(), 0); err != nil { + t.Fatal(err.Error()) + } + + wantErr := &hst.AppError{Step: "acquire lock on the state store", + Err: &os.PathError{Op: "open", Path: base.Append(storeMutexName).String(), Err: syscall.EACCES}} + if _, err := newStore(base).identityHandle(0); !reflect.DeepEqual(err, wantErr) { + t.Errorf("identityHandle: error = %#v, want %#v", err, wantErr) + } + }) + + t.Run("access segment", func(t *testing.T) { + t.Parallel() + + base := check.MustAbs(t.TempDir()).Append("inaccessible") + if err := os.MkdirAll(base.String(), 0700); err != nil { + t.Fatal(err.Error()) + } + if f, err := os.Create(base.Append(storeMutexName).String()); err != nil { + t.Fatal(err.Error()) + } else if err = f.Close(); err != nil { + t.Fatal(err.Error()) + } + if err := os.Chmod(base.String(), 0100); err != nil { + t.Fatal(err.Error()) + } + t.Cleanup(func() { + if err := os.Chmod(base.String(), 0700); err != nil { + t.Fatal(err.Error()) + } + }) + + wantErr := &hst.AppError{Step: "create store segment directory", + Err: &os.PathError{Op: "mkdir", Path: base.Append("0").String(), Err: syscall.EACCES}} + if _, err := newStore(base).identityHandle(0); !reflect.DeepEqual(err, wantErr) { + t.Errorf("identityHandle: error = %#v, want %#v", err, wantErr) + } + }) +} + +func TestStateStoreSegments(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + ents [2][]string + want []segmentIdentity + ext func(t *testing.T, segments iter.Seq[segmentIdentity], n int) + }{ + {"errors", [2][]string{{ + "f0-invalid-file", + }, { + "f1-invalid-syntax", + "9999", + "16384", + }}, []segmentIdentity{ + {-1, &hst.AppError{Step: "process store segment", Err: syscall.EISDIR, + Msg: `skipped non-directory entry "f0-invalid-file"`}}, + {-1, &hst.AppError{Step: "process store segment", Err: syscall.ERANGE, + Msg: `skipped out of bounds entry 16384`}}, + {-1, &hst.AppError{Step: "process store segment", + Err: &strconv.NumError{Func: "Atoi", Num: "f1-invalid-syntax", Err: strconv.ErrSyntax}, + Msg: `skipped non-identity entry "f1-invalid-syntax"`}}, + {9999, nil}, + }, nil}, + + {"success", [2][]string{{ + "lock", + }, { + "0", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "9", + "13", + "20", + "31", + "197", + }}, []segmentIdentity{ + {0, nil}, + {1, nil}, + {2, nil}, + {3, nil}, + {4, nil}, + {5, nil}, + {6, nil}, + {7, nil}, + {9, nil}, + {13, nil}, + {20, nil}, + {31, nil}, + {197, nil}, + }, func(t *testing.T, segments iter.Seq[segmentIdentity], n int) { + if n != 13 { + t.Fatalf("segments: n = %d", n) + } + + // check partial drain + for range segments { + break + } + }}, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + base := check.MustAbs(t.TempDir()).Append("store") + if err := os.Mkdir(base.String(), 0700); err != nil { + t.Fatal(err.Error()) + } + createEntries(t, base, tc.ents) + + var got []segmentIdentity + if segments, n, err := newStore(base).segments(); err != nil { + t.Fatalf("segments: error = %v", err) + } else { + got = slices.AppendSeq(make([]segmentIdentity, 0, n), segments) + if tc.ext != nil { + tc.ext(t, segments, n) + } + } + + slices.SortFunc(got, func(a, b segmentIdentity) int { + if a.identity == b.identity { + return strings.Compare(a.err.Error(), b.err.Error()) + } + return cmp.Compare(a.identity, b.identity) + }) + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("segments: %#v, want %#v", got, tc.want) + } + }) + } + + t.Run("access", func(t *testing.T) { + t.Parallel() + + base := check.MustAbs(t.TempDir()).Append("inaccessible") + if err := os.MkdirAll(base.String(), 0); err != nil { + t.Fatal(err.Error()) + } + + wantErr := &hst.AppError{Step: "acquire lock on the state store", + Err: &os.PathError{Op: "open", Path: base.Append(storeMutexName).String(), Err: syscall.EACCES}} + if _, _, err := newStore(base).segments(); !reflect.DeepEqual(err, wantErr) { + t.Errorf("segments: error = %#v, want %#v", err, wantErr) + } + }) +} -- cgit v1.3.1