Skip to main content

zlayer_types/
lib.rs

1//! Shared wire types for the `ZLayer` platform.
2//!
3//! This crate is the SDK-facing types crate: API DTOs, OCI image
4//! references, and other serde-friendly wire shapes consumed by both
5//! the daemon and clients. It is intentionally lightweight — no axum,
6//! no tokio, no reqwest. Heavier server-side abstractions live in
7//! `zlayer-api`, `zlayer-core`, and friends.
8
9/// Canonical OCI image reference.
10///
11/// Re-export of [`oci_client::Reference`] (which itself re-exports
12/// `oci_spec::distribution::Reference`). Use this as the wire type for
13/// any image reference — the OCI spec grammar
14/// `[host[:port]/]name[:tag][@digest]`, with built-in normalization
15/// for Docker Hub defaults.
16pub use oci_client::Reference as ImageReference;
17
18/// Serde helpers to (de)serialize an [`ImageReference`] as its OCI-spec
19/// canonical string form (`[host[:port]/]name[:tag][@digest]`) instead of
20/// the default struct shape `{registry, repository, tag, digest}`.
21///
22/// Use with `#[serde(with = "zlayer_types::image_ref_serde")]` on a field
23/// of type `ImageReference`. For optional fields use `image_ref_serde::option`.
24pub mod image_ref_serde {
25    use super::ImageReference;
26    use serde::{Deserialize, Deserializer, Serializer};
27    use std::str::FromStr;
28
29    /// # Errors
30    ///
31    /// Returns the serializer's error if writing the string form fails.
32    pub fn serialize<S: Serializer>(r: &ImageReference, s: S) -> Result<S::Ok, S::Error> {
33        s.serialize_str(&r.to_string())
34    }
35
36    /// # Errors
37    ///
38    /// Returns a deserialization error if the input is not a valid OCI image reference string.
39    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<ImageReference, D::Error> {
40        let s = String::deserialize(d)?;
41        ImageReference::from_str(&s).map_err(serde::de::Error::custom)
42    }
43
44    pub mod option {
45        use super::ImageReference;
46        use serde::{Deserialize, Deserializer, Serializer};
47        use std::str::FromStr;
48
49        /// # Errors
50        ///
51        /// Returns the serializer's error if writing the string form fails.
52        pub fn serialize<S: Serializer>(
53            r: &Option<ImageReference>,
54            s: S,
55        ) -> Result<S::Ok, S::Error> {
56            match r {
57                Some(r) => s.serialize_str(&r.to_string()),
58                None => s.serialize_none(),
59            }
60        }
61
62        /// # Errors
63        ///
64        /// Returns a deserialization error if the input is not a valid OCI image reference string.
65        pub fn deserialize<'de, D: Deserializer<'de>>(
66            d: D,
67        ) -> Result<Option<ImageReference>, D::Error> {
68            let s: Option<String> = Option::deserialize(d)?;
69            match s {
70                Some(s) => ImageReference::from_str(&s)
71                    .map(Some)
72                    .map_err(serde::de::Error::custom),
73                None => Ok(None),
74            }
75        }
76    }
77}
78
79/// Image reference that preserves the user's RAW input string alongside
80/// the parsed canonical [`ImageReference`].
81///
82/// `ImageReference` (from `oci_client`) normalizes Docker Hub defaults at
83/// parse time — `nginx` becomes `docker.io/library/nginx`. That's correct
84/// for resolution, but destroys the ability to detect "the user wrote a
85/// bare name and we should look for it locally first."
86///
87/// `ImageRef` holds both:
88/// - `parsed`: the canonical [`ImageReference`] for normalized comparisons,
89///   registry calls, and tag/digest extraction.
90/// - `original`: the exact bytes the user (or wire format) gave us.
91///
92/// Equality and hashing use the canonical `parsed` form, so two `ImageRef`s
93/// resolving to the same image compare equal even if one was qualified.
94/// `Display` / `Serialize` use the original, so roundtripping preserves
95/// what the user typed.
96#[derive(Debug, Clone)]
97pub struct ImageRef {
98    parsed: ImageReference,
99    original: String,
100}
101
102impl ImageRef {
103    /// Wrap an already-parsed [`ImageReference`]. The `original` field is
104    /// set to the canonical string form, so [`Self::is_unqualified`] on the
105    /// result will return `false` (the canonical form always has a host).
106    #[must_use]
107    pub fn from_parsed(parsed: ImageReference) -> Self {
108        let original = parsed.to_string();
109        Self { parsed, original }
110    }
111
112    /// Access the parsed canonical [`ImageReference`].
113    #[must_use]
114    pub fn parsed(&self) -> &ImageReference {
115        &self.parsed
116    }
117
118    /// Access the user's original input string verbatim.
119    #[must_use]
120    pub fn original(&self) -> &str {
121        &self.original
122    }
123
124    /// Returns true when the user's original string did NOT include a registry
125    /// host (no `host[:port]/` prefix). Detection rule: split on the FIRST `/`;
126    /// if there's no `/` at all, or the segment before the first `/` contains
127    /// neither `.` nor `:` and is not `localhost`, treat as unqualified.
128    ///
129    /// Examples:
130    /// - `nginx`                              -> unqualified
131    /// - `nginx:latest`                       -> unqualified
132    /// - `library/nginx`                      -> unqualified (Docker namespace, no host)
133    /// - `foo/bar`                            -> unqualified
134    /// - `docker.io/library/nginx`            -> qualified (host has `.`)
135    /// - `ghcr.io/foo/bar`                    -> qualified (host has `.`)
136    /// - `localhost/foo`                      -> qualified (literal `localhost`)
137    /// - `localhost:5000/foo`                 -> qualified (`:` port)
138    /// - `registry:5000/foo`                  -> qualified (`:` port)
139    #[must_use]
140    pub fn is_unqualified(&self) -> bool {
141        image_str_is_unqualified(&self.original)
142    }
143}
144
145/// Standalone form of [`ImageRef::is_unqualified`] operating on a raw string.
146///
147/// Useful at boundary layers (e.g. the registry client) that receive the
148/// user-original string via [`ImageRef::to_string`] / `Display` and need to
149/// decide whether to fall back to a remote pull without round-tripping
150/// through `ImageRef`.
151///
152/// Detection rule matches [`ImageRef::is_unqualified`]: split on the first
153/// `/`; if there's no `/` at all, or the segment before the first `/`
154/// contains neither `.` nor `:` and is not `localhost`, the reference is
155/// considered unqualified.
156#[must_use]
157pub fn image_str_is_unqualified(s: &str) -> bool {
158    let without_digest = match s.split_once('@') {
159        Some((head, _)) => head,
160        None => s,
161    };
162    let Some((head, _rest)) = without_digest.split_once('/') else {
163        return true;
164    };
165    if head == "localhost" {
166        return false;
167    }
168    if head.contains('.') || head.contains(':') {
169        return false;
170    }
171    true
172}
173
174/// Candidate `(name, reference)` spellings for the LITERAL image string,
175/// in priority order, for cross-spelling lookups in a local store.
176///
177/// This is pure string work — it NEVER canonicalizes the reference or
178/// invents a registry host. It exists so a store that extracted an image
179/// under one spelling (e.g. `docker.io/library/alpine:latest`) can still be
180/// found when the user later asks for an equivalent spelling (`alpine:latest`)
181/// WITHOUT rewriting the storage key to a canonical form. The `reference`
182/// (tag or digest body) is split off once and shared across every candidate.
183///
184/// Order (first match wins; duplicates removed):
185/// 1. The primary name as given.
186/// 2. With `docker.io/` prefix stripped.
187/// 3. With `docker.io/library/` prefix stripped.
188/// 4. With `library/` prefix stripped.
189/// 5. With `library/` prefix added (only when the primary name has no `/`).
190/// 6. The bare last path segment.
191#[must_use]
192pub fn image_ref_candidates(image: &str) -> Vec<(String, String)> {
193    // Split off digest/tag to get the primary name + reference.
194    let (primary, reference) = if let Some(at_pos) = image.find('@') {
195        (image[..at_pos].to_string(), image[at_pos + 1..].to_string())
196    } else if let Some(colon_pos) = image.rfind(':') {
197        let potential_tag = &image[colon_pos + 1..];
198        if !potential_tag.contains('/') && !potential_tag.is_empty() {
199            (image[..colon_pos].to_string(), potential_tag.to_string())
200        } else {
201            (image.to_string(), "latest".to_string())
202        }
203    } else {
204        (image.to_string(), "latest".to_string())
205    };
206
207    let mut candidates: Vec<(String, String)> = Vec::new();
208    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
209    let mut push = |name: String| {
210        if seen.insert(name.clone()) {
211            candidates.push((name, reference.clone()));
212        }
213    };
214
215    // 1. Primary name as-is.
216    push(primary.clone());
217    // 2. Strip `docker.io/` prefix.
218    if let Some(rest) = primary.strip_prefix("docker.io/") {
219        push(rest.to_string());
220    }
221    // 3. Strip `docker.io/library/` prefix.
222    if let Some(rest) = primary.strip_prefix("docker.io/library/") {
223        push(rest.to_string());
224    }
225    // 4. Strip `library/` prefix.
226    if let Some(rest) = primary.strip_prefix("library/") {
227        push(rest.to_string());
228    }
229    // 5. Add `library/` prefix when the primary has no `/` at all.
230    if !primary.contains('/') {
231        push(format!("library/{primary}"));
232    }
233    // 6. Bare last path segment.
234    if let Some(last) = primary.rsplit('/').next() {
235        if !last.is_empty() {
236            push(last.to_string());
237        }
238    }
239
240    candidates
241}
242
243impl std::str::FromStr for ImageRef {
244    type Err = <ImageReference as std::str::FromStr>::Err;
245
246    fn from_str(s: &str) -> Result<Self, Self::Err> {
247        let parsed = ImageReference::from_str(s)?;
248        Ok(Self {
249            parsed,
250            original: s.to_string(),
251        })
252    }
253}
254
255impl std::ops::Deref for ImageRef {
256    type Target = ImageReference;
257
258    fn deref(&self) -> &Self::Target {
259        &self.parsed
260    }
261}
262
263impl std::fmt::Display for ImageRef {
264    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
265        f.write_str(&self.original)
266    }
267}
268
269impl PartialEq for ImageRef {
270    fn eq(&self, other: &Self) -> bool {
271        self.parsed == other.parsed
272    }
273}
274
275impl Eq for ImageRef {}
276
277impl std::hash::Hash for ImageRef {
278    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
279        // `ImageReference` doesn't implement `Hash`, so hash its canonical
280        // string form. This stays consistent with `PartialEq`, which
281        // compares `parsed` directly.
282        self.parsed.to_string().hash(state);
283    }
284}
285
286impl serde::Serialize for ImageRef {
287    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
288        s.serialize_str(&self.original)
289    }
290}
291
292impl<'de> serde::Deserialize<'de> for ImageRef {
293    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
294        use std::str::FromStr;
295        let s = String::deserialize(d)?;
296        Self::from_str(&s).map_err(serde::de::Error::custom)
297    }
298}
299
300/// Wire-type modules. Each maps to one logical area; downstream crates
301/// import via `pub use zlayer_types::<area>::...`.
302pub mod api;
303pub mod auth;
304pub mod builder;
305pub mod client;
306pub mod cluster;
307pub mod jwt;
308pub mod overlay;
309pub mod overlayd;
310pub mod scratch;
311pub mod secrets;
312pub mod spec;
313pub mod storage;
314
315pub use scratch::{Scratch, ScratchFile};
316
317#[cfg(test)]
318mod image_ref_tests {
319    use super::ImageRef;
320    use std::str::FromStr;
321
322    fn parse(s: &str) -> ImageRef {
323        ImageRef::from_str(s).unwrap_or_else(|e| panic!("failed to parse {s:?}: {e}"))
324    }
325
326    #[test]
327    fn unqualified_bare_name() {
328        assert!(parse("nginx").is_unqualified());
329    }
330
331    #[test]
332    fn unqualified_bare_name_with_tag() {
333        assert!(parse("nginx:latest").is_unqualified());
334    }
335
336    #[test]
337    fn unqualified_library_namespace() {
338        assert!(parse("library/nginx").is_unqualified());
339    }
340
341    #[test]
342    fn unqualified_user_namespace_with_tag() {
343        assert!(parse("foo/bar:1.0").is_unqualified());
344    }
345
346    #[test]
347    fn qualified_docker_io() {
348        assert!(!parse("docker.io/library/nginx").is_unqualified());
349    }
350
351    #[test]
352    fn qualified_ghcr() {
353        assert!(!parse("ghcr.io/foo/bar:1.2").is_unqualified());
354    }
355
356    #[test]
357    fn qualified_localhost() {
358        assert!(!parse("localhost/foo").is_unqualified());
359    }
360
361    #[test]
362    fn qualified_localhost_port() {
363        assert!(!parse("localhost:5000/foo").is_unqualified());
364    }
365
366    #[test]
367    fn qualified_registry_port() {
368        assert!(!parse("registry:5000/foo").is_unqualified());
369    }
370
371    #[test]
372    fn unqualified_with_digest_tail() {
373        // Bare name + digest: the host detection must strip the `@sha256:...`
374        // tail before scanning for `/`. There is no `/` left, so unqualified.
375        let s = "foo@sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
376        assert!(parse(s).is_unqualified());
377    }
378
379    #[test]
380    fn display_preserves_user_input() {
381        // `nginx:latest` would canonicalize to `docker.io/library/nginx:latest`;
382        // Display must return the original verbatim.
383        let r = ImageRef::from_str("nginx:latest").unwrap();
384        assert_eq!(r.to_string(), "nginx:latest");
385    }
386
387    #[test]
388    fn serde_json_roundtrip_preserves_original() {
389        let r = ImageRef::from_str("zarcrunner-executor:latest").unwrap();
390        let json = serde_json::to_string(&r).unwrap();
391        assert_eq!(json, "\"zarcrunner-executor:latest\"");
392        let back: ImageRef = serde_json::from_str(&json).unwrap();
393        assert_eq!(back.original(), "zarcrunner-executor:latest");
394    }
395
396    #[test]
397    fn equality_uses_canonical_form() {
398        // `nginx:latest` and `docker.io/library/nginx:latest` canonicalize
399        // to the same `ImageReference`, so the wrappers must compare equal
400        // even though their `original` strings differ.
401        let bare = ImageRef::from_str("nginx:latest").unwrap();
402        let qualified = ImageRef::from_str("docker.io/library/nginx:latest").unwrap();
403        assert_eq!(bare, qualified);
404    }
405}