Skip to main content

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}