From f347d44c2281f507e3786aa498f634bfa4c92d84 Mon Sep 17 00:00:00 2001 From: Ophestra Date: Wed, 12 Nov 2025 23:12:38 +0900 Subject: internal/helper: relocate from helper This package is ugly and is pending removal only kept alive by xdg-dbus-proxy. Its exported symbols are made available until v0.4.0 where it will be removed for #24. Signed-off-by: Ophestra --- helper/args.go | 59 -------------- helper/args_test.go | 41 ---------- helper/cmd.go | 84 -------------------- helper/cmd_test.go | 40 ---------- helper/container.go | 79 ------------------- helper/container_test.go | 45 ----------- helper/deprecated.go | 73 +++++++++++++++++ helper/helper.go | 83 ------------------- helper/helper_test.go | 139 -------------------------------- helper/proc/deprecated.go | 63 +++++++++++++++ helper/proc/files.go | 162 -------------------------------------- helper/proc/pipe.go | 110 -------------------------- helper/stub.go | 102 ------------------------ helper/stub_test.go | 11 --- internal/helper/args.go | 59 ++++++++++++++ internal/helper/args_test.go | 41 ++++++++++ internal/helper/cmd.go | 84 ++++++++++++++++++++ internal/helper/cmd_test.go | 40 ++++++++++ internal/helper/container.go | 79 +++++++++++++++++++ internal/helper/container_test.go | 45 +++++++++++ internal/helper/helper.go | 83 +++++++++++++++++++ internal/helper/helper_test.go | 139 ++++++++++++++++++++++++++++++++ internal/helper/proc/files.go | 162 ++++++++++++++++++++++++++++++++++++++ internal/helper/proc/pipe.go | 110 ++++++++++++++++++++++++++ internal/helper/stub.go | 102 ++++++++++++++++++++++++ internal/helper/stub_test.go | 11 +++ 26 files changed, 1091 insertions(+), 955 deletions(-) delete mode 100644 helper/args.go delete mode 100644 helper/args_test.go delete mode 100644 helper/cmd.go delete mode 100644 helper/cmd_test.go delete mode 100644 helper/container.go delete mode 100644 helper/container_test.go create mode 100644 helper/deprecated.go delete mode 100644 helper/helper.go delete mode 100644 helper/helper_test.go create mode 100644 helper/proc/deprecated.go delete mode 100644 helper/proc/files.go delete mode 100644 helper/proc/pipe.go delete mode 100644 helper/stub.go delete mode 100644 helper/stub_test.go create mode 100644 internal/helper/args.go create mode 100644 internal/helper/args_test.go create mode 100644 internal/helper/cmd.go create mode 100644 internal/helper/cmd_test.go create mode 100644 internal/helper/container.go create mode 100644 internal/helper/container_test.go create mode 100644 internal/helper/helper.go create mode 100644 internal/helper/helper_test.go create mode 100644 internal/helper/proc/files.go create mode 100644 internal/helper/proc/pipe.go create mode 100644 internal/helper/stub.go create mode 100644 internal/helper/stub_test.go diff --git a/helper/args.go b/helper/args.go deleted file mode 100644 index 80cd3024..00000000 --- a/helper/args.go +++ /dev/null @@ -1,59 +0,0 @@ -package helper - -import ( - "bytes" - "io" - "syscall" -) - -type argsWt [][]byte - -func (a argsWt) WriteTo(w io.Writer) (int64, error) { - nt := 0 - for _, arg := range a { - n, err := w.Write(arg) - nt += n - - if err != nil { - return int64(nt), err - } - } - - return int64(nt), nil -} - -func (a argsWt) String() string { - return string( - bytes.TrimSuffix( - bytes.ReplaceAll( - bytes.Join(a, nil), - []byte{0}, []byte{' '}, - ), - []byte{' '}, - ), - ) -} - -// NewCheckedArgs returns a checked null-terminated argument writer for a copy of args. -func NewCheckedArgs(args ...string) (wt io.WriterTo, err error) { - a := make(argsWt, len(args)) - for i, arg := range args { - a[i], err = syscall.ByteSliceFromString(arg) - if err != nil { - return - } - } - wt = a - return -} - -// MustNewCheckedArgs returns a checked null-terminated argument writer for a copy of args. -// If s contains a NUL byte this function panics instead of returning an error. -func MustNewCheckedArgs(args ...string) io.WriterTo { - a, err := NewCheckedArgs(args...) - if err != nil { - panic(err.Error()) - } - - return a -} diff --git a/helper/args_test.go b/helper/args_test.go deleted file mode 100644 index d6225c3e..00000000 --- a/helper/args_test.go +++ /dev/null @@ -1,41 +0,0 @@ -package helper_test - -import ( - "errors" - "fmt" - "strings" - "syscall" - "testing" - - "hakurei.app/helper" -) - -func TestArgsString(t *testing.T) { - t.Parallel() - wantString := strings.Join(wantArgs, " ") - if got := argsWt.(fmt.Stringer).String(); got != wantString { - t.Errorf("String: %q, want %q", got, wantString) - } -} - -func TestNewCheckedArgs(t *testing.T) { - t.Parallel() - - args := []string{"\x00"} - if _, err := helper.NewCheckedArgs(args...); !errors.Is(err, syscall.EINVAL) { - t.Errorf("NewCheckedArgs: error = %v, wantErr %v", err, syscall.EINVAL) - } - - t.Run("must panic", func(t *testing.T) { - t.Parallel() - - badPayload := []string{"\x00"} - defer func() { - wantPanic := "invalid argument" - if r := recover(); r != wantPanic { - t.Errorf("MustNewCheckedArgs: panic = %v, wantPanic %v", r, wantPanic) - } - }() - helper.MustNewCheckedArgs(badPayload...) - }) -} diff --git a/helper/cmd.go b/helper/cmd.go deleted file mode 100644 index 9a5bc3ab..00000000 --- a/helper/cmd.go +++ /dev/null @@ -1,84 +0,0 @@ -package helper - -import ( - "context" - "errors" - "io" - "os" - "os/exec" - "slices" - "sync" - "syscall" - - "hakurei.app/helper/proc" -) - -// NewDirect initialises a new direct Helper instance with wt as the null-terminated argument writer. -// Function argF returns an array of arguments passed directly to the child process. -func NewDirect( - ctx context.Context, - name string, - wt io.WriterTo, - stat bool, - argF func(argsFd, statFd int) []string, - cmdF func(cmd *exec.Cmd), - extraFiles []*os.File, -) Helper { - d, args := newHelperCmd(ctx, name, wt, stat, argF, extraFiles) - d.Args = append(d.Args, args...) - if cmdF != nil { - cmdF(d.Cmd) - } - return d -} - -func newHelperCmd( - ctx context.Context, - name string, - wt io.WriterTo, - stat bool, - argF func(argsFd, statFd int) []string, - extraFiles []*os.File, -) (cmd *helperCmd, args []string) { - cmd = new(helperCmd) - cmd.helperFiles, args = newHelperFiles(ctx, wt, stat, argF, extraFiles) - cmd.Cmd = exec.CommandContext(ctx, name) - cmd.Cmd.Cancel = func() error { return cmd.Process.Signal(syscall.SIGTERM) } - cmd.WaitDelay = WaitDelay - return -} - -// helperCmd provides a [exec.Cmd] wrapper around helper ipc. -type helperCmd struct { - mu sync.RWMutex - *helperFiles - *exec.Cmd -} - -func (h *helperCmd) Start() error { - h.mu.Lock() - defer h.mu.Unlock() - - // Check for doubled Start calls before we defer failure cleanup. If the prior - // call to Start succeeded, we don't want to spuriously close its pipes. - if h.Cmd != nil && h.Cmd.Process != nil { - return errors.New("helper: already started") - } - - h.Env = slices.Grow(h.Env, 2) - if h.useArgsFd { - h.Env = append(h.Env, HakureiHelper+"=1") - } else { - h.Env = append(h.Env, HakureiHelper+"=0") - } - if h.useStatFd { - h.Env = append(h.Env, HakureiStatus+"=1") - - // stat is populated on fulfill - h.Cancel = func() error { return h.stat.Close() } - } else { - h.Env = append(h.Env, HakureiStatus+"=0") - } - - return proc.Fulfill(h.helperFiles.ctx, &h.ExtraFiles, h.Cmd.Start, h.files, h.extraFiles) -} diff --git a/helper/cmd_test.go b/helper/cmd_test.go deleted file mode 100644 index 64ac643f..00000000 --- a/helper/cmd_test.go +++ /dev/null @@ -1,40 +0,0 @@ -package helper_test - -import ( - "context" - "errors" - "io" - "os" - "os/exec" - "testing" - - "hakurei.app/container" - "hakurei.app/helper" -) - -func TestCmd(t *testing.T) { - t.Run("start non-existent helper path", func(t *testing.T) { - h := helper.NewDirect(t.Context(), container.Nonexistent, argsWt, false, argF, nil, nil) - - if err := h.Start(); !errors.Is(err, os.ErrNotExist) { - t.Errorf("Start: error = %v, wantErr %v", - err, os.ErrNotExist) - } - }) - - t.Run("valid new helper nil check", func(t *testing.T) { - if got := helper.NewDirect(t.Context(), "hakurei", argsWt, false, argF, nil, nil); got == nil { - t.Errorf("NewDirect(%q, %q) got nil", - argsWt, "hakurei") - return - } - }) - - t.Run("implementation compliance", func(t *testing.T) { - testHelper(t, func(ctx context.Context, setOutput func(stdoutP, stderrP *io.Writer), stat bool) helper.Helper { - return helper.NewDirect(ctx, os.Args[0], argsWt, stat, argF, func(cmd *exec.Cmd) { - setOutput(&cmd.Stdout, &cmd.Stderr) - }, nil) - }) - }) -} diff --git a/helper/container.go b/helper/container.go deleted file mode 100644 index b85f5859..00000000 --- a/helper/container.go +++ /dev/null @@ -1,79 +0,0 @@ -package helper - -import ( - "context" - "errors" - "io" - "os" - "os/exec" - "slices" - "sync" - - "hakurei.app/container" - "hakurei.app/container/check" - "hakurei.app/helper/proc" - "hakurei.app/message" -) - -// New initialises a Helper instance with wt as the null-terminated argument writer. -func New( - ctx context.Context, - msg message.Msg, - pathname *check.Absolute, name string, - wt io.WriterTo, - stat bool, - argF func(argsFd, statFd int) []string, - cmdF func(z *container.Container), - extraFiles []*os.File, -) Helper { - var args []string - h := new(helperContainer) - h.helperFiles, args = newHelperFiles(ctx, wt, stat, argF, extraFiles) - h.Container = container.NewCommand(ctx, msg, pathname, name, args...) - h.WaitDelay = WaitDelay - if cmdF != nil { - cmdF(h.Container) - } - return h -} - -// helperContainer provides a [sandbox.Container] wrapper around helper ipc. -type helperContainer struct { - started bool - - mu sync.Mutex - *helperFiles - *container.Container -} - -func (h *helperContainer) Start() error { - h.mu.Lock() - defer h.mu.Unlock() - - if h.started { - return errors.New("helper: already started") - } - h.started = true - - h.Env = slices.Grow(h.Env, 2) - if h.useArgsFd { - h.Env = append(h.Env, HakureiHelper+"=1") - } else { - h.Env = append(h.Env, HakureiHelper+"=0") - } - if h.useStatFd { - h.Env = append(h.Env, HakureiStatus+"=1") - - // stat is populated on fulfill - h.Cancel = func(*exec.Cmd) error { return h.stat.Close() } - } else { - h.Env = append(h.Env, HakureiStatus+"=0") - } - - return proc.Fulfill(h.helperFiles.ctx, &h.ExtraFiles, func() error { - if err := h.Container.Start(); err != nil { - return err - } - return h.Container.Serve() - }, h.files, h.extraFiles) -} diff --git a/helper/container_test.go b/helper/container_test.go deleted file mode 100644 index 922d8253..00000000 --- a/helper/container_test.go +++ /dev/null @@ -1,45 +0,0 @@ -package helper_test - -import ( - "context" - "io" - "os" - "testing" - - "hakurei.app/container" - "hakurei.app/container/check" - "hakurei.app/container/fhs" - "hakurei.app/helper" -) - -func TestContainer(t *testing.T) { - t.Run("start invalid container", func(t *testing.T) { - h := helper.New(t.Context(), nil, check.MustAbs(container.Nonexistent), "hakurei", argsWt, false, argF, nil, nil) - - wantErr := "container: starting an invalid container" - if err := h.Start(); err == nil || err.Error() != wantErr { - t.Errorf("Start: error = %v, wantErr %q", - err, wantErr) - } - }) - - t.Run("valid new helper nil check", func(t *testing.T) { - if got := helper.New(t.Context(), nil, check.MustAbs(container.Nonexistent), "hakurei", argsWt, false, argF, nil, nil); got == nil { - t.Errorf("New(%q, %q) got nil", - argsWt, "hakurei") - return - } - }) - - t.Run("implementation compliance", func(t *testing.T) { - testHelper(t, func(ctx context.Context, setOutput func(stdoutP, stderrP *io.Writer), stat bool) helper.Helper { - return helper.New(ctx, nil, check.MustAbs(os.Args[0]), "helper", argsWt, stat, argF, func(z *container.Container) { - setOutput(&z.Stdout, &z.Stderr) - z. - Bind(fhs.AbsRoot, fhs.AbsRoot, 0). - Proc(fhs.AbsProc). - Dev(fhs.AbsDev, true) - }, nil) - }) - }) -} diff --git a/helper/deprecated.go b/helper/deprecated.go new file mode 100644 index 00000000..f09b0c84 --- /dev/null +++ b/helper/deprecated.go @@ -0,0 +1,73 @@ +// Package helper exposes the internal/helper package. +// +// Deprecated: This package will be removed in 0.4. +package helper + +import ( + "context" + "io" + "os" + "os/exec" + "time" + _ "unsafe" // for go:linkname + + "hakurei.app/container" + "hakurei.app/container/check" + "hakurei.app/internal/helper" + "hakurei.app/message" +) + +//go:linkname WaitDelay hakurei.app/internal/helper.WaitDelay +var WaitDelay time.Duration + +const ( + // HakureiHelper is set to 1 when args fd is enabled and 0 otherwise. + HakureiHelper = helper.HakureiHelper + // HakureiStatus is set to 1 when stat fd is enabled and 0 otherwise. + HakureiStatus = helper.HakureiStatus +) + +type Helper = helper.Helper + +// NewCheckedArgs returns a checked null-terminated argument writer for a copy of args. +// +//go:linkname NewCheckedArgs hakurei.app/internal/helper.NewCheckedArgs +func NewCheckedArgs(args ...string) (wt io.WriterTo, err error) + +// MustNewCheckedArgs returns a checked null-terminated argument writer for a copy of args. +// If s contains a NUL byte this function panics instead of returning an error. +// +//go:linkname MustNewCheckedArgs hakurei.app/internal/helper.MustNewCheckedArgs +func MustNewCheckedArgs(args ...string) io.WriterTo + +// NewDirect initialises a new direct Helper instance with wt as the null-terminated argument writer. +// Function argF returns an array of arguments passed directly to the child process. +// +//go:linkname NewDirect hakurei.app/internal/helper.NewDirect +func NewDirect( + ctx context.Context, + name string, + wt io.WriterTo, + stat bool, + argF func(argsFd, statFd int) []string, + cmdF func(cmd *exec.Cmd), + extraFiles []*os.File, +) Helper + +// New initialises a Helper instance with wt as the null-terminated argument writer. +// +//go:linkname New hakurei.app/internal/helper.New +func New( + ctx context.Context, + msg message.Msg, + pathname *check.Absolute, name string, + wt io.WriterTo, + stat bool, + argF func(argsFd, statFd int) []string, + cmdF func(z *container.Container), + extraFiles []*os.File, +) Helper + +// InternalHelperStub is an internal function but exported because it is cross-package; +// it is part of the implementation of the helper stub. +func InternalHelperStub() { helper.InternalHelperStub() } diff --git a/helper/helper.go b/helper/helper.go deleted file mode 100644 index 60bd7615..00000000 --- a/helper/helper.go +++ /dev/null @@ -1,83 +0,0 @@ -// Package helper runs external helpers with optional sandboxing. -package helper - -import ( - "context" - "fmt" - "io" - "os" - "time" - - "hakurei.app/helper/proc" -) - -var WaitDelay = 2 * time.Second - -const ( - // HakureiHelper is set to 1 when args fd is enabled and 0 otherwise. - HakureiHelper = "HAKUREI_HELPER" - // HakureiStatus is set to 1 when stat fd is enabled and 0 otherwise. - HakureiStatus = "HAKUREI_STATUS" -) - -type Helper interface { - // Start starts the helper process. - Start() error - // Wait blocks until Helper exits. - Wait() error - - fmt.Stringer -} - -func newHelperFiles( - ctx context.Context, - wt io.WriterTo, - stat bool, - argF func(argsFd, statFd int) []string, - extraFiles []*os.File, -) (hl *helperFiles, args []string) { - hl = new(helperFiles) - hl.ctx = ctx - hl.useArgsFd = wt != nil - hl.useStatFd = stat - - hl.extraFiles = new(proc.ExtraFilesPre) - for _, f := range extraFiles { - _, v := hl.extraFiles.Append() - *v = f - } - - argsFd := -1 - if hl.useArgsFd { - f := proc.NewWriterTo(wt) - argsFd = int(proc.InitFile(f, hl.extraFiles)) - hl.files = append(hl.files, f) - } - - statFd := -1 - if hl.useStatFd { - f := proc.NewStat(&hl.stat) - statFd = int(proc.InitFile(f, hl.extraFiles)) - hl.files = append(hl.files, f) - } - - args = argF(argsFd, statFd) - return -} - -// helperFiles provides a generic wrapper around helper ipc. -type helperFiles struct { - // whether argsFd is present - useArgsFd bool - // whether statFd is present - useStatFd bool - - // closes statFd - stat io.Closer - // deferred extraFiles fulfillment - files []proc.File - // passed through to [proc.Fulfill] and [proc.InitFile] - extraFiles *proc.ExtraFilesPre - - ctx context.Context -} diff --git a/helper/helper_test.go b/helper/helper_test.go deleted file mode 100644 index 4cc36d34..00000000 --- a/helper/helper_test.go +++ /dev/null @@ -1,139 +0,0 @@ -package helper_test - -import ( - "context" - "errors" - "fmt" - "io" - "os" - "reflect" - "strconv" - "strings" - "syscall" - "testing" - "time" - - "hakurei.app/helper" -) - -var ( - wantArgs = []string{ - "unix:path=/run/dbus/system_bus_socket", - "/tmp/hakurei.1971/12622d846cc3fe7b4c10359d01f0eb47/system_bus_socket", - "--filter", - "--talk=org.bluez", - "--talk=org.freedesktop.Avahi", - "--talk=org.freedesktop.UPower", - } - - wantPayload = strings.Join(wantArgs, "\x00") + "\x00" - argsWt = helper.MustNewCheckedArgs(wantArgs...) -) - -func argF(argsFd, statFd int) []string { - if argsFd == -1 { - panic("invalid args fd") - } - - return argFChecked(argsFd, statFd) -} - -func argFChecked(argsFd, statFd int) (args []string) { - args = make([]string, 0, 6) - if argsFd > -1 { - args = append(args, "--args", strconv.Itoa(argsFd)) - } - if statFd > -1 { - args = append(args, "--fd", strconv.Itoa(statFd)) - } - return -} - -const ( - containerTimeout = 30 * time.Second -) - -// this function tests an implementation of the helper.Helper interface -func testHelper(t *testing.T, createHelper func(ctx context.Context, setOutput func(stdoutP, stderrP *io.Writer), stat bool) helper.Helper) { - oldWaitDelay := helper.WaitDelay - helper.WaitDelay = 16 * time.Second - t.Cleanup(func() { helper.WaitDelay = oldWaitDelay }) - - t.Run("start helper with status channel and wait", func(t *testing.T) { - ctx, cancel := context.WithTimeout(t.Context(), containerTimeout) - stdout := new(strings.Builder) - h := createHelper(ctx, func(stdoutP, stderrP *io.Writer) { *stdoutP, *stderrP = stdout, os.Stderr }, true) - - t.Run("wait not yet started helper", func(t *testing.T) { - if err := h.Wait(); !reflect.DeepEqual(err, syscall.EINVAL) && - !reflect.DeepEqual(err, errors.New("exec: not started")) { - t.Errorf("Wait: error = %v", err) - } - }) - - t.Log("starting helper stub") - if err := h.Start(); err != nil { - t.Errorf("Start: error = %v", err) - cancel() - return - } - t.Log("cancelling context") - cancel() - - t.Run("start already started helper", func(t *testing.T) { - wantErr := "helper: already started" - if err := h.Start(); err != nil && err.Error() != wantErr { - t.Errorf("Start: error = %v, wantErr %v", - err, wantErr) - return - } - }) - - t.Log("waiting on helper") - if err := h.Wait(); !errors.Is(err, context.Canceled) { - t.Errorf("Wait: error = %v", - err) - } - - t.Run("wait already finalised helper", func(t *testing.T) { - wantErr := "exec: Wait was already called" - if err := h.Wait(); err != nil && err.Error() != wantErr { - t.Errorf("Wait: error = %v, wantErr %v", - err, wantErr) - return - } - }) - - if got := trimStdout(stdout); got != wantPayload { - t.Errorf("Start: stdout = %q, want %q", - got, wantPayload) - } - }) - - t.Run("start helper and wait", func(t *testing.T) { - ctx, cancel := context.WithTimeout(t.Context(), containerTimeout) - defer cancel() - stdout := new(strings.Builder) - h := createHelper(ctx, func(stdoutP, stderrP *io.Writer) { *stdoutP, *stderrP = stdout, os.Stderr }, false) - - if err := h.Start(); err != nil { - t.Errorf("Start: error = %v", - err) - return - } - - if err := h.Wait(); err != nil { - t.Errorf("Wait: error = %v stdout = %q", - err, stdout) - } - - if got := trimStdout(stdout); got != wantPayload { - t.Errorf("Start: stdout = %q, want %q", - got, wantPayload) - } - }) -} - -func trimStdout(stdout fmt.Stringer) string { - return strings.TrimPrefix(stdout.String(), "=== RUN TestHelperInit\n") -} diff --git a/helper/proc/deprecated.go b/helper/proc/deprecated.go new file mode 100644 index 00000000..91897526 --- /dev/null +++ b/helper/proc/deprecated.go @@ -0,0 +1,63 @@ +// Deprecated: This package will be removed in 0.4. +package proc + +import ( + "context" + "io" + "os" + "os/exec" + "time" + _ "unsafe" // for go:linkname + + "hakurei.app/internal/helper/proc" +) + +//go:linkname FulfillmentTimeout hakurei.app/internal/helper/proc.FulfillmentTimeout +var FulfillmentTimeout time.Duration + +// A File is an extra file with deferred initialisation. +type File = proc.File + +// ExtraFilesPre is a linked list storing addresses of [os.File]. +type ExtraFilesPre = proc.ExtraFilesPre + +// Fulfill calls the [File.Fulfill] method on all files, starts cmd and blocks until all fulfillment completes. +// +//go:linkname Fulfill hakurei.app/internal/helper/proc.Fulfill +func Fulfill(ctx context.Context, + v *[]*os.File, start func() error, + files []File, extraFiles *ExtraFilesPre, +) (err error) + +// InitFile initialises f as part of the slice extraFiles points to, +// and returns its final fd value. +// +//go:linkname InitFile hakurei.app/internal/helper/proc.InitFile +func InitFile(f File, extraFiles *ExtraFilesPre) (fd uintptr) + +// BaseFile implements the Init method of the File interface and provides indirect access to extra file state. +type BaseFile = proc.BaseFile + +//go:linkname ExtraFile hakurei.app/internal/helper/proc.ExtraFile +func ExtraFile(cmd *exec.Cmd, f *os.File) (fd uintptr) + +//go:linkname ExtraFileSlice hakurei.app/internal/helper/proc.ExtraFileSlice +func ExtraFileSlice(extraFiles *[]*os.File, f *os.File) (fd uintptr) + +// NewWriterTo returns a [File] that receives content from wt on fulfillment. +// +//go:linkname NewWriterTo hakurei.app/internal/helper/proc.NewWriterTo +func NewWriterTo(wt io.WriterTo) File + +// NewStat returns a [File] implementing the behaviour +// of the receiving end of xdg-dbus-proxy stat fd. +// +//go:linkname NewStat hakurei.app/internal/helper/proc.NewStat +func NewStat(s *io.Closer) File + +var ( + //go:linkname ErrStatFault hakurei.app/internal/helper/proc.ErrStatFault + ErrStatFault error + //go:linkname ErrStatRead hakurei.app/internal/helper/proc.ErrStatRead + ErrStatRead error +) diff --git a/helper/proc/files.go b/helper/proc/files.go deleted file mode 100644 index 0612718e..00000000 --- a/helper/proc/files.go +++ /dev/null @@ -1,162 +0,0 @@ -package proc - -import ( - "context" - "os" - "os/exec" - "sync/atomic" - "syscall" - "testing" - "time" -) - -var FulfillmentTimeout = 2 * time.Second - -func init() { - if testing.Testing() { - FulfillmentTimeout *= 10 - } -} - -// A File is an extra file with deferred initialisation. -type File interface { - // Init initialises File state. Init must not be called more than once. - Init(fd uintptr, v **os.File) uintptr - // Fd returns the fd value set on initialisation. - Fd() uintptr - // ErrCount returns count of error values emitted during fulfillment. - ErrCount() int - // Fulfill is called prior to process creation and must populate its corresponding file address. - // Calls to dispatchErr must match the return value of ErrCount. - // Fulfill must not be called more than once. - Fulfill(ctx context.Context, dispatchErr func(error)) error -} - -// ExtraFilesPre is a linked list storing addresses of [os.File]. -type ExtraFilesPre struct { - n *ExtraFilesPre - v *os.File -} - -// Append grows the list by one entry and returns an address of the address of [os.File] stored in the new entry. -func (f *ExtraFilesPre) Append() (uintptr, **os.File) { return f.append(3) } - -// Files returns a slice pointing to a continuous segment of memory containing all addresses stored in f in order. -func (f *ExtraFilesPre) Files() []*os.File { return f.copy(make([]*os.File, 0, f.len())) } - -func (f *ExtraFilesPre) append(i uintptr) (uintptr, **os.File) { - if f.n == nil { - f.n = new(ExtraFilesPre) - return i, &f.v - } - return f.n.append(i + 1) -} -func (f *ExtraFilesPre) len() uintptr { - if f == nil { - return 0 - } - return f.n.len() + 1 -} -func (f *ExtraFilesPre) copy(e []*os.File) []*os.File { - if f == nil { - // the public methods ensure the first call is never nil; - // the last element is unused, slice it off here - return e[:len(e)-1] - } - return f.n.copy(append(e, f.v)) -} - -// Fulfill calls the [File.Fulfill] method on all files, starts cmd and blocks until all fulfillment completes. -func Fulfill(ctx context.Context, - v *[]*os.File, start func() error, - files []File, extraFiles *ExtraFilesPre, -) (err error) { - var ecs int - for _, o := range files { - ecs += o.ErrCount() - } - ec := make(chan error, ecs) - - c, cancel := context.WithTimeout(ctx, FulfillmentTimeout) - defer cancel() - - for _, f := range files { - err = f.Fulfill(c, makeDispatchErr(f, ec)) - if err != nil { - return - } - } - - *v = extraFiles.Files() - if err = start(); err != nil { - return - } - - for ecs > 0 { - select { - case err = <-ec: - ecs-- - if err != nil { - break - } - case <-ctx.Done(): - err = syscall.ECANCELED - break - } - } - return -} - -// InitFile initialises f as part of the slice extraFiles points to, -// and returns its final fd value. -func InitFile(f File, extraFiles *ExtraFilesPre) (fd uintptr) { return f.Init(extraFiles.Append()) } - -// BaseFile implements the Init method of the File interface and provides indirect access to extra file state. -type BaseFile struct { - fd uintptr - v **os.File -} - -func (f *BaseFile) Init(fd uintptr, v **os.File) uintptr { - if v == nil || fd < 3 { - panic("invalid extra file initial state") - } - if f.v != nil { - panic("extra file initialised twice") - } - f.fd, f.v = fd, v - return fd -} - -func (f *BaseFile) Fd() uintptr { - if f.v == nil { - panic("use of uninitialised extra file") - } - return f.fd -} - -func (f *BaseFile) Set(v *os.File) { - *f.v = v // runtime guards against use before init -} - -func makeDispatchErr(f File, ec chan<- error) func(error) { - c := new(atomic.Int32) - c.Store(int32(f.ErrCount())) - return func(err error) { - if c.Add(-1) < 0 { - panic("unexpected error dispatches") - } - ec <- err - } -} - -func ExtraFile(cmd *exec.Cmd, f *os.File) (fd uintptr) { - return ExtraFileSlice(&cmd.ExtraFiles, f) -} - -func ExtraFileSlice(extraFiles *[]*os.File, f *os.File) (fd uintptr) { - // ExtraFiles: If non-nil, entry i becomes file descriptor 3+i. - fd = uintptr(3 + len(*extraFiles)) - *extraFiles = append(*extraFiles, f) - return -} diff --git a/helper/proc/pipe.go b/helper/proc/pipe.go deleted file mode 100644 index 838c4ae4..00000000 --- a/helper/proc/pipe.go +++ /dev/null @@ -1,110 +0,0 @@ -package proc - -import ( - "context" - "errors" - "io" - "os" - "runtime" -) - -// NewWriterTo returns a [File] that receives content from wt on fulfillment. -func NewWriterTo(wt io.WriterTo) File { return &writeToFile{wt: wt} } - -// writeToFile exports the read end of a pipe with data written by an [io.WriterTo]. -type writeToFile struct { - wt io.WriterTo - BaseFile -} - -func (f *writeToFile) ErrCount() int { return 3 } -func (f *writeToFile) Fulfill(ctx context.Context, dispatchErr func(error)) error { - r, w, err := os.Pipe() - if err != nil { - return err - } - f.Set(r) - - done := make(chan struct{}) - go func() { - _, err = f.wt.WriteTo(w) - dispatchErr(err) - dispatchErr(w.Close()) - close(done) - runtime.KeepAlive(r) - }() - go func() { - select { - case <-done: - dispatchErr(nil) - case <-ctx.Done(): - dispatchErr(w.Close()) // this aborts WriteTo with file already closed - runtime.KeepAlive(r) - } - }() - - return nil -} - -// NewStat returns a [File] implementing the behaviour -// of the receiving end of xdg-dbus-proxy stat fd. -func NewStat(s *io.Closer) File { return &statFile{s: s} } - -var ( - ErrStatFault = errors.New("generic stat fd fault") - ErrStatRead = errors.New("unexpected stat behaviour") -) - -// statFile implements xdg-dbus-proxy stat fd behaviour. -type statFile struct { - s *io.Closer - BaseFile -} - -func (f *statFile) ErrCount() int { return 2 } -func (f *statFile) Fulfill(ctx context.Context, dispatchErr func(error)) error { - r, w, err := os.Pipe() - if err != nil { - return err - } - f.Set(w) - - done := make(chan struct{}) - go func() { - defer close(done) - var n int - - n, err = r.Read(make([]byte, 1)) - switch n { - case -1: - if err == nil { - err = ErrStatFault - } - dispatchErr(err) - case 0: - if err == nil { - err = ErrStatRead - } - dispatchErr(err) - case 1: - dispatchErr(err) - default: - panic("unreachable") - } - runtime.KeepAlive(w) - }() - - go func() { - select { - case <-done: - dispatchErr(nil) - case <-ctx.Done(): - dispatchErr(r.Close()) // this aborts Read with file already closed - runtime.KeepAlive(w) - } - }() - - // this gets closed by the caller - *f.s = r - return nil -} diff --git a/helper/stub.go b/helper/stub.go deleted file mode 100644 index c1f97e63..00000000 --- a/helper/stub.go +++ /dev/null @@ -1,102 +0,0 @@ -package helper - -import ( - "flag" - "fmt" - "io" - "os" - "strconv" - "syscall" -) - -// InternalHelperStub is an internal function but exported because it is cross-package; -// it is part of the implementation of the helper stub. -func InternalHelperStub() { - // this test mocks the helper process - var ap, sp string - if v, ok := os.LookupEnv(HakureiHelper); !ok { - return - } else { - ap = v - } - if v, ok := os.LookupEnv(HakureiStatus); !ok { - panic(HakureiStatus) - } else { - sp = v - } - - genericStub(flagRestoreFiles(1, ap, sp)) - - os.Exit(0) -} - -func newFile(fd int, name, p string) *os.File { - present := false - switch p { - case "0": - case "1": - present = true - default: - panic(fmt.Sprintf("%s fd has unexpected presence value %q", name, p)) - } - - f := os.NewFile(uintptr(fd), name) - if !present && f != nil { - panic(fmt.Sprintf("%s fd set but not present", name)) - } - if present && f == nil { - panic(fmt.Sprintf("%s fd preset but unset", name)) - } - - return f -} - -func flagRestoreFiles(offset int, ap, sp string) (argsFile, statFile *os.File) { - argsFd := flag.Int("args", -1, "") - statFd := flag.Int("fd", -1, "") - _ = flag.CommandLine.Parse(os.Args[offset:]) - argsFile = newFile(*argsFd, "args", ap) - statFile = newFile(*statFd, "stat", sp) - return -} - -func genericStub(argsFile, statFile *os.File) { - if argsFile != nil { - // this output is checked by parent - if _, err := io.Copy(os.Stdout, argsFile); err != nil { - panic("cannot read args: " + err.Error()) - } - } - - if statFile != nil { - // simulate status pipe behaviour - var epoll int - if fd, err := syscall.EpollCreate1(0); err != nil { - panic("cannot open epoll fd: " + err.Error()) - } else { - defer func() { - if err = syscall.Close(fd); err != nil { - panic("cannot close epoll fd: " + err.Error()) - } - }() - epoll = fd - } - if err := syscall.EpollCtl(epoll, syscall.EPOLL_CTL_ADD, int(statFile.Fd()), &syscall.EpollEvent{}); err != nil { - panic("cannot add status pipe to epoll: " + err.Error()) - } - - if _, err := statFile.Write([]byte{'x'}); err != nil { - panic("cannot write to status pipe: " + err.Error()) - } - - // wait for status pipe close - events := make([]syscall.EpollEvent, 1) - if _, err := syscall.EpollWait(epoll, events, -1); err != nil { - panic("cannot poll status pipe: " + err.Error()) - } - if events[0].Events != syscall.EPOLLERR { - panic(strconv.Itoa(int(events[0].Events))) - - } - } -} diff --git a/helper/stub_test.go b/helper/stub_test.go deleted file mode 100644 index 58a25383..00000000 --- a/helper/stub_test.go +++ /dev/null @@ -1,11 +0,0 @@ -package helper_test - -import ( - "os" - "testing" - - "hakurei.app/container" - "hakurei.app/helper" -) - -func TestMain(m *testing.M) { container.TryArgv0(nil); helper.InternalHelperStub(); os.Exit(m.Run()) } diff --git a/internal/helper/args.go b/internal/helper/args.go new file mode 100644 index 00000000..80cd3024 --- /dev/null +++ b/internal/helper/args.go @@ -0,0 +1,59 @@ +package helper + +import ( + "bytes" + "io" + "syscall" +) + +type argsWt [][]byte + +func (a argsWt) WriteTo(w io.Writer) (int64, error) { + nt := 0 + for _, arg := range a { + n, err := w.Write(arg) + nt += n + + if err != nil { + return int64(nt), err + } + } + + return int64(nt), nil +} + +func (a argsWt) String() string { + return string( + bytes.TrimSuffix( + bytes.ReplaceAll( + bytes.Join(a, nil), + []byte{0}, []byte{' '}, + ), + []byte{' '}, + ), + ) +} + +// NewCheckedArgs returns a checked null-terminated argument writer for a copy of args. +func NewCheckedArgs(args ...string) (wt io.WriterTo, err error) { + a := make(argsWt, len(args)) + for i, arg := range args { + a[i], err = syscall.ByteSliceFromString(arg) + if err != nil { + return + } + } + wt = a + return +} + +// MustNewCheckedArgs returns a checked null-terminated argument writer for a copy of args. +// If s contains a NUL byte this function panics instead of returning an error. +func MustNewCheckedArgs(args ...string) io.WriterTo { + a, err := NewCheckedArgs(args...) + if err != nil { + panic(err.Error()) + } + + return a +} diff --git a/internal/helper/args_test.go b/internal/helper/args_test.go new file mode 100644 index 00000000..c83c6790 --- /dev/null +++ b/internal/helper/args_test.go @@ -0,0 +1,41 @@ +package helper_test + +import ( + "errors" + "fmt" + "strings" + "syscall" + "testing" + + "hakurei.app/internal/helper" +) + +func TestArgsString(t *testing.T) { + t.Parallel() + wantString := strings.Join(wantArgs, " ") + if got := argsWt.(fmt.Stringer).String(); got != wantString { + t.Errorf("String: %q, want %q", got, wantString) + } +} + +func TestNewCheckedArgs(t *testing.T) { + t.Parallel() + + args := []string{"\x00"} + if _, err := helper.NewCheckedArgs(args...); !errors.Is(err, syscall.EINVAL) { + t.Errorf("NewCheckedArgs: error = %v, wantErr %v", err, syscall.EINVAL) + } + + t.Run("must panic", func(t *testing.T) { + t.Parallel() + + badPayload := []string{"\x00"} + defer func() { + wantPanic := "invalid argument" + if r := recover(); r != wantPanic { + t.Errorf("MustNewCheckedArgs: panic = %v, wantPanic %v", r, wantPanic) + } + }() + helper.MustNewCheckedArgs(badPayload...) + }) +} diff --git a/internal/helper/cmd.go b/internal/helper/cmd.go new file mode 100644 index 00000000..babceb22 --- /dev/null +++ b/internal/helper/cmd.go @@ -0,0 +1,84 @@ +package helper + +import ( + "context" + "errors" + "io" + "os" + "os/exec" + "slices" + "sync" + "syscall" + + "hakurei.app/internal/helper/proc" +) + +// NewDirect initialises a new direct Helper instance with wt as the null-terminated argument writer. +// Function argF returns an array of arguments passed directly to the child process. +func NewDirect( + ctx context.Context, + name string, + wt io.WriterTo, + stat bool, + argF func(argsFd, statFd int) []string, + cmdF func(cmd *exec.Cmd), + extraFiles []*os.File, +) Helper { + d, args := newHelperCmd(ctx, name, wt, stat, argF, extraFiles) + d.Args = append(d.Args, args...) + if cmdF != nil { + cmdF(d.Cmd) + } + return d +} + +func newHelperCmd( + ctx context.Context, + name string, + wt io.WriterTo, + stat bool, + argF func(argsFd, statFd int) []string, + extraFiles []*os.File, +) (cmd *helperCmd, args []string) { + cmd = new(helperCmd) + cmd.helperFiles, args = newHelperFiles(ctx, wt, stat, argF, extraFiles) + cmd.Cmd = exec.CommandContext(ctx, name) + cmd.Cmd.Cancel = func() error { return cmd.Process.Signal(syscall.SIGTERM) } + cmd.WaitDelay = WaitDelay + return +} + +// helperCmd provides a [exec.Cmd] wrapper around helper ipc. +type helperCmd struct { + mu sync.RWMutex + *helperFiles + *exec.Cmd +} + +func (h *helperCmd) Start() error { + h.mu.Lock() + defer h.mu.Unlock() + + // Check for doubled Start calls before we defer failure cleanup. If the prior + // call to Start succeeded, we don't want to spuriously close its pipes. + if h.Cmd != nil && h.Cmd.Process != nil { + return errors.New("helper: already started") + } + + h.Env = slices.Grow(h.Env, 2) + if h.useArgsFd { + h.Env = append(h.Env, HakureiHelper+"=1") + } else { + h.Env = append(h.Env, HakureiHelper+"=0") + } + if h.useStatFd { + h.Env = append(h.Env, HakureiStatus+"=1") + + // stat is populated on fulfill + h.Cancel = func() error { return h.stat.Close() } + } else { + h.Env = append(h.Env, HakureiStatus+"=0") + } + + return proc.Fulfill(h.helperFiles.ctx, &h.ExtraFiles, h.Cmd.Start, h.files, h.extraFiles) +} diff --git a/internal/helper/cmd_test.go b/internal/helper/cmd_test.go new file mode 100644 index 00000000..3d29479c --- /dev/null +++ b/internal/helper/cmd_test.go @@ -0,0 +1,40 @@ +package helper_test + +import ( + "context" + "errors" + "io" + "os" + "os/exec" + "testing" + + "hakurei.app/container" + "hakurei.app/internal/helper" +) + +func TestCmd(t *testing.T) { + t.Run("start non-existent helper path", func(t *testing.T) { + h := helper.NewDirect(t.Context(), container.Nonexistent, argsWt, false, argF, nil, nil) + + if err := h.Start(); !errors.Is(err, os.ErrNotExist) { + t.Errorf("Start: error = %v, wantErr %v", + err, os.ErrNotExist) + } + }) + + t.Run("valid new helper nil check", func(t *testing.T) { + if got := helper.NewDirect(t.Context(), "hakurei", argsWt, false, argF, nil, nil); got == nil { + t.Errorf("NewDirect(%q, %q) got nil", + argsWt, "hakurei") + return + } + }) + + t.Run("implementation compliance", func(t *testing.T) { + testHelper(t, func(ctx context.Context, setOutput func(stdoutP, stderrP *io.Writer), stat bool) helper.Helper { + return helper.NewDirect(ctx, os.Args[0], argsWt, stat, argF, func(cmd *exec.Cmd) { + setOutput(&cmd.Stdout, &cmd.Stderr) + }, nil) + }) + }) +} diff --git a/internal/helper/container.go b/internal/helper/container.go new file mode 100644 index 00000000..56a51954 --- /dev/null +++ b/internal/helper/container.go @@ -0,0 +1,79 @@ +package helper + +import ( + "context" + "errors" + "io" + "os" + "os/exec" + "slices" + "sync" + + "hakurei.app/container" + "hakurei.app/container/check" + "hakurei.app/internal/helper/proc" + "hakurei.app/message" +) + +// New initialises a Helper instance with wt as the null-terminated argument writer. +func New( + ctx context.Context, + msg message.Msg, + pathname *check.Absolute, name string, + wt io.WriterTo, + stat bool, + argF func(argsFd, statFd int) []string, + cmdF func(z *container.Container), + extraFiles []*os.File, +) Helper { + var args []string + h := new(helperContainer) + h.helperFiles, args = newHelperFiles(ctx, wt, stat, argF, extraFiles) + h.Container = container.NewCommand(ctx, msg, pathname, name, args...) + h.WaitDelay = WaitDelay + if cmdF != nil { + cmdF(h.Container) + } + return h +} + +// helperContainer provides a [sandbox.Container] wrapper around helper ipc. +type helperContainer struct { + started bool + + mu sync.Mutex + *helperFiles + *container.Container +} + +func (h *helperContainer) Start() error { + h.mu.Lock() + defer h.mu.Unlock() + + if h.started { + return errors.New("helper: already started") + } + h.started = true + + h.Env = slices.Grow(h.Env, 2) + if h.useArgsFd { + h.Env = append(h.Env, HakureiHelper+"=1") + } else { + h.Env = append(h.Env, HakureiHelper+"=0") + } + if h.useStatFd { + h.Env = append(h.Env, HakureiStatus+"=1") + + // stat is populated on fulfill + h.Cancel = func(*exec.Cmd) error { return h.stat.Close() } + } else { + h.Env = append(h.Env, HakureiStatus+"=0") + } + + return proc.Fulfill(h.helperFiles.ctx, &h.ExtraFiles, func() error { + if err := h.Container.Start(); err != nil { + return err + } + return h.Container.Serve() + }, h.files, h.extraFiles) +} diff --git a/internal/helper/container_test.go b/internal/helper/container_test.go new file mode 100644 index 00000000..f55da75c --- /dev/null +++ b/internal/helper/container_test.go @@ -0,0 +1,45 @@ +package helper_test + +import ( + "context" + "io" + "os" + "testing" + + "hakurei.app/container" + "hakurei.app/container/check" + "hakurei.app/container/fhs" + "hakurei.app/internal/helper" +) + +func TestContainer(t *testing.T) { + t.Run("start invalid container", func(t *testing.T) { + h := helper.New(t.Context(), nil, check.MustAbs(container.Nonexistent), "hakurei", argsWt, false, argF, nil, nil) + + wantErr := "container: starting an invalid container" + if err := h.Start(); err == nil || err.Error() != wantErr { + t.Errorf("Start: error = %v, wantErr %q", + err, wantErr) + } + }) + + t.Run("valid new helper nil check", func(t *testing.T) { + if got := helper.New(t.Context(), nil, check.MustAbs(container.Nonexistent), "hakurei", argsWt, false, argF, nil, nil); got == nil { + t.Errorf("New(%q, %q) got nil", + argsWt, "hakurei") + return + } + }) + + t.Run("implementation compliance", func(t *testing.T) { + testHelper(t, func(ctx context.Context, setOutput func(stdoutP, stderrP *io.Writer), stat bool) helper.Helper { + return helper.New(ctx, nil, check.MustAbs(os.Args[0]), "helper", argsWt, stat, argF, func(z *container.Container) { + setOutput(&z.Stdout, &z.Stderr) + z. + Bind(fhs.AbsRoot, fhs.AbsRoot, 0). + Proc(fhs.AbsProc). + Dev(fhs.AbsDev, true) + }, nil) + }) + }) +} diff --git a/internal/helper/helper.go b/internal/helper/helper.go new file mode 100644 index 00000000..378d9471 --- /dev/null +++ b/internal/helper/helper.go @@ -0,0 +1,83 @@ +// Package helper runs external helpers with optional sandboxing. +package helper + +import ( + "context" + "fmt" + "io" + "os" + "time" + + "hakurei.app/internal/helper/proc" +) + +var WaitDelay = 2 * time.Second + +const ( + // HakureiHelper is set to 1 when args fd is enabled and 0 otherwise. + HakureiHelper = "HAKUREI_HELPER" + // HakureiStatus is set to 1 when stat fd is enabled and 0 otherwise. + HakureiStatus = "HAKUREI_STATUS" +) + +type Helper interface { + // Start starts the helper process. + Start() error + // Wait blocks until Helper exits. + Wait() error + + fmt.Stringer +} + +func newHelperFiles( + ctx context.Context, + wt io.WriterTo, + stat bool, + argF func(argsFd, statFd int) []string, + extraFiles []*os.File, +) (hl *helperFiles, args []string) { + hl = new(helperFiles) + hl.ctx = ctx + hl.useArgsFd = wt != nil + hl.useStatFd = stat + + hl.extraFiles = new(proc.ExtraFilesPre) + for _, f := range extraFiles { + _, v := hl.extraFiles.Append() + *v = f + } + + argsFd := -1 + if hl.useArgsFd { + f := proc.NewWriterTo(wt) + argsFd = int(proc.InitFile(f, hl.extraFiles)) + hl.files = append(hl.files, f) + } + + statFd := -1 + if hl.useStatFd { + f := proc.NewStat(&hl.stat) + statFd = int(proc.InitFile(f, hl.extraFiles)) + hl.files = append(hl.files, f) + } + + args = argF(argsFd, statFd) + return +} + +// helperFiles provides a generic wrapper around helper ipc. +type helperFiles struct { + // whether argsFd is present + useArgsFd bool + // whether statFd is present + useStatFd bool + + // closes statFd + stat io.Closer + // deferred extraFiles fulfillment + files []proc.File + // passed through to [proc.Fulfill] and [proc.InitFile] + extraFiles *proc.ExtraFilesPre + + ctx context.Context +} diff --git a/internal/helper/helper_test.go b/internal/helper/helper_test.go new file mode 100644 index 00000000..dae098b8 --- /dev/null +++ b/internal/helper/helper_test.go @@ -0,0 +1,139 @@ +package helper_test + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "reflect" + "strconv" + "strings" + "syscall" + "testing" + "time" + + "hakurei.app/internal/helper" +) + +var ( + wantArgs = []string{ + "unix:path=/run/dbus/system_bus_socket", + "/tmp/hakurei.1971/12622d846cc3fe7b4c10359d01f0eb47/system_bus_socket", + "--filter", + "--talk=org.bluez", + "--talk=org.freedesktop.Avahi", + "--talk=org.freedesktop.UPower", + } + + wantPayload = strings.Join(wantArgs, "\x00") + "\x00" + argsWt = helper.MustNewCheckedArgs(wantArgs...) +) + +func argF(argsFd, statFd int) []string { + if argsFd == -1 { + panic("invalid args fd") + } + + return argFChecked(argsFd, statFd) +} + +func argFChecked(argsFd, statFd int) (args []string) { + args = make([]string, 0, 6) + if argsFd > -1 { + args = append(args, "--args", strconv.Itoa(argsFd)) + } + if statFd > -1 { + args = append(args, "--fd", strconv.Itoa(statFd)) + } + return +} + +const ( + containerTimeout = 30 * time.Second +) + +// this function tests an implementation of the helper.Helper interface +func testHelper(t *testing.T, createHelper func(ctx context.Context, setOutput func(stdoutP, stderrP *io.Writer), stat bool) helper.Helper) { + oldWaitDelay := helper.WaitDelay + helper.WaitDelay = 16 * time.Second + t.Cleanup(func() { helper.WaitDelay = oldWaitDelay }) + + t.Run("start helper with status channel and wait", func(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), containerTimeout) + stdout := new(strings.Builder) + h := createHelper(ctx, func(stdoutP, stderrP *io.Writer) { *stdoutP, *stderrP = stdout, os.Stderr }, true) + + t.Run("wait not yet started helper", func(t *testing.T) { + if err := h.Wait(); !reflect.DeepEqual(err, syscall.EINVAL) && + !reflect.DeepEqual(err, errors.New("exec: not started")) { + t.Errorf("Wait: error = %v", err) + } + }) + + t.Log("starting helper stub") + if err := h.Start(); err != nil { + t.Errorf("Start: error = %v", err) + cancel() + return + } + t.Log("cancelling context") + cancel() + + t.Run("start already started helper", func(t *testing.T) { + wantErr := "helper: already started" + if err := h.Start(); err != nil && err.Error() != wantErr { + t.Errorf("Start: error = %v, wantErr %v", + err, wantErr) + return + } + }) + + t.Log("waiting on helper") + if err := h.Wait(); !errors.Is(err, context.Canceled) { + t.Errorf("Wait: error = %v", + err) + } + + t.Run("wait already finalised helper", func(t *testing.T) { + wantErr := "exec: Wait was already called" + if err := h.Wait(); err != nil && err.Error() != wantErr { + t.Errorf("Wait: error = %v, wantErr %v", + err, wantErr) + return + } + }) + + if got := trimStdout(stdout); got != wantPayload { + t.Errorf("Start: stdout = %q, want %q", + got, wantPayload) + } + }) + + t.Run("start helper and wait", func(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), containerTimeout) + defer cancel() + stdout := new(strings.Builder) + h := createHelper(ctx, func(stdoutP, stderrP *io.Writer) { *stdoutP, *stderrP = stdout, os.Stderr }, false) + + if err := h.Start(); err != nil { + t.Errorf("Start: error = %v", + err) + return + } + + if err := h.Wait(); err != nil { + t.Errorf("Wait: error = %v stdout = %q", + err, stdout) + } + + if got := trimStdout(stdout); got != wantPayload { + t.Errorf("Start: stdout = %q, want %q", + got, wantPayload) + } + }) +} + +func trimStdout(stdout fmt.Stringer) string { + return strings.TrimPrefix(stdout.String(), "=== RUN TestHelperInit\n") +} diff --git a/internal/helper/proc/files.go b/internal/helper/proc/files.go new file mode 100644 index 00000000..0612718e --- /dev/null +++ b/internal/helper/proc/files.go @@ -0,0 +1,162 @@ +package proc + +import ( + "context" + "os" + "os/exec" + "sync/atomic" + "syscall" + "testing" + "time" +) + +var FulfillmentTimeout = 2 * time.Second + +func init() { + if testing.Testing() { + FulfillmentTimeout *= 10 + } +} + +// A File is an extra file with deferred initialisation. +type File interface { + // Init initialises File state. Init must not be called more than once. + Init(fd uintptr, v **os.File) uintptr + // Fd returns the fd value set on initialisation. + Fd() uintptr + // ErrCount returns count of error values emitted during fulfillment. + ErrCount() int + // Fulfill is called prior to process creation and must populate its corresponding file address. + // Calls to dispatchErr must match the return value of ErrCount. + // Fulfill must not be called more than once. + Fulfill(ctx context.Context, dispatchErr func(error)) error +} + +// ExtraFilesPre is a linked list storing addresses of [os.File]. +type ExtraFilesPre struct { + n *ExtraFilesPre + v *os.File +} + +// Append grows the list by one entry and returns an address of the address of [os.File] stored in the new entry. +func (f *ExtraFilesPre) Append() (uintptr, **os.File) { return f.append(3) } + +// Files returns a slice pointing to a continuous segment of memory containing all addresses stored in f in order. +func (f *ExtraFilesPre) Files() []*os.File { return f.copy(make([]*os.File, 0, f.len())) } + +func (f *ExtraFilesPre) append(i uintptr) (uintptr, **os.File) { + if f.n == nil { + f.n = new(ExtraFilesPre) + return i, &f.v + } + return f.n.append(i + 1) +} +func (f *ExtraFilesPre) len() uintptr { + if f == nil { + return 0 + } + return f.n.len() + 1 +} +func (f *ExtraFilesPre) copy(e []*os.File) []*os.File { + if f == nil { + // the public methods ensure the first call is never nil; + // the last element is unused, slice it off here + return e[:len(e)-1] + } + return f.n.copy(append(e, f.v)) +} + +// Fulfill calls the [File.Fulfill] method on all files, starts cmd and blocks until all fulfillment completes. +func Fulfill(ctx context.Context, + v *[]*os.File, start func() error, + files []File, extraFiles *ExtraFilesPre, +) (err error) { + var ecs int + for _, o := range files { + ecs += o.ErrCount() + } + ec := make(chan error, ecs) + + c, cancel := context.WithTimeout(ctx, FulfillmentTimeout) + defer cancel() + + for _, f := range files { + err = f.Fulfill(c, makeDispatchErr(f, ec)) + if err != nil { + return + } + } + + *v = extraFiles.Files() + if err = start(); err != nil { + return + } + + for ecs > 0 { + select { + case err = <-ec: + ecs-- + if err != nil { + break + } + case <-ctx.Done(): + err = syscall.ECANCELED + break + } + } + return +} + +// InitFile initialises f as part of the slice extraFiles points to, +// and returns its final fd value. +func InitFile(f File, extraFiles *ExtraFilesPre) (fd uintptr) { return f.Init(extraFiles.Append()) } + +// BaseFile implements the Init method of the File interface and provides indirect access to extra file state. +type BaseFile struct { + fd uintptr + v **os.File +} + +func (f *BaseFile) Init(fd uintptr, v **os.File) uintptr { + if v == nil || fd < 3 { + panic("invalid extra file initial state") + } + if f.v != nil { + panic("extra file initialised twice") + } + f.fd, f.v = fd, v + return fd +} + +func (f *BaseFile) Fd() uintptr { + if f.v == nil { + panic("use of uninitialised extra file") + } + return f.fd +} + +func (f *BaseFile) Set(v *os.File) { + *f.v = v // runtime guards against use before init +} + +func makeDispatchErr(f File, ec chan<- error) func(error) { + c := new(atomic.Int32) + c.Store(int32(f.ErrCount())) + return func(err error) { + if c.Add(-1) < 0 { + panic("unexpected error dispatches") + } + ec <- err + } +} + +func ExtraFile(cmd *exec.Cmd, f *os.File) (fd uintptr) { + return ExtraFileSlice(&cmd.ExtraFiles, f) +} + +func ExtraFileSlice(extraFiles *[]*os.File, f *os.File) (fd uintptr) { + // ExtraFiles: If non-nil, entry i becomes file descriptor 3+i. + fd = uintptr(3 + len(*extraFiles)) + *extraFiles = append(*extraFiles, f) + return +} diff --git a/internal/helper/proc/pipe.go b/internal/helper/proc/pipe.go new file mode 100644 index 00000000..838c4ae4 --- /dev/null +++ b/internal/helper/proc/pipe.go @@ -0,0 +1,110 @@ +package proc + +import ( + "context" + "errors" + "io" + "os" + "runtime" +) + +// NewWriterTo returns a [File] that receives content from wt on fulfillment. +func NewWriterTo(wt io.WriterTo) File { return &writeToFile{wt: wt} } + +// writeToFile exports the read end of a pipe with data written by an [io.WriterTo]. +type writeToFile struct { + wt io.WriterTo + BaseFile +} + +func (f *writeToFile) ErrCount() int { return 3 } +func (f *writeToFile) Fulfill(ctx context.Context, dispatchErr func(error)) error { + r, w, err := os.Pipe() + if err != nil { + return err + } + f.Set(r) + + done := make(chan struct{}) + go func() { + _, err = f.wt.WriteTo(w) + dispatchErr(err) + dispatchErr(w.Close()) + close(done) + runtime.KeepAlive(r) + }() + go func() { + select { + case <-done: + dispatchErr(nil) + case <-ctx.Done(): + dispatchErr(w.Close()) // this aborts WriteTo with file already closed + runtime.KeepAlive(r) + } + }() + + return nil +} + +// NewStat returns a [File] implementing the behaviour +// of the receiving end of xdg-dbus-proxy stat fd. +func NewStat(s *io.Closer) File { return &statFile{s: s} } + +var ( + ErrStatFault = errors.New("generic stat fd fault") + ErrStatRead = errors.New("unexpected stat behaviour") +) + +// statFile implements xdg-dbus-proxy stat fd behaviour. +type statFile struct { + s *io.Closer + BaseFile +} + +func (f *statFile) ErrCount() int { return 2 } +func (f *statFile) Fulfill(ctx context.Context, dispatchErr func(error)) error { + r, w, err := os.Pipe() + if err != nil { + return err + } + f.Set(w) + + done := make(chan struct{}) + go func() { + defer close(done) + var n int + + n, err = r.Read(make([]byte, 1)) + switch n { + case -1: + if err == nil { + err = ErrStatFault + } + dispatchErr(err) + case 0: + if err == nil { + err = ErrStatRead + } + dispatchErr(err) + case 1: + dispatchErr(err) + default: + panic("unreachable") + } + runtime.KeepAlive(w) + }() + + go func() { + select { + case <-done: + dispatchErr(nil) + case <-ctx.Done(): + dispatchErr(r.Close()) // this aborts Read with file already closed + runtime.KeepAlive(w) + } + }() + + // this gets closed by the caller + *f.s = r + return nil +} diff --git a/internal/helper/stub.go b/internal/helper/stub.go new file mode 100644 index 00000000..c1f97e63 --- /dev/null +++ b/internal/helper/stub.go @@ -0,0 +1,102 @@ +package helper + +import ( + "flag" + "fmt" + "io" + "os" + "strconv" + "syscall" +) + +// InternalHelperStub is an internal function but exported because it is cross-package; +// it is part of the implementation of the helper stub. +func InternalHelperStub() { + // this test mocks the helper process + var ap, sp string + if v, ok := os.LookupEnv(HakureiHelper); !ok { + return + } else { + ap = v + } + if v, ok := os.LookupEnv(HakureiStatus); !ok { + panic(HakureiStatus) + } else { + sp = v + } + + genericStub(flagRestoreFiles(1, ap, sp)) + + os.Exit(0) +} + +func newFile(fd int, name, p string) *os.File { + present := false + switch p { + case "0": + case "1": + present = true + default: + panic(fmt.Sprintf("%s fd has unexpected presence value %q", name, p)) + } + + f := os.NewFile(uintptr(fd), name) + if !present && f != nil { + panic(fmt.Sprintf("%s fd set but not present", name)) + } + if present && f == nil { + panic(fmt.Sprintf("%s fd preset but unset", name)) + } + + return f +} + +func flagRestoreFiles(offset int, ap, sp string) (argsFile, statFile *os.File) { + argsFd := flag.Int("args", -1, "") + statFd := flag.Int("fd", -1, "") + _ = flag.CommandLine.Parse(os.Args[offset:]) + argsFile = newFile(*argsFd, "args", ap) + statFile = newFile(*statFd, "stat", sp) + return +} + +func genericStub(argsFile, statFile *os.File) { + if argsFile != nil { + // this output is checked by parent + if _, err := io.Copy(os.Stdout, argsFile); err != nil { + panic("cannot read args: " + err.Error()) + } + } + + if statFile != nil { + // simulate status pipe behaviour + var epoll int + if fd, err := syscall.EpollCreate1(0); err != nil { + panic("cannot open epoll fd: " + err.Error()) + } else { + defer func() { + if err = syscall.Close(fd); err != nil { + panic("cannot close epoll fd: " + err.Error()) + } + }() + epoll = fd + } + if err := syscall.EpollCtl(epoll, syscall.EPOLL_CTL_ADD, int(statFile.Fd()), &syscall.EpollEvent{}); err != nil { + panic("cannot add status pipe to epoll: " + err.Error()) + } + + if _, err := statFile.Write([]byte{'x'}); err != nil { + panic("cannot write to status pipe: " + err.Error()) + } + + // wait for status pipe close + events := make([]syscall.EpollEvent, 1) + if _, err := syscall.EpollWait(epoll, events, -1); err != nil { + panic("cannot poll status pipe: " + err.Error()) + } + if events[0].Events != syscall.EPOLLERR { + panic(strconv.Itoa(int(events[0].Events))) + + } + } +} diff --git a/internal/helper/stub_test.go b/internal/helper/stub_test.go new file mode 100644 index 00000000..4b438a5b --- /dev/null +++ b/internal/helper/stub_test.go @@ -0,0 +1,11 @@ +package helper_test + +import ( + "os" + "testing" + + "hakurei.app/container" + "hakurei.app/internal/helper" +) + +func TestMain(m *testing.M) { container.TryArgv0(nil); helper.InternalHelperStub(); os.Exit(m.Run()) } -- cgit v1.3.1