Skip to main content

objectiveai_sdk/laboratories/
image.rs

1//! The laboratory image spec — inline Containerfile or split
2//! registry reference.
3//!
4//! A laboratory's base image is either INLINE (Containerfile text the
5//! host builds with `podman build`, empty context) or a REGISTRY
6//! reference carried as its parts — `registry` + `name` + (`tag` XOR
7//! `digest`) — everywhere: the CLI
8//! command surface, the daemon↔host channel, container labels, list
9//! echoes. The fully-joined reference string
10//! (`registry/name:tag` / `registry/name@digest`) deliberately does
11//! not exist outside the laboratory host's podman internals, so an
12//! unqualified short name (podman would silently resolve it against
13//! docker.io) is unrepresentable end to end.
14//!
15//! Field rules follow the docker/OCI reference grammar (the same
16//! rules the `docker-image` crate encodes):
17//! - `registry`: lowercase domain components (`[a-z0-9]` with interior
18//!   `.`/`_`/`-`), and it must be UNAMBIGUOUSLY a registry — contain a
19//!   `.`, carry a `:port`, or be exactly `localhost`.
20//! - `name`: `/`-separated path components, each
21//!   `[a-z0-9]+([._-][a-z0-9]+)*`.
22//! - `tag`: `[A-Za-z0-9_][A-Za-z0-9._-]{0,127}`.
23//! - `digest`: `<algorithm>:<64 hex>`, e.g. `sha256:…`.
24//!
25//! `tag` and `digest` are mutually exclusive BY TYPE
26//! ([`LaboratoryImagePin`]): the reference grammar technically allows
27//! `name:tag@digest`, but the tag is decorative there (resolution is
28//! by digest), so this API refuses to model the ambiguity.
29
30use schemars::JsonSchema;
31use serde::{Deserialize, Serialize};
32
33/// One laboratory base image: inline Containerfile text, or a split
34/// registry reference. Untagged — the variants' keys are disjoint
35/// (`containerfile` vs `registry`/`name`), mirroring the agent
36/// inline-vs-remote idiom.
37#[derive(
38    Debug,
39    Clone,
40    PartialEq,
41    Eq,
42    PartialOrd,
43    Ord,
44    Hash,
45    Serialize,
46    Deserialize,
47    JsonSchema,
48    arbitrary::Arbitrary,
49)]
50#[serde(untagged)]
51#[schemars(rename = "laboratories.LaboratoryImage")]
52pub enum LaboratoryImage {
53    /// The image spec provided inline as Containerfile content. The
54    /// host builds it on EVERY create (podman's layer cache still
55    /// applies) against an empty context — `COPY`/`ADD` of local
56    /// files fails by construction.
57    #[schemars(title = "Inline")]
58    Inline(InlineLaboratoryImage),
59    /// A reference to an image hosted on a registry.
60    #[schemars(title = "Registry")]
61    Registry(RegistryLaboratoryImage),
62}
63
64impl LaboratoryImage {
65    /// Validate the spec. `Err` carries a human-readable reason.
66    pub fn validate(&self) -> Result<(), String> {
67        match self {
68            LaboratoryImage::Inline(inline) => inline.validate(),
69            LaboratoryImage::Registry(registry) => registry.validate(),
70        }
71    }
72}
73
74/// The Containerfile-embedded label rides `podman create`'s argv
75/// (Windows caps a command line at ~32K chars), so inline content is
76/// bounded to keep the worst-case-escaped label inside it.
77pub const MAX_CONTAINERFILE_BYTES: usize = 16 * 1024;
78
79/// An inline image spec: Containerfile/Dockerfile CONTENT, plain text.
80///
81/// The Containerfile's own `FROM` line is deliberately NOT validated
82/// for full qualification — the file is the user's content, and
83/// podman's build-time resolution rules apply to it verbatim.
84#[derive(
85    Debug,
86    Clone,
87    PartialEq,
88    Eq,
89    PartialOrd,
90    Ord,
91    Hash,
92    Serialize,
93    Deserialize,
94    JsonSchema,
95    arbitrary::Arbitrary,
96)]
97#[schemars(rename = "laboratories.InlineLaboratoryImage")]
98pub struct InlineLaboratoryImage {
99    /// Containerfile content (plain text — never nested/escaped JSON).
100    pub containerfile: String,
101}
102
103impl InlineLaboratoryImage {
104    /// Non-empty and within [`MAX_CONTAINERFILE_BYTES`].
105    pub fn validate(&self) -> Result<(), String> {
106        if self.containerfile.trim().is_empty() {
107            return Err("containerfile must not be empty".to_string());
108        }
109        if self.containerfile.len() > MAX_CONTAINERFILE_BYTES {
110            return Err(format!(
111                "containerfile is {} bytes; max {MAX_CONTAINERFILE_BYTES} \
112                 (it rides the container label on podman's argv)",
113                self.containerfile.len()
114            ));
115        }
116        Ok(())
117    }
118}
119
120/// One registry-hosted laboratory base image, split into its
121/// reference parts.
122#[derive(
123    Debug,
124    Clone,
125    PartialEq,
126    Eq,
127    PartialOrd,
128    Ord,
129    Hash,
130    Serialize,
131    JsonSchema,
132    arbitrary::Arbitrary,
133)]
134#[schemars(rename = "laboratories.RegistryLaboratoryImage")]
135pub struct RegistryLaboratoryImage {
136    /// The image host — e.g. `docker.io`, `ghcr.io`,
137    /// `registry.example.com:5000`, `localhost:5000`. Required and
138    /// validated to be unambiguously a registry, so short-name
139    /// resolution never happens.
140    pub registry: String,
141    /// The repository path — e.g. `library/bash`, `myorg/myimage`.
142    pub name: String,
143    /// Exactly one of `tag` / `digest`.
144    #[serde(flatten)]
145    pub pin: LaboratoryImagePin,
146}
147
148/// Manual, because the derived path cannot enforce the pin's
149/// exclusivity: `pin` is a `#[serde(flatten)]`ed externally-tagged
150/// enum, and serde's buffered flatten deserialization takes the FIRST
151/// matching key from the remaining map — a payload carrying BOTH
152/// `tag` and `digest` would silently win as `tag`. This impl rejects
153/// that (and a payload carrying neither) instead. Serialization stays
154/// derived — the wire shape is unchanged.
155impl<'de> Deserialize<'de> for RegistryLaboratoryImage {
156    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
157    where
158        D: serde::Deserializer<'de>,
159    {
160        #[derive(Deserialize)]
161        struct Wire {
162            registry: String,
163            name: String,
164            #[serde(default)]
165            tag: Option<String>,
166            #[serde(default)]
167            digest: Option<String>,
168        }
169        let wire = Wire::deserialize(deserializer)?;
170        let pin = match (wire.tag, wire.digest) {
171            (Some(tag), None) => LaboratoryImagePin::Tag(tag),
172            (None, Some(digest)) => LaboratoryImagePin::Digest(digest),
173            (Some(_), Some(_)) => {
174                return Err(serde::de::Error::custom(
175                    "`tag` and `digest` are mutually exclusive",
176                ));
177            }
178            (None, None) => {
179                return Err(serde::de::Error::custom(
180                    "one of `tag` / `digest` is required",
181                ));
182            }
183        };
184        Ok(Self {
185            registry: wire.registry,
186            name: wire.name,
187            pin,
188        })
189    }
190}
191
192/// The image's version pin: a floating `tag` or a content-addressed
193/// `digest` — mutually exclusive by construction.
194#[derive(
195    Debug,
196    Clone,
197    PartialEq,
198    Eq,
199    PartialOrd,
200    Ord,
201    Hash,
202    Serialize,
203    Deserialize,
204    JsonSchema,
205    arbitrary::Arbitrary,
206)]
207#[schemars(rename = "laboratories.LaboratoryImagePin")]
208#[serde(rename_all = "snake_case")]
209pub enum LaboratoryImagePin {
210    /// `registry/name:tag`.
211    #[schemars(title = "Tag")]
212    Tag(String),
213    /// `registry/name@digest` — content-addressed, immutable.
214    #[schemars(title = "Digest")]
215    Digest(String),
216}
217
218impl RegistryLaboratoryImage {
219    /// Validate every part against the reference grammar. `Err`
220    /// carries a human-readable reason naming the offending part.
221    pub fn validate(&self) -> Result<(), String> {
222        validate_registry(&self.registry)?;
223        validate_name(&self.name)?;
224        match &self.pin {
225            LaboratoryImagePin::Tag(tag) => validate_tag(tag),
226            LaboratoryImagePin::Digest(digest) => validate_digest(digest),
227        }
228    }
229}
230
231/// One lowercase component: `[a-z0-9]+([._-][a-z0-9]+)*`.
232fn is_lower_component(s: &str) -> bool {
233    let mut prev_sep = true; // leading separator is invalid
234    for c in s.chars() {
235        match c {
236            'a'..='z' | '0'..='9' => prev_sep = false,
237            '.' | '_' | '-' => {
238                if prev_sep {
239                    return false;
240                }
241                prev_sep = true;
242            }
243            _ => return false,
244        }
245    }
246    !s.is_empty() && !prev_sep // trailing separator is invalid
247}
248
249fn validate_registry(registry: &str) -> Result<(), String> {
250    let (host, port) = match registry.split_once(':') {
251        Some((host, port)) => (host, Some(port)),
252        None => (registry, None),
253    };
254    if let Some(port) = port {
255        if port.is_empty() || !port.chars().all(|c| c.is_ascii_digit()) {
256            return Err(format!(
257                "registry '{registry}': port must be digits after ':'"
258            ));
259        }
260    }
261    if !is_lower_component(host) {
262        return Err(format!(
263            "registry '{registry}': host must be lowercase alphanumerics \
264             with interior '.', '_' or '-'"
265        ));
266    }
267    // The load-bearing rule: the registry must be UNAMBIGUOUSLY a
268    // registry, never a name podman would short-name-resolve. A
269    // domain dot, an explicit port, or literal `localhost` qualifies.
270    if !host.contains('.') && port.is_none() && host != "localhost" {
271        return Err(format!(
272            "registry '{registry}' is not a fully-qualified image host — \
273             expected a domain (e.g. docker.io), a host:port, or localhost"
274        ));
275    }
276    Ok(())
277}
278
279fn validate_name(name: &str) -> Result<(), String> {
280    if name.is_empty() {
281        return Err("name must not be empty".to_string());
282    }
283    for component in name.split('/') {
284        if !is_lower_component(component) {
285            return Err(format!(
286                "name '{name}': component '{component}' must be lowercase \
287                 alphanumerics with interior '.', '_' or '-'"
288            ));
289        }
290    }
291    Ok(())
292}
293
294fn validate_tag(tag: &str) -> Result<(), String> {
295    let mut chars = tag.chars();
296    let valid_first = matches!(
297        chars.next(),
298        Some(c) if c.is_ascii_alphanumeric() || c == '_'
299    );
300    if !valid_first
301        || tag.len() > 128
302        || !chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
303    {
304        return Err(format!(
305            "tag '{tag}': expected [A-Za-z0-9_][A-Za-z0-9._-]{{0,127}}"
306        ));
307    }
308    Ok(())
309}
310
311fn validate_digest(digest: &str) -> Result<(), String> {
312    let err = || {
313        format!(
314            "digest '{digest}': expected '<algorithm>:<64 hex>' \
315             (e.g. sha256:…)"
316        )
317    };
318    let Some((algorithm, hex)) = digest.split_once(':') else {
319        return Err(err());
320    };
321    if !is_lower_component(algorithm)
322        || hex.len() != 64
323        || !hex.chars().all(|c| c.is_ascii_hexdigit())
324    {
325        return Err(err());
326    }
327    Ok(())
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333
334    fn image(registry: &str, name: &str, pin: LaboratoryImagePin) -> LaboratoryImage {
335        LaboratoryImage::Registry(RegistryLaboratoryImage {
336            registry: registry.to_string(),
337            name: name.to_string(),
338            pin,
339        })
340    }
341
342    fn tag(t: &str) -> LaboratoryImagePin {
343        LaboratoryImagePin::Tag(t.to_string())
344    }
345
346    #[test]
347    fn accepts_fully_qualified_forms() {
348        for (registry, name) in [
349            ("docker.io", "library/bash"),
350            ("ghcr.io", "org/img"),
351            ("registry.example.com:5000", "team/app"),
352            ("localhost", "img"),
353            ("localhost:5000", "a/b/c"),
354        ] {
355            image(registry, name, tag("latest")).validate().unwrap();
356        }
357        image(
358            "docker.io",
359            "library/bash",
360            LaboratoryImagePin::Digest(format!("sha256:{}", "a".repeat(64))),
361        )
362        .validate()
363        .unwrap();
364    }
365
366    #[test]
367    fn rejects_short_name_registries() {
368        // A bare word would be short-name-resolved by podman — the
369        // exact silent-docker.io fallback this type exists to forbid.
370        for registry in ["myorg", "bash", "registry", ""] {
371            image(registry, "img", tag("latest")).validate().unwrap_err();
372        }
373    }
374
375    #[test]
376    fn rejects_bad_parts() {
377        image("docker.io", "Library/bash", tag("latest")).validate().unwrap_err();
378        image("docker.io", "library//bash", tag("latest")).validate().unwrap_err();
379        image("docker.io", "bash", tag(".dot-first")).validate().unwrap_err();
380        image("docker.io", "bash", tag(&"t".repeat(129))).validate().unwrap_err();
381        image(
382            "docker.io",
383            "bash",
384            LaboratoryImagePin::Digest("sha256:short".to_string()),
385        )
386        .validate()
387        .unwrap_err();
388        image("docker.io:", "bash", tag("latest")).validate().unwrap_err();
389    }
390
391    #[test]
392    fn inline_validates_and_roundtrips() {
393        let inline = LaboratoryImage::Inline(InlineLaboratoryImage {
394            containerfile: "FROM docker.io/library/bash:latest\n".to_string(),
395        });
396        inline.validate().unwrap();
397        // Untagged: inline serializes to just {containerfile}, and the
398        // enum picks the right variant back.
399        let json = serde_json::to_value(&inline).unwrap();
400        assert_eq!(
401            json,
402            serde_json::json!({ "containerfile": "FROM docker.io/library/bash:latest\n" })
403        );
404        assert_eq!(
405            serde_json::from_value::<LaboratoryImage>(json).unwrap(),
406            inline
407        );
408        // Empty and oversized are rejected.
409        LaboratoryImage::Inline(InlineLaboratoryImage {
410            containerfile: "  ".to_string(),
411        })
412        .validate()
413        .unwrap_err();
414        LaboratoryImage::Inline(InlineLaboratoryImage {
415            containerfile: "x".repeat(MAX_CONTAINERFILE_BYTES + 1),
416        })
417        .validate()
418        .unwrap_err();
419    }
420
421    #[test]
422    fn wire_shape_is_flat_and_exclusive() {
423        let json = serde_json::to_value(image("docker.io", "library/bash", tag("latest")))
424            .unwrap();
425        assert_eq!(
426            json,
427            serde_json::json!({
428                "registry": "docker.io",
429                "name": "library/bash",
430                "tag": "latest",
431            })
432        );
433        // Both pins present must not deserialize.
434        assert!(
435            serde_json::from_value::<LaboratoryImage>(serde_json::json!({
436                "registry": "docker.io",
437                "name": "bash",
438                "tag": "latest",
439                "digest": format!("sha256:{}", "a".repeat(64)),
440            }))
441            .is_err()
442        );
443    }
444}