OXYGENHUB
# aded4110f0bd oxygen / crates / cylinder / src / lib.rs
23.1 KiB · Rust · last changed Cylinder C1, first part: artifacts as trees of the repository's chunks, oxy artifact pack/list/show/unpack/verify, kept by gc (refs #108) 7 days ago source blame history raw
642da263 Andy McCall ↶ 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
36
fn bad<T>(msg: impl Into<String>) -> Result<T> {
37
    Err(Error::Bad(msg.into()))
38
}
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
59
    pub fn as_str(self) -> &'static str {
60
        match self {
61
            Kind::Container => "container",
62
            Kind::RunnerImage => "runner-image",
63
            Kind::Package => "package",
64
            Kind::Archive => "archive",
65
            Kind::Report => "report",
66
        }
67
    }
68
69
    pub fn parse(s: &str) -> Result<Kind> {
70
        Kind::ALL.into_iter().find(|k| k.as_str() == s).map_or_else(
71
            || {
72
                bad(format!(
73
                    "{s:?} is not a kind of artifact: {}",
74
                    Kind::ALL.map(|k| k.as_str()).join(", ")
75
                ))
76
            },
77
            Ok,
78
        )
79
    }
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
104
fn path_ok(p: &str) -> bool {
105
    !p.is_empty()
106
        && !p.starts_with('/')
107
        && p.split('/').all(|s| !s.is_empty() && s != "." && s != "..")
108
}
109
110
fn obj(r: &ObjectRef) -> Value {
111
    Value::Bytes(r.0.to_vec())
112
}
113
114
fn get_obj(v: &Value, what: &str) -> Result<ObjectRef> {
115
    match v.as_bytes() {
116
        Some(b) if b.len() == 32 => {
117
            let mut a = [0u8; 32];
118
            a.copy_from_slice(b);
119
            Ok(ObjectRef(a))
120
        }
121
        _ => bad(format!("{what} is not an object reference")),
122
    }
123
}
124
125
fn get_text(v: &Value, what: &str) -> Result<String> {
126
    v.as_text()
127
        .map(str::to_string)
128
        .map_or_else(|| bad(format!("{what} is not text")), Ok)
129
}
130
131
fn get_uint(v: &Value, what: &str) -> Result<u64> {
132
    v.as_uint()
133
        .map_or_else(|| bad(format!("{what} is not a number")), Ok)
134
}
135
136
impl DirTree {
137
    pub fn encode(&self) -> Vec<u8> {
138
        let entries = self
139
            .entries
140
            .iter()
141
            .map(|e| {
142
                Value::Array(vec![
143
                    Value::Text(e.path.clone()),
144
                    Value::Uint(e.mode as u64),
145
                    obj(&e.blob),
146
                    Value::Uint(e.size),
147
                ])
148
            })
149
            .collect();
150
        cbor::encode(&Value::map([
151
            (0, Value::Uint(VERSION)),
152
            (1, Value::Array(entries)),
153
        ]))
154
    }
155
156
    /// Read and check a tree: a known version, clean paths, each once, in
157
    /// order, with a known mode.
158
    pub fn decode(bytes: &[u8]) -> Result<DirTree> {
159
        let v = cbor::decode(bytes)?;
160
        let m = v.as_map().map_or_else(|| bad("a tree is a map"), Ok)?;
161
        if m.get(&0).and_then(Value::as_uint) != Some(VERSION) {
162
            return bad(format!(
163
                "tree version {:?} is not known; this reads {VERSION}",
164
                m.get(&0)
165
            ));
166
        }
167
        let mut entries = Vec::new();
168
        for item in m.get(&1).and_then(Value::as_array).unwrap_or_default() {
169
            let [p, mode, blob, size] = item.as_array().unwrap_or_default() else {
170
                return bad("a tree entry is [path, mode, blob, size]");
171
            };
172
            let path = get_text(p, "a path")?;
173
            let mode = get_uint(mode, "a mode")? as u32;
174
            if !path_ok(&path) {
175
                return bad(format!("{path:?} is not a clean relative path"));
176
            }
177
            if ![MODE_FILE, MODE_EXEC, MODE_LINK].contains(&mode) {
178
                return bad(format!(
179
                    "{path}: mode {mode:o} is not a file, an executable or a link"
180
                ));
181
            }
182
            if entries.last().is_some_and(|l: &Entry| l.path >= path) {
183
                return bad(format!(
184
                    "{path}: entries are not in path order, or one is there twice"
185
                ));
186
            }
187
            entries.push(Entry {
188
                path,
189
                mode,
190
                blob: get_obj(blob, "a blob")?,
191
                size: get_uint(size, "a size")?,
192
            });
193
        }
194
        Ok(DirTree { entries })
195
    }
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
218
fn pairs(m: &BTreeMap<String, String>) -> Value {
219
    if m.is_empty() {
220
        return Value::Null;
221
    }
222
    Value::Array(
223
        m.iter()
224
            .map(|(k, v)| Value::Array(vec![Value::Text(k.clone()), Value::Text(v.clone())]))
225
            .collect(),
226
    )
227
}
228
229
fn get_pairs(v: Option<&Value>, what: &str) -> Result<BTreeMap<String, String>> {
230
    let mut out = BTreeMap::new();
231
    let Some(v) = v.filter(|v| !v.is_null()) else {
232
        return Ok(out);
233
    };
234
    for p in v
235
        .as_array()
236
        .map_or_else(|| bad(format!("{what} is a list of pairs")), Ok)?
237
    {
238
        let [k, val] = p.as_array().unwrap_or_default() else {
239
            return bad(format!("{what}: a pair is [key, value]"));
240
        };
241
        let k = get_text(k, what)?;
242
        if out.insert(k.clone(), get_text(val, what)?).is_some() {
243
            return bad(format!("{what}: {k} is there twice"));
244
        }
245
    }
246
    Ok(out)
247
}
248
249
fn opt_text(v: &Option<String>) -> Value {
250
    v.as_ref().map_or(Value::Null, |s| Value::Text(s.clone()))
251
}
252
253
impl Artifact {
254
    /// Its canonical CBOR, keys as in docs/cylinder.md §4.1.
255
    pub fn encode(&self) -> Vec<u8> {
256
        let entry = self.entrypoint.as_ref().map_or(Value::Null, |a| {
257
            Value::Array(a.iter().cloned().map(Value::Text).collect())
258
        });
259
        cbor::encode(&Value::map([
260
            (0, Value::Uint(VERSION)),
261
            (1, Value::Text(self.kind.as_str().into())),
262
            (2, opt_text(&self.arch)),
263
            (3, opt_text(&self.os)),
264
            (4, obj(&self.tree)),
265
            (5, entry),
266
            (6, pairs(&self.env)),
267
            (7, pairs(&self.labels)),
268
            (8, Value::Uint(self.total_size)),
269
            (9, self.receipt.as_ref().map_or(Value::Null, obj)),
270
        ]))
271
    }
272
273
    pub fn decode(bytes: &[u8]) -> Result<Artifact> {
274
        let v = cbor::decode(bytes)?;
275
        let m = v.as_map().map_or_else(|| bad("an artifact is a map"), Ok)?;
276
        if m.get(&0).and_then(Value::as_uint) != Some(VERSION) {
277
            return bad(format!(
278
                "artifact version {:?} is not known; this reads {VERSION}",
279
                m.get(&0)
280
            ));
281
        }
282
        if let Some(k) = m.keys().find(|k| **k > 9) {
283
            return bad(format!("key {k} is not part of an artifact"));
284
        }
285
        let text = |k: u64, what: &str| -> Result<Option<String>> {
286
            match m.get(&k) {
287
                None | Some(Value::Null) => Ok(None),
288
                Some(v) => get_text(v, what).map(Some),
289
            }
290
        };
291
        let entrypoint = match m.get(&5) {
292
            None | Some(Value::Null) => None,
293
            Some(v) => Some(
294
                v.as_array()
295
                    .unwrap_or_default()
296
                    .iter()
297
                    .map(|a| get_text(a, "the entrypoint"))
298
                    .collect::<Result<_>>()?,
299
            ),
300
        };
301
        let receipt = match m.get(&9) {
302
            None | Some(Value::Null) => None,
303
            Some(v) => Some(get_obj(v, "the receipt")?),
304
        };
305
        Ok(Artifact {
306
            kind: Kind::parse(&text(1, "the kind")?.unwrap_or_default())?,
307
            arch: text(2, "the architecture")?,
308
            os: text(3, "the system")?,
309
            tree: get_obj(m.get(&4).unwrap_or(&Value::Null), "the tree")?,
310
            entrypoint,
311
            env: get_pairs(m.get(&6), "env")?,
312
            labels: get_pairs(m.get(&7), "labels")?,
313
            total_size: get_uint(m.get(&8).unwrap_or(&Value::Null), "the size")?,
314
            receipt,
315
        })
316
    }
317
318
    /// Its name: the BLAKE3 of its canonical encoding, hex.
319
    pub fn name(&self) -> String {
320
        blake3::hash(&self.encode()).to_hex().to_string()
321
    }
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 {
336
    pub fn new(kind: Kind) -> Spec {
337
        Spec {
338
            kind,
339
            arch: None,
340
            os: None,
341
            entrypoint: None,
342
            env: BTreeMap::new(),
343
            labels: BTreeMap::new(),
344
        }
345
    }
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
357
fn kept_dir(repo: &Repo) -> PathBuf {
358
    repo.oxy_dir().join("artifacts")
359
}
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.
363
fn walk(dir: &Path) -> Result<Vec<(String, PathBuf, std::fs::Metadata)>> {
364
    let mut out = Vec::new();
365
    let mut stack = vec![(dir.to_path_buf(), String::new())];
366
    while let Some((d, rel)) = stack.pop() {
367
        for e in std::fs::read_dir(&d)? {
368
            let e = e?;
369
            let name = e
370
                .file_name()
371
                .into_string()
372
                .map_err(|n| Error::Bad(format!("{n:?} is not UTF-8")))?;
373
            let path = if rel.is_empty() {
374
                name.clone()
375
            } else {
376
                format!("{rel}/{name}")
377
            };
378
            let md = std::fs::symlink_metadata(e.path())?;
379
            if md.is_dir() {
380
                if name != ".oxy" && name != ".git" {
381
                    stack.push((e.path(), path));
382
                }
383
            } else if md.is_file() || md.file_type().is_symlink() {
384
                out.push((path, e.path(), md));
385
            }
386
        }
387
    }
388
    out.sort_by(|a, b| a.0.cmp(&b.0));
389
    Ok(out)
390
}
391
392
#[cfg(unix)]
393
fn mode_of(md: &std::fs::Metadata) -> u32 {
394
    use std::os::unix::fs::PermissionsExt;
395
    if md.file_type().is_symlink() {
396
        MODE_LINK
397
    } else if md.permissions().mode() & 0o111 != 0 {
398
        MODE_EXEC
399
    } else {
400
        MODE_FILE
401
    }
402
}
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.
417
pub fn pack(repo: &Repo, dir: &Path, spec: &Spec) -> Result<Kept> {
418
    if !dir.is_dir() {
419
        return bad(format!("{} is not a directory", dir.display()));
420
    }
421
    let mut tree = DirTree::default();
422
    for (path, full, md) in walk(dir)? {
423
        let mode = mode_of(&md);
424
        let bytes = if mode == MODE_LINK {
425
            std::fs::read_link(&full)?
426
                .to_string_lossy()
427
                .into_owned()
428
                .into_bytes()
429
        } else {
430
            std::fs::read(&full)?
431
        };
432
        let blob = repo.store_blob(&bytes)?;
433
        tree.entries.push(Entry {
434
            path,
435
            mode,
436
            blob,
437
            size: bytes.len() as u64,
438
        });
439
    }
440
    let artifact = Artifact {
441
        kind: spec.kind,
442
        arch: spec.arch.clone(),
443
        os: spec.os.clone(),
444
        tree: repo.store_blob(&tree.encode())?,
445
        entrypoint: spec.entrypoint.clone(),
446
        env: spec.env.clone(),
447
        labels: spec.labels.clone(),
448
        total_size: tree.entries.iter().map(|e| e.size).sum(),
449
        receipt: None,
450
    };
451
    let bytes = artifact.encode();
452
    let blob = repo.store_blob(&bytes)?;
453
    let name = blake3::hash(&bytes).to_hex().to_string();
454
    std::fs::create_dir_all(kept_dir(repo))?;
455
    std::fs::write(kept_dir(repo).join(&name), blob.to_hex())?;
456
    Ok(Kept {
457
        name,
458
        artifact,
459
        blob,
460
    })
461
}
462
463
/// The names of the artifacts kept in `repo`, sorted.
464
pub fn list(repo: &Repo) -> Result<Vec<String>> {
465
    let mut out = Vec::new();
466
    match std::fs::read_dir(kept_dir(repo)) {
467
        Ok(rd) => {
468
            for e in rd {
469
                let n = e?.file_name().to_string_lossy().into_owned();
470
                if n.len() == 64 && n.bytes().all(|b| b.is_ascii_hexdigit()) {
471
                    out.push(n);
472
                }
473
            }
474
        }
475
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
476
        Err(e) => return Err(e.into()),
477
    }
478
    out.sort();
479
    Ok(out)
480
}
481
482
/// The kept artifact `name` names, or a unique prefix of it (at least six
483
/// characters), checked against its name.
484
pub fn open(repo: &Repo, name: &str) -> Result<Kept> {
485
    let name = name.to_ascii_lowercase();
486
    if name.len() < 6 {
487
        return bad("name an artifact by at least six characters of its name");
488
    }
489
    let found: Vec<String> = list(repo)?
490
        .into_iter()
491
        .filter(|n| n.starts_with(&name))
492
        .collect();
493
    let full = match found.as_slice() {
494
        [one] => one.clone(),
495
        [] => {
496
            return bad(format!(
497
                "no artifact {name} here; `oxy artifact list` shows them"
498
            ))
499
        }
500
        _ => {
501
            return bad(format!(
502
                "{name} names {} artifacts: give more of the name",
503
                found.len()
504
            ))
505
        }
506
    };
507
    let hex = std::fs::read_to_string(kept_dir(repo).join(&full))?;
508
    let blob = ObjectRef::from_hex(hex.trim()).map_or_else(
509
        || bad(format!("artifact {full}: its record is damaged")),
510
        Ok,
511
    )?;
512
    let bytes = repo.read_blob(&blob)?;
513
    if blake3::hash(&bytes).to_hex().as_str() != full {
514
        return bad(format!(
515
            "artifact {full}: its encoding no longer hashes to its name"
516
        ));
517
    }
518
    Ok(Kept {
519
        name: full,
520
        artifact: Artifact::decode(&bytes)?,
521
        blob,
522
    })
523
}
524
525
/// An artifact's tree.
526
pub fn tree(repo: &Repo, a: &Artifact) -> Result<DirTree> {
527
    DirTree::decode(&repo.read_blob(&a.tree)?)
528
}
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.
532
pub fn verify(repo: &Repo, k: &Kept) -> Result<usize> {
533
    let t = tree(repo, &k.artifact)?;
534
    let mut total = 0;
535
    for e in &t.entries {
536
        let n = repo.read_blob(&e.blob)?.len() as u64;
537
        if n != e.size {
538
            return bad(format!(
539
                "{}: {n} bytes, but the tree says {}",
540
                e.path, e.size
541
            ));
542
        }
543
        total += n;
544
    }
545
    if total != k.artifact.total_size {
546
        return bad(format!(
547
            "the files are {total} bytes together, but the artifact says {}",
548
            k.artifact.total_size
549
        ));
550
    }
551
    Ok(t.entries.len())
552
}
553
554
/// Write `k`'s files under `dest`, which must not exist or be empty.
555
pub fn unpack(repo: &Repo, k: &Kept, dest: &Path) -> Result<usize> {
556
    if dest.exists() && std::fs::read_dir(dest)?.next().is_some() {
557
        return bad(format!("{} is not empty", dest.display()));
558
    }
559
    let t = tree(repo, &k.artifact)?;
560
    for e in &t.entries {
561
        let out = dest.join(&e.path);
562
        if let Some(p) = out.parent() {
563
            std::fs::create_dir_all(p)?;
564
        }
565
        let bytes = repo.read_blob(&e.blob)?;
566
        if e.mode == MODE_LINK {
567
            #[cfg(unix)]
568
            std::os::unix::fs::symlink(String::from_utf8_lossy(&bytes).as_ref(), &out)?;
569
            #[cfg(not(unix))]
570
            std::fs::write(&out, &bytes)?;
571
            continue;
572
        }
573
        std::fs::write(&out, &bytes)?;
574
        #[cfg(unix)]
575
        if e.mode == MODE_EXEC {
576
            use std::os::unix::fs::PermissionsExt;
577
            std::fs::set_permissions(&out, std::fs::Permissions::from_mode(0o755))?;
578
        }
579
    }
580
    Ok(t.entries.len())
581
}
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`).
585
pub fn roots(repo: &Repo) -> Result<Vec<ObjectRef>> {
586
    let mut out = Vec::new();
587
    for name in list(repo)? {
588
        let k = open(repo, &name)?;
589
        out.push(k.blob);
590
        out.push(k.artifact.tree);
591
        out.extend(tree(repo, &k.artifact)?.entries.iter().map(|e| e.blob));
592
    }
593
    Ok(out)
594
}
595
596
#[cfg(test)]
597
mod tests {
598
    use super::*;
599
600
    fn repo(dir: &Path) -> Repo {
601
        Repo::init(&dir.join("repo")).unwrap()
602
    }
603
604
    #[test]
605
    fn a_directory_packs_into_an_artifact_and_back() {
606
        let t = tempfile::tempdir().unwrap();
607
        let r = repo(t.path());
608
        let src = t.path().join("out");
609
        std::fs::create_dir_all(src.join("bin")).unwrap();
610
        std::fs::write(src.join("README"), b"hello\n").unwrap();
611
        std::fs::write(src.join("bin/tool"), vec![7u8; 300_000]).unwrap();
612
        #[cfg(unix)]
613
        {
614
            use std::os::unix::fs::PermissionsExt;
615
            std::fs::set_permissions(src.join("bin/tool"), std::fs::Permissions::from_mode(0o755))
616
                .unwrap();
617
            std::os::unix::fs::symlink("bin/tool", src.join("tool")).unwrap();
618
        }
619
        let mut spec = Spec::new(Kind::Archive);
620
        spec.arch = Some("x86_64".into());
621
        spec.labels.insert("version".into(), "0.1.0".into());
622
        let k = pack(&r, &src, &spec).unwrap();
623
        assert_eq!(k.name, k.artifact.name());
624
        assert_eq!(
625
            k.artifact.total_size,
626
            300_006 + if cfg!(unix) { 8 } else { 0 }
627
        );
628
        assert_eq!(list(&r).unwrap(), vec![k.name.clone()]);
629
630
        let again = open(&r, &k.name[..10]).unwrap();
631
        assert_eq!(again.artifact, k.artifact);
632
        let paths: Vec<(String, u32)> = tree(&r, &again.artifact)
633
            .unwrap()
634
            .entries
635
            .into_iter()
636
            .map(|e| (e.path, e.mode))
637
            .collect();
638
        assert_eq!(paths[0], ("README".into(), MODE_FILE));
639
        assert_eq!(
640
            paths[1],
641
            (
642
                "bin/tool".into(),
643
                if cfg!(unix) { MODE_EXEC } else { MODE_FILE }
644
            )
645
        );
646
        assert_eq!(verify(&r, &again).unwrap(), paths.len());
647
648
        let dest = t.path().join("back");
649
        unpack(&r, &again, &dest).unwrap();
650
        assert_eq!(
651
            std::fs::read(dest.join("bin/tool")).unwrap(),
652
            vec![7u8; 300_000]
653
        );
654
        #[cfg(unix)]
655
        assert_eq!(
656
            std::fs::read_link(dest.join("tool")).unwrap(),
657
            Path::new("bin/tool")
658
        );
659
        assert!(unpack(&r, &again, &dest)
660
            .unwrap_err()
661
            .to_string()
662
            .contains("not empty"));
663
664
        // The same files pack to the same name; garbage collection keeps it.
665
        assert_eq!(pack(&r, &src, &spec).unwrap().name, k.name);
666
        let keep = roots(&r).unwrap();
667
        assert!(keep.contains(&k.blob) && keep.contains(&k.artifact.tree));
668
        r.gc_keeping(true, &keep, &mut |_| {}).unwrap();
669
        assert_eq!(
670
            verify(&r, &open(&r, &k.name).unwrap()).unwrap(),
671
            paths.len()
672
        );
673
    }
674
675
    #[test]
676
    fn encodings_are_canonical_and_checked() {
677
        let a = Artifact {
678
            kind: Kind::RunnerImage,
679
            arch: Some("riscv64".into()),
680
            os: Some("linux".into()),
681
            tree: ObjectRef([1; 32]),
682
            entrypoint: Some(vec!["/sbin/init".into()]),
683
            env: [("PATH".to_string(), "/bin".to_string())].into(),
684
            labels: BTreeMap::new(),
685
            total_size: 42,
686
            receipt: Some(ObjectRef([2; 32])),
687
        };
688
        let bytes = a.encode();
689
        assert_eq!(Artifact::decode(&bytes).unwrap(), a);
690
        assert_eq!(a.name(), blake3::hash(&bytes).to_hex().to_string());
691
        assert!(Kind::parse("tarball")
692
            .unwrap_err()
693
            .to_string()
694
            .contains("runner-image"));
695
696
        let wrong = |v: Value| Artifact::decode(&cbor::encode(&v)).unwrap_err().to_string();
697
        assert!(wrong(Value::map([(0, Value::Uint(2))])).contains("version"));
698
        assert!(wrong(Value::map([(0, Value::Uint(1)), (10, Value::Null)])).contains("key 10"));
699
700
        let t = DirTree {
701
            entries: vec![Entry {
702
                path: "a".into(),
703
                mode: MODE_FILE,
704
                blob: ObjectRef([3; 32]),
705
                size: 1,
706
            }],
707
        };
708
        assert_eq!(DirTree::decode(&t.encode()).unwrap(), t);
709
        let entry = |p: &str, mode: u32| {
710
            Value::Array(vec![
711
                Value::Text(p.into()),
712
                Value::Uint(mode as u64),
713
                Value::Bytes(vec![0; 32]),
714
                Value::Uint(0),
715
            ])
716
        };
717
        let tree = |items: Vec<Value>| {
718
            DirTree::decode(&cbor::encode(&Value::map([
719
                (0, Value::Uint(1)),
720
                (1, Value::Array(items)),
721
            ])))
722
            .unwrap_err()
723
            .to_string()
724
        };
725
        assert!(tree(vec![entry("../x", MODE_FILE)]).contains("clean"));
726
        assert!(tree(vec![entry("b", MODE_FILE), entry("a", MODE_FILE)]).contains("order"));
727
        assert!(tree(vec![entry("a", 0o040000)]).contains("mode"));
728
    }
729
}