objectiveai_sdk/laboratories/
image.rs1use schemars::JsonSchema;
31use serde::{Deserialize, Serialize};
32
33#[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 #[schemars(title = "Inline")]
58 Inline(InlineLaboratoryImage),
59 #[schemars(title = "Registry")]
61 Registry(RegistryLaboratoryImage),
62}
63
64impl LaboratoryImage {
65 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
74pub const MAX_CONTAINERFILE_BYTES: usize = 16 * 1024;
78
79#[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 pub containerfile: String,
101}
102
103impl InlineLaboratoryImage {
104 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#[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 pub registry: String,
141 pub name: String,
143 #[serde(flatten)]
145 pub pin: LaboratoryImagePin,
146}
147
148impl<'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#[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 #[schemars(title = "Tag")]
212 Tag(String),
213 #[schemars(title = "Digest")]
215 Digest(String),
216}
217
218impl RegistryLaboratoryImage {
219 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
231fn is_lower_component(s: &str) -> bool {
233 let mut prev_sep = true; 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 }
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 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 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 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 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 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}