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:
2026-09-19 02:50:26 -07:00
parent 9150effc8e
commit e2ab29aa15
19 changed files with 1003 additions and 0 deletions
+375
View File
@@ -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(),
});
}