Download
394 lines 13.1 KiBGoModified 2026-10-31 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394
package sandbox

import (
	"bytes"
	"context"
	"fmt"
	"io/fs"
	"os"
	"os/exec"
	"path/filepath"
	"runtime"
	"slices"
	"strings"
	"testing"
	"time"

	"blinker/internal/policy"
)

// preview runs a script with Cmd.Preview, and lists its changes, one line
// each, with paths relative to the project.
func (f *fixture) preview(t *testing.T, mode policy.Mode, script string) (*Preview, Result, []string) {
	t.Helper()
	if runtime.GOOS != "linux" {
		t.Skip("previews need Linux")
	}
	var out bytes.Buffer
	res, err := f.sb.Run(context.Background(), Cmd{
		Mode: mode, Argv: []string{f.sb.Shell(), "-c", script},
		Stdout: &out, Stderr: &out, Timeout: 30 * time.Second, Network: true, Preview: true,
	})
	if err != nil {
		t.Fatalf("%s: %v: %s", script, err, out.String())
	}
	t.Cleanup(func() { res.Preview.Discard() })
	var lines []string
	for _, c := range res.Preview.Changes {
		name := c.Path
		if rel, err := filepath.Rel(f.project, c.Path); err == nil && !strings.HasPrefix(rel, "..") {
			name = rel
		}
		switch c.Kind {
		case Write:
			data, err := c.Contents()
			if err != nil {
				t.Fatal(err)
			}
			lines = append(lines, fmt.Sprintf("write %s %o %q", name, c.Mode, data))
		case Mkdir:
			lines = append(lines, fmt.Sprintf("mkdir %s %o opaque=%v", name, c.Mode, c.Opaque))
		case Symlink:
			lines = append(lines, fmt.Sprintf("symlink %s -> %s", name, c.Target))
		case Remove:
			lines = append(lines, "remove "+name)
		}
	}
	return res.Preview, res, lines
}

// A preview keeps every kind of change off the host and lists it, and only
// what changed.
func TestPreview(t *testing.T) {
	f := setup(t, basicPolicy(), Options{})
	files := map[string]string{"f.txt": "a\nb\n", "gone.txt": "gone\n", "same.txt": "same\n", "x.sh": "echo\n", "d/a": "a\n", "e/a": "a\n"}
	for name, text := range files {
		p := filepath.Join(f.project, name)
		if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
			t.Fatal(err)
		}
		if err := os.WriteFile(p, []byte(text), 0o644); err != nil {
			t.Fatal(err)
		}
	}
	if err := os.Symlink("f.txt", filepath.Join(f.project, "link")); err != nil {
		t.Fatal(err)
	}
	p, res, got := f.preview(t, policy.UnsafeMode, `set -e
sed -i s/b/B/ f.txt
rm gone.txt
echo new > new.txt
mkdir -p made/deep && echo x > made/deep/x
chmod +x x.sh
touch same.txt
rm -rf d && mkdir d && echo b > d/b
rm -rf e && mkdir e && echo a > e/a
touch empty
ln -sfn x.sh link
echo notes > ~/notes.txt
echo scratch > "$TMPDIR/s"`)
	if res.ExitCode != 0 {
		t.Fatalf("exit %d", res.ExitCode)
	}
	want := []string{
		`mkdir d 755 opaque=true`,
		`write d/b 644 "b\n"`,
		`mkdir e 755 opaque=true`,
		`write e/a 644 "a\n"`, // the same as before, but in a new directory
		`write empty 644 ""`,
		`write f.txt 644 "a\nB\n"`,
		`remove gone.txt`,
		`symlink link -> x.sh`,
		`mkdir made 755 opaque=false`,
		`mkdir made/deep 755 opaque=false`,
		`write made/deep/x 644 "x\n"`,
		`write new.txt 644 "new\n"`,
		`write x.sh 755 "echo\n"`,
		`write ` + filepath.Join(f.home, "notes.txt") + ` 644 "notes\n"`,
		// Not the temp dir's: only the command sees what it writes there.
	}
	slices.Sort(got)
	slices.Sort(want)
	if !slices.Equal(got, want) {
		t.Errorf("changes:\n%s\nwant:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n"))
	}
	// Nothing reached the host.
	for name, text := range files {
		if data, err := os.ReadFile(filepath.Join(f.project, name)); err != nil || string(data) != text {
			t.Errorf("%s on the host: %q, %v", name, data, err)
		}
	}
	for _, name := range []string{filepath.Join(f.project, "new.txt"), filepath.Join(f.home, "notes.txt"), filepath.Join(f.sb.scratch, "s")} {
		if _, err := os.Lstat(name); err == nil {
			t.Errorf("%s was written on the host", name)
		}
	}
	if fi, err := os.Stat(filepath.Join(f.project, "x.sh")); err != nil || fi.Mode().Perm() != 0o644 {
		t.Errorf("x.sh on the host: %v, %v", fi, err)
	}
	if target, err := os.Readlink(filepath.Join(f.project, "link")); err != nil || target != "f.txt" {
		t.Errorf("link on the host: %q, %v", target, err)
	}
	if err := p.Discard(); err != nil {
		t.Fatal(err)
	}
	if _, err := os.Stat(p.dir); err == nil {
		t.Error("Discard left the preview's files")
	}
}

// The mount points bubblewrap makes in a writable directory, for missing
// protected paths, are not the command's changes.
func TestPreviewLeavesOutMountPoints(t *testing.T) {
	f := setup(t, basicPolicy(), Options{})
	profile := filepath.Join(f.home, ".profile") // protected, and missing
	_, _, got := f.preview(t, policy.UnsafeMode, "test -e ~/.profile && echo hi > ok.txt")
	if want := []string{`write ok.txt 644 "hi\n"`}; !slices.Equal(got, want) {
		t.Errorf("changes: %q, want %q", got, want)
	}
	if _, err := os.Lstat(profile); err == nil {
		t.Error("the mount point was made on the host")
	}
}

// Under a read-only project, a writable directory's changes are kept, and
// the rest stays read-only.
func TestPreviewNested(t *testing.T) {
	f := setup(t, &testPolicy{Files: []testRule{
		rule("~", policy.Read, policy.Read),
		rule("src", policy.Write, policy.Write),
	}}, Options{})
	if err := os.Mkdir(filepath.Join(f.project, "src"), 0o755); err != nil {
		t.Fatal(err)
	}
	_, res, got := f.preview(t, policy.UnsafeMode, "echo a > src/a; echo top > top.txt")
	if want := []string{`write src/a 644 "a\n"`}; !slices.Equal(got, want) || res.ExitCode == 0 {
		t.Errorf("exit %d, changes: %q, want %q", res.ExitCode, got, want)
	}
	if _, err := os.Lstat(filepath.Join(f.project, "src", "a")); err == nil {
		t.Error("src/a was written on the host")
	}
	// One not discarded goes with the session.
	f.sb.Close()
	if _, err := os.Stat(f.sb.session); err == nil {
		t.Error("closing the session left its dir, with a preview in it")
	}
}

// What a preview can't show, it refuses, and keeps nothing.
func TestPreviewRefuses(t *testing.T) {
	if runtime.GOOS != "linux" {
		t.Skip("previews need Linux")
	}
	f := setup(t, basicPolicy(), Options{})
	var out bytes.Buffer
	_, err := f.sb.Run(context.Background(), Cmd{
		Mode: policy.UnsafeMode, Argv: []string{f.sb.Shell(), "-c", "mkfifo pipe"},
		Stdout: &out, Stderr: &out, Timeout: 30 * time.Second, Preview: true,
	})
	if err == nil || !strings.Contains(err.Error(), "cannot show") {
		t.Errorf("a FIFO: %v: %s", err, out.String())
	}
	entries, err := os.ReadDir(f.sb.session)
	if err != nil {
		t.Fatal(err)
	}
	if slices.ContainsFunc(entries, func(e fs.DirEntry) bool { return strings.HasPrefix(e.Name(), "preview-") }) {
		t.Error("a refused preview's files are left")
	}
}

// A preview has no network, and asks about no host.
func TestPreviewHasNoNetwork(t *testing.T) {
	if _, err := exec.LookPath("curl"); err != nil {
		t.Skip("curl not installed")
	}
	asked := 0
	pol := basicPolicy()
	pol.UnsafeNet = []policy.NetRule{{Host: "*", Access: policy.Ask}}
	f := setup(t, pol, Options{AskDomain: func(context.Context, string) DomainAnswer {
		asked++
		return DomainAllowOnce
	}})
	_, res, _ := f.preview(t, policy.UnsafeMode, "curl -sS -f --max-time 5 http://example.com/")
	if res.ExitCode == 0 || asked != 0 {
		t.Errorf("the network: exit %d, asked %d", res.ExitCode, asked)
	}
}

// tree lists what is under dir, one line each: its type, mode and
// contents or target. Another test's fixture, made in dir, is left out.
func tree(t *testing.T, dir string) []string {
	t.Helper()
	var lines []string
	err := filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error {
		if err != nil || p == dir {
			return err
		}
		if strings.HasPrefix(d.Name(), ".sandbox-test-") {
			return fs.SkipDir
		}
		rel, _ := filepath.Rel(dir, p)
		fi, err := d.Info()
		if err != nil {
			return err
		}
		switch {
		case fi.Mode()&fs.ModeSymlink != 0:
			target, err := os.Readlink(p)
			lines = append(lines, fmt.Sprintf("%s -> %s", rel, target))
			return err
		case fi.IsDir():
			lines = append(lines, fmt.Sprintf("%s/ %o", rel, fi.Mode().Perm()))
		default:
			data, err := os.ReadFile(p)
			lines = append(lines, fmt.Sprintf("%s %o %q", rel, fi.Mode().Perm(), data))
			return err
		}
		return nil
	})
	if err != nil {
		t.Fatal(err)
	}
	return lines
}

// Applying a preview leaves what running the command would have.
func TestApply(t *testing.T) {
	script := `set -e
sed -i s/b/B/ f.txt
rm gone.txt
echo new > new.txt
mkdir -p made/deep && echo x > made/deep/x
chmod +x x.sh
rm -rf d && mkdir d && echo a > d/a && echo b > d/b
rm -rf dir2file && echo f > dir2file
rm -rf file2dir && mkdir -p file2dir/sub && echo x > file2dir/sub/x
rm -rf dir2link && ln -s x.sh dir2link
mkdir closed && echo c > closed/c && chmod 555 closed
touch empty
ln -sfn x.sh link
chmod 700 keep`
	fill := func(f *fixture) {
		files := map[string]string{"f.txt": "a\nb\n", "gone.txt": "gone\n", "x.sh": "echo\n", "d/a": "a\n", "d/old": "old\n", "dir2file/a": "a\n", "file2dir": "f\n", "dir2link/a": "a\n", "keep/k": "k\n"}
		for name, text := range files {
			p := filepath.Join(f.project, name)
			if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
				t.Fatal(err)
			}
			if err := os.WriteFile(p, []byte(text), 0o644); err != nil {
				t.Fatal(err)
			}
		}
		if err := os.Symlink("f.txt", filepath.Join(f.project, "link")); err != nil {
			t.Fatal(err)
		}
	}
	// What running it does.
	ran := setup(t, basicPolicy(), Options{})
	fill(ran)
	if out, res := ran.bash(t, policy.UnsafeMode, script); res.ExitCode != 0 {
		t.Fatalf("running it: %s", out)
	}
	f := setup(t, basicPolicy(), Options{})
	fill(f)
	p, _, _ := f.preview(t, policy.UnsafeMode, script)
	if err := f.sb.Apply(context.Background(), p); err != nil {
		t.Fatal(err)
	}
	if got, want := tree(t, f.project), tree(t, ran.project); !slices.Equal(got, want) {
		t.Errorf("applied:\n%s\nran:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n"))
	}
	for _, d := range []*fixture{f, ran} {
		os.Chmod(filepath.Join(d.project, "closed"), 0o755) // for the cleanup
	}
}

// A directory blinker makes for a mount, to protect a missing path in it,
// is gone again when the preview is read: what the command wrote there is
// new, and applied, with the directory.
func TestPreviewMadeDir(t *testing.T) {
	pol := basicPolicy()
	pol.Files = append(pol.Files, rule("build/secret", policy.None, policy.None))
	f := setup(t, pol, Options{})
	p, _, got := f.preview(t, policy.UnsafeMode, "echo x > build/out; echo y > top")
	slices.Sort(got)
	if want := []string{`write build/out 644 "x\n"`, `write top 644 "y\n"`}; !slices.Equal(got, want) {
		t.Errorf("changes: %q, want %q", got, want)
	}
	if err := f.sb.Apply(context.Background(), p); err != nil {
		t.Fatal(err)
	}
	if data, err := os.ReadFile(filepath.Join(f.project, "build", "out")); err != nil || string(data) != "x\n" {
		t.Errorf("build/out: %q, %v", data, err)
	}
}

// A preview applies only to what it was made from: if a path it changes
// has changed since, nothing is applied.
func TestApplyConflict(t *testing.T) {
	f := setup(t, basicPolicy(), Options{})
	for name, text := range map[string]string{"a.txt": "a\n", "b.txt": "b\n"} {
		if err := os.WriteFile(filepath.Join(f.project, name), []byte(text), 0o644); err != nil {
			t.Fatal(err)
		}
	}
	p, _, _ := f.preview(t, policy.UnsafeMode, "echo A > a.txt; echo B > b.txt; echo c > c.txt")
	if err := os.WriteFile(filepath.Join(f.project, "b.txt"), []byte("edited\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(filepath.Join(f.project, "c.txt"), []byte("mine\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	err := f.sb.Apply(context.Background(), p)
	if err == nil || !strings.Contains(err.Error(), "b.txt") || !strings.Contains(err.Error(), "c.txt") {
		t.Errorf("applied over changes since the preview: %v", err)
	}
	for name, want := range map[string]string{"a.txt": "a\n", "b.txt": "edited\n", "c.txt": "mine\n"} {
		if data, err := os.ReadFile(filepath.Join(f.project, name)); err != nil || string(data) != want {
			t.Errorf("%s after a refused apply: %q, %v", name, data, err)
		}
	}
}

// A change that fails when applied stops there, and the error says the
// ones before it were made.
func TestApplyStops(t *testing.T) {
	f := setup(t, basicPolicy(), Options{})
	ro := filepath.Join(f.project, "ro")
	if err := os.Mkdir(ro, 0o755); err != nil {
		t.Fatal(err)
	}
	p, _, _ := f.preview(t, policy.UnsafeMode, "echo a > a.txt; echo b > ro/b.txt")
	if err := os.Chmod(ro, 0o555); err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { os.Chmod(ro, 0o755) })
	err := f.sb.Apply(context.Background(), p)
	if err == nil || !strings.Contains(err.Error(), "stopped at "+filepath.Join(ro, "b.txt")) {
		t.Errorf("applying into a read-only directory: %v", err)
	}
	if data, err := os.ReadFile(filepath.Join(f.project, "a.txt")); err != nil || string(data) != "a\n" {
		t.Errorf("a.txt, before it: %q, %v", data, err)
	}
}

// A file writable on its own, which an overlay cannot cover, is read-only
// in a preview.
func TestPreviewFile(t *testing.T) {
	f := setup(t, &testPolicy{Files: []testRule{
		rule("~", policy.Read, policy.Read),
		rule("notes.txt", policy.Write, policy.Write),
	}}, Options{})
	notes := filepath.Join(f.project, "notes.txt")
	if err := os.WriteFile(notes, []byte("a\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	_, res, got := f.preview(t, policy.UnsafeMode, "echo b >> notes.txt")
	if res.ExitCode == 0 || len(got) != 0 {
		t.Errorf("exit %d, changes: %q", res.ExitCode, got)
	}
	if data, err := os.ReadFile(notes); err != nil || string(data) != "a\n" {
		t.Errorf("notes.txt: %q, %v", data, err)
	}
}