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}