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