package buildstep import ( "context" "os" "path/filepath" "strings" "sync" "testing" "git.novox.be/novox/mesh-sdk/go/forgekey" ) // pairs says whether argv holds the words of want one after another. func holds(argv []string, want ...string) bool { for i := 0; i+len(want) <= len(argv); i++ { match := true for j, w := range want { if argv[i+j] != w { match = false break } } if match { return true } } return false } func fixedSystem(t *testing.T) { t.Helper() before := systemArgs systemArgs = func() []string { return []string{"--ro-bind", "/usr", "/usr", "--symlink", "usr/bin", "/bin"} } t.Cleanup(func() { systemArgs = before }) } // A run step's command runs in bubblewrap (novox/hq ADR 0448; issue 1309): no network, no namespace shared, an // environment of exactly what the step is given, the workspace the one tree it writes, the toolchain read-only, and // nothing else of the machine's: not the account's home, not the forge key or a bus login, not the runtime directory, // and no name service. func TestARunStepRunsShutOffInBubblewrap(t *testing.T) { fixedSystem(t) iso := Isolation{Workspace: "/work/ab12/tree", ReadOnly: []string{"/usr/lib/go"}, Writable: []string{"/work/ab12/out"}, Env: []string{"GOFLAGS=-mod=mod", "PATH=/usr/bin"}, Hosts: "/work/ab12/hosts"} argv, err := iso.Command("go", "test", "./...") if err != nil { t.Fatal(err) } for _, want := range [][]string{ {"--unshare-all"}, {"--die-with-parent"}, {"--new-session"}, {"--clearenv"}, {"--proc", "/proc"}, {"--dev", "/dev"}, {"--tmpfs", "/tmp"}, {"--ro-bind", "/usr", "/usr"}, {"--symlink", "usr/bin", "/bin"}, {"--ro-bind", "/usr/lib/go", "/usr/lib/go"}, {"--bind", "/work/ab12/tree", "/work/ab12/tree"}, {"--bind", "/work/ab12/out", "/work/ab12/out"}, {"--ro-bind", "/work/ab12/hosts", "/etc/hosts"}, {"--setenv", "GOFLAGS", "-mod=mod"}, {"--setenv", "PATH", "/usr/bin"}, {"--setenv", "HOME", "/tmp/home"}, {"--chdir", "/work/ab12/tree"}, {"--", "go", "test", "./..."}, } { if !holds(argv, want...) { t.Errorf("the command has no %v: %v", want, argv) } } if holds(argv, "--share-net") || strings.Contains(strings.Join(argv, " "), "resolv.conf") || strings.Contains(strings.Join(argv, " "), "/run") { t.Errorf("the command reaches the network, the name service or the runtime directory: %v", argv) } // The workspace is mounted after /tmp's tmpfs, so a workspace under /tmp is not hidden by it. if strings.Index(strings.Join(argv, "\x00"), "--tmpfs\x00/tmp") > strings.Index(strings.Join(argv, "\x00"), "--bind\x00/work/ab12/tree") { t.Errorf("the tmpfs of /tmp comes after the workspace: %v", argv) } // A HOME the step is given is its own. iso.Env = append(iso.Env, "HOME=/work/ab12/home") argv, _ = iso.Command("true") if holds(argv, "--setenv", "HOME", "/tmp/home") || !holds(argv, "--setenv", "HOME", "/work/ab12/home") { t.Errorf("the step's own HOME was replaced: %v", argv) } } func TestAnIsolationNamesNoPathItCannotPin(t *testing.T) { for _, iso := range []Isolation{ {Workspace: ""}, {Workspace: "tree"}, {Workspace: "/"}, {Workspace: "/w", ReadOnly: []string{"relative"}}, {Workspace: "/w", Writable: []string{"/"}}, {Workspace: "/w", Caches: []string{"/a/../b"}}, {Workspace: "/w", Env: []string{"NOEQUALS"}}, } { if _, err := iso.Command("true"); err == nil { t.Errorf("%+v was accepted", iso) } } } // Isolated runs every command through bwrap, with the step's environment inside and a hosts file that names only the // loopback, removed after. func TestIsolatedRunsEachCommandThroughBubblewrap(t *testing.T) { fixedSystem(t) var ran []string var hosts string inner := func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran { ran = append([]string{name}, args...) for i, a := range args { if a == "/etc/hosts" && i > 0 { hosts = args[i-1] } } return forgekey.Ran{Stdout: "ok"} } r := Isolated(inner, Isolation{Workspace: "/w"})(context.Background(), []string{"A=1"}, "go", "build") if r.Stdout != "ok" || ran[0] != Bubblewrap || !holds(ran, "--setenv", "A", "1") || !holds(ran, "--", "go", "build") { t.Fatalf("ran %v: %+v", ran, r) } if hosts == "" { t.Fatalf("no hosts file was bound: %v", ran) } // 1921/S2: with a cache layer, what one command wrote into the caches is kept for the ask's later commands, the // shared cache stays unchanged, two commands at once write into layers of their own (overlayfs refuses an upper // layer two mounts share), and a command's own layer is gone when it ends. root := t.TempDir() shared := filepath.Join(root, "gobuild") if err := os.MkdirAll(shared, 0o700); err != nil { t.Fatal(err) } layer := filepath.Join(root, "ws", "cachelayer") var mu sync.Mutex uppers := map[string]bool{} gate := make(chan struct{}) var started sync.WaitGroup started.Add(2) writer := func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran { up := overlayOf(args, shared) mu.Lock() uppers[up] = true mu.Unlock() started.Done() <-gate // both commands are running before either ends os.MkdirAll(filepath.Join(up, "ab"), 0o700) os.WriteFile(filepath.Join(up, "ab", args[len(args)-1]+"-d"), []byte("compiled"), 0o600) return forgekey.Ran{} } run := Isolated(writer, Isolation{Workspace: filepath.Join(root, "ws"), Caches: []string{shared}, CacheLayer: layer}) var wg sync.WaitGroup for _, pkg := range []string{"one", "two"} { wg.Add(1) go func() { defer wg.Done(); run(context.Background(), nil, "go", "build", pkg) }() } started.Wait() close(gate) wg.Wait() if len(uppers) != 2 { t.Fatalf("two commands at once wrote into %d layers, want 2 of their own: %v", len(uppers), uppers) } for _, pkg := range []string{"one", "two"} { if b, err := os.ReadFile(filepath.Join(keptLayer(layer, 0), "ab", pkg+"-d")); err != nil || string(b) != "compiled" { t.Errorf("what the build of %s compiled is not kept for the ask: %q %v", pkg, b, err) } } for up := range uppers { if _, err := os.Stat(up); !os.IsNotExist(err) { t.Errorf("the command's own layer %s outlived it: %v", up, err) } } if entries, _ := os.ReadDir(shared); len(entries) != 0 { t.Errorf("the shared cache was written: %v", entries) } }