//! The canonical job graph (spec §3.2) and object hashes. Pipelines, //! jobs and receipts are encoded with Oxygen's deterministic CBOR profile //! (Oxygen spec §5.1: unsigned integer map keys in order) and hashed the //! way Oxygen hashes objects: `BLAKE3("oxy1 " || type || " " || len || 0x00 || body)`. use crate::pipeline::{Job, Pipeline, Sandbox}; use oxy_core::cbor::{encode, Value}; /// A 32-byte object reference, shown as hex. pub type Hash = [u8; 32]; /// Oxygen's object hash construction for a Flow object type. pub fn object_hash(ty: &str, body: &[u8]) -> Hash { let mut h = blake3::Hasher::new(); h.update(format!("oxy1 {ty} {}\0", body.len()).as_bytes()); h.update(body); *h.finalize().as_bytes() } fn text(s: &str) -> Value { Value::Text(s.to_string()) } fn texts(v: &[String]) -> Value { Value::Array(v.iter().map(|s| text(s)).collect()) } fn hex32(s: &str) -> Value { Value::Bytes(hex::decode(s).unwrap_or_default()) } pub fn sandbox_value(s: &Sandbox) -> Value { Value::map([ (0, text(&s.name)), (1, text(s.runtime.as_str())), (2, hex32(&s.rootfs)), (3, Value::Uint(s.memory_mib as u64)), (4, Value::Uint(s.cpus as u64)), (5, text(s.network.as_str())), ]) } pub fn job_value(j: &Job) -> Value { Value::map([ (0, text(&j.name)), (1, text(&j.sandbox)), (2, texts(&j.scope)), (3, texts(&j.depends_on)), // Environment as sorted [name, value] pairs: the profile has integer map keys only. (4, Value::Array(j.env.iter().map(|(k, v)| Value::Array(vec![text(k), text(v)])).collect())), (5, texts(&j.exec)), ]) } impl Pipeline { /// The canonical CBOR job graph: sandboxes and jobs in name order. pub fn canonical(&self) -> Vec { encode(&Value::map([ (0, Value::Uint(self.schema_version as u64)), (1, Value::Array(self.sandboxes.values().map(sandbox_value).collect())), (2, Value::Array(self.jobs.values().map(job_value).collect())), ])) } /// `pipeline_hash` of receipts and provenance. pub fn hash(&self) -> Hash { object_hash("pipeline", &self.canonical()) } } impl Job { /// `Hash(J)` of spec §6.2: the job with its sandbox, so a change to /// either is a different job. pub fn hash(&self, sandbox: &Sandbox) -> Hash { object_hash("job", &encode(&Value::Array(vec![job_value(self), sandbox_value(sandbox)]))) } } /// BLAKE3 tree hash of the files a scope selects (spec §6.2): sorted /// `(path, content hash)` pairs. An empty scope selects every file. pub fn scope_tree_hash<'a>(scope: &[String], files: impl IntoIterator) -> Hash { let set = { let mut b = globset::GlobSetBuilder::new(); for g in scope { if let Ok(g) = globset::Glob::new(g) { b.add(g); } } b.build().unwrap_or_else(|_| globset::GlobSet::empty()) }; let mut picked: Vec<(&str, Hash)> = files.into_iter().filter(|(p, _)| scope.is_empty() || set.is_match(p)).collect(); picked.sort(); let body = encode(&Value::Array(picked.iter().map(|(p, h)| Value::Array(vec![text(p), Value::Bytes(h.to_vec())])).collect())); object_hash("scope", &body) }