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/sppulse.go | |
| 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/sppulse.go')
| -rw-r--r-- | internal/outcome/sppulse.go | 210 |
1 files changed, 210 insertions, 0 deletions
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 + } +} |
