OXYGENHUB

The artifact as an object type of its own: oxy-core's artifact module, named by its object hash, checked by fsck (refs #108) 6c3dbe2018e0

Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key f2a4febd0309
When
· 2026-10-29 01:08 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
6c3dbe2018e0dc400f47dd6a8f33748393649b94b7fd05ba62120bbe7a3d9ccf
Depends on
0588de69a84d 387335ff7f4d 5b9c6ad45f6e 642da2636de2 8fd78eb52ad2 a2387d547373 b582948f1912 d0016e229b04 ff0af1ee299d
Depended on by
nothing yet
In bookmarks
main
In tags
v0.10.0 v0.11.0 v0.9.0
Edits
31 atoms, 1 tree operation, 7 files

Browse the tree at this change

added crates/oxy-core/src/artifact.rs +303 −0
1 +
//! Artifacts (Cylinder, docs/cylinder.md §4.1, oxygen#108): an immutable
2 +
//! object naming a tree of files, each an ordinary blob of FastCDC BLAKE3
3 +
//! chunks, so an image rebuilt with one changed file moves only that file's
4 +
//! changed chunks, and a binary in an archive and in an image is the same
5 +
//! chunks. An artifact is an object of its own (`artifact`) in canonical
6 +
//! CBOR; its tree is a blob. The `cylinder` crate packs, unpacks and keeps
7 +
//! them.
8 +
9 +
use crate::cbor::{self, Value};
10 +
use crate::hash::{hash_object, ObjectRef, ObjectType};
11 +
use crate::objects::{schema, ObjectError};
12 +
use std::collections::BTreeMap;
13 +
14 +
type R<T> = std::result::Result<T, ObjectError>;
15 +
16 +
/// The encoding's version this crate writes and reads.
17 +
pub const VERSION: u64 = 1;
18 +
19 +
/// What an artifact is for.
20 +
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
21 +
pub enum Kind {
22 +
    Container,
23 +
    RunnerImage,
24 +
    Package,
25 +
    Archive,
26 +
    Report,
27 +
}
28 +
29 +
impl Kind {
30 +
    pub const ALL: [Kind; 5] = [
31 +
        Kind::Container,
32 +
        Kind::RunnerImage,
33 +
        Kind::Package,
34 +
        Kind::Archive,
35 +
        Kind::Report,
36 +
    ];
37 +
38 +
    pub fn as_str(self) -> &'static str {
39 +
        match self {
40 +
            Kind::Container => "container",
41 +
            Kind::RunnerImage => "runner-image",
42 +
            Kind::Package => "package",
43 +
            Kind::Archive => "archive",
44 +
            Kind::Report => "report",
45 +
        }
46 +
    }
47 +
48 +
    pub fn parse(s: &str) -> R<Kind> {
49 +
        Kind::ALL.into_iter().find(|k| k.as_str() == s).map_or_else(
50 +
            || {
51 +
                schema(format!(
52 +
                    "{s:?} is not a kind of artifact: {}",
53 +
                    Kind::ALL.map(|k| k.as_str()).join(", ")
54 +
                ))
55 +
            },
56 +
            Ok,
57 +
        )
58 +
    }
59 +
}
60 +
61 +
/// A file's mode in a tree: a regular file, an executable one, or a
62 +
/// symbolic link (whose blob is its target).
63 +
pub const MODE_FILE: u32 = 0o100644;
64 +
pub const MODE_EXEC: u32 = 0o100755;
65 +
pub const MODE_LINK: u32 = 0o120000;
66 +
67 +
/// A file of an artifact's tree.
68 +
#[derive(Clone, Debug, PartialEq, Eq)]
69 +
pub struct Entry {
70 +
    /// `/`-separated, relative, never `.` or `..` parts.
71 +
    pub path: String,
72 +
    pub mode: u32,
73 +
    pub blob: ObjectRef,
74 +
    pub size: u64,
75 +
}
76 +
77 +
/// An artifact's files, in path order: paths, modes, blobs and sizes.
78 +
#[derive(Clone, Debug, Default, PartialEq, Eq)]
79 +
pub struct DirTree {
80 +
    pub entries: Vec<Entry>,
81 +
}
82 +
83 +
fn path_ok(p: &str) -> bool {
84 +
    !p.is_empty()
85 +
        && !p.starts_with('/')
86 +
        && p.split('/').all(|s| !s.is_empty() && s != "." && s != "..")
87 +
}
88 +
89 +
fn obj(r: &ObjectRef) -> Value {
90 +
    Value::Bytes(r.0.to_vec())
91 +
}
92 +
93 +
fn get_obj(v: &Value, what: &str) -> R<ObjectRef> {
94 +
    match v.as_bytes() {
95 +
        Some(b) if b.len() == 32 => {
96 +
            let mut a = [0u8; 32];
97 +
            a.copy_from_slice(b);
98 +
            Ok(ObjectRef(a))
99 +
        }
100 +
        _ => schema(format!("{what} is not an object reference")),
101 +
    }
102 +
}
103 +
104 +
fn get_text(v: &Value, what: &str) -> R<String> {
105 +
    v.as_text()
106 +
        .map(str::to_string)
107 +
        .map_or_else(|| schema(format!("{what} is not text")), Ok)
108 +
}
109 +
110 +
fn get_uint(v: &Value, what: &str) -> R<u64> {
111 +
    v.as_uint()
112 +
        .map_or_else(|| schema(format!("{what} is not a number")), Ok)
113 +
}
114 +
115 +
impl DirTree {
116 +
    pub fn encode(&self) -> Vec<u8> {
117 +
        let entries = self
118 +
            .entries
119 +
            .iter()
120 +
            .map(|e| {
121 +
                Value::Array(vec![
122 +
                    Value::Text(e.path.clone()),
123 +
                    Value::Uint(e.mode as u64),
124 +
                    obj(&e.blob),
125 +
                    Value::Uint(e.size),
126 +
                ])
127 +
            })
128 +
            .collect();
129 +
        cbor::encode(&Value::map([
130 +
            (0, Value::Uint(VERSION)),
131 +
            (1, Value::Array(entries)),
132 +
        ]))
133 +
    }
134 +
135 +
    /// Read and check a tree: a known version, clean paths, each once, in
136 +
    /// order, with a known mode.
137 +
    pub fn decode(bytes: &[u8]) -> R<DirTree> {
138 +
        let v = cbor::decode(bytes)?;
139 +
        let m = v.as_map().map_or_else(|| schema("a tree is a map"), Ok)?;
140 +
        if m.get(&0).and_then(Value::as_uint) != Some(VERSION) {
141 +
            return schema(format!(
142 +
                "tree version {:?} is not known; this reads {VERSION}",
143 +
                m.get(&0)
144 +
            ));
145 +
        }
146 +
        let mut entries = Vec::new();
147 +
        for item in m.get(&1).and_then(Value::as_array).unwrap_or_default() {
148 +
            let [p, mode, blob, size] = item.as_array().unwrap_or_default() else {
149 +
                return schema("a tree entry is [path, mode, blob, size]");
150 +
            };
151 +
            let path = get_text(p, "a path")?;
152 +
            let mode = get_uint(mode, "a mode")? as u32;
153 +
            if !path_ok(&path) {
154 +
                return schema(format!("{path:?} is not a clean relative path"));
155 +
            }
156 +
            if ![MODE_FILE, MODE_EXEC, MODE_LINK].contains(&mode) {
157 +
                return schema(format!(
158 +
                    "{path}: mode {mode:o} is not a file, an executable or a link"
159 +
                ));
160 +
            }
161 +
            if entries.last().is_some_and(|l: &Entry| l.path >= path) {
162 +
                return schema(format!(
163 +
                    "{path}: entries are not in path order, or one is there twice"
164 +
                ));
165 +
            }
166 +
            entries.push(Entry {
167 +
                path,
168 +
                mode,
169 +
                blob: get_obj(blob, "a blob")?,
170 +
                size: get_uint(size, "a size")?,
171 +
            });
172 +
        }
173 +
        Ok(DirTree { entries })
174 +
    }
175 +
}
176 +
177 +
/// An artifact (docs/cylinder.md §4.1).
178 +
#[derive(Clone, Debug, PartialEq, Eq)]
179 +
pub struct Artifact {
180 +
    pub kind: Kind,
181 +
    /// `x86_64`, `riscv64`, `aarch64`.
182 +
    pub arch: Option<String>,
183 +
    /// `linux`, `darwin`, `none`.
184 +
    pub os: Option<String>,
185 +
    /// The [`DirTree`]'s blob.
186 +
    pub tree: ObjectRef,
187 +
    /// The default command, for a container or an image.
188 +
    pub entrypoint: Option<Vec<String>>,
189 +
    pub env: BTreeMap<String, String>,
190 +
    pub labels: BTreeMap<String, String>,
191 +
    /// The files' bytes together.
192 +
    pub total_size: u64,
193 +
    /// The receipt of the Flow job that built it, when one did.
194 +
    pub receipt: Option<ObjectRef>,
195 +
}
196 +
197 +
fn pairs(m: &BTreeMap<String, String>) -> Value {
198 +
    if m.is_empty() {
199 +
        return Value::Null;
200 +
    }
201 +
    Value::Array(
202 +
        m.iter()
203 +
            .map(|(k, v)| Value::Array(vec![Value::Text(k.clone()), Value::Text(v.clone())]))
204 +
            .collect(),
205 +
    )
206 +
}
207 +
208 +
fn get_pairs(v: Option<&Value>, what: &str) -> R<BTreeMap<String, String>> {
209 +
    let mut out = BTreeMap::new();
210 +
    let Some(v) = v.filter(|v| !v.is_null()) else {
211 +
        return Ok(out);
212 +
    };
213 +
    for p in v
214 +
        .as_array()
215 +
        .map_or_else(|| schema(format!("{what} is a list of pairs")), Ok)?
216 +
    {
217 +
        let [k, val] = p.as_array().unwrap_or_default() else {
218 +
            return schema(format!("{what}: a pair is [key, value]"));
219 +
        };
220 +
        let k = get_text(k, what)?;
221 +
        if out.insert(k.clone(), get_text(val, what)?).is_some() {
222 +
            return schema(format!("{what}: {k} is there twice"));
223 +
        }
224 +
    }
225 +
    Ok(out)
226 +
}
227 +
228 +
fn opt_text(v: &Option<String>) -> Value {
229 +
    v.as_ref().map_or(Value::Null, |s| Value::Text(s.clone()))
230 +
}
231 +
232 +
impl Artifact {
233 +
    /// Its canonical CBOR, keys as in docs/cylinder.md §4.1.
234 +
    pub fn encode(&self) -> Vec<u8> {
235 +
        let entry = self.entrypoint.as_ref().map_or(Value::Null, |a| {
236 +
            Value::Array(a.iter().cloned().map(Value::Text).collect())
237 +
        });
238 +
        cbor::encode(&Value::map([
239 +
            (0, Value::Uint(VERSION)),
240 +
            (1, Value::Text(self.kind.as_str().into())),
241 +
            (2, opt_text(&self.arch)),
242 +
            (3, opt_text(&self.os)),
243 +
            (4, obj(&self.tree)),
244 +
            (5, entry),
245 +
            (6, pairs(&self.env)),
246 +
            (7, pairs(&self.labels)),
247 +
            (8, Value::Uint(self.total_size)),
248 +
            (9, self.receipt.as_ref().map_or(Value::Null, obj)),
249 +
        ]))
250 +
    }
251 +
252 +
    pub fn decode(bytes: &[u8]) -> R<Artifact> {
253 +
        let v = cbor::decode(bytes)?;
254 +
        let m = v
255 +
            .as_map()
256 +
            .map_or_else(|| schema("an artifact is a map"), Ok)?;
257 +
        if m.get(&0).and_then(Value::as_uint) != Some(VERSION) {
258 +
            return schema(format!(
259 +
                "artifact version {:?} is not known; this reads {VERSION}",
260 +
                m.get(&0)
261 +
            ));
262 +
        }
263 +
        if let Some(k) = m.keys().find(|k| **k > 9) {
264 +
            return schema(format!("key {k} is not part of an artifact"));
265 +
        }
266 +
        let text = |k: u64, what: &str| -> R<Option<String>> {
267 +
            match m.get(&k) {
268 +
                None | Some(Value::Null) => Ok(None),
269 +
                Some(v) => get_text(v, what).map(Some),
270 +
            }
271 +
        };
272 +
        let entrypoint = match m.get(&5) {
273 +
            None | Some(Value::Null) => None,
274 +
            Some(v) => Some(
275 +
                v.as_array()
276 +
                    .unwrap_or_default()
277 +
                    .iter()
278 +
                    .map(|a| get_text(a, "the entrypoint"))
279 +
                    .collect::<R<_>>()?,
280 +
            ),
281 +
        };
282 +
        let receipt = match m.get(&9) {
283 +
            None | Some(Value::Null) => None,
284 +
            Some(v) => Some(get_obj(v, "the receipt")?),
285 +
        };
286 +
        Ok(Artifact {
287 +
            kind: Kind::parse(&text(1, "the kind")?.unwrap_or_default())?,
288 +
            arch: text(2, "the architecture")?,
289 +
            os: text(3, "the system")?,
290 +
            tree: get_obj(m.get(&4).unwrap_or(&Value::Null), "the tree")?,
291 +
            entrypoint,
292 +
            env: get_pairs(m.get(&6), "env")?,
293 +
            labels: get_pairs(m.get(&7), "labels")?,
294 +
            total_size: get_uint(m.get(&8).unwrap_or(&Value::Null), "the size")?,
295 +
            receipt,
296 +
        })
297 +
    }
298 +
299 +
    /// Its name: its object's hash, as every Oxygen object is named.
300 +
    pub fn name(&self) -> ObjectRef {
301 +
        hash_object(ObjectType::Artifact, &self.encode())
302 +
    }
303 +
}
modified CHANGELOG.md +1 −0
…
2 unchanged lines
3 3
## Unreleased
4 4
5 5
- **Fixed: an expired checkout token no longer hides your sign-in (#155).** A checkout's own token for a remote (`remote.NAME.token`) is used first, so when it expired every fetch, pull and push answered 401 although `oxy auth login` had a good sign-in for the same hub. A checkout token the hub no longer knows (401) now falls back to that sign-in, with a warning naming `oxy config --unset remote.NAME.token` to drop the stale one; never to another checkout's token, and not when the token is refused for permission (403, 404), so a narrowly scoped token is never swapped for a wider sign-in. When both are refused, the error says to sign in again and unset it.
6 +
- **Added: the `artifact` object type (Cylinder C1, #108).** An artifact is now an object of its own in the store (oxy-core's `artifact` module), named by its object hash as every object is, and `oxy fsck` checks it; its tree stays a blob. Stores written before read as they did: the type is a new one at the end of the list.
6 7
- **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.
7 8
- **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`).
8 9
…
299 unchanged lines
modified crates/cylinder/src/lib.rs +42 −317
…
3 unchanged lines
4 4
//! one changed file moves only that file's changed chunks, and a binary in
5 5
//! a release archive and in a runner image is the same chunks.
6 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`]).
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`]).
12 13
13 -
use oxy_core::cbor::{self, Value};
14 -
use oxy_core::hash::ObjectRef;
14 +
use oxy_core::hash::{ObjectRef, ObjectType};
15 15
use oxy_core::repo::Repo;
16 16
use std::collections::BTreeMap;
17 17
use std::path::{Path, PathBuf};
18 18
19 -
/// The encoding's version this crate writes and reads.
20 -
pub const VERSION: u64 = 1;
19 +
pub use oxy_core::artifact::{
20 +
    Artifact, DirTree, Entry, Kind, MODE_EXEC, MODE_FILE, MODE_LINK, VERSION,
21 +
};
21 22
22 23
#[derive(Debug, thiserror::Error)]
23 24
pub enum Error {
…
1 unchanged lines
25 26
    Repo(#[from] oxy_core::repo::RepoError),
26 27
    #[error("io: {0}")]
27 28
    Io(#[from] std::io::Error),
28 -
    #[error("not a canonical artifact encoding: {0}")]
29 -
    Cbor(#[from] oxy_core::cbor::CborError),
29 +
    #[error("not an artifact: {0}")]
30 +
    Object(#[from] oxy_core::objects::ObjectError),
31 +
    #[error("store: {0}")]
32 +
    Store(#[from] oxy_core::store::StoreError),
30 33
    #[error("{0}")]
31 34
    Bad(String),
32 35
}
…
4 unchanged lines
37 40
    Err(Error::Bad(msg.into()))
38 41
}
39 42
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 43
/// What to pack a directory as.
325 44
#[derive(Clone, Debug)]
326 45
pub struct Spec {
…
23 unchanged lines
350 69
pub struct Kept {
351 70
    pub name: String,
352 71
    pub artifact: Artifact,
353 -
    /// The blob its encoding is kept in.
354 -
    pub blob: ObjectRef,
72 +
    /// Its object (its name, as a reference).
73 +
    pub object: ObjectRef,
355 74
}
356 75
357 76
fn kept_dir(repo: &Repo) -> PathBuf {
…
90 unchanged lines
448 167
        total_size: tree.entries.iter().map(|e| e.size).sum(),
449 168
        receipt: None,
450 169
    };
451 -
    let bytes = artifact.encode();
452 -
    let blob = repo.store_blob(&bytes)?;
453 -
    let name = blake3::hash(&bytes).to_hex().to_string();
170 +
    let object = repo.store().put(ObjectType::Artifact, &artifact.encode())?;
171 +
    let name = object.to_hex();
454 172
    std::fs::create_dir_all(kept_dir(repo))?;
455 -
    std::fs::write(kept_dir(repo).join(&name), blob.to_hex())?;
173 +
    std::fs::write(kept_dir(repo).join(&name), b"")?;
456 174
    Ok(Kept {
457 175
        name,
458 176
        artifact,
459 -
        blob,
177 +
        object,
460 178
    })
461 179
}
462 180
…
17 unchanged lines
480 198
}
481 199
482 200
/// The kept artifact `name` names, or a unique prefix of it (at least six
483 -
/// characters), checked against its name.
201 +
/// characters), read from the store, which checks it against its name.
484 202
pub fn open(repo: &Repo, name: &str) -> Result<Kept> {
485 203
    let name = name.to_ascii_lowercase();
486 204
    if name.len() < 6 {
…
17 unchanged lines
504 222
            ))
505 223
        }
506 224
    };
507 -
    let hex = std::fs::read_to_string(kept_dir(repo).join(&full))?;
508 -
    let blob = ObjectRef::from_hex(hex.trim()).map_or_else(
225 +
    let object = ObjectRef::from_hex(&full).map_or_else(
509 226
        || bad(format!("artifact {full}: its record is damaged")),
510 227
        Ok,
511 228
    )?;
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 -
    }
229 +
    // The store checks the bytes against the object's name as it reads.
230 +
    let bytes = repo.store().get_typed(&object, ObjectType::Artifact)?;
518 231
    Ok(Kept {
519 232
        name: full,
520 233
        artifact: Artifact::decode(&bytes)?,
521 -
        blob,
234 +
        object,
522 235
    })
523 236
}
524 237
525 238
/// An artifact's tree.
526 239
pub fn tree(repo: &Repo, a: &Artifact) -> Result<DirTree> {
527 -
    DirTree::decode(&repo.read_blob(&a.tree)?)
240 +
    Ok(DirTree::decode(&repo.read_blob(&a.tree)?)?)
528 241
}
529 242
530 243
/// Check that every file of `k` is in the store whole: its bytes read, at
…
55 unchanged lines
586 299
    let mut out = Vec::new();
587 300
    for name in list(repo)? {
588 301
        let k = open(repo, &name)?;
589 -
        out.push(k.blob);
302 +
        out.push(k.object);
590 303
        out.push(k.artifact.tree);
591 304
        out.extend(tree(repo, &k.artifact)?.entries.iter().map(|e| e.blob));
592 305
    }
…
3 unchanged lines
596 309
#[cfg(test)]
597 310
mod tests {
598 311
    use super::*;
312 +
    use oxy_core::cbor::{self, Value};
599 313
600 314
    fn repo(dir: &Path) -> Repo {
601 315
        Repo::init(&dir.join("repo")).unwrap()
…
18 unchanged lines
620 334
        spec.arch = Some("x86_64".into());
621 335
        spec.labels.insert("version".into(), "0.1.0".into());
622 336
        let k = pack(&r, &src, &spec).unwrap();
623 -
        assert_eq!(k.name, k.artifact.name());
337 +
        assert_eq!(k.name, k.artifact.name().to_hex());
624 338
        assert_eq!(
625 339
            k.artifact.total_size,
626 340
            300_006 + if cfg!(unix) { 8 } else { 0 }
…
37 unchanged lines
664 378
        // The same files pack to the same name; garbage collection keeps it.
665 379
        assert_eq!(pack(&r, &src, &spec).unwrap().name, k.name);
666 380
        let keep = roots(&r).unwrap();
667 -
        assert!(keep.contains(&k.blob) && keep.contains(&k.artifact.tree));
381 +
        assert!(keep.contains(&k.object) && keep.contains(&k.artifact.tree));
668 382
        r.gc_keeping(true, &keep, &mut |_| {}).unwrap();
383 +
        // An artifact is an object of its own, which fsck reads and accepts.
384 +
        let report = r.fsck().unwrap();
385 +
        let errors: Vec<_> = report
386 +
            .problems
387 +
            .iter()
388 +
            .filter(|p| p.severity == oxy_core::repo::Severity::Error)
389 +
            .collect();
390 +
        assert!(errors.is_empty(), "{errors:?}");
669 391
        assert_eq!(
670 392
            verify(&r, &open(&r, &k.name).unwrap()).unwrap(),
671 393
            paths.len()
…
15 unchanged lines
687 409
        };
688 410
        let bytes = a.encode();
689 411
        assert_eq!(Artifact::decode(&bytes).unwrap(), a);
690 -
        assert_eq!(a.name(), blake3::hash(&bytes).to_hex().to_string());
412 +
        assert_eq!(
413 +
            a.name(),
414 +
            oxy_core::hash::hash_object(ObjectType::Artifact, &bytes)
415 +
        );
691 416
        assert!(Kind::parse("tarball")
692 417
            .unwrap_err()
693 418
            .to_string()
…
36 unchanged lines
modified crates/oxy-core/src/hash.rs +6 −1
…
67 unchanged lines
68 68
    Certificate,
69 69
    /// An author's grant to a certified subject (§14.7).
70 70
    Grant,
71 +
    /// A tree of files as one immutable thing: an image, a package, an
72 +
    /// archive (Cylinder, docs/cylinder.md §4.1).
73 +
    Artifact,
71 74
}
72 75
73 76
impl ObjectType {
74 -
    pub const ALL: [ObjectType; 12] = [
77 +
    pub const ALL: [ObjectType; 13] = [
75 78
        ObjectType::Chunk,
76 79
        ObjectType::Blob,
77 80
        ObjectType::Change,
…
6 unchanged lines
84 87
        ObjectType::Receipt,
85 88
        ObjectType::Certificate,
86 89
        ObjectType::Grant,
90 +
        ObjectType::Artifact,
87 91
    ];
88 92
89 93
    pub fn name(self) -> &'static str {
…
10 unchanged lines
100 104
            ObjectType::Receipt => "receipt",
101 105
            ObjectType::Certificate => "certificate",
102 106
            ObjectType::Grant => "grant",
107 +
            ObjectType::Artifact => "artifact",
103 108
        }
104 109
    }
105 110
…
59 unchanged lines
modified crates/oxy-core/src/lib.rs +1 −0
…
6 unchanged lines
7 7
8 8
pub mod alloc_stats;
9 9
pub mod approval;
10 +
pub mod artifact;
10 11
pub mod attest;
11 12
pub mod attributes;
12 13
pub mod cbor;
…
30 unchanged lines
modified crates/oxy-core/src/repo/fsck.rs +1 −0
…
102 unchanged lines
103 103
        ObjectType::Receipt => crate::receipt::Receipt::decode(body).map(|_| ()),
104 104
        ObjectType::Certificate => crate::cert::Certificate::decode(body).map(|_| ()),
105 105
        ObjectType::Grant => crate::cert::Grant::decode(body).map(|_| ()),
106 +
        ObjectType::Artifact => crate::artifact::Artifact::decode(body).map(|_| ()),
106 107
        ObjectType::State => crate::objects::StateObject::decode(body).map(|_| ()),
107 108
    };
108 109
    r.map_err(|e| e.to_string())
…
408 unchanged lines
modified docs/cylinder.md +5 −3
…
376 unchanged lines
377 377
  the profile's maps take integer keys), packing a directory into the
378 378
  repository's own blobs and chunks, verifying and unpacking, and the
379 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.
380 +
  unpack | verify`. The artifact is an object type of its own
381 +
  (`artifact`, in oxy-core's `artifact` module, checked by `oxy fsck`),
382 +
  named by its object hash like every object; its tree is a blob, and
383 +
  `.oxy/artifacts/NAME` records the artifacts a repository keeps. Next:
384 +
  runner images as artifacts, then moving them between repositories (C2).
383 385
- **C2. Channels, provenance and consensus.** Flow publishes artifacts;
384 386
  signed channel move attestations; multi-party `reproduced(N)` consensus;
385 387
  revset predicates.
…
38 unchanged lines