//! 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 { 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)> = 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)); }