Download
224 lines 7.2 KiBGoModified 2026-10-31 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224
package sandbox

import (
	"os"
	"path/filepath"
	"slices"
	"strings"
	"testing"

	"blinker/internal/policy"
)

// mountOp is one mount in a bubblewrap command line, as the kernel stacks
// them: a later mount at or above an earlier one's point hides it.
type mountOp struct {
	dst   string
	state state
}

// mountsOf reads the mounts from bubblewrap's arguments. A -try bind of a
// missing source is skipped, as bubblewrap does. Binding a different
// source (/dev/null, an empty file) over a path hides it.
func mountsOf(t *testing.T, args []string) []mountOp {
	t.Helper()
	var ops []mountOp
	for i := 0; i < len(args); i++ {
		switch a := args[i]; a {
		case "--die-with-parent", "--new-session", "--unshare-all":
		case "--cap-drop", "--proc", "--dev", "--chdir", "--remount-ro", "--dir":
			i++
		case "--tmpfs":
			ops = append(ops, mountOp{args[i+1], none})
			i++
		case "--ro-bind-try", "--bind-try", "--ro-bind", "--bind":
			src, dst := args[i+1], args[i+2]
			i += 2
			if _, err := os.Lstat(src); err != nil {
				if strings.HasSuffix(a, "-try") {
					continue
				}
				t.Fatalf("%s of missing %s", a, src)
			}
			s := ro
			if !strings.HasPrefix(a, "--ro-") {
				s = rw
			}
			if src != dst {
				s = none
			}
			ops = append(ops, mountOp{dst, s})
		default:
			t.Fatalf("unknown argument %q in %q", a, args)
		}
	}
	return ops
}

// seen is what a path shows through the mounts: the deepest mount above it
// that no later mount hides.
func seen(ops []mountOp, p string) (state, bool) {
	best, found := -1, false
	for i, op := range ops {
		if !policy.Under(p, op.dst) {
			continue
		}
		hidden := slices.ContainsFunc(ops[i+1:], func(later mountOp) bool { return policy.Under(op.dst, later.dst) })
		if !hidden && (best < 0 || len(op.dst) > len(ops[best].dst)) {
			best, found = i, true
		}
	}
	if !found {
		return none, false
	}
	return ops[best].state, ops[best].dst == p
}

// fuzzPatterns and fuzzNames build the policies and trees FuzzBwrapMatchesPolicy
// tries: names a rule may or may not match, and rules over them.
var (
	fuzzNames    = []string{"a", "b", ".env", "x.pem", ".git", "c d"}
	fuzzPatterns = []string{".", "a", "a/b", "b/a", "**", "**/.env*", "*.pem", "**/b", "a/*", "~", "~/a", "~/p/a", "~/p", ".git", ".git/hooks", "missing/deep", "b/missing"}
	fuzzPerms    = [][2]policy.Perm{{policy.None, policy.None}, {policy.Read, policy.Read}, {policy.Read, policy.Write}, {policy.Write, policy.Write}, {policy.None, policy.Read}, {policy.None, policy.Write}}
)

// The bubblewrap command line enforces exactly what the policy says: for
// random trees and policies, every path shows through the mounts the
// access policy.Compiled.Access gives it. And guard's promises hold: no
// protected path can be created where it is missing, and the directories
// above a protected path cannot be renamed.
func FuzzBwrapMatchesPolicy(f *testing.F) {
	f.Add([]byte{0, 1, 2, 3, 4, 5}, []byte{9, 1, 5, 0, 8, 2})
	f.Add([]byte{1, 7, 13, 2, 22, 40}, []byte{2, 3, 14, 0, 15, 4, 16, 1})
	f.Add([]byte{3, 3, 3, 9, 9, 9}, []byte{1, 3, 0, 1, 12, 5, 13, 0})
	f.Fuzz(func(t *testing.T, tree, rules []byte) {
		if len(tree) > 24 || len(rules) > 16 {
			return
		}
		base, err := os.MkdirTemp(".", ".fuzz-") // outside /tmp, which the sandbox replaces
		if err != nil {
			t.Fatal(err)
		}
		defer os.RemoveAll(base)
		if base, err = filepath.Abs(base); err != nil {
			t.Fatal(err)
		}
		home := filepath.Join(base, "h")
		project := filepath.Join(home, "p")
		scratch := filepath.Join(base, "scratch")
		for _, d := range []string{project, scratch} {
			if err := os.MkdirAll(d, 0o755); err != nil {
				t.Fatal(err)
			}
		}
		// Each pair of bytes makes a path: a parent among those made, and
		// a name; odd names are files.
		made := []string{project, home}
		for i := 0; i+1 < len(tree); i += 2 {
			parent := made[int(tree[i])%len(made)]
			if fi, err := os.Stat(parent); err != nil || !fi.IsDir() {
				continue
			}
			p := filepath.Join(parent, fuzzNames[int(tree[i+1])%len(fuzzNames)])
			if tree[i+1]%2 == 1 {
				err = os.WriteFile(p, nil, 0o644)
			} else {
				err = os.MkdirAll(p, 0o755)
			}
			if err == nil {
				made = append(made, p)
			}
		}
		// A policy for each mode, each rule giving one access in each.
		var files [2]policy.File
		for i := 0; i+1 < len(rules); i += 2 {
			perm := fuzzPerms[int(rules[i+1])%len(fuzzPerms)]
			for m := range files {
				files[m].Files = append(files[m].Files, policy.FileRule{Path: fuzzPatterns[int(rules[i])%len(fuzzPatterns)], Access: perm[m]})
			}
		}
		env := policy.Env{Home: home, Project: project, Scratch: scratch}
		var cs [2]*policy.Compiled
		for m := range cs {
			var err error
			if cs[m], err = policy.Compile(policy.Layer{Name: "fuzz", File: &files[m]}, policy.DefaultFloor(), env); err != nil {
				t.Fatal(err)
			}
		}
		var paths []string
		if err := filepath.Walk(home, func(p string, _ os.FileInfo, err error) error {
			if err == nil {
				paths = append(paths, p)
			}
			return nil
		}); err != nil {
			t.Fatal(err)
		}
		for _, mode := range []policy.Mode{policy.SafeMode, policy.UnsafeMode} {
			c, file := cs[mode], files[mode]
			args, created, err := bwrapArgs(c, bwrapOpts{empty: "/dev/null"})
			if err != nil {
				t.Fatal(err)
			}
			ops := mountsOf(t, args)
			for _, p := range paths {
				if slices.Contains(created, p) {
					continue // made for the command, which sees it as guard says
				}
				got, _ := seen(ops, p)
				if want := stateOf(c.Access(p)); got != want {
					t.Fatalf("%s mode: %s shows %v, the policy says %v\nrules %+v\nargs %q", mode, p, got, want, file.Files, args)
				}
			}
			for _, r := range file.Files {
				m, err := policy.NewMatcher(r.Path, home, project)
				if err != nil || m.IsGlob() {
					continue
				}
				// A missing protected path cannot be created: something is
				// mounted there, or the directory holding it is not writable.
				if _, err := os.Lstat(m.Path); err != nil && !slices.Contains(created, m.Path) && !c.Access(m.Path).Write {
					// The nearest place above it that exists in the sandbox: on
					// the host, or a mount point (a blocker guard added).
					existing, blocked := filepath.Dir(m.Path), false
					for {
						if s, point := seen(ops, existing); point && s != rw {
							blocked = true
							break
						}
						if _, err := os.Lstat(existing); err == nil {
							break
						}
						existing = filepath.Dir(existing)
					}
					if fi, err := os.Stat(existing); blocked || err != nil || !fi.IsDir() {
						continue // under a file or a blocker: nothing can be created
					}
					if s, _ := seen(ops, existing); s == rw {
						t.Fatalf("%s mode: missing %s can be created in %s\nargs %q", mode, m.Path, existing, args)
					}
				}
			}
			// Above every mount that gives less than writing, each directory
			// in a writable one is a mount point.
			for _, op := range ops {
				if op.state == rw {
					continue
				}
				for a := filepath.Dir(op.dst); a != "/"; a = filepath.Dir(a) {
					if s, _ := seen(ops, filepath.Dir(a)); s != rw {
						continue
					}
					if _, point := seen(ops, a); !point {
						t.Fatalf("%s mode: %s, above protected %s, can be renamed\nargs %q", mode, a, op.dst, args)
					}
				}
			}
			for _, p := range slices.Backward(created) {
				if err := os.Remove(p); err != nil && !os.IsNotExist(err) {
					t.Fatal(err)
				}
			}
		}
	})
}