OXYGENHUB

Coverage / crates/flow-core/src/canonical.rs 99.2%

121 of 122 lines run, 24 of 52 functions · run #178 on main · run missed

1
//! The canonical job graph (spec §3.2) and object hashes. Pipelines,
2
//! jobs and receipts are encoded with Oxygen's deterministic CBOR profile
3
//! (Oxygen spec §5.1: unsigned integer map keys in order) and hashed the
4
//! way Oxygen hashes objects: `BLAKE3("oxy1 " || type || " " || len || 0x00 || body)`.
5
6
use crate::pipeline::{Job, Pipeline, Sandbox};
7
use oxy_core::cbor::{encode, Value};
8
9
/// A 32-byte object reference, shown as hex.
10
pub type Hash = [u8; 32];
11
12
/// Oxygen's object hash construction for a Flow object type.
1347×
pub fn object_hash(ty: &str, body: &[u8]) -> Hash {
1447×
    let mut h = blake3::Hasher::new();
1547×
    h.update(format!("oxy1 {ty} {}\0", body.len()).as_bytes());
1647×
    h.update(body);
1747×
    *h.finalize().as_bytes()
1847×
}
19
20386×
fn text(s: &str) -> Value {
21386×
    Value::Text(s.to_string())
22386×
}
23
24155×
fn texts(v: &[String]) -> Value {
25155×
    Value::Array(v.iter().map(|s| text(s)).collect())
26155×
}
27
2842×
fn hex32(s: &str) -> Value {
2942×
    Value::Bytes(hex::decode(s).unwrap_or_default())
3042×
}
31
3242×
pub fn sandbox_value(s: &Sandbox) -> Value {
3342×
    let mut fields = vec![
3442×
        (0, text(&s.name)),
3542×
        (1, text(s.runtime.as_str())),
3642×
        (2, hex32(&s.rootfs)),
3742×
        (3, Value::Uint(s.memory_mib as u64)),
3842×
        (4, Value::Uint(s.cpus as u64)),
3942×
        (5, text(s.network.as_str())),
40
    ];
41
    // Environment as sorted [name, value] pairs; absent when empty, so
42
    // sandboxes without one hash as before.
4342×
    if !s.env.is_empty() {
442×
        fields.push((6, Value::Array(s.env.iter().map(|(k, v)| Value::Array(vec![text(k), text(v)])).collect())));
4541×
    }
46
    // The architecture asked for; absent when the image decides.
4742×
    if let Some(a) = &s.arch {
481×
        fields.push((7, text(a)));
4941×
    }
50
    // The hosts egress may reach, sorted; absent without egress.
5142×
    if !s.egress_allow.is_empty() {
522×
        fields.push((8, texts(&s.egress_allow)));
5340×
    }
5442×
    Value::map(fields)
5542×
}
56
5751×
pub fn job_value(j: &Job) -> Value {
5851×
    let mut fields = vec![
5951×
        (0, text(&j.name)),
6051×
        (1, text(&j.sandbox)),
6151×
        (2, texts(&j.scope)),
6251×
        (3, texts(&j.depends_on)),
63
        // Environment as sorted [name, value] pairs: the profile has integer map keys only.
6451×
        (4, Value::Array(j.env.iter().map(|(k, v)| Value::Array(vec![text(k), text(v)])).collect())),
6551×
        (5, texts(&j.exec)),
66
    ];
67
    // Mounts as [source, target, size MiB] sorted by target; absent when
68
    // there are none, so jobs without mounts hash as before.
6951×
    if !j.mounts.is_empty() {
709×
        fields.push((
71
            6,
72
            Value::Array(
739×
                j.mounts
749×
                    .iter()
7514×
                    .map(|m| {
76
                        // A cache mount adds its name and key files; a plain
77
                        // one encodes as before caches existed.
7814×
                        let mut v = vec![text(m.source.as_str()), text(&m.target), Value::Uint(m.size_mib as u64)];
7914×
                        if let Some(c) = &m.cache {
803×
                            v.push(text(c));
814×
                            v.push(Value::Array(m.key.iter().map(|k| text(k)).collect()));
8211×
                        }
8314×
                        Value::Array(v)
8414×
                    })
859×
                    .collect(),
86
            ),
87
        ));
8842×
    }
89
    // Outputs as [name, path] sorted by name, a report's with "report"
90
    // after them; absent when there are none.
9151×
    if !j.outputs.is_empty() {
928×
        let output = |o: &crate::pipeline::Output| {
938×
            let mut v = vec![text(&o.name), text(&o.path)];
948×
            if !o.kind.is_artifact() {
953×
                v.push(text(o.kind.word()));
965×
            }
978×
            Value::Array(v)
988×
        };
995×
        fields.push((7, Value::Array(j.outputs.iter().map(output).collect())));
10046×
    }
101
    // Secrets as [name, mount path] sorted by name; absent when there are none.
10251×
    if !j.secrets.is_empty() {
1033×
        fields.push((8, Value::Array(j.secrets.iter().map(|s| Value::Array(vec![text(&s.name), text(&s.mount_path)])).collect())));
10449×
    }
105
    // Identities as [audience, mount path] sorted by audience; absent when none.
10651×
    if !j.identities.is_empty() {
1072×
        fields.push((9, Value::Array(j.identities.iter().map(|i| Value::Array(vec![text(&i.audience), text(&i.mount_path)])).collect())));
10850×
    }
109
    // Inputs as [artifact, from, path] sorted by path; absent when none.
110
    // Approval as [by, others-only, expires seconds]; absent without one.
11151×
    if let Some(a) = &j.approval {
1124×
        fields.push((11, Value::Array(vec![text(&a.by), Value::Bool(a.others_only), Value::Uint(a.expires_s), Value::Bool(a.sealed)])));
11347×
    }
114
    // The environment it deploys to; absent without one.
11551×
    if let Some(e) = &j.environment {
1161×
        fields.push((12, text(e)));
11750×
    }
11851×
    if !j.inputs.is_empty() {
1191×
        fields.push((
120
            10,
1211×
            Value::Array(j.inputs.iter().map(|i| Value::Array(vec![text(&i.artifact), text(&i.from), text(&i.path)])).collect()),
122
        ));
12350×
    }
12451×
    Value::map(fields)
12551×
}
126
127
impl Pipeline {
128
    /// The canonical CBOR job graph: sandboxes and jobs in name order.
12934×
    pub fn canonical(&self) -> Vec<u8> {
13034×
        encode(&Value::map([
13134×
            (0, Value::Uint(self.schema_version as u64)),
13234×
            (1, Value::Array(self.sandboxes.values().map(sandbox_value).collect())),
13334×
            (2, Value::Array(self.jobs.values().map(job_value).collect())),
13434×
        ]))
13534×
    }
136
137
    /// `pipeline_hash` of receipts and provenance.
13830×
    pub fn hash(&self) -> Hash {
13930×
        object_hash("pipeline", &self.canonical())
14030×
    }
141
}
142
143
impl Job {
144
    /// `Hash(J)` of spec §6.2: the job with its sandbox, so a change to
145
    /// either is a different job.
1468×
    pub fn hash(&self, sandbox: &Sandbox) -> Hash {
1478×
        object_hash("job", &encode(&Value::Array(vec![job_value(self), sandbox_value(sandbox)])))
1488×
    }
149
}
150
151
/// BLAKE3 tree hash of the files a scope selects (spec §6.2): sorted
152
/// `(path, content hash)` pairs. An empty scope selects every file.
1535×
pub fn scope_tree_hash<'a>(scope: &[String], files: impl IntoIterator<Item = (&'a str, Hash)>) -> Hash {
1545×
    let set = {
1555×
        let mut b = globset::GlobSetBuilder::new();
1565×
        for g in scope {
1573×
            if let Ok(g) = globset::Glob::new(g) {
1583×
                b.add(g);
1593×
            }
160
        }
1615×
        b.build().unwrap_or_else(|_| globset::GlobSet::empty())
162
    };
1638×
    let mut picked: Vec<(&str, Hash)> = files.into_iter().filter(|(p, _)| scope.is_empty() || set.is_match(p)).collect();
1645×
    picked.sort();
1655×
    let body = encode(&Value::Array(picked.iter().map(|(p, h)| Value::Array(vec![text(p), Value::Bytes(h.to_vec())])).collect()));
1665×
    object_hash("scope", &body)
1675×
}