Review of 62a50eb: the layer lay in the workspace, which every step binds read-write,
so a change's command could swap a kept directory for a symlink and the move, run
outside the sandbox, would write into the shared cache (ADR 0541 §2). The layer now
lies beside the workspace (Workspaces.CacheLayer, removed by Release and Sweep),
Command refuses a layer within reach of the workspace, a writable directory or a
cache, and every move goes through an os.Root on the layer and keeps no symlink. Only
LayerCaches (the build cache) are layered; a module cache keeps its throwaway layer
(issue 1921).
178 lines
6.5 KiB
Go
178 lines
6.5 KiB
Go
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, "ws1.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{}
|
|
}
|
|
// A symlink in the kept layer (none is ever kept; planted here) leads no move outside the layer: the kept directory
|
|
// replaces it, and the shared cache it points to stays unchanged.
|
|
if err := os.MkdirAll(keptLayer(layer, 0), 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Symlink(shared, filepath.Join(keptLayer(layer, 0), "ab")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
run := Isolated(writer, Isolation{Workspace: filepath.Join(root, "ws1"), Caches: []string{shared}, CacheLayer: layer, LayerCaches: []string{shared}})
|
|
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)
|
|
}
|
|
}
|