aboutsummaryrefslogtreecommitdiffhomepage
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/helper/args.go59
-rw-r--r--internal/helper/args_test.go41
-rw-r--r--internal/helper/cmd.go84
-rw-r--r--internal/helper/cmd_test.go40
-rw-r--r--internal/helper/container.go79
-rw-r--r--internal/helper/container_test.go45
-rw-r--r--internal/helper/helper.go83
-rw-r--r--internal/helper/helper_test.go139
-rw-r--r--internal/helper/proc/files.go162
-rw-r--r--internal/helper/proc/pipe.go110
-rw-r--r--internal/helper/stub.go102
-rw-r--r--internal/helper/stub_test.go11
12 files changed, 955 insertions, 0 deletions
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()) }