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 `localhost/` prefix stripped (buildah tags bare local builds as
190///    `localhost/<name>:<tag>`, so a `localhost/`-spelled image must resolve
191///    against a store keyed under the bare `<name>`).
192/// 6. With `library/` prefix added (only when the primary name has no `/`).
193/// 7. The bare last path segment.
194#[must_use]
195pub fn image_ref_candidates(image: &str) -> Vec<(String, String)> {
196    // Split off digest/tag to get the primary name + reference.
197    let (primary, reference) = if let Some(at_pos) = image.find('@') {
198        (image[..at_pos].to_string(), image[at_pos + 1..].to_string())
199    } else if let Some(colon_pos) = image.rfind(':') {
200        let potential_tag = &image[colon_pos + 1..];
201        if !potential_tag.contains('/') && !potential_tag.is_empty() {
202            (image[..colon_pos].to_string(), potential_tag.to_string())
203        } else {
204            (image.to_string(), "latest".to_string())
205        }
206    } else {
207        (image.to_string(), "latest".to_string())
208    };
209
210    let mut candidates: Vec<(String, String)> = Vec::new();
211    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
212    let mut push = |name: String| {
213        if seen.insert(name.clone()) {
214            candidates.push((name, reference.clone()));
215        }
216    };
217
218    // 1. Primary name as-is.
219    push(primary.clone());
220    // 2. Strip `docker.io/` prefix.
221    if let Some(rest) = primary.strip_prefix("docker.io/") {
222        push(rest.to_string());
223    }
224    // 3. Strip `docker.io/library/` prefix.
225    if let Some(rest) = primary.strip_prefix("docker.io/library/") {
226        push(rest.to_string());
227    }
228    // 4. Strip `library/` prefix.
229    if let Some(rest) = primary.strip_prefix("library/") {
230        push(rest.to_string());
231    }
232    // 5. Strip `localhost/` prefix. buildah tags a bare `docker build -t name:tag`
233    //    as `localhost/name:tag`; the Docker-compat socket build path imports
234    //    under that spelling, so a later `docker run localhost/name:tag` (or the
235    //    daemon canonicalizing a bare `name:tag`) must be able to find it keyed
236    //    under the host-stripped `name:tag`.
237    if let Some(rest) = primary.strip_prefix("localhost/") {
238        push(rest.to_string());
239    }
240    // 6. Add `library/` prefix when the primary has no `/` at all.
241    if !primary.contains('/') {
242        push(format!("library/{primary}"));
243    }
244    // 7. Bare last path segment.
245    if let Some(last) = primary.rsplit('/').next() {
246        if !last.is_empty() {
247            push(last.to_string());
248        }
249    }
250
251    candidates
252}
253
254impl std::str::FromStr for ImageRef {
255    type Err = <ImageReference as std::str::FromStr>::Err;
256
257    fn from_str(s: &str) -> Result<Self, Self::Err> {
258        let parsed = ImageReference::from_str(s)?;
259        Ok(Self {
260            parsed,
261            original: s.to_string(),
262        })
263    }
264}
265
266impl std::ops::Deref for ImageRef {
267    type Target = ImageReference;
268
269    fn deref(&self) -> &Self::Target {
270        &self.parsed
271    }
272}
273
274impl std::fmt::Display for ImageRef {
275    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
276        f.write_str(&self.original)
277    }
278}
279
280impl PartialEq for ImageRef {
281    fn eq(&self, other: &Self) -> bool {
282        self.parsed == other.parsed
283    }
284}
285
286impl Eq for ImageRef {}
287
288impl std::hash::Hash for ImageRef {
289    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
290        // `ImageReference` doesn't implement `Hash`, so hash its canonical
291        // string form. This stays consistent with `PartialEq`, which
292        // compares `parsed` directly.
293        self.parsed.to_string().hash(state);
294    }
295}
296
297impl serde::Serialize for ImageRef {
298    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
299        s.serialize_str(&self.original)
300    }
301}
302
303impl<'de> serde::Deserialize<'de> for ImageRef {
304    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
305        use std::str::FromStr;
306        let s = String::deserialize(d)?;
307        Self::from_str(&s).map_err(serde::de::Error::custom)
308    }
309}
310
311/// Wire-type modules. Each maps to one logical area; downstream crates
312/// import via `pub use zlayer_types::<area>::...`.
313pub mod api;
314pub mod auth;
315pub mod builder;
316pub mod client;
317pub mod cluster;
318pub mod jwt;
319pub mod local_image;
320pub mod logs;
321pub mod nat_wire;
322pub mod overlay;
323pub mod overlayd;
324pub mod package_index;
325pub mod scratch;
326pub mod secrets;
327pub mod spec;
328pub mod storage;
329pub mod toolchain_lock;
330
331pub use scratch::{Scratch, ScratchFile};
332
333#[cfg(test)]
334mod image_ref_tests {
335    use super::{image_ref_candidates, ImageRef};
336    use std::str::FromStr;
337
338    fn parse(s: &str) -> ImageRef {
339        ImageRef::from_str(s).unwrap_or_else(|e| panic!("failed to parse {s:?}: {e}"))
340    }
341
342    #[test]
343    fn unqualified_bare_name() {
344        assert!(parse("nginx").is_unqualified());
345    }
346
347    #[test]
348    fn unqualified_bare_name_with_tag() {
349        assert!(parse("nginx:latest").is_unqualified());
350    }
351
352    #[test]
353    fn unqualified_library_namespace() {
354        assert!(parse("library/nginx").is_unqualified());
355    }
356
357    #[test]
358    fn unqualified_user_namespace_with_tag() {
359        assert!(parse("foo/bar:1.0").is_unqualified());
360    }
361
362    #[test]
363    fn qualified_docker_io() {
364        assert!(!parse("docker.io/library/nginx").is_unqualified());
365    }
366
367    #[test]
368    fn qualified_ghcr() {
369        assert!(!parse("ghcr.io/foo/bar:1.2").is_unqualified());
370    }
371
372    #[test]
373    fn qualified_localhost() {
374        assert!(!parse("localhost/foo").is_unqualified());
375    }
376
377    #[test]
378    fn qualified_localhost_port() {
379        assert!(!parse("localhost:5000/foo").is_unqualified());
380    }
381
382    #[test]
383    fn qualified_registry_port() {
384        assert!(!parse("registry:5000/foo").is_unqualified());
385    }
386
387    #[test]
388    fn unqualified_with_digest_tail() {
389        // Bare name + digest: the host detection must strip the `@sha256:...`
390        // tail before scanning for `/`. There is no `/` left, so unqualified.
391        let s = "foo@sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
392        assert!(parse(s).is_unqualified());
393    }
394
395    #[test]
396    fn display_preserves_user_input() {
397        // `nginx:latest` would canonicalize to `docker.io/library/nginx:latest`;
398        // Display must return the original verbatim.
399        let r = ImageRef::from_str("nginx:latest").unwrap();
400        assert_eq!(r.to_string(), "nginx:latest");
401    }
402
403    #[test]
404    fn serde_json_roundtrip_preserves_original() {
405        let r = ImageRef::from_str("zarcrunner-executor:latest").unwrap();
406        let json = serde_json::to_string(&r).unwrap();
407        assert_eq!(json, "\"zarcrunner-executor:latest\"");
408        let back: ImageRef = serde_json::from_str(&json).unwrap();
409        assert_eq!(back.original(), "zarcrunner-executor:latest");
410    }
411
412    #[test]
413    fn equality_uses_canonical_form() {
414        // `nginx:latest` and `docker.io/library/nginx:latest` canonicalize
415        // to the same `ImageReference`, so the wrappers must compare equal
416        // even though their `original` strings differ.
417        let bare = ImageRef::from_str("nginx:latest").unwrap();
418        let qualified = ImageRef::from_str("docker.io/library/nginx:latest").unwrap();
419        assert_eq!(bare, qualified);
420    }
421
422    fn candidate_contains(candidates: &[(String, String)], name: &str, reference: &str) -> bool {
423        candidates.iter().any(|(n, r)| n == name && r == reference)
424    }
425
426    #[test]
427    fn candidates_strip_localhost_prefix() {
428        // buildah tags a bare `docker build -t local-run-test:1` as
429        // `localhost/local-run-test:1`. A `docker run` of that spelling must be
430        // able to resolve against a local store keyed under the host-stripped
431        // `local-run-test:1`, so the candidate list must include both.
432        let c = image_ref_candidates("localhost/local-run-test:1");
433        assert!(
434            candidate_contains(&c, "localhost/local-run-test", "1"),
435            "missing primary localhost/ candidate in {c:?}",
436        );
437        assert!(
438            candidate_contains(&c, "local-run-test", "1"),
439            "missing localhost/-stripped candidate in {c:?}",
440        );
441    }
442
443    #[test]
444    fn candidates_strip_localhost_prefix_namespaced() {
445        // `localhost/team/svc:1.2` strips to `team/svc:1.2`; the bare last
446        // segment fallback (`svc`) is still produced too.
447        let c = image_ref_candidates("localhost/team/svc:1.2");
448        assert!(
449            candidate_contains(&c, "team/svc", "1.2"),
450            "missing localhost/-stripped namespaced candidate in {c:?}",
451        );
452        assert!(
453            candidate_contains(&c, "svc", "1.2"),
454            "missing bare last-segment fallback in {c:?}",
455        );
456    }
457}