Files
werkdock/internal/engine/bwrap_test.go
6d6060bb07 RFC 0005 milestone 2: instance lifecycle — run -d, ps, stop, logs, rm, exec (#4)
An instance is a directory under instances/<id>/ (config.json, state.json,
stdout, stderr, run/, volumes/) written by package instance, the service
behind the verbs. run -d and start fork this binary as a supervisor
(werkdock supervise ID) that records the pids and the exit code; stop,
kill, wait, logs, ps, rm, inspect read the files. exec is a second bwrap
over the same instance tree. The command is pid 1 of its sandbox
(bwrap --as-pid-1) so stop's SIGTERM reaches it, as in docker; /run and
the anonymous volumes are instance directories, kept across stop/start
and shared with exec. Version 0.5.0.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: mhoennig <michael@hoennig.de>
Reviewed-on: #4
2026-09-10 20:55:59 +02:00

353 lines
12 KiB
Go

package engine
import (
"bytes"
"os"
"os/exec"
"path/filepath"
"reflect"
"strings"
"syscall"
"testing"
"time"
)
func TestArgvAssemblesTheHardenedInvocation(t *testing.T) {
b := &Bwrap{}
spec := RunSpec{
RootFS: "/store/images/buildenv/rootfs",
Mounts: []Mount{
{Mode: MountRoBind, Source: "/etc/resolv.conf", Dest: "/etc/resolv.conf"},
{Mode: MountRoBind, Source: "/repo/.git", Dest: "/repo/.git"},
{Mode: MountTmpfs, Dest: "/repo/.git/werkator"},
{Mode: MountBind, Source: "/repo", Dest: "/repo"},
{Mode: MountBind, Source: "/cache", Dest: "/root/.gradle"},
},
Env: []EnvVar{{Key: "CI", Value: "true"}, {Key: "TERM", Value: "dumb"}},
Workdir: "/repo",
Command: []string{"/bin/sh", "-c", "./gradlew build"},
}
argv, err := b.Argv(spec)
if err != nil {
t.Fatal(err)
}
want := []string{
"bwrap",
"--unshare-user", "--unshare-pid", "--as-pid-1", "--die-with-parent", "--info-fd", "3",
"--uid", "0", "--gid", "0",
"--ro-bind", "/store/images/buildenv/rootfs", "/",
"--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp", "--tmpfs", "/root", "--tmpfs", "/run",
"--ro-bind", "/etc/resolv.conf", "/etc/resolv.conf",
"--ro-bind", "/repo/.git", "/repo/.git",
"--tmpfs", "/repo/.git/werkator",
"--bind", "/repo", "/repo",
"--bind", "/cache", "/root/.gradle",
"--clearenv",
"--setenv", "HOME", "/root",
"--setenv", "PATH", DefaultPATH,
"--setenv", "CI", "true",
"--setenv", "TERM", "dumb",
"--dir", "/repo",
"--chdir", "/repo", "--",
"/bin/sh", "-c", "./gradlew build",
}
if !reflect.DeepEqual(argv, want) {
t.Errorf("argv mismatch:\n got %q\nwant %q", argv, want)
}
}
func TestArgvMapsTheCallerToTheRequestedUser(t *testing.T) {
argv, err := (&Bwrap{}).Argv(RunSpec{RootFS: "/r", Command: []string{"id"}, UID: 999, GID: 50, Home: "/var/lib/postgresql"})
if err != nil {
t.Fatal(err)
}
joined := strings.Join(argv, " ")
for _, want := range []string{"--uid 999 --gid 50", "--setenv HOME /var/lib/postgresql", "--tmpfs /root"} {
if !strings.Contains(joined, want) {
t.Errorf("argv lacks %q: %s", want, joined)
}
}
}
func TestArgvSetsEachEnvironmentVariableOnce(t *testing.T) {
spec := RunSpec{RootFS: "/r", Command: []string{"env"}, Env: []EnvVar{
{Key: "PATH", Value: "/opt/bin:/usr/bin"}, {Key: "HOME", Value: "/home/me"}, {Key: "PATH", Value: "/only"}, {Key: "X", Value: "1"},
}}
argv, err := (&Bwrap{}).Argv(spec)
if err != nil {
t.Fatal(err)
}
var env []string
for i := 0; i+2 < len(argv); i++ {
if argv[i] == "--setenv" {
env = append(env, argv[i+1]+"="+argv[i+2])
}
}
want := []string{"HOME=/home/me", "PATH=/only", "X=1"}
if !reflect.DeepEqual(env, want) {
t.Errorf("env: got %q, want %q", env, want)
}
}
func TestArgvValidation(t *testing.T) {
tests := []struct {
name string
spec RunSpec
wantErr string
}{
{"missing rootfs", RunSpec{Command: []string{"true"}}, "rootfs must be set"},
{"relative rootfs", RunSpec{RootFS: "rootfs", Command: []string{"true"}}, "absolute"},
{"missing command", RunSpec{RootFS: "/r"}, "no command specified"},
{
"relative mount dest",
RunSpec{RootFS: "/r", Mounts: []Mount{{Mode: MountBind, Source: "/s", Dest: "work"}}, Command: []string{"true"}},
"absolute",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := (&Bwrap{}).Argv(tt.spec)
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Errorf("got error %v, want it to contain %q", err, tt.wantErr)
}
})
}
}
func TestArgvDefaultsWorkdirToRoot(t *testing.T) {
argv, err := (&Bwrap{}).Argv(RunSpec{RootFS: "/r", Command: []string{"true"}})
if err != nil {
t.Fatal(err)
}
joined := strings.Join(argv, " ")
if !strings.Contains(joined, "--chdir / --") {
t.Errorf("expected default workdir /, got: %s", joined)
}
}
func TestArgvCreatesTheWorkdirOnlyWhereTheSandboxIsWritable(t *testing.T) {
tests := []struct {
name string
spec RunSpec
wantDir bool
}{
{"under implicit /tmp", RunSpec{Workdir: "/tmp/build"}, true},
{"under a user tmpfs", RunSpec{Workdir: "/cache/x", Mounts: []Mount{{Mode: MountTmpfs, Dest: "/cache"}}}, true},
{"under a rw bind", RunSpec{Workdir: "/repo/build", Mounts: []Mount{{Mode: MountBind, Source: "/r", Dest: "/repo"}}}, true},
{"the bind itself", RunSpec{Workdir: "/repo", Mounts: []Mount{{Mode: MountBind, Source: "/r", Dest: "/repo"}}}, true},
{"under a ro bind", RunSpec{Workdir: "/repo/build", Mounts: []Mount{{Mode: MountRoBind, Source: "/r", Dest: "/repo"}}}, false},
{"ro bind shadows the rw bind below", RunSpec{Workdir: "/repo/.git/x", Mounts: []Mount{
{Mode: MountBind, Source: "/r", Dest: "/repo"},
{Mode: MountRoBind, Source: "/r/.git", Dest: "/repo/.git"},
}}, false},
{"tmpfs inside a ro bind", RunSpec{Workdir: "/repo/.git/werkator/x", Mounts: []Mount{
{Mode: MountRoBind, Source: "/r/.git", Dest: "/repo/.git"},
{Mode: MountTmpfs, Dest: "/repo/.git/werkator"},
}}, true},
{"in the read-only rootfs", RunSpec{Workdir: "/opt/build"}, false},
{"a sibling with a common prefix", RunSpec{Workdir: "/repository", Mounts: []Mount{{Mode: MountBind, Source: "/r", Dest: "/repo"}}}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tt.spec.RootFS, tt.spec.Command = "/rootfs", []string{"true"}
argv, err := (&Bwrap{}).Argv(tt.spec)
if err != nil {
t.Fatal(err)
}
joined := strings.Join(argv, " ")
if got := strings.Contains(joined, "--dir "+tt.spec.Workdir+" --chdir"); got != tt.wantDir {
t.Errorf("--dir emitted: %v, want %v (argv: %s)", got, tt.wantDir, joined)
}
})
}
}
func TestCheckWorkdirRequiresExistenceOutsideWritableAreas(t *testing.T) {
rootfs := t.TempDir()
if err := os.MkdirAll(filepath.Join(rootfs, "opt", "app"), 0o755); err != nil {
t.Fatal(err)
}
roSrc := t.TempDir()
if err := os.MkdirAll(filepath.Join(roSrc, "sub"), 0o755); err != nil {
t.Fatal(err)
}
ro := []Mount{{Mode: MountRoBind, Source: roSrc, Dest: "/ro"}}
tests := []struct {
name string
spec RunSpec
wantErr bool
}{
{"exists in the rootfs", RunSpec{Workdir: "/opt/app"}, false},
{"missing in the rootfs", RunSpec{Workdir: "/opt/missing"}, true},
{"exists behind a ro bind", RunSpec{Workdir: "/ro/sub", Mounts: ro}, false},
{"missing behind a ro bind", RunSpec{Workdir: "/ro/nope", Mounts: ro}, true},
{"missing but writable", RunSpec{Workdir: "/tmp/anything"}, false},
{"unset", RunSpec{}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tt.spec.RootFS = rootfs
err := CheckWorkdir(tt.spec)
if (err != nil) != tt.wantErr {
t.Errorf("got %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestEnsureMountpointsCreatesMissingAndSkipsExisting(t *testing.T) {
rootfs := t.TempDir()
// The rootfs ships /etc/resolv.conf as a file with content — it
// must be left alone.
if err := os.MkdirAll(filepath.Join(rootfs, "etc"), 0o755); err != nil {
t.Fatal(err)
}
shipped := filepath.Join(rootfs, "etc", "resolv.conf")
if err := os.WriteFile(shipped, []byte("nameserver 127.0.0.53\n"), 0o644); err != nil {
t.Fatal(err)
}
srcDir := t.TempDir()
srcFile := filepath.Join(srcDir, "hosts")
if err := os.WriteFile(srcFile, []byte("127.0.0.1 localhost\n"), 0o644); err != nil {
t.Fatal(err)
}
spec := RunSpec{
RootFS: rootfs,
Mounts: []Mount{
{Mode: MountRoBind, Source: "/etc", Dest: "/etc/resolv.conf"}, // exists: skipped (source type irrelevant)
{Mode: MountBind, Source: srcDir, Dest: "/repo/workspace"}, // missing dir mountpoint
{Mode: MountBind, Source: srcFile, Dest: "/etc/hosts.werkdock"}, // missing file mountpoint
{Mode: MountTmpfs, Dest: "/repo/.git/werkator"}, // tmpfs mountpoint, no source
},
Command: []string{"true"},
}
if err := EnsureMountpoints(spec); err != nil {
t.Fatal(err)
}
for _, dir := range []string{"proc", "dev", "tmp", "root", "repo/workspace", "repo/.git/werkator"} {
fi, err := os.Stat(filepath.Join(rootfs, dir))
if err != nil || !fi.IsDir() {
t.Errorf("expected directory mountpoint %s in the rootfs: %v", dir, err)
}
}
fi, err := os.Stat(filepath.Join(rootfs, "etc", "hosts.werkdock"))
if err != nil || !fi.Mode().IsRegular() {
t.Errorf("expected file mountpoint etc/hosts.werkdock in the rootfs: %v", err)
}
content, err := os.ReadFile(shipped)
if err != nil || string(content) != "nameserver 127.0.0.53\n" {
t.Errorf("shipped rootfs file was modified: %q, %v", content, err)
}
}
func TestEnsureMountpointsRefusesEscapingDestinations(t *testing.T) {
spec := RunSpec{
RootFS: t.TempDir(),
Mounts: []Mount{{Mode: MountBind, Source: "/tmp", Dest: "/../outside"}},
Command: []string{"true"},
}
err := EnsureMountpoints(spec)
if err == nil || !strings.Contains(err.Error(), "escapes the rootfs") {
t.Errorf("got %v, want an escape refusal", err)
}
}
// TestRunInsideRealSandbox is the gated integration test: it runs only
// where bwrap and unprivileged user namespaces actually work. The host
// / serves as the read-only rootfs, so nothing is unpacked and (all
// mountpoints existing) nothing is written.
func TestRunInsideRealSandbox(t *testing.T) {
if _, err := exec.LookPath("bwrap"); err != nil {
t.Skip("bwrap not installed")
}
if err := exec.Command("bwrap", "--unshare-user", "--uid", "0", "--ro-bind", "/", "/", "true").Run(); err != nil {
t.Skipf("unprivileged user namespaces not usable here: %v", err)
}
var stdout, stderr bytes.Buffer
b := &Bwrap{Stdout: &stdout, Stderr: &stderr}
code, err := b.Run(RunSpec{
RootFS: "/",
Command: []string{"id", "-u"},
})
if err != nil {
t.Fatalf("run failed: %v (stderr: %s)", err, stderr.String())
}
if code != 0 {
t.Fatalf("exit code %d, stderr: %s", code, stderr.String())
}
if got := strings.TrimSpace(stdout.String()); got != "0" {
t.Errorf("expected uid 0 inside the sandbox, got %q", got)
}
}
func TestRunCreatesAMissingWorkdirInsideATmpfs(t *testing.T) {
if _, err := exec.LookPath("bwrap"); err != nil {
t.Skip("bwrap not installed")
}
if err := exec.Command("bwrap", "--unshare-user", "--uid", "0", "--ro-bind", "/", "/", "true").Run(); err != nil {
t.Skipf("unprivileged user namespaces not usable here: %v", err)
}
var stdout bytes.Buffer
b := &Bwrap{Stdout: &stdout, Stderr: &bytes.Buffer{}}
code, err := b.Run(RunSpec{RootFS: "/", Workdir: "/tmp/werkdock-test/build", Command: []string{"pwd"}})
if err != nil || code != 0 {
t.Fatalf("run: code %d, err %v", code, err)
}
if got := strings.TrimSpace(stdout.String()); got != "/tmp/werkdock-test/build" {
t.Errorf("pwd inside the sandbox: %q", got)
}
}
func TestRunPassesTheExitCodeThrough(t *testing.T) {
if _, err := exec.LookPath("bwrap"); err != nil {
t.Skip("bwrap not installed")
}
if err := exec.Command("bwrap", "--unshare-user", "--uid", "0", "--ro-bind", "/", "/", "true").Run(); err != nil {
t.Skipf("unprivileged user namespaces not usable here: %v", err)
}
b := &Bwrap{Stdout: &bytes.Buffer{}, Stderr: &bytes.Buffer{}}
code, err := b.Run(RunSpec{RootFS: "/", Command: []string{"sh", "-c", "exit 42"}})
if err != nil {
t.Fatal(err)
}
if code != 42 {
t.Errorf("expected exit code 42, got %d", code)
}
}
// A signal sent through Process reaches the command as pid 1 of the
// sandbox — what `stop` relies on (bwrap's own init would swallow it).
func TestStartDeliversSignalsToTheCommand(t *testing.T) {
if _, err := exec.LookPath("bwrap"); err != nil {
t.Skip("bwrap not installed")
}
if err := exec.Command("bwrap", "--unshare-user", "--uid", "0", "--ro-bind", "/", "/", "true").Run(); err != nil {
t.Skipf("unprivileged user namespaces not usable here: %v", err)
}
var stdout, stderr bytes.Buffer
b := &Bwrap{Stdout: &stdout, Stderr: &stderr}
p, err := b.Start(RunSpec{
RootFS: "/",
Command: []string{"sh", "-c", `trap 'echo terminated; exit 3' TERM; echo ready; while :; do sleep 0.1; done`},
})
if err != nil {
t.Fatalf("start failed: %v (stderr: %s)", err, stderr.String())
}
if p.ChildPID() <= 0 {
t.Fatalf("no child pid reported")
}
for i := 0; i < 50 && !strings.Contains(stdout.String(), "ready"); i++ {
time.Sleep(100 * time.Millisecond)
}
if err := p.Signal(syscall.SIGTERM); err != nil {
t.Fatal(err)
}
code, err := p.Wait()
if err != nil {
t.Fatal(err)
}
if code != 3 || !strings.Contains(stdout.String(), "terminated") {
t.Errorf("exit %d, stdout %q, stderr %q", code, stdout.String(), stderr.String())
}
}