crates/brokerd/src/grants.rs reads grants/*.toml into a GrantSet: load reports every problem in every file and returns either a complete valid set or the full problem list, never a partial one; from_grants sorts by id and collects every rule-2..9 problem; render prints each problem then the runbook pointer. All 17 grants tests pass; make gate prints gate: ok. Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
483 lines
15 KiB
Rust
483 lines
15 KiB
Rust
//! Tests for loading grant files. Do not edit these or the fixtures.
|
|
//!
|
|
//! One case per loading rule in the M3a spec, each with words its problem text must contain, and
|
|
//! the rule that matters most: one invalid file makes the whole set invalid.
|
|
|
|
#[path = "support/tmp.rs"]
|
|
mod tmp;
|
|
|
|
use brokerd::grants::{GrantSet, LoadedGrant, RUNBOOK, load, render, valid_id};
|
|
use proto::{Constraints, DataClass, Grant, GrantProblem, Hash32, Mode};
|
|
use std::path::{Path, PathBuf};
|
|
use tmp::TempDir;
|
|
|
|
fn fixture(case: &str) -> PathBuf {
|
|
Path::new(env!("CARGO_MANIFEST_DIR"))
|
|
.join("tests/fixtures/grants")
|
|
.join(case)
|
|
}
|
|
|
|
const GOOD: &str = "tool = \"read_file\"\nmode = \"auto\"\nmax_taint = \"secret\"\n\n\
|
|
[constraints]\npaths = [\"/home/kyle/notes\"]\n";
|
|
|
|
/// A directory holding one good grant and the given files; returns the problems of loading it.
|
|
fn problems_of(files: &[(&str, &str)]) -> Vec<GrantProblem> {
|
|
let dir = TempDir::new("grants");
|
|
dir.write("good.toml", GOOD);
|
|
for (name, text) in files {
|
|
dir.write(name, text);
|
|
}
|
|
load(dir.path()).expect_err("the set should be invalid")
|
|
}
|
|
|
|
/// Exactly one problem, in `file`, whose text contains every one of `words`.
|
|
fn one_problem(files: &[(&str, &str)], file: &str, words: &[&str]) -> GrantProblem {
|
|
let problems = problems_of(files);
|
|
assert_eq!(problems.len(), 1, "{problems:?}");
|
|
let p = problems.into_iter().next().unwrap();
|
|
assert_eq!(p.file, file);
|
|
for word in words {
|
|
assert!(p.problem.contains(word), "{:?} lacks {word:?}", p.problem);
|
|
}
|
|
p
|
|
}
|
|
|
|
fn body(tool: &str, mode: &str, rest: &str) -> String {
|
|
format!("tool = \"{tool}\"\nmode = \"{mode}\"\nmax_taint = \"secret\"\n{rest}")
|
|
}
|
|
|
|
#[test]
|
|
fn the_valid_fixture_loads_in_id_order_with_file_hashes() {
|
|
let set = load(&fixture("valid")).unwrap();
|
|
let ids: Vec<&str> = set.grants().iter().map(|g| g.id.as_str()).collect();
|
|
assert_eq!(
|
|
ids,
|
|
[
|
|
"fetch-example",
|
|
"no-fetch-internal",
|
|
"notes-read",
|
|
"scratch-write",
|
|
"shell-bare"
|
|
]
|
|
);
|
|
let notes = &set.grants()[2];
|
|
assert_eq!(notes.grant.tool, "read_file");
|
|
assert_eq!(notes.grant.mode, Mode::Auto);
|
|
assert!(!notes.grant.untrusted);
|
|
assert_eq!(notes.grant.constraints.paths, ["/home/kyle/notes"]);
|
|
let bytes = std::fs::read(fixture("valid").join("notes-read.toml")).unwrap();
|
|
assert_eq!(notes.sha256, proto::sha256(&bytes).unwrap());
|
|
// Defaults from `proto::Grant`.
|
|
let scratch = &set.grants()[3];
|
|
assert_eq!(scratch.grant.result_class, DataClass::Private);
|
|
assert!(scratch.grant.untrusted);
|
|
assert_eq!(scratch.grant.expires, None);
|
|
}
|
|
|
|
#[test]
|
|
fn files_that_do_not_end_in_toml_are_ignored() {
|
|
let dir = TempDir::new("grants");
|
|
dir.write("good.toml", GOOD);
|
|
dir.write("good.toml~", "not toml at all {{{");
|
|
dir.write("good.toml.bak", "not toml at all {{{");
|
|
dir.write("README.md", "# notes");
|
|
dir.write("toml", "x");
|
|
std::fs::create_dir(dir.path().join("archive")).unwrap();
|
|
let set = load(dir.path()).unwrap();
|
|
assert_eq!(set.grants().len(), 1);
|
|
assert_eq!(set.grants()[0].id, "good");
|
|
}
|
|
|
|
#[test]
|
|
fn an_empty_directory_is_a_valid_empty_set() {
|
|
let set = load(&fixture("empty")).unwrap();
|
|
assert!(set.grants().is_empty());
|
|
assert_eq!(set, GrantSet::default());
|
|
}
|
|
|
|
#[test]
|
|
fn a_missing_directory_is_a_problem_not_an_empty_set() {
|
|
let missing = fixture("does-not-exist");
|
|
let problems = load(&missing).unwrap_err();
|
|
assert_eq!(problems.len(), 1);
|
|
assert!(problems[0].file.contains("does-not-exist"), "{problems:?}");
|
|
assert!(problems[0].problem.contains("cannot be read"));
|
|
// A file where the directory should be is the same.
|
|
let dir = TempDir::new("grants");
|
|
let file = dir.write("grants", "x");
|
|
assert!(load(&file).is_err());
|
|
}
|
|
|
|
/// The rule the whole design leans on. `fetch-example` alone would allow a fetch that the
|
|
/// mistyped `no-fetch-internal` was written to forbid, so nothing loads at all.
|
|
#[test]
|
|
fn one_invalid_file_makes_the_whole_set_invalid() {
|
|
let problems = load(&fixture("one-bad")).unwrap_err();
|
|
assert_eq!(problems.len(), 1, "{problems:?}");
|
|
assert_eq!(problems[0].file, "no-fetch-internal.toml");
|
|
assert_eq!(problems[0].line, Some(3));
|
|
assert!(problems[0].problem.contains("mdoe"), "{problems:?}");
|
|
// The same directory without the bad file is fine.
|
|
let dir = TempDir::new("grants");
|
|
for name in ["notes-read.toml", "fetch-example.toml"] {
|
|
let text = std::fs::read_to_string(fixture("one-bad").join(name)).unwrap();
|
|
dir.write(name, &text);
|
|
}
|
|
assert_eq!(load(dir.path()).unwrap().grants().len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn every_problem_in_every_file_is_reported() {
|
|
let problems = load(&fixture("many-bad")).unwrap_err();
|
|
let got: Vec<(&str, Option<u64>)> =
|
|
problems.iter().map(|p| (p.file.as_str(), p.line)).collect();
|
|
assert_eq!(
|
|
got,
|
|
[
|
|
("a-secret.toml", None),
|
|
("a-secret.toml", None),
|
|
("b-paths.toml", None),
|
|
("b-paths.toml", None),
|
|
("b-paths.toml", None),
|
|
("b-paths.toml", None),
|
|
("c-syntax.toml", Some(3)),
|
|
],
|
|
"{problems:?}"
|
|
);
|
|
let all: String = problems
|
|
.iter()
|
|
.map(|p| format!("{}\n", p.problem))
|
|
.collect();
|
|
for words in [
|
|
"secrets are not supported until M4",
|
|
"patterns are not supported",
|
|
"read_file does not take hosts",
|
|
"\"notes\" is not a valid absolute path",
|
|
"\"/home/kyle/../etc\" is not a valid absolute path",
|
|
"a grant of the whole file system is not supported",
|
|
] {
|
|
assert!(all.contains(words), "missing {words:?} in:\n{all}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn rule_1_unreadable_not_utf8_or_not_a_grant() {
|
|
one_problem(&[("bad.toml", "tool = ")], "bad.toml", &[]);
|
|
let p = one_problem(
|
|
&[("bad.toml", &body("shell", "auto", "colour = \"red\"\n"))],
|
|
"bad.toml",
|
|
&["colour"],
|
|
);
|
|
assert_eq!(p.line, Some(4));
|
|
one_problem(
|
|
&[("bad.toml", "tool = \"shell\"\nmode = \"auto\"\n")],
|
|
"bad.toml",
|
|
&["max_taint"],
|
|
);
|
|
one_problem(
|
|
&[("bad.toml", &body("shell", "sometimes", ""))],
|
|
"bad.toml",
|
|
&["sometimes"],
|
|
);
|
|
one_problem(
|
|
&[(
|
|
"bad.toml",
|
|
&body("shell", "auto", "[constraints]\ncwd = [\"/a\"]\n"),
|
|
)],
|
|
"bad.toml",
|
|
&["cwd"],
|
|
);
|
|
|
|
// Not UTF-8.
|
|
let dir = TempDir::new("grants");
|
|
dir.write("good.toml", GOOD);
|
|
std::fs::write(dir.path().join("latin1.toml"), b"tool = \"caf\xe9\"\n").unwrap();
|
|
let problems = load(dir.path()).unwrap_err();
|
|
assert_eq!(problems.len(), 1);
|
|
assert_eq!(problems[0].file, "latin1.toml");
|
|
assert!(problems[0].problem.contains("UTF-8"), "{problems:?}");
|
|
|
|
// Exists but cannot be read: a directory with a grant's name.
|
|
let dir = TempDir::new("grants");
|
|
dir.write("good.toml", GOOD);
|
|
std::fs::create_dir(dir.path().join("folder.toml")).unwrap();
|
|
let problems = load(dir.path()).unwrap_err();
|
|
assert_eq!(problems.len(), 1);
|
|
assert_eq!(problems[0].file, "folder.toml");
|
|
assert!(
|
|
problems[0].problem.contains("cannot be read"),
|
|
"{problems:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn rule_1_a_file_without_read_permission_is_a_problem() {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
if tmp::running_as_root("rule_1_a_file_without_read_permission_is_a_problem") {
|
|
return;
|
|
}
|
|
let dir = TempDir::new("grants");
|
|
dir.write("good.toml", GOOD);
|
|
let locked = dir.write("locked.toml", GOOD);
|
|
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap();
|
|
let problems = load(dir.path()).unwrap_err();
|
|
assert_eq!(problems.len(), 1);
|
|
assert_eq!(problems[0].file, "locked.toml");
|
|
assert!(
|
|
problems[0].problem.contains("cannot be read"),
|
|
"{problems:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn rule_2_the_file_stem_is_the_id() {
|
|
for id in ["a", "notes-read", "0", "a-1-b", &"x".repeat(64)] {
|
|
assert!(valid_id(id), "{id:?}");
|
|
}
|
|
for id in [
|
|
"",
|
|
"Notes",
|
|
"notes_read",
|
|
"notes.read",
|
|
"notes read",
|
|
".hidden",
|
|
&"x".repeat(65),
|
|
] {
|
|
assert!(!valid_id(id), "{id:?}");
|
|
}
|
|
one_problem(
|
|
&[("Bad_Name.toml", GOOD)],
|
|
"Bad_Name.toml",
|
|
&["not a valid grant id"],
|
|
);
|
|
one_problem(&[(".toml", GOOD)], ".toml", &["not a valid grant id"]);
|
|
one_problem(&[("a.b.toml", GOOD)], "a.b.toml", &["not a valid grant id"]);
|
|
}
|
|
|
|
#[test]
|
|
fn rule_3_the_tool_is_one_of_the_four() {
|
|
for tool in ["echo", "clock", "Read_File", ""] {
|
|
one_problem(
|
|
&[("bad.toml", &body(tool, "auto", ""))],
|
|
"bad.toml",
|
|
&[
|
|
"unknown tool",
|
|
"read_file, write_file, shell and http_fetch",
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn rules_4_and_5_secrets_and_patterns_are_not_supported() {
|
|
one_problem(
|
|
&[("bad.toml", &body("shell", "ask", "secret = \"token\"\n"))],
|
|
"bad.toml",
|
|
&["secrets are not supported until M4"],
|
|
);
|
|
one_problem(
|
|
&[(
|
|
"bad.toml",
|
|
&body("shell", "ask", "[constraints]\npatterns = [\"^ls\"]\n"),
|
|
)],
|
|
"bad.toml",
|
|
&["patterns are not supported"],
|
|
);
|
|
// An empty list is the same as no list.
|
|
let dir = TempDir::new("grants");
|
|
dir.write(
|
|
"ok.toml",
|
|
&body("shell", "ask", "[constraints]\npatterns = []\nhosts = []\n"),
|
|
);
|
|
assert!(load(dir.path()).is_ok());
|
|
}
|
|
|
|
/// The table of rule 6, cell by cell.
|
|
#[test]
|
|
fn rule_6_each_tool_takes_its_own_constraints() {
|
|
let paths = "[constraints]\npaths = [\"/a\"]\n";
|
|
let hosts = "[constraints]\nhosts = [\"example.com\"]\n";
|
|
let both = "[constraints]\npaths = [\"/a\"]\nhosts = [\"example.com\"]\n";
|
|
for tool in ["read_file", "write_file"] {
|
|
one_problem(
|
|
&[("bad.toml", &body(tool, "auto", ""))],
|
|
"bad.toml",
|
|
&[tool, "needs at least one path"],
|
|
);
|
|
one_problem(
|
|
&[("bad.toml", &body(tool, "auto", both))],
|
|
"bad.toml",
|
|
&[tool, "does not take hosts"],
|
|
);
|
|
}
|
|
one_problem(
|
|
&[("bad.toml", &body("shell", "auto", both))],
|
|
"bad.toml",
|
|
&["shell does not take hosts"],
|
|
);
|
|
one_problem(
|
|
&[("bad.toml", &body("http_fetch", "auto", ""))],
|
|
"bad.toml",
|
|
&["http_fetch needs at least one host"],
|
|
);
|
|
one_problem(
|
|
&[("bad.toml", &body("http_fetch", "auto", both))],
|
|
"bad.toml",
|
|
&["http_fetch does not take paths"],
|
|
);
|
|
// The allowed cells.
|
|
let dir = TempDir::new("grants");
|
|
dir.write("r.toml", &body("read_file", "auto", paths));
|
|
dir.write("w.toml", &body("write_file", "auto", paths));
|
|
dir.write("s1.toml", &body("shell", "auto", paths));
|
|
dir.write("s2.toml", &body("shell", "auto", ""));
|
|
dir.write("h.toml", &body("http_fetch", "auto", hosts));
|
|
assert_eq!(load(dir.path()).unwrap().grants().len(), 5);
|
|
// Two wrong cells in one file are two problems.
|
|
let wrong = body("http_fetch", "auto", paths);
|
|
assert_eq!(problems_of(&[("bad.toml", &wrong)]).len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn rule_7_paths_are_valid_absolute_paths_and_never_the_root() {
|
|
for bad in ["notes", "/a/../b", "/a//b", "/a/./b", "/a/", ""] {
|
|
let text = body(
|
|
"shell",
|
|
"auto",
|
|
&format!("[constraints]\npaths = [{bad:?}]\n"),
|
|
);
|
|
one_problem(
|
|
&[("bad.toml", &text)],
|
|
"bad.toml",
|
|
&["is not a valid absolute path"],
|
|
);
|
|
}
|
|
let root = body(
|
|
"shell",
|
|
"auto",
|
|
"[constraints]\npaths = [\"/home/kyle\", \"/\"]\n",
|
|
);
|
|
one_problem(
|
|
&[("bad.toml", &root)],
|
|
"bad.toml",
|
|
&["a grant of the whole file system is not supported"],
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn rule_8_hosts_are_valid_host_patterns() {
|
|
for bad in [
|
|
"Example.com",
|
|
"example.com:443",
|
|
"127.0.0.1",
|
|
"localhost",
|
|
"*.com",
|
|
"https://example.com",
|
|
] {
|
|
let text = body(
|
|
"http_fetch",
|
|
"auto",
|
|
&format!("[constraints]\nhosts = [{bad:?}]\n"),
|
|
);
|
|
one_problem(
|
|
&[("bad.toml", &text)],
|
|
"bad.toml",
|
|
&[bad, "is not a valid host pattern"],
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn rule_9_a_deny_grant_applies_at_every_taint() {
|
|
for taint in ["public", "private"] {
|
|
let text = format!("tool = \"shell\"\nmode = \"deny\"\nmax_taint = \"{taint}\"\n");
|
|
one_problem(
|
|
&[("bad.toml", &text)],
|
|
"bad.toml",
|
|
&["a deny grant must apply at every taint"],
|
|
);
|
|
}
|
|
let dir = TempDir::new("grants");
|
|
dir.write("no-shell.toml", &body("shell", "deny", ""));
|
|
assert!(load(dir.path()).is_ok());
|
|
// The rule is about deny only.
|
|
dir.write(
|
|
"ask.toml",
|
|
"tool = \"shell\"\nmode = \"ask\"\nmax_taint = \"public\"\n",
|
|
);
|
|
assert!(load(dir.path()).is_ok());
|
|
}
|
|
|
|
fn loaded(id: &str, tool: &str, mode: Mode) -> LoadedGrant {
|
|
LoadedGrant {
|
|
id: id.to_string(),
|
|
grant: Grant {
|
|
tool: tool.to_string(),
|
|
mode,
|
|
max_taint: DataClass::Secret,
|
|
result_class: DataClass::Private,
|
|
untrusted: true,
|
|
expires: None,
|
|
secret: None,
|
|
constraints: Constraints::default(),
|
|
},
|
|
sha256: Hash32::ZERO,
|
|
}
|
|
}
|
|
|
|
/// `from_grants` is how tests and the property test build a set without files. It applies the
|
|
/// same value rules, sorts by id, and refuses two grants with one id.
|
|
#[test]
|
|
fn from_grants_applies_the_value_rules() {
|
|
let set = GrantSet::from_grants(vec![
|
|
loaded("zz", "shell", Mode::Ask),
|
|
loaded("aa", "shell", Mode::Deny),
|
|
])
|
|
.unwrap();
|
|
assert_eq!(set.grants()[0].id, "aa");
|
|
assert_eq!(set.grants()[1].id, "zz");
|
|
|
|
let problems = GrantSet::from_grants(vec![
|
|
loaded("ok", "shell", Mode::Auto),
|
|
loaded("no-paths", "read_file", Mode::Auto),
|
|
loaded("Bad", "shell", Mode::Auto),
|
|
])
|
|
.unwrap_err();
|
|
let files: Vec<&str> = problems.iter().map(|p| p.file.as_str()).collect();
|
|
assert_eq!(files, ["Bad.toml", "no-paths.toml"]);
|
|
|
|
let twice = GrantSet::from_grants(vec![
|
|
loaded("same", "shell", Mode::Auto),
|
|
loaded("same", "shell", Mode::Ask),
|
|
])
|
|
.unwrap_err();
|
|
assert!(
|
|
twice[0].problem.contains("two grants have this id"),
|
|
"{twice:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn render_prints_every_problem_and_then_the_runbook_pointer() {
|
|
let problems = [
|
|
GrantProblem {
|
|
file: "a.toml".to_string(),
|
|
line: Some(3),
|
|
problem: "unknown field `mdoe`".to_string(),
|
|
},
|
|
GrantProblem {
|
|
file: "Bad_Name.toml".to_string(),
|
|
line: None,
|
|
problem: "the file name is not a valid grant id".to_string(),
|
|
},
|
|
];
|
|
assert_eq!(
|
|
render(&problems),
|
|
"a.toml:3: unknown field `mdoe`\n\
|
|
Bad_Name.toml: the file name is not a valid grant id\n\
|
|
see docs/runbook.md#grants-invalid\n"
|
|
);
|
|
assert_eq!(RUNBOOK, "see docs/runbook.md#grants-invalid");
|
|
assert!(render(&problems).trim_end().ends_with(RUNBOOK));
|
|
}
|