diff --git a/internal/app/dispatcher_test.go b/internal/app/dispatcher_test.go index 2dd9c88..b87fae6 100644 --- a/internal/app/dispatcher_test.go +++ b/internal/app/dispatcher_test.go @@ -2,6 +2,7 @@ package app import ( "bytes" + "io" "io/fs" "log" "maps" @@ -34,6 +35,13 @@ const ( 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 [state.ID] value used by checkOpBehaviour to initialise outcomeState. @@ -87,6 +95,19 @@ func sysUsesInstance(next extraCheckSysFunc) extraCheckSysFunc { } } +// 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.sharePath, 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)) @@ -131,7 +152,7 @@ func checkOpBehaviour(t *testing.T, testCases []opBehaviourTestCase) { 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"}, container.Nonexistent+"/xdg_runtime_dir", nil), + call("lookupEnv", stub.ExpectArgs{"XDG_RUNTIME_DIR"}, wantRuntimePath, nil), call("getuid", stub.ExpectArgs{}, 1000, nil), call("getgid", stub.ExpectArgs{}, 100, nil), @@ -257,6 +278,18 @@ func (k *kstub) lookupEnv(key string) (string, bool) { } 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") @@ -340,6 +373,32 @@ func (k *kstub) Suspend() bool { k.Helper(); return k.Expects("suspend").Ret.(bo 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)) diff --git a/internal/app/sppulse.go b/internal/app/sppulse.go index 0adce11..9cc172d 100644 --- a/internal/app/sppulse.go +++ b/internal/app/sppulse.go @@ -20,6 +20,7 @@ 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 @@ -36,14 +37,14 @@ func (s *spPulseOp) toSystem(state *outcomeStateSys) error { if !errors.Is(err, fs.ErrNotExist) { return &hst.AppError{Step: fmt.Sprintf("access PulseAudio directory %q", pulseRuntimeDir), Err: err} } - return newWithMessage(fmt.Sprintf("PulseAudio directory %q not found", pulseRuntimeDir)) + 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 newWithMessage(fmt.Sprintf("PulseAudio directory %q found but socket does not exist", pulseRuntimeDir)) + 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)) diff --git a/internal/app/sppulse_test.go b/internal/app/sppulse_test.go new file mode 100644 index 0000000..75b1db0 --- /dev/null +++ b/internal/app/sppulse_test.go @@ -0,0 +1,171 @@ +package app + +import ( + "bytes" + "os" + "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, _ 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("verbosef", stub.ExpectArgs{"loading up to %d bytes from %q", []any{1 << 8, "/proc/nonexistent/cookie"}}, nil, nil), + call("stat", stub.ExpectArgs{"/proc/nonexistent/cookie"}, &stubFi{isDir: false, size: 1 << 8}, 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}, + + {"success cookie", func(isShim bool, _ 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), nil), + call("stat", stub.ExpectArgs{wantRuntimePath + "/pulse/native"}, &stubFi{mode: 0666}, nil), + call("lookupEnv", stub.ExpectArgs{"PULSE_COOKIE"}, "/proc/nonexistent/cookie", nil), + call("verbosef", stub.ExpectArgs{"loading up to %d bytes from %q", []any{1 << 8, "/proc/nonexistent/cookie"}}, nil, nil), + call("stat", stub.ExpectArgs{"/proc/nonexistent/cookie"}, &stubFi{isDir: false, size: 1 << 8}, 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}, + }) +}