Download
873 lines 26.7 KiBGoModified 2026-10-31 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873
package policy

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

var testEnv = Env{Home: "/home/u", Project: "/home/u/proj", Scratch: "/tmp/scratch", SystemRead: []string{"/usr", "/etc"}}

// compile compiles a policy written in parts, one after another.
func compile(t *testing.T, floor Floor, parts ...string) *Compiled {
	t.Helper()
	f, err := Parse([]byte(strings.Join(parts, "")))
	if err != nil {
		t.Fatal(err)
	}
	c, err := Compile(Layer{Name: "test policy", File: f}, floor, testEnv)
	if err != nil {
		t.Fatal(err)
	}
	return c
}

// testStore is a store in a temp config dir, with the named policies
// blinker is built with.
func testStore(t *testing.T) Store {
	t.Helper()
	dir := t.TempDir()
	return Store{ConfigDir: dir, Dirs: NamedDirs(dir, nil), Builtin: os.DirFS("../../policies")}
}

// pairOf compiles a project's policies as a store has them.
func pairOf(t *testing.T, s Store, env Env) Pair {
	t.Helper()
	l, err := s.Load(env.Project)
	if err != nil {
		t.Fatal(err)
	}
	p, err := l.Compile(Floor{}, env)
	if err != nil {
		t.Fatal(err)
	}
	return p
}

func TestParse(t *testing.T) {
	text := `# A comment.
project /p

# type  access  pattern
include agent-safe
file    write   .
# why
#
# and how
file	hide	dir with spaces/x
  host  ask     *.GitHub.com

tool    allow   Commit
# not a note: a blank line follows

env     deny    GH_*
env     set     GOTOOLCHAIN local
`
	f, err := Parse([]byte(text))
	if err != nil {
		t.Fatal(err)
	}
	if f.Directory != "/p" || len(f.Includes) != 1 || f.Includes[0] != (Include{Name: "agent-safe", Line: 5}) || len(f.Files) != 2 || f.Files[1].Path != "dir with spaces/x" || f.Files[1].Access != None {
		t.Errorf("parsed %+v", f)
	}
	if f.Files[0].Note != "" || f.Files[1].Note != "why and how" || f.Files[1].Line != 10 || f.Env[0].Note != "" {
		t.Errorf("notes and lines: %+v %+v", f.Files, f.Env)
	}
	if f.Network[0].Host != "*.github.com" || f.Network[0].Access != Ask || f.Tools[0].Access != Allow || f.Env[0].Access != Deny {
		t.Errorf("parsed %+v", f)
	}
	if r := f.Env[1]; r.Name != "GOTOOLCHAIN" || r.Value != "local" || r.Access != Allow {
		t.Errorf("env set: %+v", r)
	}
	if again := f.Marshal(); string(again) != text {
		t.Errorf("a parsed file is not written back as it was:\n%s", again)
	}
	// A file built in code is written one rule per line, notes above.
	built := &File{
		Directory: "/p",
		Includes:  []Include{{Name: "agent-safe"}},
		Files:     []FileRule{{Path: "a b", Access: Write, Note: "n"}},
		Tools:     []ToolRule{{Tool: "X", Access: Allow}},
		Env:       []EnvRule{{Name: "A", Access: Allow, Value: "~/x"}},
	}
	data := built.Marshal()
	want := "project /p\n\n" + header + "\ninclude agent-safe\n# n\nfile    write   a b\ntool    allow   X\nenv     set     A ~/x\n"
	if string(data) != want {
		t.Errorf("Marshal = %q, want %q", data, want)
	}
	if g, err := Parse(data); err != nil || g.Files[0].Note != "n" || g.Tools[0].Access != Allow || g.Env[0].Value != "~/x" || len(g.Includes) != 1 {
		t.Errorf("round trip: %+v, %v", g, err)
	}
}

func TestParseErrors(t *testing.T) {
	for name, text := range map[string]string{
		"missing pattern":  "file read",
		"missing host":     "host allow",
		"unknown type":     "path read x",
		"bad access":       "file rw x",
		"none":             "file none x",
		"trailing space":   "file read x ",
		"trailing tab":     "file read x\t",
		"control char":     "file read a\rb",
		"not utf-8":        "file read \xff",
		"two projects":     "project /a\nproject /b",
		"relative project": "project a",
		"dotdot":           "file read ../x",
		"quote":            `file read a"b`,
		"backslash":        `file read a\b`,
		"tilde user":       "file read ~bob/x",
		"bad glob":         "file read [x",
		"bad host":         "host ask bad host!",
		"dollar inside":    "file read /x/$PATH",
		"dollar suffix":    "file read $PATH/bin",
		"floored var path": "file read $ANTHROPIC_API_KEY",
		"env bad name":     "env allow A=B",
		"env asks":         "env ask X",
		"tool asks":        "tool ask Commit",
		"tool builtin":     "tool allow Safe_Bash",
		"include two":      "include a b",
		"include path":     "include ../a",
		"json":             `{"files": []}`,
	} {
		if _, err := Parse([]byte(text)); err == nil {
			t.Errorf("%s: parsed %q", name, text)
		}
	}
	if _, err := Parse([]byte("# ok\nfile read x\nfile rw y\n")); err == nil || !strings.HasPrefix(err.Error(), "line 3:") {
		t.Errorf("error without its line: %v", err)
	}
}

func TestRewrite(t *testing.T) {
	f, err := Parse([]byte(`# The project.
project /p

# type  access  pattern
# a's note
file    read    a
file    read    b

# About c.
file    read    c
# trailing comment
`))
	if err != nil {
		t.Fatal(err)
	}
	g, err := f.rewrite([]int{f.Files[0].Line, f.Files[2].Line}, (&FileRule{Path: "d", Access: Read, Note: "new"}).Lines())
	if err != nil {
		t.Fatal(err)
	}
	data := g.Marshal()
	want := `# The project.
project /p

# type  access  pattern
file    read    b

# trailing comment

# new
file    read    d
`
	if string(data) != want {
		t.Errorf("rewrite:\n%s\nwant:\n%s", data, want)
	}
	if len(g.Files) != 2 || g.Files[1].Note != "new" {
		t.Errorf("rewritten rules: %+v", g.Files)
	}
}

func TestLastMatchWins(t *testing.T) {
	// The example from the design discussion.
	c := compile(t, Floor{}, `file write .
file    read    .git/
file    hide    **/.env
file    write   python_project/.env
`)
	cases := []struct {
		path        string
		read, write bool
	}{
		{"/home/u/proj/main.go", true, true},
		{"/home/u/proj/.git/config", true, false},
		{"/home/u/proj/.env", false, false},
		{"/home/u/proj/a/b/.env", false, false},
		{"/home/u/proj/python_project/.env", true, true},
		{"/home/u/other", false, false}, // no rule, not baseline
		{"/usr/bin/ls", true, false},    // baseline
		{"/tmp/scratch/x", true, true},  // scratch
	}
	for _, tc := range cases {
		a := c.Access(tc.path)
		if a.Read != tc.read || a.Write != tc.write {
			t.Errorf("%s: read %v write %v, want %v %v", tc.path, a.Read, a.Write, tc.read, tc.write)
		}
	}
}

func TestDecide(t *testing.T) {
	safe := compile(t, Floor{}, "file read .\nfile hide secret\n")
	unsafe := compile(t, Floor{}, "file write .\nfile hide secret\n")
	p, err := Bind(safe, unsafe)
	if err != nil {
		t.Fatal(err)
	}
	if a := p.In(UnsafeMode).Access("/home/u/proj/x"); !a.Write {
		t.Error("unsafe mode cannot write")
	}
	if got := p.Decide("/home/u/proj/x", false); got != Safe {
		t.Errorf("read: %v", got)
	}
	if got := p.Decide("/home/u/proj/x", true); got != Confirm {
		t.Errorf("write: %v", got)
	}
	if got := p.Decide("/home/u/proj/secret/k", false); got != Forbid {
		t.Errorf("secret: %v", got)
	}
	if e := p.Explain("/home/u/proj/secret/k", false); !strings.Contains(e, "safe policy: decided by test policy line 2") || !strings.Contains(e, "forbidden") {
		t.Errorf("Explain = %s", e)
	}
}

func TestStacking(t *testing.T) {
	global := `file    read    .
file    hide    **/.env*
`
	project := `project /home/u/proj

# type  access  pattern
file    write   src
file    read    .env.example
`
	c := compile(t, Floor{}, global, project)
	if !c.Access("/home/u/proj/src/a.go").Write {
		t.Error("project grant ignored")
	}
	if c.Access("/home/u/proj/main.go").Write {
		t.Error("global restriction lost")
	}
	if !c.Access("/home/u/proj/.env.example").Read || c.Access("/home/u/proj/.env").Read {
		t.Error("exceptions wrong")
	}
}

func TestGlobs(t *testing.T) {
	c := compile(t, Floor{}, `file read **
file    write   **/*.md
file    write   docs/?.txt
file    hide    a/[bc]/x
`)
	for path, want := range map[string]bool{
		"/home/u/proj/README.md":    true,
		"/home/u/proj/d/e/notes.md": true,
		"/home/u/proj/main.go":      false,
		"/home/u/proj/docs/a.txt":   true,
		"/home/u/proj/docs/ab.txt":  false,
		"/home/u/proj/x.md.go":      false,
		// A leading "." is not special, unlike in the shell.
		"/home/u/proj/.notes.md":        true,
		"/home/u/proj/.hidden/notes.md": true,
	} {
		if got := c.Access(path).Write; got != want {
			t.Errorf("write %s = %v, want %v", path, got, want)
		}
	}
	if c.Access("/home/u/proj/a/c/x/y").Read || !c.Access("/home/u/proj/a/d/x").Read {
		t.Error("character class wrong")
	}
	// "dir/**" is the plain path "dir": no filesystem search needed.
	m, err := NewMatcher("build/**", testEnv.Home, testEnv.Project)
	if err != nil || m.IsGlob() || m.Path != "/home/u/proj/build" {
		t.Errorf("build/** = %+v, %v", m, err)
	}
	m, err = NewMatcher("**", testEnv.Home, testEnv.Project)
	if err != nil || m.IsGlob() || m.Path != "/home/u/proj" {
		t.Errorf("** = %+v, %v", m, err)
	}
}

func TestFloorWins(t *testing.T) {
	c := compile(t, Floor{Blocklist: []string{"~/.ssh", "~/**/*.pem"}, NeverWrite: []string{"~/.bashrc"}},
		`file write ~
`)
	if a := c.Access("/home/u/.ssh/id_rsa"); a.Read || !a.Floor {
		t.Errorf("blocklisted path: %+v", a)
	}
	if c.Access("/home/u/proj/certs/x.pem").Read {
		t.Error("blocklisted glob readable")
	}
	if a := c.Access("/home/u/.bashrc"); !a.Read || a.Write {
		t.Errorf("never-write: %+v", a)
	}
	if _, err := Compile(Layer{File: &File{}}, Floor{Blocklist: []string{"relative"}}, testEnv); err == nil {
		t.Error("relative floor entry accepted")
	}
}

func TestNet(t *testing.T) {
	c := compile(t, Floor{}, `host    ask     *.github.com
host    deny    evil.github.com
host    allow   proxy.golang.org
`)
	for host, want := range map[string]NetPerm{
		"proxy.golang.org":  Allow,
		"PROXY.golang.org.": Allow,
		"api.github.com":    Ask,
		"github.com":        Deny, // no rule matches
		"evil.github.com":   Deny,
		"example.com":       Deny,
	} {
		if got := c.Net(host); got != want {
			t.Errorf("Net(%s) = %v, want %v", host, got, want)
		}
	}
	if !c.NeedsNetwork() || compile(t, Floor{}, ``).NeedsNetwork() || compile(t, Floor{}, "host deny *\n").NeedsNetwork() || !compile(t, Floor{}, "host ask *\n").NeedsNetwork() {
		t.Error("NeedsNetwork wrong")
	}
	if got := c.AllowedHosts(); !slices.Equal(got, []string{"proxy.golang.org"}) {
		t.Errorf("AllowedHosts = %q", got)
	}
}

func lintText(ws []Warning) string {
	var s []string
	for _, w := range ws {
		s = append(s, w.Text)
	}
	return strings.Join(s, "\n")
}

func TestLint(t *testing.T) {
	global := `file hide **/.env
file    read    src/a.go
file    read    build
host    deny    x.example.com
`
	project := `project /home/u/proj

# type  access  pattern
file    write   .
file    read    build
host    allow   *.example.com
`
	c := compile(t, Floor{}, global, project)
	warnings := lintText(c.Lint())
	for _, want := range []string{`test policy line 1 ("**/.env")`, `test policy line 2 ("src/a.go")`, `test policy line 4 ("x.example.com")`} {
		if !strings.Contains(warnings, want) {
			t.Errorf("lint missed %s:\n%s", want, warnings)
		}
	}
	// The same access again is redundant, not a surprise.
	if strings.Contains(warnings, `line 3 ("build")`) {
		t.Errorf("lint flagged an identical rule:\n%s", warnings)
	}
	// Lines added above the rules move them, but the warnings are about
	// the same rules, so a review does not show them as new.
	moved := compile(t, Floor{}, "# A comment.\n\n"+global, strings.Replace(project, "# type", "# Another.\n\n# type", 1))
	before, after := c.Lint(), moved.Lint()
	if lintText(after) == lintText(before) || len(after) != len(before) {
		t.Fatalf("the lines did not move:\n%s", lintText(after))
	}
	for i := range after {
		if !after[i].Same(before[i]) {
			t.Errorf("%q is not the same as %q", after[i].Text, before[i].Text)
		}
	}
	other := compile(t, Floor{}, strings.Replace(global, "src/a.go", "src/b.go", 1), project)
	if other.Lint()[1].Same(before[1]) {
		t.Error("warnings about different rules are the same")
	}
}

// The agent's built-in policies compile, with nothing in them that never
// applies, and give what they say.
func TestAgentPolicies(t *testing.T) {
	s := testStore(t)
	l, err := s.Load(testEnv.Project)
	if err != nil {
		t.Fatal(err)
	}
	var cs [2]*Compiled
	for _, m := range []Mode{SafeMode, UnsafeMode} {
		if cs[m], err = Compile(l.Layer(m), DefaultFloor("/home/u/.config/blinker"), testEnv); err != nil {
			t.Fatal(err)
		}
		if w := cs[m].Lint(); len(w) > 0 {
			t.Errorf("the %s policy lints: %v", m, w)
		}
	}
	p, err := Bind(cs[SafeMode], cs[UnsafeMode])
	if err != nil {
		t.Fatal(err)
	}
	if p.Decide("/home/u/proj/main.go", false) != Safe || p.Decide("/home/u/proj/main.go", true) != Confirm {
		t.Error("project access")
	}
	if p.Decide("/home/u/proj/.env", false) != Forbid {
		t.Error(".env is not hidden")
	}
	if p.Safe.NeedsNetwork() || p.Unsafe.Net("example.com") != Ask {
		t.Error("network")
	}
	names := []string{"Commit", "Define", "Fetch", "Usage"}
	access, err := ToolAccess(l.Layer(SafeMode), l.Layer(UnsafeMode), names)
	if err != nil {
		t.Fatal(err)
	}
	for i, name := range names {
		want := map[string]NetPerm{"Define": Allow, "Usage": Allow, "Commit": Ask, "Fetch": Ask}[name]
		if access[i] != want {
			t.Errorf("%s: %v, want %v", name, access[i], want)
		}
	}
}

func TestEnvAllowed(t *testing.T) {
	c := compile(t, Floor{}, `env     allow   GO*
env     deny    GOOGLE_*
env     deny    LANG
env     deny    *
env     allow   BLINKER_*
env     allow   ANTHROPIC_API_KEY
`, `env     allow   LC_ALL
`)
	for name, want := range map[string]bool{
		"GOFLAGS":           false, // the later "*" decides
		"LC_ALL":            true,  // the project's rule comes last
		"BLINKER_MODE":      false,
		"ANTHROPIC_API_KEY": false,
	} {
		if c.EnvAllowed(name) != want {
			t.Errorf("%s: want %v", name, want)
		}
	}
	c = compile(t, Floor{}, `env     allow   GO*
env     deny    GOOGLE_*
`)
	for name, want := range map[string]bool{
		"GOFLAGS":       true,
		"GOOGLE_API":    false,
		"PATH":          true, // the baseline
		"LC_CTYPE":      true,
		"RANDOM_THING":  false,
		"SSH_AUTH_SOCK": false,
		"HTTPS_PROXY":   false,
	} {
		if c.EnvAllowed(name) != want {
			t.Errorf("%s: want %v", name, want)
		}
	}
}

// A "$NAME" rule covers the variable's directories, as plain paths, but
// never home, the project, or a directory containing them.
func TestEnvPaths(t *testing.T) {
	root, err := filepath.EvalSymlinks(t.TempDir())
	if err != nil {
		t.Fatal(err)
	}
	home := filepath.Join(root, "home")
	project := filepath.Join(home, "proj")
	bin := filepath.Join(home, ".local", "bin")
	globby := filepath.Join(root, "g[1]")
	for _, d := range []string{project, bin, globby} {
		if err := os.MkdirAll(d, 0o755); err != nil {
			t.Fatal(err)
		}
	}
	link := filepath.Join(root, "link")
	if err := os.Symlink(bin, link); err != nil {
		t.Fatal(err)
	}
	t.Setenv("TEST_DIRS", strings.Join([]string{link, "", ".", "rel/bin", "/", root, home, project, filepath.Join(project, "bin"), globby, filepath.Join(root, "missing")}, ":"))
	env := Env{Home: home, Project: project}
	got := envPaths("TEST_DIRS", env)
	if len(got) != 1 || got[0].path != bin || len(got[0].links) != 1 || got[0].links[0] != link {
		t.Errorf("envPaths = %+v, want only %q, through %q", got, bin, link)
	}
	f, err := Parse([]byte(`file    write   $TEST_DIRS
`))
	if err != nil {
		t.Fatal(err)
	}
	c, err := Compile(Layer{"global policy", f}, DefaultFloor(), env)
	if err != nil {
		t.Fatal(err)
	}
	if !c.Access(filepath.Join(bin, "tool")).Read || c.Access(filepath.Join(home, "notes")).Read {
		t.Error("access through $TEST_DIRS")
	}
	if c.Access(filepath.Join(bin, "tool")).Write {
		t.Error("the floor lets ~/.local/bin be written")
	}
	if _, err := NewMatcher("$TEST_DIRS", home, project); err == nil {
		t.Error("NewMatcher took a variable")
	}
	t.Setenv("TEST_DIRS", "")
	if c, err = Compile(Layer{"global policy", f}, Floor{}, env); err != nil || len(c.rules) != 0 {
		t.Errorf("an empty variable gives rules: %v %v", c.rules, err)
	}
}

func TestRealPathFollowsDanglingLink(t *testing.T) {
	dir, err := filepath.EvalSymlinks(t.TempDir())
	if err != nil {
		t.Fatal(err)
	}
	link := filepath.Join(dir, "link")
	if err := os.Symlink(filepath.Join(dir, "target", "file"), link); err != nil {
		t.Fatal(err)
	}
	if got := RealPath(link); got != filepath.Join(dir, "target", "file") {
		t.Errorf("RealPath = %s", got)
	}
}

func TestStore(t *testing.T) {
	s := testStore(t)
	l, err := s.Load("/p")
	if err != nil || l.Own[SafeMode].Exists || l.Own[UnsafeMode].Exists || l.Agent[SafeMode].Exists || len(l.Stacks[SafeMode].Files) == 0 {
		t.Fatalf("empty store: %+v, %v", l, err)
	}
	if inc := l.Own[UnsafeMode].File.Includes; len(inc) != 1 || inc[0].Name != "agent-unsafe" {
		t.Errorf("a new unsafe policy includes %+v", inc)
	}
	path, err := s.AppendNetRule("/p", NetRule{Host: "example.com", Access: Allow, Note: "added"})
	if err != nil {
		t.Fatal(err)
	}
	if l, err = s.Load("/p"); err != nil || l.Own[UnsafeMode].Path != path || !l.Own[UnsafeMode].Exists || l.Own[UnsafeMode].File.Network[0].Host != "example.com" || l.Own[SafeMode].Exists {
		t.Fatalf("after append: %+v, %v", l, err)
	}
	// A policy copied to another project's name is refused.
	other := filepath.Join(ProjectDir(s.ConfigDir, "/q"), "unsafe.policy")
	data, err := os.ReadFile(path)
	if err != nil {
		t.Fatal(err)
	}
	if err := os.MkdirAll(filepath.Dir(other), 0o700); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(other, data, 0o600); err != nil {
		t.Fatal(err)
	}
	if _, err := s.Load("/q"); err == nil {
		t.Error("loaded another project's policy")
	}
	// A directory whose name would add lines to the file is not written.
	if _, err := s.Create("/x\nfile write /", false); err == nil {
		t.Error("wrote a project line with a newline in it")
	}
}

func TestFloorFollowsSymlinks(t *testing.T) {
	home, err := filepath.EvalSymlinks(t.TempDir())
	if err != nil {
		t.Fatal(err)
	}
	dots := filepath.Join(home, "dotfiles", "ssh")
	if err := os.MkdirAll(dots, 0o700); err != nil {
		t.Fatal(err)
	}
	if err := os.Symlink(dots, filepath.Join(home, ".ssh")); err != nil {
		t.Fatal(err)
	}
	f, err := Parse([]byte(`file    write   ~
`))
	if err != nil {
		t.Fatal(err)
	}
	env := Env{Home: home, Project: filepath.Join(home, "proj")}
	c, err := Compile(Layer{"global policy", f}, Floor{Blocklist: []string{"~/.ssh"}}, env)
	if err != nil {
		t.Fatal(err)
	}
	if a := c.Access(filepath.Join(dots, "id_x")); a.Read || !a.Floor {
		t.Errorf("the floor's symlink target is readable: %+v", a)
	}
	if a := c.Access(filepath.Join(home, "dotfiles", "other")); !a.Write {
		t.Errorf("beside the target: %+v", a)
	}
}

func TestEmptyScratchGrantsNothing(t *testing.T) {
	c, err := Compile(Layer{File: &File{}}, Floor{}, Env{Home: "/home/u", Project: "/home/u/proj"})
	if err != nil {
		t.Fatal(err)
	}
	if a := c.Access("/home/u/x"); a.Read || a.Write {
		t.Errorf("no rule and no scratch dir, but %+v", a)
	}
}

func TestShowHide(t *testing.T) {
	home, err := filepath.EvalSymlinks(t.TempDir())
	if err != nil {
		t.Fatal(err)
	}
	env := Env{Home: home, Project: filepath.Join(home, "proj")}
	for _, d := range []string{"proj/src", "other"} {
		if err := os.MkdirAll(filepath.Join(home, d), 0o700); err != nil {
			t.Fatal(err)
		}
	}
	s := testStore(t)
	access := func(path string, m Mode) Access {
		t.Helper()
		return pairOf(t, s, env).In(m).Access(filepath.Join(home, path))
	}
	own := func(m Mode) []FileRule {
		t.Helper()
		l, err := s.Load(env.Project)
		if err != nil {
			t.Fatal(err)
		}
		return l.Own[m].File.Files
	}

	r, err := s.Show(env, "../other")
	if err != nil || r.Path != "~/other" || r.Safe != Read || r.Unsafe != Read {
		t.Fatalf("show ../other: %+v, %v", r, err)
	}
	if a := access("other/x", SafeMode); !a.Read || a.Write {
		t.Errorf("shown: %+v", a)
	}
	// Showing it again replaces the rules rather than adding others.
	if _, err := s.Show(env, "~/other/"); err != nil {
		t.Fatal(err)
	}
	if len(own(SafeMode)) != 1 || len(own(UnsafeMode)) != 1 {
		t.Fatalf("shown twice: %+v %+v", own(SafeMode), own(UnsafeMode))
	}

	// Showing a project directory leaves the unsafe policy, which may
	// write it, alone, and keeps .env files hidden.
	if r, err = s.Show(env, "src"); err != nil || r.Path != "src" || r.Unsafe != Write {
		t.Fatalf("show src: %+v, %v", r, err)
	}
	if a := access("proj/src/.env", SafeMode); a.Read {
		t.Errorf("showing src revealed .env: %+v", a)
	}
	if a := access("proj/src/x", UnsafeMode); !a.Write {
		t.Errorf("showing src lost write access: %+v", a)
	}
	if slices.ContainsFunc(own(UnsafeMode), func(r FileRule) bool { return r.Path == "src" }) {
		t.Errorf("a rule for src in the unsafe policy: %+v", own(UnsafeMode))
	}

	if r, err = s.Hide(env, "../other"); err != nil || r.Safe != None || r.Unsafe != None {
		t.Fatalf("hide: %+v, %v", r, err)
	}
	if a := access("other/x", UnsafeMode); a.Read {
		t.Errorf("hidden: %+v", a)
	}
	for _, m := range []Mode{SafeMode, UnsafeMode} {
		rules := own(m)
		for _, r := range rules[:len(rules)-1] {
			if r.Path == "~/other" {
				t.Errorf("the shown rule is still in the %s policy: %+v", m, rules)
			}
		}
	}

	for _, bad := range []string{"", "*.go", "~root/x"} {
		if _, err := s.Hide(env, bad); err == nil {
			t.Errorf("hid %q", bad)
		}
	}
	if _, err := s.Show(env, "missing"); err == nil {
		t.Error("showed a missing path")
	}
}

func TestReplace(t *testing.T) {
	s := testStore(t)
	l, err := s.Load("/p")
	if err != nil {
		t.Fatal(err)
	}
	src := l.Agent[SafeMode] // the built-in one: no file yet
	was := src.File.Marshal()
	f := &File{Files: []FileRule{{Path: "**", Access: Read}}}
	if err := s.Replace("/p", src.Path, was, f); err != nil {
		t.Fatal(err)
	}
	if l, err = s.Load("/p"); err != nil || !l.Agent[SafeMode].Exists || len(l.Agent[SafeMode].File.Files) != 1 || len(l.Stacks[SafeMode].Files) != 1 {
		t.Fatalf("after replace: %+v, %v", l, err)
	}
	// Saving over a change made since is refused.
	if err := s.Replace("/p", src.Path, was, &File{}); err == nil {
		t.Error("replaced a policy that changed in the meantime")
	}
	was = l.Own[UnsafeMode].File.Marshal()
	if _, err := s.AppendNetRule("/p", NetRule{Host: "example.com"}); err != nil {
		t.Fatal(err)
	}
	if err := s.Replace("/p", l.Own[UnsafeMode].Path, was, &File{Directory: "/p"}); err == nil {
		t.Error("replaced a project policy that changed in the meantime")
	}
	// A project's policy needs its project line, and the agent's has none.
	if _, err := s.With(l, l.Own[SafeMode], &File{}); err == nil {
		t.Error("a project policy without its project line")
	}
	if _, err := s.With(l, l.Agent[UnsafeMode], &File{Directory: "/p"}); err == nil {
		t.Error("the agent's policy with a project line")
	}
	// A proposed agent policy is what the project's includes.
	n, err := s.With(l, l.Agent[UnsafeMode], &File{Files: []FileRule{{Path: "x", Access: Write}}})
	if err != nil || len(n.Stacks[UnsafeMode].Files) != 1 || n.Stacks[UnsafeMode].Files[0].Path != "x" {
		t.Errorf("with a proposed agent-unsafe: %+v, %v", n, err)
	}
}

func TestEditsKeepComments(t *testing.T) {
	home, err := filepath.EvalSymlinks(t.TempDir())
	if err != nil {
		t.Fatal(err)
	}
	env := Env{Home: home, Project: filepath.Join(home, "proj")}
	if err := os.MkdirAll(filepath.Join(env.Project, "src"), 0o700); err != nil {
		t.Fatal(err)
	}
	s := testStore(t)
	dir := ProjectDir(s.ConfigDir, env.Project)
	text := "# My notes.\nproject " + env.Project + `

# type  access  pattern
include agent-safe
# Keep secrets in src hidden.
file    hide    src/*.key
# Old rule for src.
file    read    src
`
	if err := os.MkdirAll(dir, 0o700); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(filepath.Join(dir, "safe.policy"), []byte(text), 0o600); err != nil {
		t.Fatal(err)
	}
	if _, err := s.Show(env, "src"); err != nil {
		t.Fatal(err)
	}
	if _, err := s.AppendNetRule(env.Project, NetRule{Host: "example.com", Access: Allow, Note: "Allowed."}); err != nil {
		t.Fatal(err)
	}
	read := func(name string) string {
		t.Helper()
		data, err := os.ReadFile(filepath.Join(dir, name))
		if err != nil {
			t.Fatal(err)
		}
		return string(data)
	}
	// The rule for src is replaced, and the ones inside it, the included
	// .env rule and the policy's own, move after the new rule with their
	// notes.
	got := read("safe.policy")
	for _, want := range []string{
		"# My notes.\nproject " + env.Project + "\n\n" + header + "\ninclude agent-safe\n# Shown with /show, ",
		"\nfile    read    src\nfile    hide    **/.env*\n# Keep secrets in src hidden.\nfile    hide    src/*.key\n",
	} {
		if !strings.Contains(got, want) {
			t.Errorf("missing %q in:\n%s", want, got)
		}
	}
	if strings.Contains(got, "Old rule") {
		t.Errorf("the replaced rule's note is still there:\n%s", got)
	}
	if got := read("unsafe.policy"); !strings.HasSuffix(got, "include agent-unsafe\n# Allowed.\nhost    allow   example.com\n") {
		t.Errorf("the unsafe policy:\n%s", got)
	}
	if r, err := s.Show(env, "."); err != nil || r.Path != "." {
		t.Errorf("show the project: %+v, %v", r, err)
	}
}

// Only ASCII letters fold: the Kelvin sign is not k, nor ı i.
func TestHostCaseIsASCII(t *testing.T) {
	for _, c := range [][2]string{{"Key.com", "key.com"}, {"ı", "i"}, {"İ", "i"}, {"x.Key.com", "*.key.com"}} {
		if HostMatches(c[0], c[1]) {
			t.Errorf("HostMatches(%q, %q)", c[0], c[1])
		}
	}
	if _, err := ValidateHost("Key.com"); err == nil {
		t.Error("ValidateHost took the Kelvin sign for k")
	}
	if !HostMatches("API.GitHub.COM.", "*.github.com") {
		t.Error("ASCII case or a final dot mattered")
	}
}

func TestFloorInHome(t *testing.T) {
	f := Floor{Blocklist: []string{"~/.ssh", "/etc/x", "~/**/*.pem"}, NeverWrite: []string{"~/.bashrc"}}.InHome("/home/bob")
	if !slices.Equal(f.Blocklist, []string{"/home/bob/.ssh"}) || !slices.Equal(f.NeverWrite, []string{"/home/bob/.bashrc"}) {
		t.Errorf("%+v", f)
	}
}

// underSet is Under over a set, so the floor's sets decide as checking
// each path would.
func TestUnderSet(t *testing.T) {
	sets := []map[string]bool{
		{"/a/b": true},
		{"/": true},
		{"/a": true, "/c/d/e": true},
		{},
	}
	paths := []string{"/", "/a", "/a/b", "/a/b/c", "/a/bc", "/a/b/", "/ab", "/c/d", "/c/d/e/f", "/a/../b", "/a//b", "/x"}
	for _, set := range sets {
		for _, p := range paths {
			want := false
			for d := range set {
				want = want || Under(p, d)
			}
			if got := underSet(p, set); got != want {
				t.Errorf("underSet(%q, %v) = %v, want %v", p, set, got, want)
			}
		}
	}
}

// matchPartsEvery is matchParts without its shortcut for a "**" followed
// by one pattern: every split is tried.
func matchPartsEvery(g, n []string) bool {
	for len(g) > 0 {
		if g[0] == "**" {
			for i := 0; i <= len(n); i++ {
				if matchPartsEvery(g[1:], n[i:]) {
					return true
				}
			}
			return false
		}
		if len(n) == 0 {
			return false
		}
		if ok, err := path.Match(g[0], n[0]); err != nil || !ok {
			return false
		}
		g, n = g[1:], n[1:]
	}
	return len(n) == 0
}

func TestMatchPartsShortcut(t *testing.T) {
	globs := []string{"**/*.pem", "a/**/id_rsa*", "**/.env*", "a/**/b", "**/**/x", "**", "a/**", "**/[", "**/b/c", "a/*/**/*.key"}
	paths := []string{"", "x", "a", "a/b", "a/b/c", "a/x.pem", "x.pem", "a/b/.env.dev", "a/id_rsa.pub", "a/b/id_rsa", "b/c", "a/q/z/k.key", "["}
	for _, g := range globs {
		for _, p := range paths {
			gp, np := strings.Split(g, "/"), strings.Split(p, "/")
			if p == "" {
				np = nil
			}
			if got, want := matchParts(gp, np), matchPartsEvery(gp, np); got != want {
				t.Errorf("matchParts(%q, %q) = %v, want %v", g, p, got, want)
			}
		}
	}
}