Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/Skidbladnir

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}");
    }
}