diff --git a/internal/app/dispatcher_test.go b/internal/app/dispatcher_test.go index b87fae6..7e91b81 100644 --- a/internal/app/dispatcher_test.go +++ b/internal/app/dispatcher_test.go @@ -124,24 +124,39 @@ func paramsWantEnv(config *hst.Config, wantEnv map[string]string, next extraChec } } +// opBehaviourTestCase checks outcomeOp behaviour against outcomeStateSys and outcomeStateParams. type opBehaviourTestCase struct { - name string - newOp func(isShim, clearUnexported bool) outcomeOp + 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 pStateSysFunc - toSystem []stub.Call - wantSys *system.I + // 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 pStateContainerFunc - toContainer []stub.Call - wantParams *container.Params + // 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() @@ -258,6 +273,40 @@ func checkOpBehaviour(t *testing.T, testCases []opBehaviourTestCase) { 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] @@ -416,6 +465,11 @@ func (nameDentry) IsDir() bool { panic("unreachable") } func (nameDentry) Type() fs.FileMode { panic("unreachable") } func (nameDentry) Info() (fs.FileInfo, error) { panic("unreachable") } +// errorReader implements [io.Reader] that unconditionally returns -1, val. +type errorReader struct{ val error } + +func (r errorReader) Read([]byte) (int, error) { return -1, r.val } + // panicMsgContext implements [message.Msg] and [context.Context] with methods wrapping panic. // This should be assigned to test cases to be checked against. type panicMsgContext struct{} diff --git a/internal/app/sppulse.go b/internal/app/sppulse.go index 9cc172d..fe63f36 100644 --- a/internal/app/sppulse.go +++ b/internal/app/sppulse.go @@ -24,6 +24,8 @@ func init() { gob.Register(new(spPulseOp)) } 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 { @@ -60,7 +62,7 @@ func (s *spPulseOp) toSystem(state *outcomeStateSys) error { return err } else if a != nil { s.Cookie = new([pulseCookieSizeMax]byte) - if err = loadFile(state.msg, state.k, "PulseAudio cookie", a.String(), s.Cookie[:]); err != nil { + if s.CookieSize, err = loadFile(state.msg, state.k, "PulseAudio cookie", a.String(), s.Cookie[:]); err != nil { return err } } else { @@ -80,8 +82,12 @@ func (s *spPulseOp) toContainer(state *outcomeStateParams) error { 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[:]) + state.params.Place(innerDst, s.Cookie[:s.CookieSize]) } return nil @@ -161,44 +167,44 @@ func discoverPulseCookie(k syscallDispatcher) (*check.Absolute, error) { func loadFile( msg message.Msg, k syscallDispatcher, description, pathname string, buf []byte, -) error { +) (int, error) { n := len(buf) if n == 0 { - return errors.New("invalid buffer") + return -1, errors.New("invalid buffer") } - msg.Verbosef("loading up to %d bytes from %q", n, pathname) if fi, err := k.stat(pathname); err != nil { - return &hst.AppError{Step: "access " + description, Err: err} + return -1, &hst.AppError{Step: "access " + description, Err: err} } else { if fi.IsDir() { - return &hst.AppError{Step: "read " + description, + 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 newWithMessageError( - description+" at "+strconv.Quote(pathname)+" exceeds maximum expected size", + 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 longer than expected", description, pathname, int64(n)-s) + 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 &hst.AppError{Step: "open " + description, Err: err} + 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 &hst.AppError{Step: "read " + description, Err: err} + return n, &hst.AppError{Step: "read " + description, Err: err} } - msg.Verbosef("copied %d bytes from %q", n, pathname) - } // nil error indicates a partial read, which is handled after stat + } if err = f.Close(); err != nil { - return &hst.AppError{Step: "close " + description, Err: err} + return n, &hst.AppError{Step: "close " + description, Err: err} } - return nil + return n, nil } } diff --git a/internal/app/sppulse_test.go b/internal/app/sppulse_test.go index 75b1db0..7c050bf 100644 --- a/internal/app/sppulse_test.go +++ b/internal/app/sppulse_test.go @@ -2,7 +2,9 @@ package app import ( "bytes" + "errors" "os" + "syscall" "testing" "hakurei.app/container" @@ -28,7 +30,7 @@ func TestSpPulseOp(t *testing.T) { return c }, nil, nil, nil, nil, errNotEnabled, nil, nil, nil, nil, nil}, - {"socketDir stat", func(isShim bool, _ bool) outcomeOp { + {"socketDir stat", func(isShim, _ bool) outcomeOp { if !isShim { return new(spPulseOp) } @@ -99,25 +101,94 @@ func TestSpPulseOp(t *testing.T) { 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("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}, - {"success cookie", func(isShim bool, _ bool) outcomeOp { + {"cookie bad shim size", func(isShim, clearUnexported bool) outcomeOp { if !isShim { return new(spPulseOp) } - return &spPulseOp{Cookie: (*[256]byte)(sampleCookie)} + 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("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("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 @@ -169,3 +240,125 @@ func TestSpPulseOp(t *testing.T) { }, nil), nil}, }) } + +func TestLoadFile(t *testing.T) { + t.Parallel() + + 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", func(k *kstub) error { + n, err := loadFile(k, k, + "simulated PulseAudio cookie", + "/home/ophestra/xdg/config/pulse/cookie", + make([]byte, 1<<8+0xfd)) + k.Verbose(n) + return err + }, 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", func(k *kstub) error { + n, err := loadFile(k, k, + "simulated PulseAudio cookie", + "/home/ophestra/xdg/config/pulse/cookie", + make([]byte, 1<<8+0xfd)) + k.Verbose(n) + return err + }, 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", func(k *kstub) error { + n, err := loadFile(k, k, + "simulated PulseAudio cookie", + "/home/ophestra/xdg/config/pulse/cookie", + make([]byte, 1<<8+0xfd)) + k.Verbose(n) + return err + }, 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", func(k *kstub) error { + n, err := loadFile(k, k, + "simulated PulseAudio cookie", + "/home/ophestra/xdg/config/pulse/cookie", + make([]byte, 1<<8+0xfd)) + k.Verbose(n) + return err + }, 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", func(k *kstub) error { + n, err := loadFile(k, k, + "simulated PulseAudio cookie", + "/home/ophestra/xdg/config/pulse/cookie", + make([]byte, 1<<8+0xfd)) + k.Verbose(n) + return err + }, 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", 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 + }, 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", 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 + }, 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}, + }) +}