From 3b3cabd2b0debbcfeb20aa7291039a2f962931fc Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 22 Oct 2026 17:46:10 +0200 Subject: [PATCH] Prove a run step reaches no key, login, name or socket and leaves the caches unchanged (issue 1309, S19, ADR 0448 check) --- go/buildstep/isolate_integration_test.go | 66 ++++++++++++++++++++ go/buildstep/testdata/isolated-probe/main.go | 49 +++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 go/buildstep/isolate_integration_test.go create mode 100644 go/buildstep/testdata/isolated-probe/main.go diff --git a/go/buildstep/isolate_integration_test.go b/go/buildstep/isolate_integration_test.go new file mode 100644 index 0000000..efd140c --- /dev/null +++ b/go/buildstep/isolate_integration_test.go @@ -0,0 +1,66 @@ +//go:build integration + +package buildstep + +import ( + "context" + "net" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/forgekey" +) + +// 1309/S19, the issue's proof: a run step, in bubblewrap as Isolated runs it, reads no key and no login, resolves no +// name, reaches no TCP or unix socket of the machine, finds no home of the account, and leaves the shared cache it +// wrote unchanged; the workspace it writes keeps what it wrote. It needs bubblewrap and unprivileged user namespaces +// (issue 1308) on the machine that runs it, and fails naming them when they are not there. +func TestARunStepReachesNothingAndLeavesTheCachesUnchanged(t *testing.T) { + if _, err := exec.LookPath(Bubblewrap); err != nil { + t.Fatalf("bubblewrap is not installed here, so the isolation of a run step cannot be proved (issue 1309): %v", err) + } + root := t.TempDir() + account := filepath.Join(root, "account") + key, login := filepath.Join(account, ".ssh", "id_ed25519"), filepath.Join(account, "bus-login.json") + writeFile(t, filepath.Dir(key), filepath.Base(key), "PRIVATE KEY") + writeFile(t, account, "bus-login.json", `{"password":"secret"}`) + cacheDir := filepath.Join(root, "cache") + writeFile(t, cacheDir, "warm", "warm\n") + workspace := filepath.Join(root, "work", "tree") + if err := os.MkdirAll(workspace, 0o755); err != nil { + t.Fatal(err) + } + probe := filepath.Join(workspace, "probe") + if out, err := exec.Command("go", "build", "-o", probe, "./testdata/isolated-probe").CombinedOutput(); err != nil { + t.Fatalf("building the probe: %v\n%s", err, out) + } + tcp, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer tcp.Close() + socket := filepath.Join(account, "bus.sock") + unix, err := net.Listen("unix", socket) + if err != nil { + t.Fatal(err) + } + defer unix.Close() + + run := Isolated(forgekey.ExecRunnerFor(0), Isolation{Workspace: workspace, Caches: []string{cacheDir}}) + r := run(context.Background(), []string{"PATH=/usr/bin:/bin"}, probe, key, login, tcp.Addr().String(), socket, "localhost.localdomain.invalid", filepath.Join(cacheDir, "warm")) + if r.Status != 0 || r.Err != "" || strings.TrimSpace(r.Stdout) != "shut off" { + t.Fatalf("the run step was not shut off (status %d, %s):\n%s%s", r.Status, r.Err, r.Stdout, r.Stderr) + } + if b, _ := os.ReadFile(filepath.Join(cacheDir, "warm")); string(b) != "warm\n" { + t.Fatalf("the step changed the shared cache: %q", b) + } + + // The workspace is the step's own: what it writes there stays. + r = run(context.Background(), nil, "/bin/sh", "-c", "echo built > out") + if b, err := os.ReadFile(filepath.Join(workspace, "out")); r.Status != 0 || err != nil || string(b) != "built\n" { + t.Fatalf("the step's write to its workspace did not stay (status %d, %s%s): %q %v", r.Status, r.Stderr, r.Err, b, err) + } +} diff --git a/go/buildstep/testdata/isolated-probe/main.go b/go/buildstep/testdata/isolated-probe/main.go new file mode 100644 index 0000000..04a33ba --- /dev/null +++ b/go/buildstep/testdata/isolated-probe/main.go @@ -0,0 +1,49 @@ +// The probe a run step's isolation is proved with (novox/hq ADR 0448; issue 1309, S19): run inside the step, it says +// on stdout every way it reached what a step must not, and "shut off" when it reached none. Its arguments: the files +// it must not read (the forge key, a bus login), a TCP address and a unix socket of the machine it must not reach, a +// name it must not resolve, and a cache file it writes, which must be unchanged after the step. +package main + +import ( + "fmt" + "net" + "os" + "time" +) + +func main() { + if len(os.Args) != 7 { + fmt.Println("usage: probe ") + os.Exit(2) + } + key, login, tcp, socket, name, cache := os.Args[1], os.Args[2], os.Args[3], os.Args[4], os.Args[5], os.Args[6] + reached := 0 + say := func(f string, a ...any) { reached++; fmt.Printf(f+"\n", a...) } + for _, f := range []string{key, login} { + if _, err := os.ReadFile(f); err == nil { + say("read %s", f) + } + } + if c, err := net.DialTimeout("tcp", tcp, 2*time.Second); err == nil { + c.Close() + say("reached %s", tcp) + } + if c, err := net.DialTimeout("unix", socket, 2*time.Second); err == nil { + c.Close() + say("reached %s", socket) + } + if addrs, err := net.LookupHost(name); err == nil { + say("resolved %s to %v", name, addrs) + } + if home, err := os.UserHomeDir(); err == nil { + if entries, err := os.ReadDir(home); err == nil && len(entries) > 0 { + say("the home %s holds %d entries", home, len(entries)) + } + } + if err := os.WriteFile(cache, []byte("written by the step\n"), 0o644); err != nil { + say("could not write the cache under its throwaway layer: %v", err) + } + if reached == 0 { + fmt.Println("shut off") + } +}