OXYGENHUB

Cylinder C1, first part: artifacts as trees of the repository's chunks, oxy artifact pack/list/show/unpack/verify, kept by gc (refs #108) 642da2636de2

Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 609cfbd8113c
When
· 2026-10-28 22:09 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
642da2636de23fd2aee00ace74e1ec4abace8241fb73d3f30519be0fdc1d4c8c
Depends on
135212de5370 2b5ee371060e 4dee277c327a 64dc5197f9df 6a81dbab471d 7f617ee9b3ad 8d80356371c5 8fd78eb52ad2 982864068a59 9ae4d15a0e4b b582948f1912 c6aa5bd9d1c8 d0016e229b04 e4595038502a e59512f2f075 e993a086c13f fc1a466a1e66 fce44ae9ad29 ff0af1ee299d ff1f62fee654
Depended on by
835ceb9b3ea7 135bdcadec4a ed931d1d1102 6c3dbe2018e0 0588de69a84d 569802ebae0e
In bookmarks
main
In tags
v0.10.0 v0.11.0 v0.9.0
Edits
18 atoms, 5 tree operations, 11 files

Browse the tree at this change

added crates/cylinder/Cargo.toml +14 −0
1 +
[package]
2 +
name = "cylinder"
3 +
version.workspace = true
4 +
edition.workspace = true
5 +
license.workspace = true
6 +
description = "Cylinder: artifacts as trees of Oxygen chunks (docs/cylinder.md)"
7 +
8 +
[dependencies]
9 +
oxy-core = { path = "../oxy-core" }
10 +
blake3 = "1"
11 +
thiserror = "1"
12 +
13 +
[dev-dependencies]
14 +
tempfile = "3"
added crates/cylinder/src/lib.rs +729 −0
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 +
}
added src/cli/artifact.rs +180 −0
1 +
//! `oxy artifact`: Cylinder's artifacts (docs/cylinder.md §4.1, step C1):
2 +
//! a directory packed into a tree of the repository's own chunks, named by
3 +
//! the BLAKE3 of its canonical encoding.
4 +
5 +
use super::json;
6 +
use super::open;
7 +
use super::ui;
8 +
use anyhow::Result;
9 +
use cylinder::{Kind, Spec};
10 +
use std::path::Path;
11 +
12 +
/// `KEY=VALUE` pairs, refused when one has no `=`.
13 +
fn pairs(list: &[String], what: &str) -> Result<std::collections::BTreeMap<String, String>> {
14 +
    list.iter()
15 +
        .map(|p| {
16 +
            p.split_once('=')
17 +
                .map(|(k, v)| (k.to_string(), v.to_string()))
18 +
                .ok_or_else(|| anyhow::anyhow!("{what} {p:?}: write it KEY=VALUE"))
19 +
        })
20 +
        .collect()
21 +
}
22 +
23 +
fn summary(k: &cylinder::Kept) -> String {
24 +
    let a = &k.artifact;
25 +
    let mut parts = vec![a.kind.as_str().to_string()];
26 +
    parts.extend(a.os.clone());
27 +
    parts.extend(a.arch.clone());
28 +
    format!(
29 +
        "{} {} {}",
30 +
        ui::hash(&k.name[..12]),
31 +
        parts.join(" "),
32 +
        ui::dim(format!("{} bytes", a.total_size))
33 +
    )
34 +
}
35 +
36 +
fn as_json(k: &cylinder::Kept, files: usize) -> String {
37 +
    let a = &k.artifact;
38 +
    let text = |v: &Option<String>| v.as_deref().map(json::s).unwrap_or_else(|| "null".into());
39 +
    let map = |m: &std::collections::BTreeMap<String, String>| {
40 +
        let inner: Vec<String> = m
41 +
            .iter()
42 +
            .map(|(k, v)| format!("{}:{}", json::s(k), json::s(v)))
43 +
            .collect();
44 +
        format!("{{{}}}", inner.join(","))
45 +
    };
46 +
    json::obj([
47 +
        ("name", json::s(&k.name)),
48 +
        ("kind", json::s(a.kind.as_str())),
49 +
        ("arch", text(&a.arch)),
50 +
        ("os", text(&a.os)),
51 +
        ("tree", json::s(&a.tree.to_hex())),
52 +
        ("files", json::n(files as u64)),
53 +
        ("total_size", json::n(a.total_size)),
54 +
        ("env", map(&a.env)),
55 +
        ("labels", map(&a.labels)),
56 +
    ])
57 +
}
58 +
59 +
#[allow(clippy::too_many_arguments)]
60 +
pub fn pack(
61 +
    dir: &Path,
62 +
    kind: &str,
63 +
    arch: Option<String>,
64 +
    os: Option<String>,
65 +
    entrypoint: Vec<String>,
66 +
    env: &[String],
67 +
    labels: &[String],
68 +
    as_json_out: bool,
69 +
) -> Result<i32> {
70 +
    let repo = open()?;
71 +
    let mut spec = Spec::new(Kind::parse(kind)?);
72 +
    spec.arch = arch;
73 +
    spec.os = os;
74 +
    spec.entrypoint = (!entrypoint.is_empty()).then_some(entrypoint);
75 +
    spec.env = pairs(env, "env")?;
76 +
    spec.labels = pairs(labels, "label")?;
77 +
    let sp = ui::spinner("Packing…");
78 +
    let k = cylinder::pack(&repo, dir, &spec)?;
79 +
    sp.clear();
80 +
    let files = cylinder::tree(&repo, &k.artifact)?.entries.len();
81 +
    if as_json_out {
82 +
        println!("{}", as_json(&k, files));
83 +
    } else {
84 +
        println!(
85 +
            "{} {} as {}",
86 +
            ui::done("Packed"),
87 +
            ui::path(dir.display()),
88 +
            summary(&k)
89 +
        );
90 +
        println!("{}", ui::kv("name", &k.name));
91 +
        println!("{}", ui::kv("files", files.to_string()));
92 +
    }
93 +
    Ok(0)
94 +
}
95 +
96 +
pub fn list(as_json_out: bool) -> Result<i32> {
97 +
    let repo = open()?;
98 +
    let names = cylinder::list(&repo)?;
99 +
    let kept: Vec<cylinder::Kept> = names
100 +
        .iter()
101 +
        .map(|n| cylinder::open(&repo, n))
102 +
        .collect::<std::result::Result<_, _>>()?;
103 +
    if as_json_out {
104 +
        let items: Vec<String> = kept
105 +
            .iter()
106 +
            .map(|k| {
107 +
                let files = cylinder::tree(&repo, &k.artifact)
108 +
                    .map(|t| t.entries.len())
109 +
                    .unwrap_or(0);
110 +
                as_json(k, files)
111 +
            })
112 +
            .collect();
113 +
        println!("[{}]", items.join(","));
114 +
        return Ok(0);
115 +
    }
116 +
    if kept.is_empty() {
117 +
        println!("No artifacts yet: oxy artifact pack DIR --kind archive");
118 +
    }
119 +
    for k in &kept {
120 +
        println!("{}", summary(k));
121 +
    }
122 +
    Ok(0)
123 +
}
124 +
125 +
pub fn show(name: &str, as_json_out: bool) -> Result<i32> {
126 +
    let repo = open()?;
127 +
    let k = cylinder::open(&repo, name)?;
128 +
    let t = cylinder::tree(&repo, &k.artifact)?;
129 +
    if as_json_out {
130 +
        println!("{}", as_json(&k, t.entries.len()));
131 +
        return Ok(0);
132 +
    }
133 +
    println!("{}", summary(&k));
134 +
    println!("{}", ui::kv("name", &k.name));
135 +
    if let Some(e) = &k.artifact.entrypoint {
136 +
        println!("{}", ui::kv("entrypoint", e.join(" ")));
137 +
    }
138 +
    for (key, v) in &k.artifact.labels {
139 +
        println!("{}", ui::kv("label", format!("{key}={v}")));
140 +
    }
141 +
    for (key, v) in &k.artifact.env {
142 +
        println!("{}", ui::kv("env", format!("{key}={v}")));
143 +
    }
144 +
    for e in &t.entries {
145 +
        let mode = match e.mode {
146 +
            cylinder::MODE_EXEC => "x",
147 +
            cylinder::MODE_LINK => "l",
148 +
            _ => " ",
149 +
        };
150 +
        println!("  {mode} {:>10}  {}", e.size, e.path);
151 +
    }
152 +
    Ok(0)
153 +
}
154 +
155 +
pub fn unpack(name: &str, dest: &Path) -> Result<i32> {
156 +
    let repo = open()?;
157 +
    let k = cylinder::open(&repo, name)?;
158 +
    let n = cylinder::unpack(&repo, &k, dest)?;
159 +
    println!(
160 +
        "{} {} files of {} into {}",
161 +
        ui::done("Unpacked"),
162 +
        n,
163 +
        ui::hash(&k.name[..12]),
164 +
        ui::path(dest.display())
165 +
    );
166 +
    Ok(0)
167 +
}
168 +
169 +
pub fn verify(name: &str) -> Result<i32> {
170 +
    let repo = open()?;
171 +
    let k = cylinder::open(&repo, name)?;
172 +
    let n = cylinder::verify(&repo, &k)?;
173 +
    println!(
174 +
        "{} {}: its name, tree and {} files check out",
175 +
        ui::done("Verified"),
176 +
        ui::hash(&k.name[..12]),
177 +
        n
178 +
    );
179 +
    Ok(0)
180 +
}
modified CHANGELOG.md +1 −0
…
1 unchanged lines
2 2
3 3
## Unreleased
4 4
5 +
- **Added: artifacts (Cylinder, step C1, #108).** `oxy artifact pack DIR --kind archive|package|container|runner-image|report [--arch --os --entrypoint --env K=V --label K=V]` packs a directory into an artifact: each file an ordinary blob of the repository's store, so files and chunks it holds already cost nothing, under a tree and an artifact encoding in canonical CBOR, named by the BLAKE3 of that encoding (docs/cylinder.md §4.1). `oxy artifact list | show NAME | unpack NAME DEST | verify NAME` (any unique prefix of six characters or more; `--json` on list, show and pack), and `oxy gc` keeps every artifact's blobs. The new `cylinder` crate holds the model.
5 6
- **Added: oxy on Haiku (#154).** oxy builds and runs on Haiku R1/beta6 (x86_64) with HaikuPorts' Rust; `oxy-VERSION-haiku-x86_64.tar.gz` is on the releases page from 0.8.0, with its own `SHA256SUMS-haiku`. The README says how to install it and how to build it (`ZSTD_SYS_USE_PKG_CONFIG=1`).
6 7
7 8
- **Fixed: a subrepo put back after its removal is pinned in the change that registers it (#152).** `oxy submodule remove` leaves a marker so the next snapshot drops the node while the directory stays; the snapshot cleared it only after reading it, so when nothing looked at the working copy between the removal's commit and the subrepo's return, the change that registered it again in `.oxymodules` left it out and a second commit was needed. A marker is now dropped before it is read once the removal is recorded or the path is registered again.
…
298 unchanged lines
modified Cargo.lock +11 −0
…
571 unchanged lines
572 572
]
573 573
574 574
[[package]]
575 +
name = "cylinder"
576 +
version = "0.8.0"
577 +
dependencies = [
578 +
 "blake3",
579 +
 "oxy-core",
580 +
 "tempfile",
581 +
 "thiserror 1.0.69",
582 +
]
583 +
584 +
[[package]]
575 585
name = "data-encoding"
576 586
version = "2.11.1"
577 587
source = "registry+https://github.com/rust-lang/crates.io-index"
…
900 unchanged lines
1478 1488
 "clap_complete",
1479 1489
 "clap_mangen",
1480 1490
 "console",
1491 +
 "cylinder",
1481 1492
 "indicatif",
1482 1493
 "libc",
1483 1494
 "oxy-core",
…
1471 unchanged lines
modified Cargo.toml +2 −1
1 1
[workspace]
2 -
members = ["crates/oxy-core", "crates/oxy-server", "crates/oxy-strata", "."]
2 +
members = ["crates/oxy-core", "crates/oxy-server", "crates/oxy-strata", "crates/cylinder", "."]
3 3
resolver = "2"
4 4
5 5
[workspace.package]
…
17 unchanged lines
23 23
oxy-core = { path = "crates/oxy-core" }
24 24
oxy-server = { path = "crates/oxy-server" }
25 25
oxy-strata = { path = "crates/oxy-strata" }
26 +
cylinder = { path = "crates/cylinder" }
26 27
clap = { version = "4", features = ["derive"] }
27 28
anyhow = "1"
28 29
libc = "0.2"
…
18 unchanged lines
modified crates/oxy-core/src/repo/mod.rs +5 −0
…
693 unchanged lines
694 694
    pub fn root(&self) -> &Path {
695 695
        &self.root
696 696
    }
697 +
    /// The repository's `.oxy` directory (shared by its workspaces), for
698 +
    /// what other crates keep beside the store (Cylinder's artifacts).
699 +
    pub fn oxy_dir(&self) -> &Path {
700 +
        &self.oxy
701 +
    }
697 702
    pub fn store(&self) -> &Store {
698 703
        &self.store
699 704
    }
…
2114 unchanged lines
modified docs/cylinder.md +9 −0
…
370 unchanged lines
371 371
- **C1. The service boundary and artifact model.** Implement the
372 372
  `Artifact` CBOR object type and FastCDC chunk engine in `cylinder`;
373 373
  unify runner images with artifacts.
374 +
  *Started 2026-10-28 (oxygen#108):* `crates/cylinder` has the
375 +
  `Artifact` and `DirTree` encodings of §4.1 (canonical CBOR; a name is
376 +
  the BLAKE3 of the encoding; `env` and `labels` as sorted pairs, since
377 +
  the profile's maps take integer keys), packing a directory into the
378 +
  repository's own blobs and chunks, verifying and unpacking, and the
379 +
  roots garbage collection keeps; `oxy artifact pack | list | show |
380 +
  unpack | verify`. Until the artifact is an object type of its own, its
381 +
  encoding and tree are blobs and `.oxy/artifacts/NAME` records it. Next:
382 +
  the object type, then runner images as artifacts.
374 383
- **C2. Channels, provenance and consensus.** Flow publishes artifacts;
375 384
  signed channel move attestations; multi-party `reproduced(N)` consensus;
376 385
  revset predicates.
…
38 unchanged lines
modified src/cli/mod.rs +78 −0
1 1
//! Command-line definitions and dispatch.
2 2
3 3
mod approve;
4 +
mod artifact;
4 5
mod auth;
5 6
mod format;
6 7
mod identity;
…
395 unchanged lines
402 403
        #[command(subcommand)]
403 404
        cmd: TlogCmd,
404 405
    },
406 +
    /// Artifacts: a directory packed into a tree of this repository's chunks (Cylinder, docs/cylinder.md)
407 +
    Artifact {
408 +
        #[command(subcommand)]
409 +
        cmd: ArtifactCmd,
410 +
    },
405 411
    /// Execution receipts: signed records of what a runner ran on a state
406 412
    Receipt {
407 413
        #[command(subcommand)]
…
370 unchanged lines
778 784
}
779 785
780 786
#[derive(Subcommand)]
787 +
enum ArtifactCmd {
788 +
    /// Pack DIR into an artifact: each file a blob of this repository's store, so files and chunks it holds cost nothing
789 +
    Pack {
790 +
        /// The directory whose files make the artifact
791 +
        dir: std::path::PathBuf,
792 +
        /// container, runner-image, package, archive or report
793 +
        #[arg(long, default_value = "archive")]
794 +
        kind: String,
795 +
        /// x86_64, riscv64, aarch64…
796 +
        #[arg(long)]
797 +
        arch: Option<String>,
798 +
        /// linux, darwin, none…
799 +
        #[arg(long)]
800 +
        os: Option<String>,
801 +
        /// The default command, one word per flag
802 +
        #[arg(long = "entrypoint", value_name = "WORD")]
803 +
        entrypoint: Vec<String>,
804 +
        /// KEY=VALUE for the environment (repeatable)
805 +
        #[arg(long = "env", value_name = "KEY=VALUE")]
806 +
        env: Vec<String>,
807 +
        /// KEY=VALUE metadata (repeatable)
808 +
        #[arg(long = "label", value_name = "KEY=VALUE")]
809 +
        labels: Vec<String>,
810 +
        /// Machine-readable output: one JSON document on stdout
811 +
        #[arg(long)]
812 +
        json: bool,
813 +
    },
814 +
    /// The artifacts kept here
815 +
    List {
816 +
        /// Machine-readable output: one JSON document on stdout
817 +
        #[arg(long)]
818 +
        json: bool,
819 +
    },
820 +
    /// One artifact and its files
821 +
    Show {
822 +
        /// Its name, or a unique prefix of at least six characters
823 +
        name: String,
824 +
        /// Machine-readable output: one JSON document on stdout
825 +
        #[arg(long)]
826 +
        json: bool,
827 +
    },
828 +
    /// Write an artifact's files into DEST (missing or empty)
829 +
    Unpack {
830 +
        /// Its name, or a unique prefix of at least six characters
831 +
        name: String,
832 +
        /// Where to write its files
833 +
        dest: std::path::PathBuf,
834 +
    },
835 +
    /// Check an artifact's name, tree and every file against the store
836 +
    Verify {
837 +
        /// Its name, or a unique prefix of at least six characters
838 +
        name: String,
839 +
    },
840 +
}
841 +
842 +
#[derive(Subcommand)]
781 843
enum ReceiptCmd {
782 844
    /// The receipts kept for a state (default: the bookmark heads)
783 845
    List {
…
590 unchanged lines
1374 1436
                all,
1375 1437
                json,
1376 1438
            } => tlog::verify(hub.as_deref(), key.as_deref(), all, json),
1439 +
        },
1440 +
        Cmd::Artifact { cmd } => match cmd {
1441 +
            ArtifactCmd::Pack {
1442 +
                dir,
1443 +
                kind,
1444 +
                arch,
1445 +
                os,
1446 +
                entrypoint,
1447 +
                env,
1448 +
                labels,
1449 +
                json,
1450 +
            } => artifact::pack(&dir, &kind, arch, os, entrypoint, &env, &labels, json),
1451 +
            ArtifactCmd::List { json } => artifact::list(json),
1452 +
            ArtifactCmd::Show { name, json } => artifact::show(&name, json),
1453 +
            ArtifactCmd::Unpack { name, dest } => artifact::unpack(&name, &dest),
1454 +
            ArtifactCmd::Verify { name } => artifact::verify(&name),
1377 1455
        },
1378 1456
        Cmd::Receipt { cmd } => match cmd {
1379 1457
            ReceiptCmd::List { rev, json } => receipt::list(rev.as_deref(), json),
…
206 unchanged lines
modified src/cli/workspace.rs +3 −1
…
1827 unchanged lines
1828 1828
        repo.config.snapshot_retention_days = 0;
1829 1829
    }
1830 1830
    let sp = ui::spinner("Packing and pruning…");
1831 -
    let r = repo.gc_with(now, &mut |phase| sp.set(&format!("{phase}…")))?;
1831 +
    // Cylinder's artifacts are named from outside the history: keep them.
1832 +
    let artifacts = cylinder::roots(&repo)?;
1833 +
    let r = repo.gc_keeping(now, &artifacts, &mut |phase| sp.set(&format!("{phase}…")))?;
1832 1834
    sp.clear();
1833 1835
    if r.packed.objects > 0 {
1834 1836
        println!(
…
1262 unchanged lines
modified tests/cli_json.rs +69 −0
…
256 unchanged lines
257 257
258 258
/// `oxy commit -F`, `oxy describe` naming and tags as changes (feedback
259 259
/// 4 and 6).
260 +
/// Cylinder C1 (oxygen#108): a directory packs into an artifact of this
261 +
/// repository's chunks, lists and shows as JSON, survives gc, and unpacks.
262 +
#[test]
263 +
fn artifacts_pack_show_survive_gc_and_unpack() {
264 +
    let tmp = tempfile::tempdir().unwrap();
265 +
    let home = tmp.path().join("home");
266 +
    let dir = tmp.path().join("r");
267 +
    std::fs::create_dir_all(&dir).unwrap();
268 +
    assert_eq!(oxy(&dir, &home, &["init"]).0, 0);
269 +
    write(tmp.path(), "out/bin/tool", "#!/bin/sh\necho hi\n");
270 +
    write(tmp.path(), "out/README", "a tool\n");
271 +
    let out = tmp.path().join("out");
272 +
    let pack = [
273 +
        "artifact",
274 +
        "pack",
275 +
        out.to_str().unwrap(),
276 +
        "--kind",
277 +
        "package",
278 +
        "--arch",
279 +
        "x86_64",
280 +
        "--label",
281 +
        "version=0.1.0",
282 +
        "--json",
283 +
    ];
284 +
    let a = json(&dir, &home, &pack);
285 +
    let name = a["name"].as_str().unwrap().to_string();
286 +
    assert_eq!(
287 +
        (
288 +
            a["kind"].as_str(),
289 +
            a["files"].as_u64(),
290 +
            a["labels"]["version"].as_str()
291 +
        ),
292 +
        (Some("package"), Some(2), Some("0.1.0"))
293 +
    );
294 +
    let list = json(&dir, &home, &["artifact", "list", "--json"]);
295 +
    assert_eq!(list[0]["name"], name.as_str());
296 +
    let (code, _, err) = oxy(
297 +
        &dir,
298 +
        &home,
299 +
        &[
300 +
            "artifact",
301 +
            "pack",
302 +
            out.to_str().unwrap(),
303 +
            "--kind",
304 +
            "tarball",
305 +
        ],
306 +
    );
307 +
    assert_ne!(code, 0);
308 +
    assert!(err.contains("runner-image"), "{err}");
309 +
    assert_eq!(oxy(&dir, &home, &["gc", "--now"]).0, 0);
310 +
    let (code, verified, err) = oxy(&dir, &home, &["artifact", "verify", &name[..8]]);
311 +
    assert_eq!(code, 0, "{err}");
312 +
    assert!(String::from_utf8_lossy(&verified).contains("2 files check out"));
313 +
    let back = tmp.path().join("back");
314 +
    assert_eq!(
315 +
        oxy(
316 +
            &dir,
317 +
            &home,
318 +
            &["artifact", "unpack", &name, back.to_str().unwrap()]
319 +
        )
320 +
        .0,
321 +
        0
322 +
    );
323 +
    assert_eq!(
324 +
        std::fs::read_to_string(back.join("bin/tool")).unwrap(),
325 +
        "#!/bin/sh\necho hi\n"
326 +
    );
327 +
}
328 +
260 329
/// oxygen#144: a first identity needs no repository; the next repository
261 330
/// signs as it.
262 331
#[test]
…
499 unchanged lines