app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

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 }