aboutsummaryrefslogtreecommitdiffhomepage
path: root/internal/outcome
diff options
context:
space:
mode:
authorOphestra <cat@gensokyo.uk>2025-10-29 04:32:43 +0900
committerOphestra <cat@gensokyo.uk>2025-10-29 04:33:13 +0900
commita0b4e47acc228e48165966d8e139277b8f6f450e (patch)
treed363a6781536ce946aa445271ca1d3df587e6031 /internal/outcome
parenta52f7038e5a607dd0901244abb791c96deaf9c2d (diff)
internal/outcome: rename from app
This is less ambiguous, and more accurately describes the purpose of the package. Signed-off-by: Ophestra <cat@gensokyo.uk>
Diffstat (limited to 'internal/outcome')
-rw-r--r--internal/outcome/dispatcher.go163
-rw-r--r--internal/outcome/dispatcher_test.go662
-rw-r--r--internal/outcome/finalise.go85
-rw-r--r--internal/outcome/hsu.go101
-rw-r--r--internal/outcome/hsu_test.go84
-rw-r--r--internal/outcome/main.go27
-rw-r--r--internal/outcome/main_test.go923
-rw-r--r--internal/outcome/outcome.go303
-rw-r--r--internal/outcome/outcome_test.go34
-rw-r--r--internal/outcome/process.go321
-rw-r--r--internal/outcome/shim-signal.c65
-rw-r--r--internal/outcome/shim-signal.h3
-rw-r--r--internal/outcome/shim.go235
-rw-r--r--internal/outcome/shim_test.go156
-rw-r--r--internal/outcome/spaccount.go56
-rw-r--r--internal/outcome/spaccount_test.go72
-rw-r--r--internal/outcome/spcontainer.go391
-rw-r--r--internal/outcome/spcontainer_test.go477
-rw-r--r--internal/outcome/spdbus.go58
-rw-r--r--internal/outcome/spdbus_test.go190
-rw-r--r--internal/outcome/sppulse.go210
-rw-r--r--internal/outcome/sppulse_test.go460
-rw-r--r--internal/outcome/spruntime.go125
-rw-r--r--internal/outcome/spruntime_test.go128
-rw-r--r--internal/outcome/sptmpdir.go44
-rw-r--r--internal/outcome/sptmpdir_test.go34
-rw-r--r--internal/outcome/spwayland.go63
-rw-r--r--internal/outcome/spwayland_test.go104
-rw-r--r--internal/outcome/spx11.go71
-rw-r--r--internal/outcome/spx11_test.go119
30 files changed, 5764 insertions, 0 deletions
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 <errno.h>
+#include <signal.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <unistd.h>
+
+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 <signal.h>
+
+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},
+ })
+}