Download
204 lines 6.4 KiBGoModified 2026-10-31 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204
package tooldef

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

	"blinker/internal/policy"
)

const sampleTool = `#!/bin/bash
# blinker-tool
# description: Commit the given files.
# description: Hooks do not run.
#
# arg message string: The commit message.
# arg level enum(low|high): How much.
# arg files path...: The files.
# write: .git
# read: .git/hooks
# network: Example.com
# timeout: 30s
msg=$1; shift
# An ordinary comment, after the header.
git commit -m "$msg" -- "$@"
`

func TestParse(t *testing.T) {
	tool, err := Parse("Commit", "Commit.sh", []byte(sampleTool))
	if err != nil {
		t.Fatal(err)
	}
	if tool.Description != "Commit the given files. Hooks do not run." {
		t.Errorf("description %q", tool.Description)
	}
	if len(tool.Args) != 3 || tool.Args[1].Type != "enum" || strings.Join(tool.Args[1].Values, ",") != "low,high" || !tool.Args[2].Variadic || tool.Args[2].Type != "path" {
		t.Errorf("args %+v", tool.Args)
	}
	if len(tool.Files) != 2 || tool.Files[0] != (policy.FileRule{Path: ".git", Access: policy.Write}) || tool.Network[0] != "example.com" || tool.Timeout != 30*time.Second {
		t.Errorf("access %+v %v %v", tool.Files, tool.Network, tool.Timeout)
	}
	if tool.Script != sampleTool {
		t.Error("the script is not the whole file")
	}
	if g := tool.Grants(); g != "write .git, read .git/hooks, network example.com" {
		t.Errorf("Grants = %q", g)
	}
}

func TestParseErrors(t *testing.T) {
	header := "# blinker-tool\n# description: d\n"
	for name, text := range map[string]string{
		"no marker":        "# description: d\necho\n",
		"no description":   "# blinker-tool\n# arg a string: x\necho\n",
		"unknown key":      header + "# shell: zsh\n",
		"not key value":    header + "# just a comment\n",
		"bad type":         header + "# arg a file: x\n",
		"no arg text":      header + "# arg a string:\n",
		"bad arg name":     header + "# arg A string: x\n",
		"list not last":    header + "# arg a string...: x\n# arg b string: x\n",
		"duplicate arg":    header + "# arg a string: x\n# arg a path: x\n",
		"empty enum value": header + "# arg a enum(x||y): x\n",
		"bad path":         header + "# write: ../x\n",
		"bad host":         header + "# network: not a host\n",
		"bad timeout":      header + "# timeout: soon\n",
		"nul":              header + "echo \x00\n",
	} {
		if _, err := Parse("T", name, []byte(text)); err == nil {
			t.Errorf("%s: parsed", name)
		}
	}
	for _, name := range []string{"Safe_Bash", "read", "my tool", "1x", ""} {
		if _, err := Parse(name, "x", []byte(header)); err == nil {
			t.Errorf("tool name %q accepted", name)
		}
	}
}

func TestDefaultTools(t *testing.T) {
	tools, err := Load(t.TempDir())
	if err != nil {
		t.Fatal(err)
	}
	var names []string
	for _, tool := range tools {
		names = append(names, tool.Name)
	}
	if strings.Join(names, ",") != "Commit,Define,Fetch,Usage" {
		t.Errorf("built-in tools: %v", names)
	}
}

func TestLoadOverride(t *testing.T) {
	dir := t.TempDir()
	write := func(name, text string) {
		if err := os.WriteFile(filepath.Join(dir, name), []byte(text), 0o600); err != nil {
			t.Fatal(err)
		}
	}
	write("Commit.sh", "# blinker-tool\n# description: Mine.\necho mine\n")
	write("Format.sh", "#!/bin/sh\n# blinker-tool\n# description: Format.\n# arg file path: The file.\ngofmt -w -- \"$1\"\n")
	write("notes.txt", "not a tool")
	write("Format.sh~", "an editor backup")
	tools, err := Load(dir)
	if err != nil {
		t.Fatal(err)
	}
	byName := map[string]Def{}
	for _, tool := range tools {
		byName[tool.Name] = tool
	}
	if byName["Commit"].Description != "Mine." || byName["Format"].Args[0].Type != "path" || len(byName) != 5 {
		t.Errorf("loaded %+v", byName)
	}
	write("Broken.sh", "echo no header\n")
	if _, err := Load(dir); err == nil {
		t.Error("a broken tool file loaded")
	}
}

func TestCreate(t *testing.T) {
	dir := t.TempDir()
	for _, name := range []string{"Format", "Define", "Commit"} {
		path, err := Create(dir, name)
		if err != nil {
			t.Fatal(err)
		}
		data, err := os.ReadFile(path)
		if err != nil {
			t.Fatal(err)
		}
		if _, err := Parse(name, path, data); err != nil {
			t.Errorf("%s: the new file does not parse: %v", name, err)
		}
	}
	// A built-in script starts from its file; Commit, which blinker runs
	// itself, has none, so it starts from the template.
	if data, _ := os.ReadFile(Path(dir, "Define")); !strings.Contains(string(data), "ctags") {
		t.Error("Define did not start from the built-in tool")
	}
	if data, _ := os.ReadFile(Path(dir, "Commit")); !strings.Contains(string(data), "Hello from") {
		t.Error("Commit started from something other than the template")
	}
	if _, err := Create(dir, "Safe_Bash"); err == nil {
		t.Error("created a tool with a built-in name")
	}
}

// Fetch's hosts are valid host rules, each listed once; Commit and Fetch
// have no files.
func TestFetchTool(t *testing.T) {
	tool := fetchTool()
	seen := map[string]bool{}
	for _, h := range tool.Network {
		if v, err := policy.ValidateHost(h); err != nil || v != h || seen[h] {
			t.Errorf("host %q: %v", h, err)
		}
		seen[h] = true
	}
	if len(tool.Args) != 1 || tool.Args[0].Name != "url" || !strings.Contains(tool.Description, "pkg.go.dev") {
		t.Errorf("Fetch = %+v", tool)
	}
	for _, name := range []string{"Commit", "Fetch"} {
		if _, ok := builtinFile(name); ok {
			t.Errorf("%s, which blinker runs itself, has a built-in file", name)
		}
	}
}

// A tool that asks runs under the unsafe policy, one allowed without
// asking under the safe one, each with its own rules added.
func TestPolicy(t *testing.T) {
	env := policy.Env{Home: "/home/u", Project: "/home/u/proj"}
	compile := func(text string) *policy.Compiled {
		t.Helper()
		f, err := policy.Parse([]byte(text))
		if err != nil {
			t.Fatal(err)
		}
		c, err := policy.Compile(policy.Layer{Name: "test", File: f}, policy.Floor{}, env)
		if err != nil {
			t.Fatal(err)
		}
		return c
	}
	p, err := policy.Bind(compile("file read .\n"), compile("file write .\n"))
	if err != nil {
		t.Fatal(err)
	}
	tool := Def{Name: "Commit", Access: policy.Allow, Files: []policy.FileRule{{Path: ".git", Access: policy.Write}}}
	c, err := tool.Policy(p)
	if err != nil {
		t.Fatal(err)
	}
	if !c.Access("/home/u/proj/.git/index").Write || c.Access("/home/u/proj/main.go").Write {
		t.Error("a tool that runs without asking did not get the safe policy and its rules")
	}
	tool.Access = policy.Ask
	if c, err = tool.Policy(p); err != nil || !c.Access("/home/u/proj/main.go").Write {
		t.Errorf("a tool that asks did not get the unsafe policy: %v", err)
	}
}