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}