OXYGENHUB

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

281 of 296 lines run, 37 of 49 functions · run #410 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
//! The model is oxy-core's ([`oxy_core::artifact`]): an `artifact` object
8
//! in canonical CBOR, named by its object hash, whose tree is a blob. This
9
//! crate packs a directory into one, opens, verifies and unpacks it, and
10
//! keeps it: `.oxy/artifacts/NAME` records each artifact this repository
11
//! keeps, which garbage collection keeps with its tree and files
12
//! ([`roots`]).
13
14
use oxy_core::hash::{ObjectRef, ObjectType};
15
use oxy_core::repo::Repo;
16
use std::collections::BTreeMap;
17
use std::path::{Path, PathBuf};
18
19
pub use oxy_core::artifact::{
20
    Artifact, DirTree, Entry, Kind, MODE_EXEC, MODE_FILE, MODE_LINK, VERSION,
21
};
22
23
#[derive(Debug, thiserror::Error)]
24
pub enum Error {
25
    #[error(transparent)]
26
    Repo(#[from] oxy_core::repo::RepoError),
27
    #[error("io: {0}")]
28
    Io(#[from] std::io::Error),
29
    #[error("not an artifact: {0}")]
30
    Object(#[from] oxy_core::objects::ObjectError),
31
    #[error("store: {0}")]
32
    Store(#[from] oxy_core::store::StoreError),
33
    #[error("{0}")]
34
    Bad(String),
35
}
36
37
pub type Result<T> = std::result::Result<T, Error>;
38
391×
fn bad<T>(msg: impl Into<String>) -> Result<T> {
401×
    Err(Error::Bad(msg.into()))
411×
}
42
43
/// What to pack a directory as.
44
#[derive(Clone, Debug)]
45
pub struct Spec {
46
    pub kind: Kind,
47
    pub arch: Option<String>,
48
    pub os: Option<String>,
49
    pub entrypoint: Option<Vec<String>>,
50
    pub env: BTreeMap<String, String>,
51
    pub labels: BTreeMap<String, String>,
52
}
53
54
impl Spec {
552×
    pub fn new(kind: Kind) -> Spec {
562×
        Spec {
572×
            kind,
582×
            arch: None,
592×
            os: None,
602×
            entrypoint: None,
612×
            env: BTreeMap::new(),
622×
            labels: BTreeMap::new(),
632×
        }
642×
    }
65
}
66
67
/// An artifact kept in a repository, with its name.
68
#[derive(Clone, Debug)]
69
pub struct Kept {
70
    pub name: String,
71
    pub artifact: Artifact,
72
    /// Its object (its name, as a reference).
73
    pub object: ObjectRef,
74
}
75
7617×
fn kept_dir(repo: &Repo) -> PathBuf {
7717×
    repo.oxy_dir().join("artifacts")
7817×
}
79
80
/// Every file under `dir`, by relative path: regular files and links, in
81
/// path order. `.oxy` and `.git` directories are not part of an artifact.
823×
fn walk(dir: &Path) -> Result<Vec<(String, PathBuf, std::fs::Metadata)>> {
833×
    let mut out = Vec::new();
843×
    let mut stack = vec![(dir.to_path_buf(), String::new())];
859×
    while let Some((d, rel)) = stack.pop() {
8611×
        for e in std::fs::read_dir(&d)? {
8711×
            let e = e?;
8811×
            let name = e
8911×
                .file_name()
9011×
                .into_string()
9111×
                .map_err(|n| Error::Bad(format!("{n:?} is not UTF-8")))?;
9211×
            let path = if rel.is_empty() {
938×
                name.clone()
94
            } else {
953×
                format!("{rel}/{name}")
96
            };
9711×
            let md = std::fs::symlink_metadata(e.path())?;
9811×
            if md.is_dir() {
993×
                if name != ".oxy" && name != ".git" {
1003×
                    stack.push((e.path(), path));
1013×
                }
1028×
            } else if md.is_file() || md.file_type().is_symlink() {
1038×
                out.push((path, e.path(), md));
1048×
            }
105
        }
106
    }
1077×
    out.sort_by(|a, b| a.0.cmp(&b.0));
1083×
    Ok(out)
1093×
}
110
111
#[cfg(unix)]
1128×
fn mode_of(md: &std::fs::Metadata) -> u32 {
113
    use std::os::unix::fs::PermissionsExt;
1148×
    if md.file_type().is_symlink() {
1152×
        MODE_LINK
1166×
    } else if md.permissions().mode() & 0o111 != 0 {
1172×
        MODE_EXEC
118
    } else {
1194×
        MODE_FILE
120
    }
1218×
}
122
123
#[cfg(not(unix))]
124
fn mode_of(md: &std::fs::Metadata) -> u32 {
125
    if md.file_type().is_symlink() {
126
        MODE_LINK
127
    } else {
128
        MODE_FILE
129
    }
130
}
131
132
/// Pack the files under `dir` into an artifact of `spec`'s kind in
133
/// `repo`'s store: each file a blob, so files and chunks the store holds
134
/// already cost nothing; then its tree and its encoding, kept under its
135
/// name.
1363×
pub fn pack(repo: &Repo, dir: &Path, spec: &Spec) -> Result<Kept> {
1373×
    if !dir.is_dir() {
1380×
        return bad(format!("{} is not a directory", dir.display()));
1393×
    }
1403×
    let mut tree = DirTree::default();
1418×
    for (path, full, md) in walk(dir)? {
1428×
        let mode = mode_of(&md);
1438×
        let bytes = if mode == MODE_LINK {
1442×
            std::fs::read_link(&full)?
1452×
                .to_string_lossy()
1462×
                .into_owned()
1472×
                .into_bytes()
148
        } else {
1496×
            std::fs::read(&full)?
150
        };
1518×
        let blob = repo.store_blob(&bytes)?;
1528×
        tree.entries.push(Entry {
1538×
            path,
1548×
            mode,
1558×
            blob,
1568×
            size: bytes.len() as u64,
1578×
        });
158
    }
1593×
    let artifact = Artifact {
1603×
        kind: spec.kind,
1613×
        arch: spec.arch.clone(),
1623×
        os: spec.os.clone(),
1633×
        tree: repo.store_blob(&tree.encode())?,
1643×
        entrypoint: spec.entrypoint.clone(),
1653×
        env: spec.env.clone(),
1663×
        labels: spec.labels.clone(),
1673×
        total_size: tree.entries.iter().map(|e| e.size).sum(),
1683×
        receipt: None,
169
    };
1703×
    let object = repo.store().put(ObjectType::Artifact, &artifact.encode())?;
1713×
    let name = object.to_hex();
1723×
    std::fs::create_dir_all(kept_dir(repo))?;
1733×
    std::fs::write(kept_dir(repo).join(&name), b"")?;
1743×
    Ok(Kept {
1753×
        name,
1763×
        artifact,
1773×
        object,
1783×
    })
1793×
}
180
181
/// The names of the artifacts kept in `repo`, sorted.
18211×
pub fn list(repo: &Repo) -> Result<Vec<String>> {
18311×
    let mut out = Vec::new();
18411×
    match std::fs::read_dir(kept_dir(repo)) {
18511×
        Ok(rd) => {
18611×
            for e in rd {
18711×
                let n = e?.file_name().to_string_lossy().into_owned();
188704×
                if n.len() == 64 && n.bytes().all(|b| b.is_ascii_hexdigit()) {
18911×
                    out.push(n);
19011×
                }
191
            }
192
        }
1930×
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
1940×
        Err(e) => return Err(e.into()),
195
    }
19611×
    out.sort();
19711×
    Ok(out)
19811×
}
199
200
/// The kept artifact `name` names, or a unique prefix of it (at least six
201
/// characters), read from the store, which checks it against its name.
2027×
pub fn open(repo: &Repo, name: &str) -> Result<Kept> {
2037×
    let name = name.to_ascii_lowercase();
2047×
    if name.len() < 6 {
2050×
        return bad("name an artifact by at least six characters of its name");
2067×
    }
2077×
    let found: Vec<String> = list(repo)?
2087×
        .into_iter()
2097×
        .filter(|n| n.starts_with(&name))
2107×
        .collect();
2117×
    let full = match found.as_slice() {
2127×
        [one] => one.clone(),
2130×
        [] => {
2140×
            return bad(format!(
215
                "no artifact {name} here; `oxy artifact list` shows them"
216
            ))
217
        }
218
        _ => {
2190×
            return bad(format!(
220
                "{name} names {} artifacts: give more of the name",
2210×
                found.len()
222
            ))
223
        }
224
    };
2257×
    let object = ObjectRef::from_hex(&full).map_or_else(
2260×
        || bad(format!("artifact {full}: its record is damaged")),
227
        Ok,
2280×
    )?;
229
    // The store checks the bytes against the object's name as it reads.
2307×
    let bytes = repo.store().get_typed(&object, ObjectType::Artifact)?;
231
    Ok(Kept {
2327×
        name: full,
2337×
        artifact: Artifact::decode(&bytes)?,
2347×
        object,
235
    })
2367×
}
237
238
/// An artifact's tree.
23910×
pub fn tree(repo: &Repo, a: &Artifact) -> Result<DirTree> {
24010×
    Ok(DirTree::decode(&repo.read_blob(&a.tree)?)?)
24110×
}
242
243
/// Check that every file of `k` is in the store whole: its bytes read, at
244
/// the size its tree says. Answers how many files were read.
2453×
pub fn verify(repo: &Repo, k: &Kept) -> Result<usize> {
2463×
    let t = tree(repo, &k.artifact)?;
2473×
    let mut total = 0;
2488×
    for e in &t.entries {
2498×
        let n = repo.read_blob(&e.blob)?.len() as u64;
2508×
        if n != e.size {
2510×
            return bad(format!(
252
                "{}: {n} bytes, but the tree says {}",
253
                e.path, e.size
254
            ));
2558×
        }
2568×
        total += n;
257
    }
2583×
    if total != k.artifact.total_size {
2590×
        return bad(format!(
260
            "the files are {total} bytes together, but the artifact says {}",
261
            k.artifact.total_size
262
        ));
2633×
    }
2643×
    Ok(t.entries.len())
2653×
}
266
267
/// Write `k`'s files under `dest`, which must not exist or be empty.
2683×
pub fn unpack(repo: &Repo, k: &Kept, dest: &Path) -> Result<usize> {
2693×
    if dest.exists() && std::fs::read_dir(dest)?.next().is_some() {
2701×
        return bad(format!("{} is not empty", dest.display()));
2712×
    }
2722×
    let t = tree(repo, &k.artifact)?;
2735×
    for e in &t.entries {
2745×
        let out = dest.join(&e.path);
2755×
        if let Some(p) = out.parent() {
2765×
            std::fs::create_dir_all(p)?;
2770×
        }
2785×
        let bytes = repo.read_blob(&e.blob)?;
2795×
        if e.mode == MODE_LINK {
280
            #[cfg(unix)]
2811×
            std::os::unix::fs::symlink(String::from_utf8_lossy(&bytes).as_ref(), &out)?;
282
            #[cfg(not(unix))]
283
            std::fs::write(&out, &bytes)?;
2841×
            continue;
2854×
        }
2864×
        std::fs::write(&out, &bytes)?;
287
        #[cfg(unix)]
2884×
        if e.mode == MODE_EXEC {
289
            use std::os::unix::fs::PermissionsExt;
2901×
            std::fs::set_permissions(&out, std::fs::Permissions::from_mode(0o755))?;
2913×
        }
292
    }
2932×
    Ok(t.entries.len())
2943×
}
295
296
/// The blobs garbage collection must keep for the kept artifacts: each
297
/// one's encoding, its tree and its files (`Repo::gc_keeping`).
2982×
pub fn roots(repo: &Repo) -> Result<Vec<ObjectRef>> {
2992×
    let mut out = Vec::new();
3002×
    for name in list(repo)? {
3012×
        let k = open(repo, &name)?;
3022×
        out.push(k.object);
3032×
        out.push(k.artifact.tree);
3042×
        out.extend(tree(repo, &k.artifact)?.entries.iter().map(|e| e.blob));
305
    }
3062×
    Ok(out)
3072×
}
308
309
#[cfg(test)]
310
mod tests {
311
    use super::*;
312
    use oxy_core::cbor::{self, Value};
313
3141×
    fn repo(dir: &Path) -> Repo {
3151×
        Repo::init(&dir.join("repo")).unwrap()
3161×
    }
317
318
    #[test]
3191×
    fn a_directory_packs_into_an_artifact_and_back() {
3201×
        let t = tempfile::tempdir().unwrap();
3211×
        let r = repo(t.path());
3221×
        let src = t.path().join("out");
3231×
        std::fs::create_dir_all(src.join("bin")).unwrap();
3241×
        std::fs::write(src.join("README"), b"hello\n").unwrap();
3251×
        std::fs::write(src.join("bin/tool"), vec![7u8; 300_000]).unwrap();
326
        #[cfg(unix)]
327
        {
328
            use std::os::unix::fs::PermissionsExt;
3291×
            std::fs::set_permissions(src.join("bin/tool"), std::fs::Permissions::from_mode(0o755))
3301×
                .unwrap();
3311×
            std::os::unix::fs::symlink("bin/tool", src.join("tool")).unwrap();
332
        }
3331×
        let mut spec = Spec::new(Kind::Archive);
3341×
        spec.arch = Some("x86_64".into());
3351×
        spec.labels.insert("version".into(), "0.1.0".into());
3361×
        let k = pack(&r, &src, &spec).unwrap();
3371×
        assert_eq!(k.name, k.artifact.name().to_hex());
3381×
        assert_eq!(
339
            k.artifact.total_size,
3401×
            300_006 + if cfg!(unix) { 8 } else { 0 }
341
        );
3421×
        assert_eq!(list(&r).unwrap(), vec![k.name.clone()]);
343
3441×
        let again = open(&r, &k.name[..10]).unwrap();
3451×
        assert_eq!(again.artifact, k.artifact);
3461×
        let paths: Vec<(String, u32)> = tree(&r, &again.artifact)
3471×
            .unwrap()
3481×
            .entries
3491×
            .into_iter()
3503×
            .map(|e| (e.path, e.mode))
3511×
            .collect();
3521×
        assert_eq!(paths[0], ("README".into(), MODE_FILE));
3531×
        assert_eq!(
3541×
            paths[1],
355
            (
3561×
                "bin/tool".into(),
3571×
                if cfg!(unix) { MODE_EXEC } else { MODE_FILE }
358
            )
359
        );
3601×
        assert_eq!(verify(&r, &again).unwrap(), paths.len());
361
3621×
        let dest = t.path().join("back");
3631×
        unpack(&r, &again, &dest).unwrap();
3641×
        assert_eq!(
3651×
            std::fs::read(dest.join("bin/tool")).unwrap(),
3661×
            vec![7u8; 300_000]
367
        );
368
        #[cfg(unix)]
3691×
        assert_eq!(
3701×
            std::fs::read_link(dest.join("tool")).unwrap(),
3711×
            Path::new("bin/tool")
372
        );
3731×
        assert!(unpack(&r, &again, &dest)
3741×
            .unwrap_err()
3751×
            .to_string()
3761×
            .contains("not empty"));
377
378
        // The same files pack to the same name; garbage collection keeps it.
3791×
        assert_eq!(pack(&r, &src, &spec).unwrap().name, k.name);
3801×
        let keep = roots(&r).unwrap();
3811×
        assert!(keep.contains(&k.object) && keep.contains(&k.artifact.tree));
3825×
        r.gc_keeping(true, &keep, &mut |_| {}).unwrap();
383
        // An artifact is an object of its own, which fsck reads and accepts.
3841×
        let report = r.fsck().unwrap();
3851×
        let errors: Vec<_> = report
3861×
            .problems
3871×
            .iter()
3881×
            .filter(|p| p.severity == oxy_core::repo::Severity::Error)
3891×
            .collect();
3901×
        assert!(errors.is_empty(), "{errors:?}");
3911×
        assert_eq!(
3921×
            verify(&r, &open(&r, &k.name).unwrap()).unwrap(),
3931×
            paths.len()
394
        );
3951×
    }
396
397
    #[test]
3981×
    fn encodings_are_canonical_and_checked() {
3991×
        let a = Artifact {
4001×
            kind: Kind::RunnerImage,
4011×
            arch: Some("riscv64".into()),
4021×
            os: Some("linux".into()),
4031×
            tree: ObjectRef([1; 32]),
4041×
            entrypoint: Some(vec!["/sbin/init".into()]),
4051×
            env: [("PATH".to_string(), "/bin".to_string())].into(),
4061×
            labels: BTreeMap::new(),
4071×
            total_size: 42,
4081×
            receipt: Some(ObjectRef([2; 32])),
4091×
        };
4101×
        let bytes = a.encode();
4111×
        assert_eq!(Artifact::decode(&bytes).unwrap(), a);
4121×
        assert_eq!(
4131×
            a.name(),
4141×
            oxy_core::hash::hash_object(ObjectType::Artifact, &bytes)
415
        );
4161×
        assert!(Kind::parse("tarball")
4171×
            .unwrap_err()
4181×
            .to_string()
4191×
            .contains("runner-image"));
420
4212×
        let wrong = |v: Value| Artifact::decode(&cbor::encode(&v)).unwrap_err().to_string();
4221×
        assert!(wrong(Value::map([(0, Value::Uint(2))])).contains("version"));
4231×
        assert!(wrong(Value::map([(0, Value::Uint(1)), (10, Value::Null)])).contains("key 10"));
424
4251×
        let t = DirTree {
4261×
            entries: vec![Entry {
4271×
                path: "a".into(),
4281×
                mode: MODE_FILE,
4291×
                blob: ObjectRef([3; 32]),
4301×
                size: 1,
4311×
            }],
4321×
        };
4331×
        assert_eq!(DirTree::decode(&t.encode()).unwrap(), t);
4344×
        let entry = |p: &str, mode: u32| {
4354×
            Value::Array(vec![
4364×
                Value::Text(p.into()),
4374×
                Value::Uint(mode as u64),
4384×
                Value::Bytes(vec![0; 32]),
4394×
                Value::Uint(0),
4404×
            ])
4414×
        };
4423×
        let tree = |items: Vec<Value>| {
4433×
            DirTree::decode(&cbor::encode(&Value::map([
4443×
                (0, Value::Uint(1)),
4453×
                (1, Value::Array(items)),
4463×
            ])))
4473×
            .unwrap_err()
4483×
            .to_string()
4493×
        };
4501×
        assert!(tree(vec![entry("../x", MODE_FILE)]).contains("clean"));
4511×
        assert!(tree(vec![entry("b", MODE_FILE), entry("a", MODE_FILE)]).contains("order"));
4521×
        assert!(tree(vec![entry("a", 0o040000)]).contains("mode"));
4531×
    }
454
}