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}