diff options
| author | Ophestra <cat@gensokyo.uk> | 2025-10-29 04:32:43 +0900 |
|---|---|---|
| committer | Ophestra <cat@gensokyo.uk> | 2025-10-29 04:33:13 +0900 |
| commit | a0b4e47acc228e48165966d8e139277b8f6f450e (patch) | |
| tree | d363a6781536ce946aa445271ca1d3df587e6031 /internal/outcome | |
| parent | a52f7038e5a607dd0901244abb791c96deaf9c2d (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')
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}, + }) +} |
