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)
}
}
|