Skip to main content

gunnar_server/
oci.rs

1//! **The artifact registry, and the images it holds** (roadmap phase 4).
2//!
3//! Content-addressed storage, an OCI image built out of the scratch base, and
4//! the one rule from [`crate::registry`] enforced where a manifest lands.
5//!
6//! # Why the image is attested like any other artifact
7//!
8//! An image is a manifest, which names a config and some layers, all by digest.
9//! So an image **is** a digest, and a digest is the thing `BuildRecord` already
10//! attests to. Building one therefore writes a second signed record whose
11//! *subject* is the manifest digest and whose *material* is the binary that
12//! went in — which chains image → binary → commit → line with no new machinery
13//! and, more importantly, no second kind of evidence to keep honest.
14//!
15//! That is what makes [`crate::registry::admit`] work unchanged on an image
16//! reference. A manifest nobody built is refused for exactly the reason a
17//! binary nobody built is.
18//!
19//! # The layer is an uncompressed tar, and that is deliberate
20//!
21//! `application/vnd.oci.image.layer.v1.tar` is a valid layer media type. Gzip
22//! would make the layer's digest depend on the compressor's version and level —
23//! two runs of the same build would produce two images — and reproducibility is
24//! the whole point of attesting to a digest. Uncompressed, the layer is a pure
25//! function of the files in it.
26//!
27//! Every tar header field that could carry a timestamp, a uid or a username is
28//! **zeroed** for the same reason.
29//!
30//! # Zero CVEs is a measurement, and this is the part that earns it
31//!
32//! The image has one layer and that layer has one file. There is no distro, no
33//! package manager, no shell — so "zero CVEs" is not a claim about diligence,
34//! it is a statement about surface area, and `gunnar build image` prints the
35//! file count so it stays a number rather than an adjective.
36
37use std::path::{Path, PathBuf};
38
39use anyhow::{Context, Result};
40
41use crate::stores::ServerStores;
42
43/// Where the registry keeps its bytes, under the data root.
44pub const REGISTRY_DIR: &str = "registry";
45
46/// One tar header block, 512 bytes.
47const BLOCK: usize = 512;
48
49/// A file going into a layer.
50pub struct Entry<'a> {
51    /// The path inside the image, without a leading `/`.
52    pub path: &'a str,
53    /// Its bytes.
54    pub bytes: &'a [u8],
55    /// The mode. `0o755` for something meant to be executed.
56    pub mode: u32,
57}
58
59/// Write `entries` as an uncompressed tar.
60///
61/// Reproducible by construction: no timestamps, no uid, no gid, no user names.
62/// Two calls with the same entries produce the same bytes, so the layer digest
63/// is a function of the content and of nothing else.
64pub fn tar(entries: &[Entry<'_>]) -> Result<Vec<u8>> {
65    let mut out = Vec::new();
66    for entry in entries {
67        anyhow::ensure!(
68            entry.path.len() < 100,
69            "{:?} is too long for a ustar name field; a path this deep needs the prefix field, \
70             which this writer does not use because no image gunnar builds has one",
71            entry.path
72        );
73        let mut header = [0u8; BLOCK];
74        header[..entry.path.len()].copy_from_slice(entry.path.as_bytes());
75        // Octal, NUL-terminated, exactly as ustar wants. Written by hand
76        // because every one of these fields is a place a formatter would put a
77        // space or a timestamp that made the layer irreproducible.
78        let octal = |field: &mut [u8], value: u64| {
79            let text = format!("{value:0width$o}", width = field.len() - 1);
80            field[..text.len()].copy_from_slice(text.as_bytes());
81        };
82        octal(&mut header[100..108], entry.mode as u64 & 0o7777);
83        octal(&mut header[108..116], 0); // uid
84        octal(&mut header[116..124], 0); // gid
85        octal(&mut header[124..136], entry.bytes.len() as u64);
86        octal(&mut header[136..148], 0); // mtime — zero, for reproducibility
87        header[148..156].fill(b' '); // checksum field, blank while summing
88        header[156] = b'0'; // typeflag: an ordinary file
89        header[257..263].copy_from_slice(b"ustar\0");
90        header[263..265].copy_from_slice(b"00");
91        let sum: u64 = header.iter().map(|byte| *byte as u64).sum();
92        let text = format!("{sum:06o}\0");
93        header[148..148 + text.len()].copy_from_slice(text.as_bytes());
94        out.extend_from_slice(&header);
95
96        out.extend_from_slice(entry.bytes);
97        let padding = (BLOCK - entry.bytes.len() % BLOCK) % BLOCK;
98        out.extend(std::iter::repeat_n(0u8, padding));
99    }
100    // Two zero blocks end an archive. One is not enough and readers differ
101    // about what a single one means.
102    out.extend(std::iter::repeat_n(0u8, BLOCK * 2));
103    Ok(out)
104}
105
106/// `sha256:<hex>` — how OCI names everything.
107pub fn digest_of(bytes: &[u8]) -> String {
108    // Through `gunnar_core`, so there is one place in this tree that decides
109    // what the digest of something is (one implementation) — and so this crate does not grow
110    // a hasher of its own beside the one it already links.
111    format!("sha256:{}", gunnar_core::provenance::digest_bytes(bytes))
112}
113
114/// The registry's content-addressed store.
115#[derive(Debug, Clone)]
116pub struct Registry {
117    root: PathBuf,
118}
119
120/// What a manifest push was refused for, or what it landed as.
121#[derive(Debug, Clone)]
122pub struct Landed {
123    pub digest: String,
124    pub name: String,
125    pub reference: String,
126}
127
128impl Registry {
129    /// The registry under a data root.
130    pub fn under(root: impl AsRef<Path>) -> Registry {
131        Registry {
132            root: root.as_ref().join(REGISTRY_DIR),
133        }
134    }
135
136    fn blob_path(&self, digest: &str) -> Result<PathBuf> {
137        let hex = digest.strip_prefix("sha256:").with_context(|| {
138            format!("{digest:?} is not a `sha256:` digest; this registry addresses nothing else")
139        })?;
140        anyhow::ensure!(
141            hex.len() == 64 && hex.bytes().all(|b| b.is_ascii_hexdigit()),
142            "{digest:?} is not a sha256 digest"
143        );
144        Ok(self.root.join("blobs").join("sha256").join(hex))
145    }
146
147    /// **Store bytes under their own digest.**
148    ///
149    /// The digest is computed here and never taken from the caller: a registry
150    /// that trusts a supplied digest is one where a client chooses what its
151    /// bytes are called, which is the whole attack content addressing exists to
152    /// prevent.
153    pub fn put_blob(&self, bytes: &[u8]) -> Result<String> {
154        let digest = digest_of(bytes);
155        let path = self.blob_path(&digest)?;
156        std::fs::create_dir_all(path.parent().expect("a blob has a parent"))?;
157        if path.exists() {
158            // Content-addressed: the same digest is the same bytes, so a
159            // re-push is a no-op rather than a conflict.
160            return Ok(digest);
161        }
162        // Written beside and renamed, so a reader never sees a partial blob
163        // under a digest that promises the whole thing.
164        let temp = path.with_extension("partial");
165        std::fs::write(&temp, bytes)?;
166        std::fs::rename(&temp, &path)?;
167        Ok(digest)
168    }
169
170    /// Read a blob back, verifying it still hashes to its own name.
171    ///
172    /// The check is not paranoia: a content-addressed store whose reads are not
173    /// verified is a store where bit rot becomes a supply-chain event, and the
174    /// cost is one hash of bytes already in memory.
175    pub fn get_blob(&self, digest: &str) -> Result<Option<Vec<u8>>> {
176        let path = self.blob_path(digest)?;
177        let bytes = match std::fs::read(&path) {
178            Ok(bytes) => bytes,
179            Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
180            Err(err) => return Err(err.into()),
181        };
182        let actual = digest_of(&bytes);
183        anyhow::ensure!(
184            actual == digest,
185            "the blob stored as {digest} hashes to {actual}. The registry has been corrupted \
186             or written around."
187        );
188        Ok(Some(bytes))
189    }
190
191    /// Where a tag's file lives, **inside this registry and nowhere else**.
192    ///
193    /// # An absolute name is not a name, it is a destination
194    ///
195    /// The check this replaced tested the name as ONE string: non-empty, under
196    /// 128 bytes, drawn from an alphabet that includes `/`, and holding no
197    /// `..`. Every one of those passes for `/etc/gunnar`, and
198    /// `Path::join` **replaces** the whole path when what it is handed is
199    /// absolute — so the registry root is silently discarded and the write
200    /// lands wherever the leading slash pointed. The same trick works one
201    /// component in (`team//app`, `team/./app`): the empty and `.` components
202    /// are not traversal, they are just not names, and nothing rejected them.
203    ///
204    /// So the name is checked **component by component**, which is the only
205    /// shape in which "this stays under the root" is a statement about a path
206    /// rather than about a string: no empty component (which is what a leading,
207    /// trailing or doubled `/` produces, absolute paths included), no `.`, no
208    /// `..`, and the OCI alphabet otherwise.
209    ///
210    /// The HTTP surface has already put the name through
211    /// `gunnar_core::resolve_repo_path` before a request reaches this crate.
212    /// This is not that check repeated for the sake of it: `Registry` is a
213    /// `pub` type with callers that never touched a URL (`gunnar build image`,
214    /// the tests), and a store that is only safe when its callers are careful
215    /// is a store that is one new caller away from not being safe.
216    fn tag_path(&self, name: &str, reference: &str) -> Result<PathBuf> {
217        let legal_component = |part: &str| {
218            !part.is_empty()
219                && part.len() < 128
220                && part != "."
221                && part != ".."
222                && part
223                    .bytes()
224                    .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
225        };
226        anyhow::ensure!(
227            !name.is_empty() && name.split('/').all(legal_component),
228            "{name:?} is not a usable repository name"
229        );
230        anyhow::ensure!(
231            legal_component(reference),
232            "{reference:?} is not a usable tag"
233        );
234        let mut path = self.root.join("manifests");
235        // Pushed component by component rather than as one string, so the
236        // property the check above establishes is the property the path is
237        // built with — `join` on a validated whole would still be `join` on a
238        // string somebody could later widen.
239        for part in name.split('/') {
240            path.push(part);
241        }
242        path.push(reference);
243        Ok(path)
244    }
245
246    /// **Land a manifest — if the image it names was built and attested.**
247    ///
248    /// This is [`crate::registry::admit`] at the one place bytes become
249    /// pullable. A manifest whose digest no signed, logged build record names is
250    /// refused, and the refusal says which of the four things is missing.
251    pub fn put_manifest(
252        &self,
253        stores: &ServerStores,
254        name: &str,
255        reference: &str,
256        manifest: &[u8],
257    ) -> Result<std::result::Result<Landed, crate::registry::Refusal>> {
258        let digest = digest_of(manifest);
259        // `admit` speaks bare hex, because that is what a `BuildRecord` subject
260        // carries; OCI speaks `sha256:`. The translation happens here, once.
261        let hex = digest.trim_start_matches("sha256:");
262        if let Err(refusal) = crate::registry::admit(stores, hex)? {
263            return Ok(Err(refusal));
264        }
265        self.put_blob(manifest)?;
266        let path = self.tag_path(name, reference)?;
267        std::fs::create_dir_all(path.parent().expect("a tag has a parent"))?;
268        std::fs::write(&path, &digest)?;
269        Ok(Ok(Landed {
270            digest,
271            name: name.to_owned(),
272            reference: reference.to_owned(),
273        }))
274    }
275
276    /// What digest a tag points at.
277    pub fn resolve(&self, name: &str, reference: &str) -> Result<Option<String>> {
278        // A reference that is already a digest resolves to itself; that is how
279        // `docker pull name@sha256:…` works and it must not go through the tag
280        // directory.
281        if reference.starts_with("sha256:") {
282            return Ok(Some(reference.to_owned()));
283        }
284        let path = self.tag_path(name, reference)?;
285        match std::fs::read_to_string(&path) {
286            Ok(text) => Ok(Some(text.trim().to_owned())),
287            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
288            Err(err) => Err(err.into()),
289        }
290    }
291}
292
293/// What building an image produced.
294#[derive(Debug, Clone)]
295pub struct Image {
296    /// The manifest's digest — the image's identity.
297    pub digest: String,
298    pub manifest: Vec<u8>,
299    pub config_digest: String,
300    /// The config document itself.
301    ///
302    /// Carried rather than rebuilt, because a puller fetches this blob by
303    /// digest and a second function deciding what a config says is a second
304    /// authority that can drift from the first (one implementation).
305    pub config: Vec<u8>,
306    pub layer_digest: String,
307    /// The layer tar itself, for the same reason as [`Image::config`].
308    pub layer: Vec<u8>,
309    /// How many files the image's filesystem holds. The number behind
310    /// "zero CVEs".
311    pub files: usize,
312}
313
314/// **Build an OCI image around one static binary, on nothing.**
315///
316/// `FROM scratch` is not a base image here, it is the *absence* of one: the
317/// manifest names one layer, and that layer is a tar with one file in it. There
318/// is no distro to have a CVE.
319pub fn build_image(binary: &[u8], entrypoint: &str) -> Result<Image> {
320    let path = entrypoint.trim_start_matches('/');
321    anyhow::ensure!(
322        !path.is_empty(),
323        "an image needs an entrypoint; a layer with a binary nobody can name is not an image"
324    );
325    let layer = tar(&[Entry {
326        path,
327        bytes: binary,
328        mode: 0o755,
329    }])?;
330    let layer_digest = digest_of(&layer);
331
332    // `diff_ids` are the digests of the **uncompressed** layers, which for an
333    // uncompressed layer is the same digest the manifest carries. Naming it
334    // twice is OCI's shape, not a duplication that can drift: both come from
335    // one variable.
336    let config = format!(
337        concat!(
338            "{{\"architecture\":\"amd64\",\"os\":\"linux\",",
339            "\"config\":{{\"Entrypoint\":[\"/{path}\"]}},",
340            "\"rootfs\":{{\"type\":\"layers\",\"diff_ids\":[\"{layer}\"]}}}}"
341        ),
342        path = path,
343        layer = layer_digest
344    );
345    let config_digest = digest_of(config.as_bytes());
346
347    let manifest = format!(
348        concat!(
349            "{{\"schemaVersion\":2,",
350            "\"mediaType\":\"application/vnd.oci.image.manifest.v1+json\",",
351            "\"config\":{{\"mediaType\":\"application/vnd.oci.image.config.v1+json\",",
352            "\"digest\":\"{config}\",\"size\":{config_size}}},",
353            "\"layers\":[{{\"mediaType\":\"application/vnd.oci.image.layer.v1.tar\",",
354            "\"digest\":\"{layer}\",\"size\":{layer_size}}}]}}"
355        ),
356        config = config_digest,
357        config_size = config.len(),
358        layer = layer_digest,
359        layer_size = layer.len()
360    );
361
362    Ok(Image {
363        digest: digest_of(manifest.as_bytes()),
364        manifest: manifest.into_bytes(),
365        config_digest,
366        config: config.into_bytes(),
367        layer_digest,
368        layer,
369        files: 1,
370    })
371}
372
373/// Build an image and put every part of it in the registry.
374///
375/// Returns the image, with its blobs already stored — a manifest whose layers
376/// are not there is a manifest nobody can pull, and landing them separately is
377/// how that happens.
378pub fn build_and_store(registry: &Registry, binary: &[u8], entrypoint: &str) -> Result<Image> {
379    let image = build_image(binary, entrypoint)?;
380    // Both blobs the manifest names, not just the layer. A puller fetches the
381    // config first — it is where the entrypoint lives — and an image whose
382    // config is missing is an image that resolves and then fails halfway.
383    let stored_layer = registry.put_blob(&image.layer)?;
384    let stored_config = registry.put_blob(&image.config)?;
385    debug_assert_eq!(stored_layer, image.layer_digest);
386    debug_assert_eq!(stored_config, image.config_digest);
387    Ok(image)
388}
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393
394    #[test]
395    fn a_tar_is_reproducible_and_has_the_shape_a_reader_expects() {
396        let a = tar(&[Entry {
397            path: "gunnar",
398            bytes: b"binary",
399            mode: 0o755,
400        }])
401        .expect("tar");
402        let b = tar(&[Entry {
403            path: "gunnar",
404            bytes: b"binary",
405            mode: 0o755,
406        }])
407        .expect("tar");
408        assert_eq!(a, b, "two identical layers produced different bytes");
409
410        // ustar magic where a reader looks for it, and the name at the front.
411        assert_eq!(&a[257..262], b"ustar");
412        assert_eq!(&a[..6], b"gunnar");
413        // Header, one padded data block, two zero blocks.
414        assert_eq!(a.len(), BLOCK * 4);
415        assert!(
416            a[a.len() - BLOCK * 2..].iter().all(|byte| *byte == 0),
417            "an archive must end in two zero blocks"
418        );
419        // The checksum field must be filled in, or `tar` refuses the archive.
420        assert_ne!(&a[148..154], b"      ", "the checksum was left blank");
421    }
422
423    /// GNU tar is the outside oracle: gunnar's own reader agreeing with
424    /// gunnar's own writer would prove nothing.
425    #[test]
426    fn stock_tar_reads_what_this_writer_produced() {
427        let Ok(probe) = std::process::Command::new("tar").arg("--version").output() else {
428            eprintln!("skipped: no `tar` on PATH");
429            return;
430        };
431        assert!(probe.status.success());
432
433        let dir = tempfile::tempdir().expect("tempdir");
434        let archive = dir.path().join("layer.tar");
435        std::fs::write(
436            &archive,
437            tar(&[Entry {
438                path: "gunnar",
439                bytes: b"the binary\n",
440                mode: 0o755,
441            }])
442            .expect("tar"),
443        )
444        .expect("write");
445
446        let out = std::process::Command::new("tar")
447            .arg("-xf")
448            .arg(&archive)
449            .current_dir(dir.path())
450            .output()
451            .expect("run tar");
452        assert!(
453            out.status.success(),
454            "stock tar refused the layer this writer produced: {}",
455            String::from_utf8_lossy(&out.stderr)
456        );
457        assert_eq!(
458            std::fs::read_to_string(dir.path().join("gunnar")).expect("extracted"),
459            "the binary\n"
460        );
461        // **The mode in the ARCHIVE, not on the extracted file.** GNU tar
462        // applies the caller's umask on extraction unless it is root or `-p`
463        // was passed, so the file on disk here is `0o755 & !umask` and says
464        // more about this shell than about the layer. `-tv` prints what the
465        // header records, which is the thing under test.
466        let listing = std::process::Command::new("tar")
467            .arg("-tvf")
468            .arg(&archive)
469            .output()
470            .expect("run tar -tv");
471        let listing = String::from_utf8_lossy(&listing.stdout);
472        assert!(
473            listing.contains("rwxr-xr-x"),
474            "the layer does not record the execute bit: {listing}"
475        );
476        assert!(
477            listing.contains("0/0"),
478            "the layer records a uid or gid, so it is not reproducible across machines: \
479             {listing}"
480        );
481    }
482
483    #[test]
484    fn an_image_is_one_layer_of_one_file_and_names_its_own_parts() {
485        let image = build_image(b"ELF...", "/gunnar").expect("image");
486        assert_eq!(
487            image.files, 1,
488            "the scratch image must hold exactly one file"
489        );
490        let manifest = String::from_utf8(image.manifest.clone()).expect("utf-8");
491        assert!(manifest.contains(&image.config_digest));
492        assert!(manifest.contains(&image.layer_digest));
493        assert!(manifest.contains("application/vnd.oci.image.manifest.v1+json"));
494        // Uncompressed on purpose; see the module docs.
495        assert!(manifest.contains("application/vnd.oci.image.layer.v1.tar\""));
496        assert_eq!(image.digest, digest_of(&image.manifest));
497
498        // Building it twice must produce the same image, or attesting to a
499        // digest says nothing about a rebuild.
500        assert_eq!(
501            build_image(b"ELF...", "/gunnar").expect("again").digest,
502            image.digest
503        );
504        assert_ne!(
505            build_image(b"different", "/gunnar").expect("other").digest,
506            image.digest,
507            "two different binaries produced the same image digest"
508        );
509    }
510
511    #[test]
512    fn a_blob_is_stored_under_its_own_digest_and_read_back_verified() {
513        let dir = tempfile::tempdir().expect("tempdir");
514        let registry = Registry::under(dir.path());
515        let digest = registry.put_blob(b"hello").expect("put");
516        assert_eq!(digest, digest_of(b"hello"));
517        assert_eq!(
518            registry.get_blob(&digest).expect("get").as_deref(),
519            Some(&b"hello"[..])
520        );
521        assert!(registry
522            .get_blob(&digest_of(b"absent"))
523            .expect("get")
524            .is_none());
525
526        // **A blob written around must not read back clean.** The store is
527        // content-addressed; if a read does not check, bit rot is a supply
528        // chain event.
529        let path = registry.blob_path(&digest).expect("path");
530        std::fs::write(&path, b"tampered").expect("rewrite");
531        let err = registry.get_blob(&digest).expect_err("a rewritten blob");
532        assert!(format!("{err}").contains("hashes to"), "{err}");
533    }
534
535    #[test]
536    fn a_digest_a_client_supplies_cannot_name_bytes_it_does_not_hash_to() {
537        let dir = tempfile::tempdir().expect("tempdir");
538        let registry = Registry::under(dir.path());
539        // There is no API to supply one — that is the point — so what is
540        // asserted is that a malformed reference is refused rather than
541        // creating a path.
542        assert!(registry.get_blob("sha256:short").is_err());
543        assert!(registry.get_blob("md5:whatever").is_err());
544        assert!(registry
545            .get_blob(&format!("sha256:{}", "z".repeat(64)))
546            .is_err());
547    }
548
549    #[test]
550    fn a_tag_may_not_escape_the_registry() {
551        let dir = tempfile::tempdir().expect("tempdir");
552        let registry = Registry::under(dir.path());
553        assert!(registry.tag_path("team/app", "v1").is_ok());
554        assert!(registry.tag_path("../escape", "v1").is_err());
555        assert!(registry.tag_path("team/app", "../../etc/passwd").is_err());
556        assert!(registry.tag_path("team/app", "a/b").is_err());
557        assert!(registry.tag_path("", "v1").is_err());
558    }
559
560    /// **An absolute name is a destination, and `Path::join` takes it.**
561    ///
562    /// `join` replaces the whole path when it is handed an absolute one, so a
563    /// name beginning with `/` discarded the registry root and the manifest
564    /// landed wherever the name pointed. The `..` check never fired: there is
565    /// no `..` in `/etc/gunnar`.
566    ///
567    /// The assertion is on the resulting PATH and not only on the refusal, so a
568    /// `tag_path` that started returning `Ok` with the root thrown away could
569    /// not satisfy this test by returning an error somewhere else.
570    #[test]
571    fn an_absolute_name_does_not_become_an_absolute_path() {
572        let dir = tempfile::tempdir().expect("tempdir");
573        let registry = Registry::under(dir.path());
574
575        for escape in [
576            "/etc/gunnar",
577            "//etc/gunnar",
578            "/",
579            "team//app",
580            "team/./app",
581        ] {
582            let refused = registry.tag_path(escape, "v1");
583            assert!(
584                refused.is_err(),
585                "{escape:?} was accepted as a repository name and resolved to {:?}",
586                refused.map(|path| path.display().to_string())
587            );
588        }
589
590        // The accept side, first — without it a `tag_path` that refused
591        // everything would pass every line above.
592        let legal = registry.tag_path("team/app", "v1").expect("a legal name");
593        assert!(
594            legal.starts_with(dir.path()),
595            "a legal name left the registry root: {}",
596            legal.display()
597        );
598    }
599
600    /// The same hole, reached through the method that WRITES. `put_manifest`
601    /// goes through `admit` first, so this drives `resolve`, the read half that
602    /// takes the same path and is reachable with no attestation at all.
603    #[test]
604    fn a_read_cannot_be_steered_out_of_the_registry_either() {
605        let dir = tempfile::tempdir().expect("tempdir");
606        let registry = Registry::under(dir.path());
607        // A file outside the registry, which an absolute name would have read.
608        let outside = dir.path().join("secret");
609        std::fs::write(&outside, "sha256:not-yours").expect("write");
610        let name = outside.to_str().expect("utf-8 tempdir");
611        let err = registry.resolve(name, "v1").expect_err("an absolute name");
612        assert!(
613            format!("{err}").contains("not a usable repository name"),
614            "{err}"
615        );
616    }
617}