zlayer_types/local_image.rs
1//! Local image-store metadata sidecar.
2//!
3//! A natively-built image — most notably one produced by the macOS Seatbelt
4//! builder — never round-trips a registry, so the OCI manifest + config blobs
5//! that normally carry `os` / `architecture` are never written anywhere. That
6//! left two gaps: `inspect` had no platform metadata to report, and dispatch
7//! routing had no `os` hint, so a Mac-native bundle could be mis-routed to the
8//! Linux VM.
9//!
10//! This sidecar closes both. It is a plain JSON file written beside the image's
11//! `rootfs/` at `{images}/{sanitized_ref}/metadata.json`, so it is a durable,
12//! single-writer source of truth with none of the redb single-writer-per-file
13//! fragility of stamping the shared blob cache from a second process. The
14//! builder writes it; image inspection and the composite's OS-resolution read
15//! it back.
16
17use serde::{Deserialize, Serialize};
18
19/// File name of the per-image metadata sidecar, stored next to `rootfs/` inside
20/// the image's directory.
21pub const LOCAL_IMAGE_METADATA_FILE: &str = "metadata.json";
22
23/// Platform + identity metadata recorded for a locally-built image.
24///
25/// Serialized to [`LOCAL_IMAGE_METADATA_FILE`]. Only `reference`, `os`, and
26/// `architecture` are guaranteed; the remaining fields are populated when they
27/// are cheaply available at write time and are otherwise `None` (and resolved
28/// live at read time).
29#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
30pub struct LocalImageMetadata {
31 /// Canonical image reference (the first build tag), e.g. `myapp:latest`.
32 pub reference: String,
33 /// OCI `os` field (`"darwin"` for a macOS Seatbelt build). Maps to
34 /// [`crate::spec::OsKind`] via `OsKind::from_oci_str`.
35 pub os: String,
36 /// OCI `architecture` field (Go `GOARCH`: `"arm64"`, `"amd64"`, …).
37 pub architecture: String,
38 /// Total on-disk size of the `rootfs/` tree in bytes, when recorded.
39 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub size: Option<u64>,
41 /// Content digest of the image, when one is known.
42 #[serde(default, skip_serializing_if = "Option::is_none")]
43 pub digest: Option<String>,
44 /// RFC 3339 creation timestamp, when recorded.
45 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub created: Option<String>,
47}
48
49impl LocalImageMetadata {
50 /// Construct the minimal sidecar (reference + platform), leaving the
51 /// optional fields unset.
52 #[must_use]
53 pub fn new(
54 reference: impl Into<String>,
55 os: impl Into<String>,
56 architecture: impl Into<String>,
57 ) -> Self {
58 Self {
59 reference: reference.into(),
60 os: os.into(),
61 architecture: architecture.into(),
62 size: None,
63 digest: None,
64 created: None,
65 }
66 }
67}
68
69#[cfg(test)]
70mod tests {
71 use super::*;
72
73 #[test]
74 fn round_trips_through_json() {
75 let meta = LocalImageMetadata {
76 reference: "myapp:latest".to_string(),
77 os: "darwin".to_string(),
78 architecture: "arm64".to_string(),
79 size: Some(4096),
80 digest: Some("sha256:abc".to_string()),
81 created: None,
82 };
83 let json = serde_json::to_string(&meta).expect("serialize");
84 let back: LocalImageMetadata = serde_json::from_str(&json).expect("deserialize");
85 assert_eq!(meta, back);
86 }
87
88 #[test]
89 fn unset_optionals_are_omitted_and_default_back() {
90 let meta = LocalImageMetadata::new("myapp:latest", "darwin", "arm64");
91 let json = serde_json::to_string(&meta).expect("serialize");
92 // Absent optionals must not appear on the wire.
93 assert!(!json.contains("size"));
94 assert!(!json.contains("digest"));
95 assert!(!json.contains("created"));
96 let back: LocalImageMetadata = serde_json::from_str(&json).expect("deserialize");
97 assert_eq!(meta, back);
98 assert!(back.size.is_none());
99 }
100}