OXYGENHUB

Coverage / crates/cylinder/src/lib.rs 94.5%

451 of 477 lines run, 94 of 132 functions · run #406 on main · run missed

1
//! Cylinder, step C1 (docs/cylinder.md §4.1): an **artifact** is an
2
//! immutable, content-addressed object naming a tree of files, each kept as
3
//! an ordinary Oxygen blob of FastCDC BLAKE3 chunks. An image rebuilt with
4
//! one changed file moves only that file's changed chunks, and a binary in
5
//! a release archive and in a runner image is the same chunks.
6
//!
7
//! An artifact is its canonical CBOR (spec §5.1), named by the BLAKE3 of
8
//! that encoding. Until the artifact is an object type of its own (the rest
9
//! of C1), the encoding and its tree are kept as blobs in the repository's
10
//! store, and `.oxy/artifacts/NAME` holds the encoding's blob so the
11
//! artifact can be found and garbage collection keeps it ([`roots`]).
12
13
use oxy_core::cbor::{self, Value};
14
use oxy_core::hash::ObjectRef;
15
use oxy_core::repo::Repo;
16
use std::collections::BTreeMap;
17
use std::path::{Path, PathBuf};
18
19
/// The encoding's version this crate writes and reads.
20
pub const VERSION: u64 = 1;
21
22
#[derive(Debug, thiserror::Error)]
23
pub enum Error {
24
    #[error(transparent)]
25
    Repo(#[from] oxy_core::repo::RepoError),
26
    #[error("io: {0}")]
27
    Io(#[from] std::io::Error),
28
    #[error("not a canonical artifact encoding: {0}")]
29
    Cbor(#[from] oxy_core::cbor::CborError),
30
    #[error("{0}")]
31
    Bad(String),
32
}
33
34
pub type Result<T> = std::result::Result<T, Error>;
35
368×
fn bad<T>(msg: impl Into<String>) -> Result<T> {
378×
    Err(Error::Bad(msg.into()))
388×
}
39
40
/// What an artifact is for.
41
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
42
pub enum Kind {
43
    Container,
44
    RunnerImage,
45
    Package,
46
    Archive,
47
    Report,
48
}
49
50
impl Kind {
51
    pub const ALL: [Kind; 5] = [
52
        Kind::Container,
53
        Kind::RunnerImage,
54
        Kind::Package,
55
        Kind::Archive,
56
        Kind::Report,
57
    ];
58
5957×
    pub fn as_str(self) -> &'static str {
6057×
        match self {
6113×
            Kind::Container => "container",
6215×
            Kind::RunnerImage => "runner-image",
6315×
            Kind::Package => "package",
6410×
            Kind::Archive => "archive",
654×
            Kind::Report => "report",
66
        }
6757×
    }
68
6911×
    pub fn parse(s: &str) -> Result<Kind> {
7039×
        Kind::ALL.into_iter().find(|k| k.as_str() == s).map_or_else(
712×
            || {
722×
                bad(format!(
73
                    "{s:?} is not a kind of artifact: {}",
7410×
                    Kind::ALL.map(|k| k.as_str()).join(", ")
75
                ))
762×
            },
77
            Ok,
78
        )
7911×
    }
80
}
81
82
/// A file's mode in a tree: a regular file, an executable one, or a
83
/// symbolic link (whose blob is its target).
84
pub const MODE_FILE: u32 = 0o100644;
85
pub const MODE_EXEC: u32 = 0o100755;
86
pub const MODE_LINK: u32 = 0o120000;
87
88
/// A file of an artifact's tree.
89
#[derive(Clone, Debug, PartialEq, Eq)]
90
pub struct Entry {
91
    /// `/`-separated, relative, never `.` or `..` parts.
92
    pub path: String,
93
    pub mode: u32,
94
    pub blob: ObjectRef,
95
    pub size: u64,
96
}
97
98
/// An artifact's files, in path order: paths, modes, blobs and sizes.
99
#[derive(Clone, Debug, Default, PartialEq, Eq)]
100
pub struct DirTree {
101
    pub entries: Vec<Entry>,
102
}
103
10430×
fn path_ok(p: &str) -> bool {
10530×
    !p.is_empty()
10630×
        && !p.starts_with('/')
10740×
        && p.split('/').all(|s| !s.is_empty() && s != "." && s != "..")
10830×
}
109
11017×
fn obj(r: &ObjectRef) -> Value {
11117×
    Value::Bytes(r.0.to_vec())
11217×
}
113
11436×
fn get_obj(v: &Value, what: &str) -> Result<ObjectRef> {
11536×
    match v.as_bytes() {
11636×
        Some(b) if b.len() == 32 => {
11736×
            let mut a = [0u8; 32];
11836×
            a.copy_from_slice(b);
11936×
            Ok(ObjectRef(a))
120
        }
1210×
        _ => bad(format!("{what} is not an object reference")),
122
    }
12336×
}
124
12564×
fn get_text(v: &Value, what: &str) -> Result<String> {
12664×
    v.as_text()
12764×
        .map(str::to_string)
12864×
        .map_or_else(|| bad(format!("{what} is not text")), Ok)
12964×
}
130
13165×
fn get_uint(v: &Value, what: &str) -> Result<u64> {
13265×
    v.as_uint()
13365×
        .map_or_else(|| bad(format!("{what} is not a number")), Ok)
13465×
}
135
136
impl DirTree {
1374×
    pub fn encode(&self) -> Vec<u8> {
1384×
        let entries = self
1394×
            .entries
1404×
            .iter()
1419×
            .map(|e| {
1429×
                Value::Array(vec![
1439×
                    Value::Text(e.path.clone()),
1449×
                    Value::Uint(e.mode as u64),
1459×
                    obj(&e.blob),
1469×
                    Value::Uint(e.size),
1479×
                ])
1489×
            })
1494×
            .collect();
1504×
        cbor::encode(&Value::map([
1514×
            (0, Value::Uint(VERSION)),
1524×
            (1, Value::Array(entries)),
1534×
        ]))
1544×
    }
155
156
    /// Read and check a tree: a known version, clean paths, each once, in
157
    /// order, with a known mode.
15814×
    pub fn decode(bytes: &[u8]) -> Result<DirTree> {
15914×
        let v = cbor::decode(bytes)?;
16014×
        let m = v.as_map().map_or_else(|| bad("a tree is a map"), Ok)?;
16114×
        if m.get(&0).and_then(Value::as_uint) != Some(VERSION) {
1620×
            return bad(format!(
163
                "tree version {:?} is not known; this reads {VERSION}",
1640×
                m.get(&0)
165
            ));
16614×
        }
16714×
        let mut entries = Vec::new();
16830×
        for item in m.get(&1).and_then(Value::as_array).unwrap_or_default() {
16930×
            let [p, mode, blob, size] = item.as_array().unwrap_or_default() else {
1700×
                return bad("a tree entry is [path, mode, blob, size]");
171
            };
17230×
            let path = get_text(p, "a path")?;
17330×
            let mode = get_uint(mode, "a mode")? as u32;
17430×
            if !path_ok(&path) {
1751×
                return bad(format!("{path:?} is not a clean relative path"));
17629×
            }
17729×
            if ![MODE_FILE, MODE_EXEC, MODE_LINK].contains(&mode) {
1781×
                return bad(format!(
179
                    "{path}: mode {mode:o} is not a file, an executable or a link"
180
                ));
18128×
            }
18228×
            if entries.last().is_some_and(|l: &Entry| l.path >= path) {
1831×
                return bad(format!(
184
                    "{path}: entries are not in path order, or one is there twice"
185
                ));
18627×
            }
18727×
            entries.push(Entry {
18827×
                path,
18927×
                mode,
19027×
                blob: get_obj(blob, "a blob")?,
19127×
                size: get_uint(size, "a size")?,
192
            });
193
        }
19411×
        Ok(DirTree { entries })
19514×
    }
196
}
197
198
/// An artifact (docs/cylinder.md §4.1).
199
#[derive(Clone, Debug, PartialEq, Eq)]
200
pub struct Artifact {
201
    pub kind: Kind,
202
    /// `x86_64`, `riscv64`, `aarch64`.
203
    pub arch: Option<String>,
204
    /// `linux`, `darwin`, `none`.
205
    pub os: Option<String>,
206
    /// The [`DirTree`]'s blob.
207
    pub tree: ObjectRef,
208
    /// The default command, for a container or an image.
209
    pub entrypoint: Option<Vec<String>>,
210
    pub env: BTreeMap<String, String>,
211
    pub labels: BTreeMap<String, String>,
212
    /// The files' bytes together.
213
    pub total_size: u64,
214
    /// The receipt of the Flow job that built it, when one did.
215
    pub receipt: Option<ObjectRef>,
216
}
217
21812×
fn pairs(m: &BTreeMap<String, String>) -> Value {
21912×
    if m.is_empty() {
2206×
        return Value::Null;
2216×
    }
222
    Value::Array(
2236×
        m.iter()
2246×
            .map(|(k, v)| Value::Array(vec![Value::Text(k.clone()), Value::Text(v.clone())]))
2256×
            .collect(),
226
    )
22712×
}
228
22916×
fn get_pairs(v: Option<&Value>, what: &str) -> Result<BTreeMap<String, String>> {
23016×
    let mut out = BTreeMap::new();
23116×
    let Some(v) = v.filter(|v| !v.is_null()) else {
2328×
        return Ok(out);
233
    };
2348×
    for p in v
2358×
        .as_array()
2368×
        .map_or_else(|| bad(format!("{what} is a list of pairs")), Ok)?
237
    {
2388×
        let [k, val] = p.as_array().unwrap_or_default() else {
2390×
            return bad(format!("{what}: a pair is [key, value]"));
240
        };
2418×
        let k = get_text(k, what)?;
2428×
        if out.insert(k.clone(), get_text(val, what)?).is_some() {
2430×
            return bad(format!("{what}: {k} is there twice"));
2448×
        }
245
    }
2468×
    Ok(out)
24716×
}
248
24912×
fn opt_text(v: &Option<String>) -> Value {
25012×
    v.as_ref().map_or(Value::Null, |s| Value::Text(s.clone()))
25112×
}
252
253
impl Artifact {
254
    /// Its canonical CBOR, keys as in docs/cylinder.md §4.1.
2556×
    pub fn encode(&self) -> Vec<u8> {
2566×
        let entry = self.entrypoint.as_ref().map_or(Value::Null, |a| {
2572×
            Value::Array(a.iter().cloned().map(Value::Text).collect())
2582×
        });
2596×
        cbor::encode(&Value::map([
2606×
            (0, Value::Uint(VERSION)),
2616×
            (1, Value::Text(self.kind.as_str().into())),
2626×
            (2, opt_text(&self.arch)),
2636×
            (3, opt_text(&self.os)),
2646×
            (4, obj(&self.tree)),
2656×
            (5, entry),
2666×
            (6, pairs(&self.env)),
2676×
            (7, pairs(&self.labels)),
2686×
            (8, Value::Uint(self.total_size)),
2696×
            (9, self.receipt.as_ref().map_or(Value::Null, obj)),
2706×
        ]))
2716×
    }
272
27310×
    pub fn decode(bytes: &[u8]) -> Result<Artifact> {
27410×
        let v = cbor::decode(bytes)?;
27510×
        let m = v.as_map().map_or_else(|| bad("an artifact is a map"), Ok)?;
27610×
        if m.get(&0).and_then(Value::as_uint) != Some(VERSION) {
2771×
            return bad(format!(
278
                "artifact version {:?} is not known; this reads {VERSION}",
2791×
                m.get(&0)
280
            ));
2819×
        }
28282×
        if let Some(k) = m.keys().find(|k| **k > 9) {
2831×
            return bad(format!("key {k} is not part of an artifact"));
2848×
        }
28524×
        let text = |k: u64, what: &str| -> Result<Option<String>> {
28624×
            match m.get(&k) {
2877×
                None | Some(Value::Null) => Ok(None),
28817×
                Some(v) => get_text(v, what).map(Some),
289
            }
29024×
        };
2918×
        let entrypoint = match m.get(&5) {
2927×
            None | Some(Value::Null) => None,
2931×
            Some(v) => Some(
2941×
                v.as_array()
2951×
                    .unwrap_or_default()
2961×
                    .iter()
2971×
                    .map(|a| get_text(a, "the entrypoint"))
2981×
                    .collect::<Result<_>>()?,
299
            ),
300
        };
3018×
        let receipt = match m.get(&9) {
3027×
            None | Some(Value::Null) => None,
3031×
            Some(v) => Some(get_obj(v, "the receipt")?),
304
        };
305
        Ok(Artifact {
3068×
            kind: Kind::parse(&text(1, "the kind")?.unwrap_or_default())?,
3078×
            arch: text(2, "the architecture")?,
3088×
            os: text(3, "the system")?,
3098×
            tree: get_obj(m.get(&4).unwrap_or(&Value::Null), "the tree")?,
3108×
            entrypoint,
3118×
            env: get_pairs(m.get(&6), "env")?,
3128×
            labels: get_pairs(m.get(&7), "labels")?,
3138×
            total_size: get_uint(m.get(&8).unwrap_or(&Value::Null), "the size")?,
3148×
            receipt,
315
        })
31610×
    }
317
318
    /// Its name: the BLAKE3 of its canonical encoding, hex.
3192×
    pub fn name(&self) -> String {
3202×
        blake3::hash(&self.encode()).to_hex().to_string()
3212×
    }
322
}
323
324
/// What to pack a directory as.
325
#[derive(Clone, Debug)]
326
pub struct Spec {
327
    pub kind: Kind,
328
    pub arch: Option<String>,
329
    pub os: Option<String>,
330
    pub entrypoint: Option<Vec<String>>,
331
    pub env: BTreeMap<String, String>,
332
    pub labels: BTreeMap<String, String>,
333
}
334
335
impl Spec {
3362×
    pub fn new(kind: Kind) -> Spec {
3372×
        Spec {
3382×
            kind,
3392×
            arch: None,
3402×
            os: None,
3412×
            entrypoint: None,
3422×
            env: BTreeMap::new(),
3432×
            labels: BTreeMap::new(),
3442×
        }
3452×
    }
346
}
347
348
/// An artifact kept in a repository, with its name.
349
#[derive(Clone, Debug)]
350
pub struct Kept {
351
    pub name: String,
352
    pub artifact: Artifact,
353
    /// The blob its encoding is kept in.
354
    pub blob: ObjectRef,
355
}
356
35724×
fn kept_dir(repo: &Repo) -> PathBuf {
35824×
    repo.oxy_dir().join("artifacts")
35924×
}
360
361
/// Every file under `dir`, by relative path: regular files and links, in
362
/// path order. `.oxy` and `.git` directories are not part of an artifact.
3633×
fn walk(dir: &Path) -> Result<Vec<(String, PathBuf, std::fs::Metadata)>> {
3643×
    let mut out = Vec::new();
3653×
    let mut stack = vec![(dir.to_path_buf(), String::new())];
3669×
    while let Some((d, rel)) = stack.pop() {
36711×
        for e in std::fs::read_dir(&d)? {
36811×
            let e = e?;
36911×
            let name = e
37011×
                .file_name()
37111×
                .into_string()
37211×
                .map_err(|n| Error::Bad(format!("{n:?} is not UTF-8")))?;
37311×
            let path = if rel.is_empty() {
3748×
                name.clone()
375
            } else {
3763×
                format!("{rel}/{name}")
377
            };
37811×
            let md = std::fs::symlink_metadata(e.path())?;
37911×
            if md.is_dir() {
3803×
                if name != ".oxy" && name != ".git" {
3813×
                    stack.push((e.path(), path));
3823×
                }
3838×
            } else if md.is_file() || md.file_type().is_symlink() {
3848×
                out.push((path, e.path(), md));
3858×
            }
386
        }
387
    }
3887×
    out.sort_by(|a, b| a.0.cmp(&b.0));
3893×
    Ok(out)
3903×
}
391
392
#[cfg(unix)]
3938×
fn mode_of(md: &std::fs::Metadata) -> u32 {
394
    use std::os::unix::fs::PermissionsExt;
3958×
    if md.file_type().is_symlink() {
3962×
        MODE_LINK
3976×
    } else if md.permissions().mode() & 0o111 != 0 {
3982×
        MODE_EXEC
399
    } else {
4004×
        MODE_FILE
401
    }
4028×
}
403
404
#[cfg(not(unix))]
405
fn mode_of(md: &std::fs::Metadata) -> u32 {
406
    if md.file_type().is_symlink() {
407
        MODE_LINK
408
    } else {
409
        MODE_FILE
410
    }
411
}
412
413
/// Pack the files under `dir` into an artifact of `spec`'s kind in
414
/// `repo`'s store: each file a blob, so files and chunks the store holds
415
/// already cost nothing; then its tree and its encoding, kept under its
416
/// name.
4173×
pub fn pack(repo: &Repo, dir: &Path, spec: &Spec) -> Result<Kept> {
4183×
    if !dir.is_dir() {
4190×
        return bad(format!("{} is not a directory", dir.display()));
4203×
    }
4213×
    let mut tree = DirTree::default();
4228×
    for (path, full, md) in walk(dir)? {
4238×
        let mode = mode_of(&md);
4248×
        let bytes = if mode == MODE_LINK {
4252×
            std::fs::read_link(&full)?
4262×
                .to_string_lossy()
4272×
                .into_owned()
4282×
                .into_bytes()
429
        } else {
4306×
            std::fs::read(&full)?
431
        };
4328×
        let blob = repo.store_blob(&bytes)?;
4338×
        tree.entries.push(Entry {
4348×
            path,
4358×
            mode,
4368×
            blob,
4378×
            size: bytes.len() as u64,
4388×
        });
439
    }
4403×
    let artifact = Artifact {
4413×
        kind: spec.kind,
4423×
        arch: spec.arch.clone(),
4433×
        os: spec.os.clone(),
4443×
        tree: repo.store_blob(&tree.encode())?,
4453×
        entrypoint: spec.entrypoint.clone(),
4463×
        env: spec.env.clone(),
4473×
        labels: spec.labels.clone(),
4483×
        total_size: tree.entries.iter().map(|e| e.size).sum(),
4493×
        receipt: None,
450
    };
4513×
    let bytes = artifact.encode();
4523×
    let blob = repo.store_blob(&bytes)?;
4533×
    let name = blake3::hash(&bytes).to_hex().to_string();
4543×
    std::fs::create_dir_all(kept_dir(repo))?;
4553×
    std::fs::write(kept_dir(repo).join(&name), blob.to_hex())?;
4563×
    Ok(Kept {
4573×
        name,
4583×
        artifact,
4593×
        blob,
4603×
    })
4613×
}
462
463
/// The names of the artifacts kept in `repo`, sorted.
46411×
pub fn list(repo: &Repo) -> Result<Vec<String>> {
46511×
    let mut out = Vec::new();
46611×
    match std::fs::read_dir(kept_dir(repo)) {
46711×
        Ok(rd) => {
46811×
            for e in rd {
46911×
                let n = e?.file_name().to_string_lossy().into_owned();
470704×
                if n.len() == 64 && n.bytes().all(|b| b.is_ascii_hexdigit()) {
47111×
                    out.push(n);
47211×
                }
473
            }
474
        }
4750×
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
4760×
        Err(e) => return Err(e.into()),
477
    }
47811×
    out.sort();
47911×
    Ok(out)
48011×
}
481
482
/// The kept artifact `name` names, or a unique prefix of it (at least six
483
/// characters), checked against its name.
4847×
pub fn open(repo: &Repo, name: &str) -> Result<Kept> {
4857×
    let name = name.to_ascii_lowercase();
4867×
    if name.len() < 6 {
4870×
        return bad("name an artifact by at least six characters of its name");
4887×
    }
4897×
    let found: Vec<String> = list(repo)?
4907×
        .into_iter()
4917×
        .filter(|n| n.starts_with(&name))
4927×
        .collect();
4937×
    let full = match found.as_slice() {
4947×
        [one] => one.clone(),
4950×
        [] => {
4960×
            return bad(format!(
497
                "no artifact {name} here; `oxy artifact list` shows them"
498
            ))
499
        }
500
        _ => {
5010×
            return bad(format!(
502
                "{name} names {} artifacts: give more of the name",
5030×
                found.len()
504
            ))
505
        }
506
    };
5077×
    let hex = std::fs::read_to_string(kept_dir(repo).join(&full))?;
5087×
    let blob = ObjectRef::from_hex(hex.trim()).map_or_else(
5090×
        || bad(format!("artifact {full}: its record is damaged")),
510
        Ok,
5110×
    )?;
5127×
    let bytes = repo.read_blob(&blob)?;
5137×
    if blake3::hash(&bytes).to_hex().as_str() != full {
5140×
        return bad(format!(
515
            "artifact {full}: its encoding no longer hashes to its name"
516
        ));
5177×
    }
518
    Ok(Kept {
5197×
        name: full,
5207×
        artifact: Artifact::decode(&bytes)?,
5217×
        blob,
522
    })
5237×
}
524
525
/// An artifact's tree.
52610×
pub fn tree(repo: &Repo, a: &Artifact) -> Result<DirTree> {
52710×
    DirTree::decode(&repo.read_blob(&a.tree)?)
52810×
}
529
530
/// Check that every file of `k` is in the store whole: its bytes read, at
531
/// the size its tree says. Answers how many files were read.
5323×
pub fn verify(repo: &Repo, k: &Kept) -> Result<usize> {
5333×
    let t = tree(repo, &k.artifact)?;
5343×
    let mut total = 0;
5358×
    for e in &t.entries {
5368×
        let n = repo.read_blob(&e.blob)?.len() as u64;
5378×
        if n != e.size {
5380×
            return bad(format!(
539
                "{}: {n} bytes, but the tree says {}",
540
                e.path, e.size
541
            ));
5428×
        }
5438×
        total += n;
544
    }
5453×
    if total != k.artifact.total_size {
5460×
        return bad(format!(
547
            "the files are {total} bytes together, but the artifact says {}",
548
            k.artifact.total_size
549
        ));
5503×
    }
5513×
    Ok(t.entries.len())
5523×
}
553
554
/// Write `k`'s files under `dest`, which must not exist or be empty.
5553×
pub fn unpack(repo: &Repo, k: &Kept, dest: &Path) -> Result<usize> {
5563×
    if dest.exists() && std::fs::read_dir(dest)?.next().is_some() {
5571×
        return bad(format!("{} is not empty", dest.display()));
5582×
    }
5592×
    let t = tree(repo, &k.artifact)?;
5605×
    for e in &t.entries {
5615×
        let out = dest.join(&e.path);
5625×
        if let Some(p) = out.parent() {
5635×
            std::fs::create_dir_all(p)?;
5640×
        }
5655×
        let bytes = repo.read_blob(&e.blob)?;
5665×
        if e.mode == MODE_LINK {
567
            #[cfg(unix)]
5681×
            std::os::unix::fs::symlink(String::from_utf8_lossy(&bytes).as_ref(), &out)?;
569
            #[cfg(not(unix))]
570
            std::fs::write(&out, &bytes)?;
5711×
            continue;
5724×
        }
5734×
        std::fs::write(&out, &bytes)?;
574
        #[cfg(unix)]
5754×
        if e.mode == MODE_EXEC {
576
            use std::os::unix::fs::PermissionsExt;
5771×
            std::fs::set_permissions(&out, std::fs::Permissions::from_mode(0o755))?;
5783×
        }
579
    }
5802×
    Ok(t.entries.len())
5813×
}
582
583
/// The blobs garbage collection must keep for the kept artifacts: each
584
/// one's encoding, its tree and its files (`Repo::gc_keeping`).
5852×
pub fn roots(repo: &Repo) -> Result<Vec<ObjectRef>> {
5862×
    let mut out = Vec::new();
5872×
    for name in list(repo)? {
5882×
        let k = open(repo, &name)?;
5892×
        out.push(k.blob);
5902×
        out.push(k.artifact.tree);
5912×
        out.extend(tree(repo, &k.artifact)?.entries.iter().map(|e| e.blob));
592
    }
5932×
    Ok(out)
5942×
}
595
596
#[cfg(test)]
597
mod tests {
598
    use super::*;
599
6001×
    fn repo(dir: &Path) -> Repo {
6011×
        Repo::init(&dir.join("repo")).unwrap()
6021×
    }
603
604
    #[test]
6051×
    fn a_directory_packs_into_an_artifact_and_back() {
6061×
        let t = tempfile::tempdir().unwrap();
6071×
        let r = repo(t.path());
6081×
        let src = t.path().join("out");
6091×
        std::fs::create_dir_all(src.join("bin")).unwrap();
6101×
        std::fs::write(src.join("README"), b"hello\n").unwrap();
6111×
        std::fs::write(src.join("bin/tool"), vec![7u8; 300_000]).unwrap();
612
        #[cfg(unix)]
613
        {
614
            use std::os::unix::fs::PermissionsExt;
6151×
            std::fs::set_permissions(src.join("bin/tool"), std::fs::Permissions::from_mode(0o755))
6161×
                .unwrap();
6171×
            std::os::unix::fs::symlink("bin/tool", src.join("tool")).unwrap();
618
        }
6191×
        let mut spec = Spec::new(Kind::Archive);
6201×
        spec.arch = Some("x86_64".into());
6211×
        spec.labels.insert("version".into(), "0.1.0".into());
6221×
        let k = pack(&r, &src, &spec).unwrap();
6231×
        assert_eq!(k.name, k.artifact.name());
6241×
        assert_eq!(
625
            k.artifact.total_size,
6261×
            300_006 + if cfg!(unix) { 8 } else { 0 }
627
        );
6281×
        assert_eq!(list(&r).unwrap(), vec![k.name.clone()]);
629
6301×
        let again = open(&r, &k.name[..10]).unwrap();
6311×
        assert_eq!(again.artifact, k.artifact);
6321×
        let paths: Vec<(String, u32)> = tree(&r, &again.artifact)
6331×
            .unwrap()
6341×
            .entries
6351×
            .into_iter()
6363×
            .map(|e| (e.path, e.mode))
6371×
            .collect();
6381×
        assert_eq!(paths[0], ("README".into(), MODE_FILE));
6391×
        assert_eq!(
6401×
            paths[1],
641
            (
6421×
                "bin/tool".into(),
6431×
                if cfg!(unix) { MODE_EXEC } else { MODE_FILE }
644
            )
645
        );
6461×
        assert_eq!(verify(&r, &again).unwrap(), paths.len());
647
6481×
        let dest = t.path().join("back");
6491×
        unpack(&r, &again, &dest).unwrap();
6501×
        assert_eq!(
6511×
            std::fs::read(dest.join("bin/tool")).unwrap(),
6521×
            vec![7u8; 300_000]
653
        );
654
        #[cfg(unix)]
6551×
        assert_eq!(
6561×
            std::fs::read_link(dest.join("tool")).unwrap(),
6571×
            Path::new("bin/tool")
658
        );
6591×
        assert!(unpack(&r, &again, &dest)
6601×
            .unwrap_err()
6611×
            .to_string()
6621×
            .contains("not empty"));
663
664
        // The same files pack to the same name; garbage collection keeps it.
6651×
        assert_eq!(pack(&r, &src, &spec).unwrap().name, k.name);
6661×
        let keep = roots(&r).unwrap();
6671×
        assert!(keep.contains(&k.blob) && keep.contains(&k.artifact.tree));
6685×
        r.gc_keeping(true, &keep, &mut |_| {}).unwrap();
6691×
        assert_eq!(
6701×
            verify(&r, &open(&r, &k.name).unwrap()).unwrap(),
6711×
            paths.len()
672
        );
6731×
    }
674
675
    #[test]
6761×
    fn encodings_are_canonical_and_checked() {
6771×
        let a = Artifact {
6781×
            kind: Kind::RunnerImage,
6791×
            arch: Some("riscv64".into()),
6801×
            os: Some("linux".into()),
6811×
            tree: ObjectRef([1; 32]),
6821×
            entrypoint: Some(vec!["/sbin/init".into()]),
6831×
            env: [("PATH".to_string(), "/bin".to_string())].into(),
6841×
            labels: BTreeMap::new(),
6851×
            total_size: 42,
6861×
            receipt: Some(ObjectRef([2; 32])),
6871×
        };
6881×
        let bytes = a.encode();
6891×
        assert_eq!(Artifact::decode(&bytes).unwrap(), a);
6901×
        assert_eq!(a.name(), blake3::hash(&bytes).to_hex().to_string());
6911×
        assert!(Kind::parse("tarball")
6921×
            .unwrap_err()
6931×
            .to_string()
6941×
            .contains("runner-image"));
695
6962×
        let wrong = |v: Value| Artifact::decode(&cbor::encode(&v)).unwrap_err().to_string();
6971×
        assert!(wrong(Value::map([(0, Value::Uint(2))])).contains("version"));
6981×
        assert!(wrong(Value::map([(0, Value::Uint(1)), (10, Value::Null)])).contains("key 10"));
699
7001×
        let t = DirTree {
7011×
            entries: vec![Entry {
7021×
                path: "a".into(),
7031×
                mode: MODE_FILE,
7041×
                blob: ObjectRef([3; 32]),
7051×
                size: 1,
7061×
            }],
7071×
        };
7081×
        assert_eq!(DirTree::decode(&t.encode()).unwrap(), t);
7094×
        let entry = |p: &str, mode: u32| {
7104×
            Value::Array(vec![
7114×
                Value::Text(p.into()),
7124×
                Value::Uint(mode as u64),
7134×
                Value::Bytes(vec![0; 32]),
7144×
                Value::Uint(0),
7154×
            ])
7164×
        };
7173×
        let tree = |items: Vec<Value>| {
7183×
            DirTree::decode(&cbor::encode(&Value::map([
7193×
                (0, Value::Uint(1)),
7203×
                (1, Value::Array(items)),
7213×
            ])))
7223×
            .unwrap_err()
7233×
            .to_string()
7243×
        };
7251×
        assert!(tree(vec![entry("../x", MODE_FILE)]).contains("clean"));
7261×
        assert!(tree(vec![entry("b", MODE_FILE), entry("a", MODE_FILE)]).contains("order"));
7271×
        assert!(tree(vec![entry("a", 0o040000)]).contains("mode"));
7281×
    }
729
}