OXYGENHUB

Coverage / crates/oxy-core/src/artifact.rs 94.1%

177 of 188 lines run, 28 of 66 functions · run #427 on main · run missed

1
//! Artifacts (Cylinder, docs/cylinder.md §4.1, oxygen#108): an immutable
2
//! object naming a tree of files, each an ordinary blob of FastCDC BLAKE3
3
//! chunks, so an image rebuilt with one changed file moves only that file's
4
//! changed chunks, and a binary in an archive and in an image is the same
5
//! chunks. An artifact is an object of its own (`artifact`) in canonical
6
//! CBOR; its tree is a blob. The `cylinder` crate packs, unpacks and keeps
7
//! them.
8
9
use crate::cbor::{self, Value};
10
use crate::hash::{hash_object, ObjectRef, ObjectType};
11
use crate::objects::{schema, ObjectError};
12
use std::collections::BTreeMap;
13
14
type R<T> = std::result::Result<T, ObjectError>;
15
16
/// The encoding's version this crate writes and reads.
17
pub const VERSION: u64 = 1;
18
19
/// What an artifact is for.
20
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
21
pub enum Kind {
22
    Container,
23
    RunnerImage,
24
    Package,
25
    Archive,
26
    Report,
27
}
28
29
impl Kind {
30
    pub const ALL: [Kind; 5] = [
31
        Kind::Container,
32
        Kind::RunnerImage,
33
        Kind::Package,
34
        Kind::Archive,
35
        Kind::Report,
36
    ];
37
3861×
    pub fn as_str(self) -> &'static str {
3961×
        match self {
4014×
            Kind::Container => "container",
4116×
            Kind::RunnerImage => "runner-image",
4216×
            Kind::Package => "package",
4311×
            Kind::Archive => "archive",
444×
            Kind::Report => "report",
45
        }
4661×
    }
47
4812×
    pub fn parse(s: &str) -> R<Kind> {
4943×
        Kind::ALL.into_iter().find(|k| k.as_str() == s).map_or_else(
502×
            || {
512×
                schema(format!(
52
                    "{s:?} is not a kind of artifact: {}",
5310×
                    Kind::ALL.map(|k| k.as_str()).join(", ")
54
                ))
552×
            },
56
            Ok,
57
        )
5812×
    }
59
}
60
61
/// A file's mode in a tree: a regular file, an executable one, or a
62
/// symbolic link (whose blob is its target).
63
pub const MODE_FILE: u32 = 0o100644;
64
pub const MODE_EXEC: u32 = 0o100755;
65
pub const MODE_LINK: u32 = 0o120000;
66
67
/// A file of an artifact's tree.
68
#[derive(Clone, Debug, PartialEq, Eq)]
69
pub struct Entry {
70
    /// `/`-separated, relative, never `.` or `..` parts.
71
    pub path: String,
72
    pub mode: u32,
73
    pub blob: ObjectRef,
74
    pub size: u64,
75
}
76
77
/// An artifact's files, in path order: paths, modes, blobs and sizes.
78
#[derive(Clone, Debug, Default, PartialEq, Eq)]
79
pub struct DirTree {
80
    pub entries: Vec<Entry>,
81
}
82
8330×
fn path_ok(p: &str) -> bool {
8430×
    !p.is_empty()
8530×
        && !p.starts_with('/')
8640×
        && p.split('/').all(|s| !s.is_empty() && s != "." && s != "..")
8730×
}
88
8917×
fn obj(r: &ObjectRef) -> Value {
9017×
    Value::Bytes(r.0.to_vec())
9117×
}
92
9337×
fn get_obj(v: &Value, what: &str) -> R<ObjectRef> {
9437×
    match v.as_bytes() {
9537×
        Some(b) if b.len() == 32 => {
9637×
            let mut a = [0u8; 32];
9737×
            a.copy_from_slice(b);
9837×
            Ok(ObjectRef(a))
99
        }
1000×
        _ => schema(format!("{what} is not an object reference")),
101
    }
10237×
}
103
10468×
fn get_text(v: &Value, what: &str) -> R<String> {
10568×
    v.as_text()
10668×
        .map(str::to_string)
10768×
        .map_or_else(|| schema(format!("{what} is not text")), Ok)
10868×
}
109
11066×
fn get_uint(v: &Value, what: &str) -> R<u64> {
11166×
    v.as_uint()
11266×
        .map_or_else(|| schema(format!("{what} is not a number")), Ok)
11366×
}
114
115
impl DirTree {
1164×
    pub fn encode(&self) -> Vec<u8> {
1174×
        let entries = self
1184×
            .entries
1194×
            .iter()
1209×
            .map(|e| {
1219×
                Value::Array(vec![
1229×
                    Value::Text(e.path.clone()),
1239×
                    Value::Uint(e.mode as u64),
1249×
                    obj(&e.blob),
1259×
                    Value::Uint(e.size),
1269×
                ])
1279×
            })
1284×
            .collect();
1294×
        cbor::encode(&Value::map([
1304×
            (0, Value::Uint(VERSION)),
1314×
            (1, Value::Array(entries)),
1324×
        ]))
1334×
    }
134
135
    /// Read and check a tree: a known version, clean paths, each once, in
136
    /// order, with a known mode.
13714×
    pub fn decode(bytes: &[u8]) -> R<DirTree> {
13814×
        let v = cbor::decode(bytes)?;
13914×
        let m = v.as_map().map_or_else(|| schema("a tree is a map"), Ok)?;
14014×
        if m.get(&0).and_then(Value::as_uint) != Some(VERSION) {
1410×
            return schema(format!(
142
                "tree version {:?} is not known; this reads {VERSION}",
1430×
                m.get(&0)
144
            ));
14514×
        }
14614×
        let mut entries = Vec::new();
14730×
        for item in m.get(&1).and_then(Value::as_array).unwrap_or_default() {
14830×
            let [p, mode, blob, size] = item.as_array().unwrap_or_default() else {
1490×
                return schema("a tree entry is [path, mode, blob, size]");
150
            };
15130×
            let path = get_text(p, "a path")?;
15230×
            let mode = get_uint(mode, "a mode")? as u32;
15330×
            if !path_ok(&path) {
1541×
                return schema(format!("{path:?} is not a clean relative path"));
15529×
            }
15629×
            if ![MODE_FILE, MODE_EXEC, MODE_LINK].contains(&mode) {
1571×
                return schema(format!(
158
                    "{path}: mode {mode:o} is not a file, an executable or a link"
159
                ));
16028×
            }
16128×
            if entries.last().is_some_and(|l: &Entry| l.path >= path) {
1621×
                return schema(format!(
163
                    "{path}: entries are not in path order, or one is there twice"
164
                ));
16527×
            }
16627×
            entries.push(Entry {
16727×
                path,
16827×
                mode,
16927×
                blob: get_obj(blob, "a blob")?,
17027×
                size: get_uint(size, "a size")?,
171
            });
172
        }
17311×
        Ok(DirTree { entries })
17414×
    }
175
}
176
177
/// An artifact (docs/cylinder.md §4.1).
178
#[derive(Clone, Debug, PartialEq, Eq)]
179
pub struct Artifact {
180
    pub kind: Kind,
181
    /// `x86_64`, `riscv64`, `aarch64`.
182
    pub arch: Option<String>,
183
    /// `linux`, `darwin`, `none`.
184
    pub os: Option<String>,
185
    /// The [`DirTree`]'s blob.
186
    pub tree: ObjectRef,
187
    /// The default command, for a container or an image.
188
    pub entrypoint: Option<Vec<String>>,
189
    pub env: BTreeMap<String, String>,
190
    pub labels: BTreeMap<String, String>,
191
    /// The files' bytes together.
192
    pub total_size: u64,
193
    /// The receipt of the Flow job that built it, when one did.
194
    pub receipt: Option<ObjectRef>,
195
}
196
19712×
fn pairs(m: &BTreeMap<String, String>) -> Value {
19812×
    if m.is_empty() {
1996×
        return Value::Null;
2006×
    }
201
    Value::Array(
2026×
        m.iter()
2036×
            .map(|(k, v)| Value::Array(vec![Value::Text(k.clone()), Value::Text(v.clone())]))
2046×
            .collect(),
205
    )
20612×
}
207
20818×
fn get_pairs(v: Option<&Value>, what: &str) -> R<BTreeMap<String, String>> {
20918×
    let mut out = BTreeMap::new();
21018×
    let Some(v) = v.filter(|v| !v.is_null()) else {
2119×
        return Ok(out);
212
    };
2139×
    for p in v
2149×
        .as_array()
2159×
        .map_or_else(|| schema(format!("{what} is a list of pairs")), Ok)?
216
    {
2179×
        let [k, val] = p.as_array().unwrap_or_default() else {
2180×
            return schema(format!("{what}: a pair is [key, value]"));
219
        };
2209×
        let k = get_text(k, what)?;
2219×
        if out.insert(k.clone(), get_text(val, what)?).is_some() {
2220×
            return schema(format!("{what}: {k} is there twice"));
2239×
        }
224
    }
2259×
    Ok(out)
22618×
}
227
22812×
fn opt_text(v: &Option<String>) -> Value {
22912×
    v.as_ref().map_or(Value::Null, |s| Value::Text(s.clone()))
23012×
}
231
232
impl Artifact {
233
    /// Its canonical CBOR, keys as in docs/cylinder.md §4.1.
2346×
    pub fn encode(&self) -> Vec<u8> {
2356×
        let entry = self.entrypoint.as_ref().map_or(Value::Null, |a| {
2362×
            Value::Array(a.iter().cloned().map(Value::Text).collect())
2372×
        });
2386×
        cbor::encode(&Value::map([
2396×
            (0, Value::Uint(VERSION)),
2406×
            (1, Value::Text(self.kind.as_str().into())),
2416×
            (2, opt_text(&self.arch)),
2426×
            (3, opt_text(&self.os)),
2436×
            (4, obj(&self.tree)),
2446×
            (5, entry),
2456×
            (6, pairs(&self.env)),
2466×
            (7, pairs(&self.labels)),
2476×
            (8, Value::Uint(self.total_size)),
2486×
            (9, self.receipt.as_ref().map_or(Value::Null, obj)),
2496×
        ]))
2506×
    }
251
25211×
    pub fn decode(bytes: &[u8]) -> R<Artifact> {
25311×
        let v = cbor::decode(bytes)?;
25411×
        let m = v
25511×
            .as_map()
25611×
            .map_or_else(|| schema("an artifact is a map"), Ok)?;
25711×
        if m.get(&0).and_then(Value::as_uint) != Some(VERSION) {
2581×
            return schema(format!(
259
                "artifact version {:?} is not known; this reads {VERSION}",
2601×
                m.get(&0)
261
            ));
26210×
        }
26392×
        if let Some(k) = m.keys().find(|k| **k > 9) {
2641×
            return schema(format!("key {k} is not part of an artifact"));
2659×
        }
26627×
        let text = |k: u64, what: &str| -> R<Option<String>> {
26727×
            match m.get(&k) {
2688×
                None | Some(Value::Null) => Ok(None),
26919×
                Some(v) => get_text(v, what).map(Some),
270
            }
27127×
        };
2729×
        let entrypoint = match m.get(&5) {
2738×
            None | Some(Value::Null) => None,
2741×
            Some(v) => Some(
2751×
                v.as_array()
2761×
                    .unwrap_or_default()
2771×
                    .iter()
2781×
                    .map(|a| get_text(a, "the entrypoint"))
2791×
                    .collect::<R<_>>()?,
280
            ),
281
        };
2829×
        let receipt = match m.get(&9) {
2838×
            None | Some(Value::Null) => None,
2841×
            Some(v) => Some(get_obj(v, "the receipt")?),
285
        };
286
        Ok(Artifact {
2879×
            kind: Kind::parse(&text(1, "the kind")?.unwrap_or_default())?,
2889×
            arch: text(2, "the architecture")?,
2899×
            os: text(3, "the system")?,
2909×
            tree: get_obj(m.get(&4).unwrap_or(&Value::Null), "the tree")?,
2919×
            entrypoint,
2929×
            env: get_pairs(m.get(&6), "env")?,
2939×
            labels: get_pairs(m.get(&7), "labels")?,
2949×
            total_size: get_uint(m.get(&8).unwrap_or(&Value::Null), "the size")?,
2959×
            receipt,
296
        })
29711×
    }
298
299
    /// Its name: its object's hash, as every Oxygen object is named.
3002×
    pub fn name(&self) -> ObjectRef {
3012×
        hash_object(ObjectType::Artifact, &self.encode())
3022×
    }
303
}