OXYGENHUB
◆ push-uses-login oxygenflow / crates / flow-core / src / canonical.rs
5.1 KiB · Rust · last changed Report outputs: a directory with report.toml, uploaded file by file with a manifest 25 days ago source blame history raw
e0918b88 Andy McCall 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.
13
pub fn object_hash(ty: &str, body: &[u8]) -> Hash {
14
    let mut h = blake3::Hasher::new();
15
    h.update(format!("oxy1 {ty} {}\0", body.len()).as_bytes());
16
    h.update(body);
17
    *h.finalize().as_bytes()
18
}
19
20
fn text(s: &str) -> Value {
21
    Value::Text(s.to_string())
22
}
23
24
fn texts(v: &[String]) -> Value {
25
    Value::Array(v.iter().map(|s| text(s)).collect())
26
}
27
28
fn hex32(s: &str) -> Value {
29
    Value::Bytes(hex::decode(s).unwrap_or_default())
30
}
31
32
pub fn sandbox_value(s: &Sandbox) -> Value {
a72abeee Andy McCall ↶ 33
    let mut fields = vec![
e0918b88 Andy McCall 34
        (0, text(&s.name)),
35
        (1, text(s.runtime.as_str())),
36
        (2, hex32(&s.rootfs)),
37
        (3, Value::Uint(s.memory_mib as u64)),
38
        (4, Value::Uint(s.cpus as u64)),
39
        (5, text(s.network.as_str())),
a72abeee Andy McCall ↶ 40
    ];
41
    // Environment as sorted [name, value] pairs; absent when empty, so
42
    // sandboxes without one hash as before.
43
    if !s.env.is_empty() {
44
        fields.push((6, Value::Array(s.env.iter().map(|(k, v)| Value::Array(vec![text(k), text(v)])).collect())));
45
    }
1abadd4a Andy McCall ↶ 46
    // The architecture asked for; absent when the image decides.
47
    if let Some(a) = &s.arch {
48
        fields.push((7, text(a)));
49
    }
a72abeee Andy McCall ↶ 50
    Value::map(fields)
e0918b88 Andy McCall 51
}
52
53
pub fn job_value(j: &Job) -> Value {
fc10d9e8 Andy McCall ↶ 54
    let mut fields = vec![
e0918b88 Andy McCall 55
        (0, text(&j.name)),
56
        (1, text(&j.sandbox)),
57
        (2, texts(&j.scope)),
58
        (3, texts(&j.depends_on)),
59
        // Environment as sorted [name, value] pairs: the profile has integer map keys only.
60
        (4, Value::Array(j.env.iter().map(|(k, v)| Value::Array(vec![text(k), text(v)])).collect())),
61
        (5, texts(&j.exec)),
fc10d9e8 Andy McCall ↶ 62
    ];
63
    // Mounts as [source, target, size MiB] sorted by target; absent when
64
    // there are none, so jobs without mounts hash as before.
65
    if !j.mounts.is_empty() {
66
        fields.push((
67
            6,
68
            Value::Array(
69
                j.mounts
70
                    .iter()
71
                    .map(|m| Value::Array(vec![text(m.source.as_str()), text(&m.target), Value::Uint(m.size_mib as u64)]))
72
                    .collect(),
73
            ),
74
        ));
75
    }
6d53fb7d Andy McCall ↶ 76
    // Outputs as [name, path] sorted by name, a report's with "report"
77
    // after them; absent when there are none.
a67b97e9 Andy McCall ↶ 78
    if !j.outputs.is_empty() {
6d53fb7d Andy McCall ↶ 79
        let output = |o: &crate::pipeline::Output| {
80
            let mut v = vec![text(&o.name), text(&o.path)];
81
            if !o.kind.is_artifact() {
82
                v.push(text(o.kind.word()));
83
            }
84
            Value::Array(v)
85
        };
86
        fields.push((7, Value::Array(j.outputs.iter().map(output).collect())));
a67b97e9 Andy McCall ↶ 87
    }
e2c12561 Andy McCall ↶ 88
    // Secrets as [name, mount path] sorted by name; absent when there are none.
89
    if !j.secrets.is_empty() {
90
        fields.push((8, Value::Array(j.secrets.iter().map(|s| Value::Array(vec![text(&s.name), text(&s.mount_path)])).collect())));
91
    }
720eb819 Andy McCall ↶ 92
    // Identities as [audience, mount path] sorted by audience; absent when none.
93
    if !j.identities.is_empty() {
94
        fields.push((9, Value::Array(j.identities.iter().map(|i| Value::Array(vec![text(&i.audience), text(&i.mount_path)])).collect())));
95
    }
fc10d9e8 Andy McCall ↶ 96
    Value::map(fields)
e0918b88 Andy McCall 97
}
98
99
impl Pipeline {
100
    /// The canonical CBOR job graph: sandboxes and jobs in name order.
101
    pub fn canonical(&self) -> Vec<u8> {
102
        encode(&Value::map([
103
            (0, Value::Uint(self.schema_version as u64)),
104
            (1, Value::Array(self.sandboxes.values().map(sandbox_value).collect())),
105
            (2, Value::Array(self.jobs.values().map(job_value).collect())),
106
        ]))
107
    }
108
109
    /// `pipeline_hash` of receipts and provenance.
110
    pub fn hash(&self) -> Hash {
111
        object_hash("pipeline", &self.canonical())
112
    }
113
}
114
115
impl Job {
116
    /// `Hash(J)` of spec §6.2: the job with its sandbox, so a change to
117
    /// either is a different job.
118
    pub fn hash(&self, sandbox: &Sandbox) -> Hash {
119
        object_hash("job", &encode(&Value::Array(vec![job_value(self), sandbox_value(sandbox)])))
120
    }
121
}
122
123
/// BLAKE3 tree hash of the files a scope selects (spec §6.2): sorted
124
/// `(path, content hash)` pairs. An empty scope selects every file.
125
pub fn scope_tree_hash<'a>(scope: &[String], files: impl IntoIterator<Item = (&'a str, Hash)>) -> Hash {
126
    let set = {
127
        let mut b = globset::GlobSetBuilder::new();
128
        for g in scope {
129
            if let Ok(g) = globset::Glob::new(g) {
130
                b.add(g);
131
            }
132
        }
133
        b.build().unwrap_or_else(|_| globset::GlobSet::empty())
134
    };
135
    let mut picked: Vec<(&str, Hash)> = files.into_iter().filter(|(p, _)| scope.is_empty() || set.is_match(p)).collect();
136
    picked.sort();
137
    let body = encode(&Value::Array(picked.iter().map(|(p, h)| Value::Array(vec![text(p), Value::Bytes(h.to_vec())])).collect()));
138
    object_hash("scope", &body)
139
}