use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(
Debug,
Clone,
PartialEq,
Eq,
PartialOrd,
Ord,
Hash,
Serialize,
Deserialize,
JsonSchema,
arbitrary::Arbitrary,
)]
#[serde(untagged)]
#[schemars(rename = "laboratories.LaboratoryImage")]
pub enum LaboratoryImage {
#[schemars(title = "Inline")]
Inline(InlineLaboratoryImage),
#[schemars(title = "Registry")]
Registry(RegistryLaboratoryImage),
}
impl LaboratoryImage {
pub fn validate(&self) -> Result<(), String> {
match self {
LaboratoryImage::Inline(inline) => inline.validate(),
LaboratoryImage::Registry(registry) => registry.validate(),
}
}
}
pub const MAX_CONTAINERFILE_BYTES: usize = 16 * 1024;
#[derive(
Debug,
Clone,
PartialEq,
Eq,
PartialOrd,
Ord,
Hash,
Serialize,
Deserialize,
JsonSchema,
arbitrary::Arbitrary,
)]
#[schemars(rename = "laboratories.InlineLaboratoryImage")]
pub struct InlineLaboratoryImage {
pub containerfile: String,
}
impl InlineLaboratoryImage {
pub fn validate(&self) -> Result<(), String> {
if self.containerfile.trim().is_empty() {
return Err("containerfile must not be empty".to_string());
}
if self.containerfile.len() > MAX_CONTAINERFILE_BYTES {
return Err(format!(
"containerfile is {} bytes; max {MAX_CONTAINERFILE_BYTES} \
(it rides the container label on podman's argv)",
self.containerfile.len()
));
}
Ok(())
}
}
#[derive(
Debug,
Clone,
PartialEq,
Eq,
PartialOrd,
Ord,
Hash,
Serialize,
JsonSchema,
arbitrary::Arbitrary,
)]
#[schemars(rename = "laboratories.RegistryLaboratoryImage")]
pub struct RegistryLaboratoryImage {
pub registry: String,
pub name: String,
#[serde(flatten)]
pub pin: LaboratoryImagePin,
}
impl<'de> Deserialize<'de> for RegistryLaboratoryImage {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
struct Wire {
registry: String,
name: String,
#[serde(default)]
tag: Option<String>,
#[serde(default)]
digest: Option<String>,
}
let wire = Wire::deserialize(deserializer)?;
let pin = match (wire.tag, wire.digest) {
(Some(tag), None) => LaboratoryImagePin::Tag(tag),
(None, Some(digest)) => LaboratoryImagePin::Digest(digest),
(Some(_), Some(_)) => {
return Err(serde::de::Error::custom(
"`tag` and `digest` are mutually exclusive",
));
}
(None, None) => {
return Err(serde::de::Error::custom(
"one of `tag` / `digest` is required",
));
}
};
Ok(Self {
registry: wire.registry,
name: wire.name,
pin,
})
}
}
#[derive(
Debug,
Clone,
PartialEq,
Eq,
PartialOrd,
Ord,
Hash,
Serialize,
Deserialize,
JsonSchema,
arbitrary::Arbitrary,
)]
#[schemars(rename = "laboratories.LaboratoryImagePin")]
#[serde(rename_all = "snake_case")]
pub enum LaboratoryImagePin {
#[schemars(title = "Tag")]
Tag(String),
#[schemars(title = "Digest")]
Digest(String),
}
impl RegistryLaboratoryImage {
pub fn validate(&self) -> Result<(), String> {
validate_registry(&self.registry)?;
validate_name(&self.name)?;
match &self.pin {
LaboratoryImagePin::Tag(tag) => validate_tag(tag),
LaboratoryImagePin::Digest(digest) => validate_digest(digest),
}
}
}
fn is_lower_component(s: &str) -> bool {
let mut prev_sep = true; for c in s.chars() {
match c {
'a'..='z' | '0'..='9' => prev_sep = false,
'.' | '_' | '-' => {
if prev_sep {
return false;
}
prev_sep = true;
}
_ => return false,
}
}
!s.is_empty() && !prev_sep }
fn validate_registry(registry: &str) -> Result<(), String> {
let (host, port) = match registry.split_once(':') {
Some((host, port)) => (host, Some(port)),
None => (registry, None),
};
if let Some(port) = port {
if port.is_empty() || !port.chars().all(|c| c.is_ascii_digit()) {
return Err(format!(
"registry '{registry}': port must be digits after ':'"
));
}
}
if !is_lower_component(host) {
return Err(format!(
"registry '{registry}': host must be lowercase alphanumerics \
with interior '.', '_' or '-'"
));
}
if !host.contains('.') && port.is_none() && host != "localhost" {
return Err(format!(
"registry '{registry}' is not a fully-qualified image host — \
expected a domain (e.g. docker.io), a host:port, or localhost"
));
}
Ok(())
}
fn validate_name(name: &str) -> Result<(), String> {
if name.is_empty() {
return Err("name must not be empty".to_string());
}
for component in name.split('/') {
if !is_lower_component(component) {
return Err(format!(
"name '{name}': component '{component}' must be lowercase \
alphanumerics with interior '.', '_' or '-'"
));
}
}
Ok(())
}
fn validate_tag(tag: &str) -> Result<(), String> {
let mut chars = tag.chars();
let valid_first = matches!(
chars.next(),
Some(c) if c.is_ascii_alphanumeric() || c == '_'
);
if !valid_first
|| tag.len() > 128
|| !chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
{
return Err(format!(
"tag '{tag}': expected [A-Za-z0-9_][A-Za-z0-9._-]{{0,127}}"
));
}
Ok(())
}
fn validate_digest(digest: &str) -> Result<(), String> {
let err = || {
format!(
"digest '{digest}': expected '<algorithm>:<64 hex>' \
(e.g. sha256:…)"
)
};
let Some((algorithm, hex)) = digest.split_once(':') else {
return Err(err());
};
if !is_lower_component(algorithm)
|| hex.len() != 64
|| !hex.chars().all(|c| c.is_ascii_hexdigit())
{
return Err(err());
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn image(registry: &str, name: &str, pin: LaboratoryImagePin) -> LaboratoryImage {
LaboratoryImage::Registry(RegistryLaboratoryImage {
registry: registry.to_string(),
name: name.to_string(),
pin,
})
}
fn tag(t: &str) -> LaboratoryImagePin {
LaboratoryImagePin::Tag(t.to_string())
}
#[test]
fn accepts_fully_qualified_forms() {
for (registry, name) in [
("docker.io", "library/bash"),
("ghcr.io", "org/img"),
("registry.example.com:5000", "team/app"),
("localhost", "img"),
("localhost:5000", "a/b/c"),
] {
image(registry, name, tag("latest")).validate().unwrap();
}
image(
"docker.io",
"library/bash",
LaboratoryImagePin::Digest(format!("sha256:{}", "a".repeat(64))),
)
.validate()
.unwrap();
}
#[test]
fn rejects_short_name_registries() {
for registry in ["myorg", "bash", "registry", ""] {
image(registry, "img", tag("latest")).validate().unwrap_err();
}
}
#[test]
fn rejects_bad_parts() {
image("docker.io", "Library/bash", tag("latest")).validate().unwrap_err();
image("docker.io", "library//bash", tag("latest")).validate().unwrap_err();
image("docker.io", "bash", tag(".dot-first")).validate().unwrap_err();
image("docker.io", "bash", tag(&"t".repeat(129))).validate().unwrap_err();
image(
"docker.io",
"bash",
LaboratoryImagePin::Digest("sha256:short".to_string()),
)
.validate()
.unwrap_err();
image("docker.io:", "bash", tag("latest")).validate().unwrap_err();
}
#[test]
fn inline_validates_and_roundtrips() {
let inline = LaboratoryImage::Inline(InlineLaboratoryImage {
containerfile: "FROM docker.io/library/bash:latest\n".to_string(),
});
inline.validate().unwrap();
let json = serde_json::to_value(&inline).unwrap();
assert_eq!(
json,
serde_json::json!({ "containerfile": "FROM docker.io/library/bash:latest\n" })
);
assert_eq!(
serde_json::from_value::<LaboratoryImage>(json).unwrap(),
inline
);
LaboratoryImage::Inline(InlineLaboratoryImage {
containerfile: " ".to_string(),
})
.validate()
.unwrap_err();
LaboratoryImage::Inline(InlineLaboratoryImage {
containerfile: "x".repeat(MAX_CONTAINERFILE_BYTES + 1),
})
.validate()
.unwrap_err();
}
#[test]
fn wire_shape_is_flat_and_exclusive() {
let json = serde_json::to_value(image("docker.io", "library/bash", tag("latest")))
.unwrap();
assert_eq!(
json,
serde_json::json!({
"registry": "docker.io",
"name": "library/bash",
"tag": "latest",
})
);
assert!(
serde_json::from_value::<LaboratoryImage>(serde_json::json!({
"registry": "docker.io",
"name": "bash",
"tag": "latest",
"digest": format!("sha256:{}", "a".repeat(64)),
}))
.is_err()
);
}
}