//! 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, } impl GrantSet { /// Build a set, checking every grant and refusing the whole set if any grant is invalid. pub fn from_grants(grants: Vec) -> Result> { let mut grants = grants; grants.sort_by(|a, b| a.id.cmp(&b.id)); let mut problems: Vec = Vec::new(); let mut seen: Vec = 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> { // 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 = 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. 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 = Vec::new(); let mut problems: Vec = 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::(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, }; let newlines = before.bytes().filter(|&b| b == b'\n').count(); u64::try_from(newlines).map_or(u64::MAX, |n| n.saturating_add(1)) } None => 1, } } /// Check one grant and push every problem it has. `file` is `".toml"`, `line` is `None`. fn check_grant(grant: &LoadedGrant, problems: &mut Vec) { 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), ); } else if path.contains([':', ',']) { // Mounted as `--volume=::ro`, where both are separators. push( problems, file.clone(), None, format!("{:?} cannot be mounted: it contains ':' or ','", 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 `".toml"`. fn check_tool_constraints( tool: ToolName, file: &str, grant: &Grant, problems: &mut Vec, ) { 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, file: String, line: Option, problem: impl Into, ) { problems.push(GrantProblem { file, line, problem: problem.into(), }); }