provenance.rs (5567B)
1 use std::collections::{BTreeMap, BTreeSet}; 2 3 use sha2::{Digest, Sha256}; 4 use toml::Value; 5 6 const ROOT_KEYS: &[&str] = &[ 7 "schema", 8 "source_product", 9 "source_repository", 10 "foundation_baseline", 11 "canonical_radroots_repository", 12 "canonical_radroots_revision", 13 ]; 14 const IMPORT_KEYS: &[&str] = &["component", "commit"]; 15 16 #[derive(Clone, Debug, Eq, PartialEq)] 17 pub struct SourceProvenance { 18 root: BTreeMap<String, String>, 19 imports: Vec<BTreeMap<String, String>>, 20 } 21 22 impl SourceProvenance { 23 pub fn foundation_baseline(&self) -> &str { 24 self.root 25 .get("foundation_baseline") 26 .expect("validated provenance has a foundation baseline") 27 } 28 29 pub fn canonical_radroots_revision(&self) -> &str { 30 self.root 31 .get("canonical_radroots_revision") 32 .expect("validated provenance has a canonical Radroots revision") 33 } 34 35 pub fn canonical(&self) -> String { 36 let mut canonical = String::new(); 37 for key in ROOT_KEYS { 38 canonical.push_str(key); 39 canonical.push('='); 40 canonical.push_str(self.root.get(*key).expect("validated provenance root key")); 41 canonical.push('\n'); 42 } 43 for import in &self.imports { 44 canonical.push_str("import.component="); 45 canonical.push_str(import.get("component").expect("validated import component")); 46 canonical.push('\n'); 47 canonical.push_str("import.commit="); 48 canonical.push_str(import.get("commit").expect("validated import commit")); 49 canonical.push('\n'); 50 } 51 canonical 52 } 53 54 pub fn digest(&self) -> String { 55 Sha256::digest(self.canonical().as_bytes()) 56 .iter() 57 .map(|byte| format!("{byte:02x}")) 58 .collect() 59 } 60 } 61 62 pub fn parse(source: &str) -> Result<SourceProvenance, String> { 63 if source.starts_with('\u{feff}') { 64 return Err("source provenance must not contain a UTF-8 BOM".to_owned()); 65 } 66 let table = toml::from_str::<toml::Table>(source).map_err(|error| error.to_string())?; 67 let expected_root = ROOT_KEYS 68 .iter() 69 .copied() 70 .chain(std::iter::once("import")) 71 .collect::<BTreeSet<_>>(); 72 let actual_root = table.keys().map(String::as_str).collect::<BTreeSet<_>>(); 73 if actual_root != expected_root { 74 return Err("source provenance root keys do not match the contract".to_owned()); 75 } 76 77 let mut root = BTreeMap::new(); 78 for key in ROOT_KEYS { 79 let value = required_string(&table, key)?; 80 validate_public_value(key, value)?; 81 root.insert((*key).to_owned(), value.to_owned()); 82 } 83 if root.get("schema").map(String::as_str) != Some("harvestcircle.source_provenance.v1") { 84 return Err("source provenance schema is not supported".to_owned()); 85 } 86 for key in ["foundation_baseline", "canonical_radroots_revision"] { 87 if !is_lower_hex(root.get(key).expect("required revision"), 40) { 88 return Err(format!( 89 "source provenance {key} is not a canonical revision" 90 )); 91 } 92 } 93 94 let import_values = table 95 .get("import") 96 .and_then(Value::as_array) 97 .ok_or_else(|| "source provenance imports must be an array of tables".to_owned())?; 98 if import_values.is_empty() { 99 return Err("source provenance imports must not be empty".to_owned()); 100 } 101 let expected_import = IMPORT_KEYS.iter().copied().collect::<BTreeSet<_>>(); 102 let mut components = BTreeSet::new(); 103 let mut imports = Vec::new(); 104 for value in import_values { 105 let import = value 106 .as_table() 107 .ok_or_else(|| "source provenance import must be a table".to_owned())?; 108 let actual = import.keys().map(String::as_str).collect::<BTreeSet<_>>(); 109 if actual != expected_import { 110 return Err("source provenance import keys do not match the contract".to_owned()); 111 } 112 let component = required_string(import, "component")?; 113 let commit = required_string(import, "commit")?; 114 validate_public_value("component", component)?; 115 if !is_lower_hex(commit, 40) { 116 return Err("source provenance import commit is not canonical".to_owned()); 117 } 118 if !components.insert(component.to_owned()) { 119 return Err(format!("duplicate source provenance component {component}")); 120 } 121 imports.push(BTreeMap::from([ 122 ("component".to_owned(), component.to_owned()), 123 ("commit".to_owned(), commit.to_owned()), 124 ])); 125 } 126 imports.sort_by(|left, right| left.get("component").cmp(&right.get("component"))); 127 Ok(SourceProvenance { root, imports }) 128 } 129 130 pub fn digest(source: &str) -> Result<String, String> { 131 Ok(parse(source)?.digest()) 132 } 133 134 fn required_string<'a>(table: &'a toml::Table, key: &str) -> Result<&'a str, String> { 135 table 136 .get(key) 137 .and_then(Value::as_str) 138 .ok_or_else(|| format!("source provenance {key} must be a string")) 139 } 140 141 fn validate_public_value(key: &str, value: &str) -> Result<(), String> { 142 if value.is_empty() || value.chars().any(char::is_control) { 143 return Err(format!( 144 "source provenance {key} is empty or contains a control character" 145 )); 146 } 147 Ok(()) 148 } 149 150 fn is_lower_hex(value: &str, width: usize) -> bool { 151 value.len() == width 152 && value 153 .bytes() 154 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) 155 }