Load grant files, failing closed on any invalid file
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)
This commit is contained in:
@@ -0,0 +1,375 @@
|
||||
//! Loading the owner's grant files into a `GrantSet`, failing closed.
|
||||
//!
|
||||
//! A mistyped `deny` grant that was skipped would silently become an allow wherever another grant
|
||||
//! matches, so if any file is invalid the whole set is invalid. `load` therefore returns either a
|
||||
//! complete valid set or the full list of problems, never a partial one, and reports every problem
|
||||
//! in every file rather than only the first.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use crate::args::{ToolName, valid_host_pattern, valid_path};
|
||||
use proto::{DataClass, Grant, GrantProblem, Mode, sha256};
|
||||
|
||||
/// Where the owner reads the rules behind a rejected set.
|
||||
pub const RUNBOOK: &str = "see docs/runbook.md#grants-invalid";
|
||||
|
||||
/// A grant read from one file: the id (the file stem), the parsed grant, and the file's hash.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct LoadedGrant {
|
||||
pub id: String, // the file stem
|
||||
pub grant: proto::Grant,
|
||||
pub sha256: proto::Hash32, // of the file's bytes as read
|
||||
}
|
||||
|
||||
/// A set that passed every rule, in id order. Its field is private: `from_grants` and `load` are
|
||||
/// the only ways to make one that is not empty.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct GrantSet {
|
||||
grants: Vec<LoadedGrant>,
|
||||
}
|
||||
|
||||
impl GrantSet {
|
||||
/// Build a set, checking every grant and refusing the whole set if any grant is invalid.
|
||||
pub fn from_grants(grants: Vec<LoadedGrant>) -> Result<GrantSet, Vec<GrantProblem>> {
|
||||
let mut grants = grants;
|
||||
grants.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
|
||||
let mut problems: Vec<GrantProblem> = Vec::new();
|
||||
let mut seen: Vec<String> = Vec::new();
|
||||
for grant in &grants {
|
||||
if seen.iter().any(|id| id == &grant.id) {
|
||||
push(
|
||||
&mut problems,
|
||||
format!("{}.toml", grant.id),
|
||||
None,
|
||||
"two grants have this id",
|
||||
);
|
||||
}
|
||||
seen.push(grant.id.clone());
|
||||
check_grant(grant, &mut problems);
|
||||
}
|
||||
|
||||
if problems.is_empty() {
|
||||
Ok(GrantSet { grants })
|
||||
} else {
|
||||
Err(problems)
|
||||
}
|
||||
}
|
||||
|
||||
/// The grants in id order.
|
||||
pub fn grants(&self) -> &[LoadedGrant] {
|
||||
&self.grants
|
||||
}
|
||||
}
|
||||
|
||||
/// A file name is a valid grant id when it is 1 to 64 characters of `a-z`, `0-9` and `-`.
|
||||
pub fn valid_id(id: &str) -> bool {
|
||||
if !(1..=64).contains(&id.len()) {
|
||||
return false;
|
||||
}
|
||||
id.bytes()
|
||||
.all(|b| matches!(b, b'a'..=b'z' | b'0'..=b'9' | b'-'))
|
||||
}
|
||||
|
||||
/// Read every `*.toml` in `dir` into a `GrantSet`, or report every problem found.
|
||||
pub fn load(dir: &Path) -> Result<GrantSet, Vec<GrantProblem>> {
|
||||
// Step 1: the directory itself must be readable. A missing directory is not an empty set.
|
||||
let entries = match std::fs::read_dir(dir) {
|
||||
Ok(entries) => entries,
|
||||
Err(error) => {
|
||||
return Err(vec![GrantProblem {
|
||||
file: dir.to_string_lossy().to_string(),
|
||||
line: None,
|
||||
problem: format!("the grants directory cannot be read: {}", error),
|
||||
}]);
|
||||
}
|
||||
};
|
||||
|
||||
// Step 2: collect and sort the file names, so problems come out in the same order every time.
|
||||
let mut names: Vec<String> = Vec::new();
|
||||
for entry in entries {
|
||||
match entry {
|
||||
Ok(entry) => names.push(entry.file_name().to_string_lossy().to_string()),
|
||||
Err(error) => {
|
||||
// An entry that cannot be read is the same problem as the directory; keep going.
|
||||
names.push(String::new());
|
||||
return Err(vec![GrantProblem {
|
||||
file: dir.to_string_lossy().to_string(),
|
||||
line: None,
|
||||
problem: format!("the grants directory cannot be read: {}", error),
|
||||
}]);
|
||||
}
|
||||
}
|
||||
}
|
||||
names.sort();
|
||||
|
||||
// Steps 3-7: read each file, keeping the valid grants and recording every other problem.
|
||||
let mut kept: Vec<LoadedGrant> = Vec::new();
|
||||
let mut problems: Vec<GrantProblem> = Vec::new();
|
||||
for name in &names {
|
||||
if !name.ends_with(".toml") {
|
||||
continue;
|
||||
}
|
||||
let id = &name[..name.len() - ".toml".len()];
|
||||
let path = dir.join(name);
|
||||
|
||||
let bytes = match std::fs::read(&path) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(error) => {
|
||||
// Never treat an unreadable file as absent.
|
||||
problems.push(GrantProblem {
|
||||
file: name.clone(),
|
||||
line: None,
|
||||
problem: format!("cannot be read: {}", error),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let text = match std::str::from_utf8(&bytes) {
|
||||
Ok(text) => text,
|
||||
Err(_) => {
|
||||
problems.push(GrantProblem {
|
||||
file: name.clone(),
|
||||
line: None,
|
||||
problem: "is not UTF-8".to_string(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
match toml::from_str::<Grant>(text) {
|
||||
Ok(grant) => {
|
||||
let sha256 = match sha256(&bytes) {
|
||||
Ok(sha256) => sha256,
|
||||
Err(error) => {
|
||||
problems.push(GrantProblem {
|
||||
file: name.clone(),
|
||||
line: None,
|
||||
problem: error.to_string(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
};
|
||||
kept.push(LoadedGrant {
|
||||
id: id.to_string(),
|
||||
grant,
|
||||
sha256,
|
||||
});
|
||||
}
|
||||
Err(error) => {
|
||||
problems.push(GrantProblem {
|
||||
file: name.clone(),
|
||||
line: Some(span_line(text, &error)),
|
||||
problem: error.message().to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 8: even when the steps above found problems, still check the valid grants so the owner
|
||||
// sees everything at once. Return Err if anything at all was reported.
|
||||
let mut all = problems;
|
||||
let set = match GrantSet::from_grants(kept) {
|
||||
Ok(set) => Some(set),
|
||||
Err(value_problems) => {
|
||||
all.extend(value_problems);
|
||||
None
|
||||
}
|
||||
};
|
||||
all.sort_by(|a, b| a.file.cmp(&b.file));
|
||||
match set {
|
||||
Some(set) => {
|
||||
if all.is_empty() {
|
||||
Ok(set)
|
||||
} else {
|
||||
Err(all)
|
||||
}
|
||||
}
|
||||
None => Err(all),
|
||||
}
|
||||
}
|
||||
|
||||
/// The 1-based line of a parse error's start: the number of newlines before it, plus one.
|
||||
fn span_line(text: &str, error: &toml::de::Error) -> u64 {
|
||||
match error.span() {
|
||||
Some(range) => {
|
||||
let before = match text.get(..range.start) {
|
||||
Some(before) => before,
|
||||
None => text,
|
||||
};
|
||||
before.bytes().filter(|&b| b == b'\n').count() as u64 + 1
|
||||
}
|
||||
None => 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check one grant and push every problem it has. `file` is `"<id>.toml"`, `line` is `None`.
|
||||
fn check_grant(grant: &LoadedGrant, problems: &mut Vec<GrantProblem>) {
|
||||
let id = &grant.id;
|
||||
let inner = &grant.grant;
|
||||
let file = format!("{}.toml", id);
|
||||
|
||||
// Rule 2: the id must be a valid grant id.
|
||||
if !valid_id(id) {
|
||||
push(
|
||||
problems,
|
||||
file.clone(),
|
||||
None,
|
||||
"the file name is not a valid grant id",
|
||||
);
|
||||
}
|
||||
|
||||
// Rule 3: the tool must be one of the four; an unknown tool skips rule 6 but not the others.
|
||||
match ToolName::parse(&grant.grant.tool) {
|
||||
None => push(
|
||||
problems,
|
||||
file.clone(),
|
||||
None,
|
||||
"unknown tool; only read_file, write_file, shell and http_fetch",
|
||||
),
|
||||
Some(tool) => check_tool_constraints(tool, &file, inner, problems),
|
||||
}
|
||||
|
||||
// Rule 4: secrets are not supported until M4.
|
||||
if inner.secret.is_some() {
|
||||
push(
|
||||
problems,
|
||||
file.clone(),
|
||||
None,
|
||||
"secrets are not supported until M4",
|
||||
);
|
||||
}
|
||||
// Rule 5: patterns are not supported.
|
||||
if !inner.constraints.patterns.is_empty() {
|
||||
push(problems, file.clone(), None, "patterns are not supported");
|
||||
}
|
||||
// Rule 7: no path may be the whole file system or an invalid absolute path.
|
||||
for path in &inner.constraints.paths {
|
||||
if path == "/" {
|
||||
push(
|
||||
problems,
|
||||
file.clone(),
|
||||
None,
|
||||
"a grant of the whole file system is not supported",
|
||||
);
|
||||
} else if !valid_path(path) {
|
||||
push(
|
||||
problems,
|
||||
file.clone(),
|
||||
None,
|
||||
format!("{:?} is not a valid absolute path", path),
|
||||
);
|
||||
}
|
||||
}
|
||||
// Rule 8: every host must be a valid host pattern.
|
||||
for host in &inner.constraints.hosts {
|
||||
if !valid_host_pattern(host) {
|
||||
push(
|
||||
problems,
|
||||
file.clone(),
|
||||
None,
|
||||
format!("{:?} is not a valid host pattern", host),
|
||||
);
|
||||
}
|
||||
}
|
||||
// Rule 9: a deny grant must apply at every taint.
|
||||
if inner.mode == Mode::Deny && inner.max_taint != DataClass::Secret {
|
||||
push(
|
||||
problems,
|
||||
file,
|
||||
None,
|
||||
"a deny grant must apply at every taint",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Rule 6: the per-tool checks on paths and hosts. `file` is `"<id>.toml"`.
|
||||
fn check_tool_constraints(
|
||||
tool: ToolName,
|
||||
file: &str,
|
||||
grant: &Grant,
|
||||
problems: &mut Vec<GrantProblem>,
|
||||
) {
|
||||
let tool = tool.as_str();
|
||||
match tool {
|
||||
"read_file" | "write_file" => {
|
||||
if grant.constraints.paths.is_empty() {
|
||||
push(
|
||||
problems,
|
||||
file.to_string(),
|
||||
None,
|
||||
format!("{} needs at least one path", tool),
|
||||
);
|
||||
}
|
||||
if !grant.constraints.hosts.is_empty() {
|
||||
push(
|
||||
problems,
|
||||
file.to_string(),
|
||||
None,
|
||||
format!("{} does not take hosts", tool),
|
||||
);
|
||||
}
|
||||
}
|
||||
"shell" => {
|
||||
if !grant.constraints.hosts.is_empty() {
|
||||
push(
|
||||
problems,
|
||||
file.to_string(),
|
||||
None,
|
||||
format!("{} does not take hosts", tool),
|
||||
);
|
||||
}
|
||||
}
|
||||
"http_fetch" => {
|
||||
if grant.constraints.hosts.is_empty() {
|
||||
push(
|
||||
problems,
|
||||
file.to_string(),
|
||||
None,
|
||||
"http_fetch needs at least one host",
|
||||
);
|
||||
}
|
||||
if !grant.constraints.paths.is_empty() {
|
||||
push(
|
||||
problems,
|
||||
file.to_string(),
|
||||
None,
|
||||
"http_fetch does not take paths",
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// One line per problem, then `RUNBOOK` on its own line; every line ends with `\n`.
|
||||
pub fn render(problems: &[GrantProblem]) -> String {
|
||||
let mut out = String::new();
|
||||
for problem in problems {
|
||||
match problem.line {
|
||||
Some(line) => {
|
||||
out.push_str(&format!("{}:{}: {}\n", problem.file, line, problem.problem));
|
||||
}
|
||||
None => {
|
||||
out.push_str(&format!("{}: {}\n", problem.file, problem.problem));
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push_str(RUNBOOK);
|
||||
out.push('\n');
|
||||
out
|
||||
}
|
||||
|
||||
fn push(
|
||||
problems: &mut Vec<GrantProblem>,
|
||||
file: String,
|
||||
line: Option<u64>,
|
||||
problem: impl Into<String>,
|
||||
) {
|
||||
problems.push(GrantProblem {
|
||||
file,
|
||||
line,
|
||||
problem: problem.into(),
|
||||
});
|
||||
}
|
||||
@@ -2,5 +2,6 @@
|
||||
|
||||
pub mod args;
|
||||
pub mod config;
|
||||
pub mod grants;
|
||||
pub mod policy;
|
||||
pub mod runner;
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
An empty set of grants is valid: every call is denied with no_grant.
|
||||
@@ -0,0 +1,8 @@
|
||||
tool = "http_fetch"
|
||||
mode = "auto"
|
||||
max_taint = "private"
|
||||
secret = "api-token"
|
||||
|
||||
[constraints]
|
||||
hosts = ["api.example.com"]
|
||||
patterns = ["^GET "]
|
||||
@@ -0,0 +1,7 @@
|
||||
tool = "read_file"
|
||||
mode = "auto"
|
||||
max_taint = "private"
|
||||
|
||||
[constraints]
|
||||
paths = ["notes", "/home/kyle/../etc", "/"]
|
||||
hosts = ["example.com"]
|
||||
@@ -0,0 +1,3 @@
|
||||
tool = "shell"
|
||||
mode = "auto"
|
||||
max_taint =
|
||||
@@ -0,0 +1,10 @@
|
||||
# The owner's notes: only the owner writes them, so their content is trusted.
|
||||
tool = "read_file"
|
||||
mode = "auto"
|
||||
max_taint = "secret"
|
||||
result_class = "private"
|
||||
untrusted = false
|
||||
expires = "2027-01-01T00:00:00.000Z"
|
||||
|
||||
[constraints]
|
||||
paths = ["/home/kyle/notes"]
|
||||
@@ -0,0 +1,7 @@
|
||||
tool = "http_fetch"
|
||||
mode = "auto"
|
||||
max_taint = "private"
|
||||
result_class = "public"
|
||||
|
||||
[constraints]
|
||||
hosts = ["example.com", "*.example.com"]
|
||||
@@ -0,0 +1,7 @@
|
||||
# The owner meant `mode`. If this file were skipped, fetch-example would allow what it forbids.
|
||||
tool = "http_fetch"
|
||||
mdoe = "deny"
|
||||
max_taint = "secret"
|
||||
|
||||
[constraints]
|
||||
hosts = ["internal.example.com"]
|
||||
@@ -0,0 +1,10 @@
|
||||
# The owner's notes: only the owner writes them, so their content is trusted.
|
||||
tool = "read_file"
|
||||
mode = "auto"
|
||||
max_taint = "secret"
|
||||
result_class = "private"
|
||||
untrusted = false
|
||||
expires = "2027-01-01T00:00:00.000Z"
|
||||
|
||||
[constraints]
|
||||
paths = ["/home/kyle/notes"]
|
||||
@@ -0,0 +1 @@
|
||||
Grants for the tests. This file is not a grant and is ignored.
|
||||
@@ -0,0 +1,7 @@
|
||||
tool = "http_fetch"
|
||||
mode = "auto"
|
||||
max_taint = "private"
|
||||
result_class = "public"
|
||||
|
||||
[constraints]
|
||||
hosts = ["example.com", "*.example.com"]
|
||||
@@ -0,0 +1,6 @@
|
||||
tool = "http_fetch"
|
||||
mode = "deny"
|
||||
max_taint = "secret"
|
||||
|
||||
[constraints]
|
||||
hosts = ["internal.example.com"]
|
||||
@@ -0,0 +1,10 @@
|
||||
# The owner's notes: only the owner writes them, so their content is trusted.
|
||||
tool = "read_file"
|
||||
mode = "auto"
|
||||
max_taint = "secret"
|
||||
result_class = "private"
|
||||
untrusted = false
|
||||
expires = "2027-01-01T00:00:00.000Z"
|
||||
|
||||
[constraints]
|
||||
paths = ["/home/kyle/notes"]
|
||||
@@ -0,0 +1,6 @@
|
||||
tool = "write_file"
|
||||
mode = "ask"
|
||||
max_taint = "private"
|
||||
|
||||
[constraints]
|
||||
paths = ["/home/kyle/scratch", "/home/kyle/scratch/out"]
|
||||
@@ -0,0 +1,4 @@
|
||||
# A shell with nothing mounted.
|
||||
tool = "shell"
|
||||
mode = "ask"
|
||||
max_taint = "secret"
|
||||
@@ -0,0 +1,482 @@
|
||||
//! 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));
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
//! Temporary directories for tests. Do not edit.
|
||||
//!
|
||||
//! Included with `#[path = "support/tmp.rs"] mod tmp;`. No crate is used: the name is made from
|
||||
//! the process id and a counter, and the directory is removed when the value is dropped.
|
||||
|
||||
#![allow(dead_code)] // each test file uses a different part of this module
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
static NEXT: AtomicU32 = AtomicU32::new(0);
|
||||
|
||||
pub struct TempDir(PathBuf);
|
||||
|
||||
impl TempDir {
|
||||
pub fn new(tag: &str) -> TempDir {
|
||||
let n = NEXT.fetch_add(1, Ordering::SeqCst);
|
||||
let path = std::env::temp_dir().join(format!("bx-{tag}-{}-{n}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&path);
|
||||
std::fs::create_dir_all(&path).unwrap();
|
||||
TempDir(path)
|
||||
}
|
||||
|
||||
pub fn path(&self) -> &Path {
|
||||
&self.0
|
||||
}
|
||||
|
||||
/// Writes `text` to `name` inside the directory and returns the full path.
|
||||
pub fn write(&self, name: &str, text: &str) -> PathBuf {
|
||||
let path = self.0.join(name);
|
||||
std::fs::write(&path, text).unwrap();
|
||||
path
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
// Put back the permissions a test may have taken away, or the removal fails.
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = std::fs::set_permissions(&self.0, std::fs::Permissions::from_mode(0o700));
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// True when the tests run as root, for whom file permissions do not apply. Tests that depend on
|
||||
/// a permission error print why they are skipped and return.
|
||||
pub fn running_as_root(test: &str) -> bool {
|
||||
let probe = TempDir::new("rootprobe");
|
||||
let file = probe.write("probe", "x");
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o000)).unwrap();
|
||||
let root = std::fs::read(&file).is_ok();
|
||||
if root {
|
||||
eprintln!("{test}: skipped, because this user can read a mode 000 file (root?)");
|
||||
}
|
||||
root
|
||||
}
|
||||
Reference in New Issue
Block a user