diff --git a/helper/bwrap.go b/helper/bwrap.go new file mode 100644 index 0000000..4ac1032 --- /dev/null +++ b/helper/bwrap.go @@ -0,0 +1,144 @@ +package helper + +import ( + "errors" + "io" + "os/exec" + "strconv" + "sync" + + "git.ophivana.moe/cat/fortify/helper/bwrap" +) + +// BubblewrapName is the file name or path to bubblewrap. +var BubblewrapName = "bwrap" + +type bubblewrap struct { + // bwrap child file name + name string + + // bwrap pipes + p *pipes + // sealed bwrap config + config *bwrap.Config + // returns an array of arguments passed directly + // to the child process spawned by bwrap + argF func(argsFD, statFD int) []string + + // pipes received by the child + // nil if no pipes are required + cp *pipes + + lock sync.RWMutex + *exec.Cmd +} + +func (b *bubblewrap) StartNotify(ready chan error) error { + b.lock.Lock() + defer b.lock.Unlock() + + if ready != nil && b.cp == nil { + panic("attempted to start with status monitoring on a bwrap child initialised without pipes") + } + + // 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 b.Cmd.Process != nil { + return errors.New("exec: already started") + } + + // prepare bwrap pipe and args + if argsFD, _, err := b.p.prepareCmd(b.Cmd); err != nil { + return err + } else { + b.Cmd.Args = append(b.Cmd.Args, "--args", strconv.Itoa(argsFD), "--", b.name) + } + + // prepare child args and pipes if enabled + if b.cp != nil { + b.cp.ready = ready + if argsFD, statFD, err := b.cp.prepareCmd(b.Cmd); err != nil { + return err + } else { + b.Cmd.Args = append(b.Cmd.Args, b.argF(argsFD, statFD)...) + } + } else { + b.Cmd.Args = append(b.Cmd.Args, b.argF(-1, -1)...) + } + + if ready != nil { + b.Cmd.Env = append(b.Cmd.Env, FortifyHelper+"=1", FortifyStatus+"=1") + } else if b.cp != nil { + b.Cmd.Env = append(b.Cmd.Env, FortifyHelper+"=1", FortifyStatus+"=0") + } else { + b.Cmd.Env = append(b.Cmd.Env, FortifyHelper+"=1", FortifyStatus+"=-1") + } + + if err := b.Cmd.Start(); err != nil { + return err + } + + // write bwrap args first + if err := b.p.readyWriteArgs(); err != nil { + return err + } + + // write child args if enabled + if b.cp != nil { + if err := b.cp.readyWriteArgs(); err != nil { + return err + } + } + + return nil +} + +func (b *bubblewrap) Close() error { + if b.cp == nil { + panic("attempted to close bwrap child initialised without pipes") + } + + return b.cp.closeStatus() +} + +func (b *bubblewrap) Start() error { + return b.StartNotify(nil) +} + +func (b *bubblewrap) Unwrap() *exec.Cmd { + return b.Cmd +} + +// MustNewBwrap initialises a new Bwrap instance with wt as the null-terminated argument writer. +// If wt is nil, the child process spawned by bwrap will not get an argument pipe. +// Function argF returns an array of arguments passed directly to the child process. +func MustNewBwrap(conf *bwrap.Config, wt io.WriterTo, name string, argF func(argsFD, statFD int) []string) Helper { + b, err := NewBwrap(conf, wt, name, argF) + if err != nil { + panic(err.Error()) + } else { + return b + } +} + +// NewBwrap initialises a new Bwrap instance with wt as the null-terminated argument writer. +// If wt is nil, the child process spawned by bwrap will not get an argument pipe. +// Function argF returns an array of arguments passed directly to the child process. +func NewBwrap(conf *bwrap.Config, wt io.WriterTo, name string, argF func(argsFD, statFD int) []string) (Helper, error) { + b := new(bubblewrap) + + if args, err := NewCheckedArgs(conf.Args()); err != nil { + return nil, err + } else { + b.p = &pipes{args: args} + } + + b.argF = argF + b.name = name + if wt != nil { + b.cp = &pipes{args: wt} + } + b.Cmd = execCommand(BubblewrapName) + + return b, nil +} diff --git a/helper/bwrap_test.go b/helper/bwrap_test.go new file mode 100644 index 0000000..8fd2d4b --- /dev/null +++ b/helper/bwrap_test.go @@ -0,0 +1,112 @@ +package helper_test + +import ( + "errors" + "fmt" + "os" + "strings" + "testing" + + "git.ophivana.moe/cat/fortify/helper" + "git.ophivana.moe/cat/fortify/helper/bwrap" +) + +func TestBwrap(t *testing.T) { + sc := &bwrap.Config{ + Unshare: nil, + Net: true, + UserNS: false, + Hostname: "localhost", + Chdir: "/nonexistent", + Clearenv: true, + NewSession: true, + DieWithParent: true, + AsInit: true, + } + + t.Run("nonexistent bwrap name", func(t *testing.T) { + bubblewrapName := helper.BubblewrapName + helper.BubblewrapName = "/nonexistent" + t.Cleanup(func() { + helper.BubblewrapName = bubblewrapName + }) + + h := helper.MustNewBwrap(sc, argsWt, "fortify", argF) + + 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.MustNewBwrap(sc, argsWt, "fortify", argF); got == nil { + t.Errorf("MustNewBwrap(%#v, %#v, %#v) got nil", + sc, argsWt, "fortify") + return + } + }) + + t.Run("invalid bwrap config new helper panic", func(t *testing.T) { + defer func() { + wantPanic := "argument contains null character" + if r := recover(); r != wantPanic { + t.Errorf("MustNewBwrap: panic = %q, want %q", + r, wantPanic) + } + }() + + helper.MustNewBwrap(&bwrap.Config{Hostname: "\x00"}, nil, "fortify", argF) + }) + + t.Run("start notify without pipes panic", func(t *testing.T) { + defer func() { + wantPanic := "attempted to start with status monitoring on a bwrap child initialised without pipes" + if r := recover(); r != wantPanic { + t.Errorf("StartNotify: panic = %q, want %q", + r, wantPanic) + } + }() + + panic(fmt.Sprintf("unreachable: %v", + helper.MustNewBwrap(sc, nil, "fortify", argF).StartNotify(make(chan error)))) + }) + + t.Run("start without pipes", func(t *testing.T) { + helper.InternalReplaceExecCommand(t) + + h := helper.MustNewBwrap(sc, nil, "crash-test-dummy", argFChecked) + cmd := h.Unwrap() + + stdout, stderr := new(strings.Builder), new(strings.Builder) + cmd.Stdout, cmd.Stderr = stdout, stderr + + t.Run("close without pipes panic", func(t *testing.T) { + defer func() { + wantPanic := "attempted to close bwrap child initialised without pipes" + if r := recover(); r != wantPanic { + t.Errorf("Close: panic = %q, want %q", + r, wantPanic) + } + }() + + panic(fmt.Sprintf("unreachable: %v", + h.Close())) + }) + + if err := h.Start(); err != nil { + t.Errorf("Start() error = %v", + err) + return + } + + if err := h.Wait(); err != nil { + t.Errorf("Wait() err = %v stderr = %s", + err, stderr) + } + }) + + t.Run("implementation compliance", func(t *testing.T) { + testHelper(t, func() helper.Helper { return helper.MustNewBwrap(sc, argsWt, "crash-test-dummy", argF) }) + }) +} diff --git a/helper/helper_test.go b/helper/helper_test.go index 23cff66..da1dae9 100644 --- a/helper/helper_test.go +++ b/helper/helper_test.go @@ -23,11 +23,15 @@ var ( argsWt = helper.MustNewCheckedArgs(wantArgs) ) -func argF(argsFD int, statFD int) []string { +func argF(argsFD, statFD int) []string { if argsFD == -1 { panic("invalid args fd") } + return argFChecked(argsFD, statFD) +} + +func argFChecked(argsFD, statFD int) []string { if statFD == -1 { return []string{"--args", strconv.Itoa(argsFD)} } else { diff --git a/helper/stub.go b/helper/stub.go index b6b5d72..78559b5 100644 --- a/helper/stub.go +++ b/helper/stub.go @@ -6,15 +6,19 @@ import ( "os" "os/exec" "strconv" + "strings" "syscall" "testing" + + "git.ophivana.moe/cat/fortify/helper/bwrap" ) // InternalChildStub is an internal function but exported because it is cross-package; // it is part of the implementation of the helper stub. func InternalChildStub() { // this test mocks the helper process - if os.Getenv(FortifyHelper) != "1" { + if os.Getenv(FortifyHelper) != "1" || + os.Getenv(FortifyStatus) == "-1" { // this indicates the stub is being invoked as a bwrap child without pipes return } @@ -22,6 +26,35 @@ func InternalChildStub() { statFD := flag.Int("fd", -1, "") _ = flag.CommandLine.Parse(os.Args[4:]) + switch os.Args[3] { + case "bwrap": + bwrapStub(argsFD, statFD) + default: + genericStub(argsFD, statFD) + } + + os.Exit(0) +} + +// InternalReplaceExecCommand is an internal function but exported because it is cross-package; +// it is part of the implementation of the helper stub. +func InternalReplaceExecCommand(t *testing.T) { + t.Cleanup(func() { + execCommand = exec.Command + }) + + // replace execCommand to have the resulting *exec.Cmd launch TestHelperChildStub + execCommand = func(name string, arg ...string) *exec.Cmd { + // pass through nonexistent path + if name == "/nonexistent" && len(arg) == 0 { + return exec.Command(name) + } + + return exec.Command(os.Args[0], append([]string{"-test.run=TestHelperChildStub", "--", name}, arg...)...) + } +} + +func genericStub(argsFD, statFD *int) { // simulate args pipe behaviour func() { if *argsFD == -1 { @@ -89,20 +122,53 @@ func InternalChildStub() { } } -// InternalReplaceExecCommand is an internal function but exported because it is cross-package; -// it is part of the implementation of the helper stub. -func InternalReplaceExecCommand(t *testing.T) { - t.Cleanup(func() { - execCommand = exec.Command - }) +func bwrapStub(argsFD, statFD *int) { + // the bwrap launcher does not ever launch with sync fd + if *statFD != -1 { + panic("attempted to launch bwrap with status monitoring") + } - // replace execCommand to have the resulting *exec.Cmd launch TestHelperChildStub - execCommand = func(name string, arg ...string) *exec.Cmd { - // pass through nonexistent path - if name == "/nonexistent" && len(arg) == 0 { - return exec.Command(name) + // test args pipe behaviour + func() { + if *argsFD == -1 { + panic("attempted to start bwrap without passing args pipe fd") } - return exec.Command(os.Args[0], append([]string{"-test.run=TestHelperChildStub", "--", name}, arg...)...) + f := os.NewFile(uintptr(*argsFD), "|0") + if f == nil { + panic("attempted to start helper without args pipe") + } + + got, want := new(strings.Builder), new(strings.Builder) + + if _, err := io.Copy(got, f); err != nil { + panic("cannot read args: " + err.Error()) + } + + // hardcoded bwrap configuration used by test + if _, err := MustNewCheckedArgs((&bwrap.Config{ + Unshare: nil, + Net: true, + UserNS: false, + Hostname: "localhost", + Chdir: "/nonexistent", + Clearenv: true, + NewSession: true, + DieWithParent: true, + AsInit: true, + }).Args()).WriteTo(want); err != nil { + panic("cannot read want: " + err.Error()) + } + + if got.String() != want.String() { + panic("bad bwrap args\ngot: " + got.String() + "\nwant: " + want.String()) + } + }() + + if err := syscall.Exec( + os.Args[0], + append([]string{os.Args[0], "-test.run=TestHelperChildStub", "--"}, flag.CommandLine.Args()...), + os.Environ()); err != nil { + panic("cannot start general stub: " + err.Error()) } }