src/coords.rs
//! **Airgap-transport coordinate TYPES** — [`Arch`], [`ReleaseArtifact`], [`ImageRef`].
//!
//! Types only, no inventory: WHAT versions ride and WHICH artefacts exist is always
//! the consumer's knowledge (its pins and its platform variants). Skidbladnir cannot
//! enumerate anyone's product — `federation::list()` returns `Err` by design — so this
//! module keeps only the vocabulary both halves of an airgap crossing speak. A `url`
//! *field* is data the caller supplies; no URL is ever hardcoded here.
use std::fmt;
// ─────────────────────────────────────────────────────────────────────────────
// Arch
// ─────────────────────────────────────────────────────────────────────────────
/// The target architecture of the *carried* artefacts.
///
/// Explicit rather than `std::env::consts::ARCH`, because the connected side that
/// fetches and the airgapped side that boots are routinely different machines: a laptop
/// packing a bundle for an amd64 rack must not silently fetch its own arm64 `talosctl`.
/// `airgap-kubernetes` hardcodes `amd64` for Talos (`bundle.go:214-216`) and uses
/// `runtime.GOARCH` for tofu (`bundle.go:221`) — two answers in one function, which is
/// exactly the bug this type exists to make impossible.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Default)]
pub enum Arch {
#[default]
Amd64,
Arm64,
}
impl Arch {
/// The Go/OCI spelling — what release URLs and image manifests use.
pub fn as_str(self) -> &'static str {
match self {
Arch::Amd64 => "amd64",
Arch::Arm64 => "arm64",
}
}
/// Parse a config string. Accepts the Rust spelling too, because a config written by
/// someone reading `uname -m` should not be a silent fallback to amd64.
pub fn parse(s: &str) -> anyhow::Result<Arch> {
match s.trim().to_ascii_lowercase().as_str() {
"amd64" | "x86_64" | "x86-64" => Ok(Arch::Amd64),
"arm64" | "aarch64" => Ok(Arch::Arm64),
other => anyhow::bail!(
"unknown arch `{other}` — this crate carries artefacts for a machine that is \
usually NOT the one packing them, so it refuses to guess. Say `amd64` or `arm64`"
),
}
}
}
impl fmt::Display for Arch {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Release-URL artefacts — the arm that is REAL
// ─────────────────────────────────────────────────────────────────────────────
/// One artefact fetched straight off a release URL — an OS installer ISO, a CLI
/// binary, a tool zip.
///
/// `expect_sha256` is `None` when the upstream publishes no per-file SHA-256 next to
/// the asset in a form the fetcher can read without a second fetch and a signature
/// story — a **declared gap**, not an oversight. The pool still content-addresses
/// whatever lands — the digest is computed from the bytes — so the artefact is
/// *identified*; it is not yet *authenticated*. Authentication belongs at the gate,
/// and pretending a `None` here is a check would be a hollow green.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ReleaseArtifact {
/// The pool binding name — stable, so a later cut can ask for "the Talos ISO"
/// without knowing its digest. Doubles as the staging filename.
pub name: String,
pub url: String,
/// A published digest to verify against, when the upstream offers one. See the
/// type note on why all three are `None`.
pub expect_sha256: Option<String>,
/// A floor on the size, so a 404 page or an empty body cannot land as a cache hit.
/// This catches truncation; it does not pretend to catch corruption.
pub min_bytes: u64,
}
// ─────────────────────────────────────────────────────────────────────────────
// OCI image coordinates — the arm that is NOT implemented
// ─────────────────────────────────────────────────────────────────────────────
/// A container image coordinate, split into the three parts a registry client needs.
///
/// Split rather than kept as a string because a registry client needs `registry` on
/// its own for token auth, and a `split(':')` at that call site would get the port in
/// `localhost:5000/x:v1` wrong. Parsing it here, once, with a test, is cheaper than
/// parsing it there, twice, without one.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct ImageRef {
/// `ghcr.io`, `quay.io`, `registry.k8s.io`, `docker.io`.
pub registry: String,
/// `siderolabs/installer`, `cilium/cilium`, `coredns/coredns`.
pub repository: String,
/// `v1.12.6`, `3.10.1`.
pub tag: String,
}
impl ImageRef {
/// Parse `registry/repo/path:tag`. The registry is the part before the FIRST `/`;
/// the tag is after the LAST `:` **only when that colon is after the last `/`**, so
/// a registry port is not mistaken for a tag.
pub fn parse(s: &str) -> anyhow::Result<ImageRef> {
let (registry, rest) = s.split_once('/').ok_or_else(|| {
anyhow::anyhow!(
"`{s}` has no registry host — this crate refuses to default one. \
Docker Hub's implicit `docker.io/library/` prefix is a convenience that \
hides which host an airgapped bundle actually pulled from"
)
})?;
let last_slash = rest.rfind('/').map(|i| i + 1).unwrap_or(0);
let colon = rest[last_slash..].rfind(':').map(|i| i + last_slash);
let (repository, tag) = match colon {
Some(i) => (&rest[..i], &rest[i + 1..]),
None => anyhow::bail!(
"`{s}` has no tag. An untagged coordinate resolves to `latest`, which is \
the one thing an airgapped bundle must never contain"
),
};
if registry.is_empty() || repository.is_empty() || tag.is_empty() {
anyhow::bail!("`{s}` has an empty registry, repository or tag");
}
Ok(ImageRef {
registry: registry.to_string(),
repository: repository.to_string(),
tag: tag.to_string(),
})
}
/// Back to the coordinate string.
pub fn coordinate(&self) -> String {
format!("{}/{}:{}", self.registry, self.repository, self.tag)
}
}
impl fmt::Display for ImageRef {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.coordinate())
}
}
// ─────────────────────────────────────────────────────────────────────────────
// RED PROBES
// ─────────────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
/// ★ **RED — an untagged coordinate is REFUSED, not defaulted to `latest`.** A
/// bundle carrying `latest` is a bundle whose content nobody can name.
#[test]
fn an_untagged_coordinate_is_refused() {
let e = ImageRef::parse("ghcr.io/siderolabs/installer").expect_err("no tag must refuse");
assert!(e.to_string().contains("latest"), "{e}");
let e = ImageRef::parse("busybox:1.37.0").expect_err("no registry must refuse");
assert!(e.to_string().contains("no registry host"), "{e}");
}
/// ★ **RED — a registry PORT is not a tag.** The naive `rsplit_once(':')` gets
/// `localhost:5000/img` wrong in the direction that matters: it would ship a
/// coordinate whose "tag" is `5000/img`.
#[test]
fn a_registry_port_is_not_mistaken_for_a_tag() {
let r = ImageRef::parse("registry.internal:5000/moln/korp:v3").unwrap();
assert_eq!(r.registry, "registry.internal:5000");
assert_eq!(r.repository, "moln/korp");
assert_eq!(r.tag, "v3");
// And the same host WITHOUT a tag still refuses rather than reading the port
// as one.
assert!(ImageRef::parse("registry.internal:5000/moln/korp").is_err());
}
#[test]
fn arch_parses_both_spellings_and_refuses_a_third() {
assert_eq!(Arch::parse("x86_64").unwrap(), Arch::Amd64);
assert_eq!(Arch::parse("AARCH64").unwrap(), Arch::Arm64);
let e = Arch::parse("riscv64").expect_err("an unknown arch must refuse");
assert!(e.to_string().contains("refuses to guess"), "{e}");
}
}