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

nordisk/gunnar

deploy/upcloud/src/upload.rs

//! The ISO on this disk, and proving that the same bytes arrived at UpCloud.
//!
//! # Why the checksum comparison is a refusal and not a log line
//!
//! The direct-upload reply carries what the far end wrote:
//!
//! ```json
//! {"written_bytes": 268435456, "md5sum": "…", "sha256sum": "…"}
//! ```
//!
//! A truncated or corrupted upload produces a storage that boots — the ISO-9660
//! header is at the front — and then fails somewhere inside the install, at the
//! far end of a fifteen-minute wait, with a message about a payload rather than
//! about a transfer. That is an hour to diagnose. Comparing two hex strings is
//! free, and it is the difference between "the upload was short" and "the
//! installer is broken".
//!
//! `sha256sum` is compared, not `md5sum`: the constellation's one hash primitive
//! is [`nornir_hash`], the ISO is hashed once for both this and the storage
//! title, and MD5 would be a second digest of the same file for no gain.

use std::path::Path;
use std::time::SystemTime;

use serde_json::Value;

/// What is known about the medium before anything is uploaded.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Iso {
    pub path: std::path::PathBuf,
    pub len: u64,
    pub sha256: String,
    /// How old the file is, in seconds, at the moment it was read.
    pub age_s: u64,
}

impl Iso {
    /// Hash it and measure it. Streaming: `nornir_hash::sha256_file` reads
    /// 64 KiB at a time and never holds the ISO in memory.
    pub fn read(path: &Path) -> Result<Iso, String> {
        let meta = std::fs::metadata(path)
            .map_err(|e| format!("no installer ISO at {} ({e})", path.display()))?;
        if meta.len() == 0 {
            return Err(format!(
                "{} is EMPTY — an exit code is not an artifact",
                path.display()
            ));
        }
        let sha256 = nornir_hash::sha256_file(path)
            .map_err(|e| format!("hashing {}: {e}", path.display()))?;
        let age_s = meta
            .modified()
            .ok()
            .and_then(|m| SystemTime::now().duration_since(m).ok())
            .map(|d| d.as_secs())
            .unwrap_or(0);
        Ok(Iso {
            path: path.to_path_buf(),
            len: meta.len(),
            sha256,
            age_s,
        })
    }

    /// The first eight characters of the digest — what goes in a storage title
    /// so that a medium in the account is identifiable as *this* ISO.
    pub fn short_sha(&self) -> &str {
        &self.sha256[..8]
    }
}

/// **The staleness gate, ported from `<ops>/deploy-appliance.sh`.**
///
/// That script carries a note that its artefacts went stale, and the day it was
/// written up it had shipped a console bundle 69 minutes old and a `gunnar-cli`
/// twenty commits behind the ISO. The lesson it recorded is that an artefact
/// taken off disk is only ever as good as the last person's build, and nothing
/// about a file says so out loud.
///
/// So an ISO older than the budget is a REFUSAL with the age in it, overridable
/// by an operator who means it (`--allow-stale-iso`). A warning was considered
/// and rejected: the entire failure mode is that nobody read the warning.
pub fn staleness_gate(iso: &Iso, max_age_h: u64) -> Result<(), String> {
    let age_h = iso.age_s / 3600;
    if age_h <= max_age_h {
        return Ok(());
    }
    Err(format!(
        "{} was built {age_h} h ago, and the limit is {max_age_h} h. Re-image with a stale \
         medium and the appliance comes back as whatever was on this disk, not as what is in \
         the tree — `<ops>/deploy-appliance.sh` shipped exactly that. Rebuild it with \
         `cargo xtask iso-installer --variant …`, or pass --allow-stale-iso if this really is \
         the medium you mean.",
        iso.path.display()
    ))
}

/// The direct-upload reply, checked against the file that was sent.
pub fn verify_upload(iso: &Iso, reply: &Value) -> Result<(), String> {
    let written = reply
        .get("written_bytes")
        .and_then(|v| {
            v.as_u64()
                .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
        })
        .ok_or_else(|| format!("the upload reply carries no `written_bytes`: {reply}"))?;
    let theirs = reply
        .get("sha256sum")
        .and_then(|v| v.as_str())
        .ok_or_else(|| format!("the upload reply carries no `sha256sum`: {reply}"))?;

    if written != iso.len {
        return Err(format!(
            "the upload was SHORT: {} bytes went up and UpCloud wrote {written}. The storage \
             would boot and the install would fail fifteen minutes later, inside the \
             installer, with a message about a payload. REFUSED.",
            iso.len
        ));
    }
    if !theirs.eq_ignore_ascii_case(&iso.sha256) {
        return Err(format!(
            "the uploaded image is NOT the file on this disk. local sha256 {} , UpCloud \
             {theirs}. The byte count matched, so this is corruption in transit rather than \
             truncation. REFUSED.",
            iso.sha256
        ));
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    fn iso(len: u64, sha: &str) -> Iso {
        Iso {
            path: "/x/gunnar-installer-open.iso".into(),
            len,
            sha256: sha.to_string(),
            age_s: 0,
        }
    }

    const SHA: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";

    #[test]
    fn a_matching_upload_passes() {
        let r = json!({"written_bytes": 1024, "md5sum": "x", "sha256sum": SHA});
        verify_upload(&iso(1024, SHA), &r).unwrap();
    }

    #[test]
    fn upcloud_may_answer_in_upper_case_hex() {
        let r = json!({"written_bytes": 1024, "sha256sum": SHA.to_uppercase()});
        verify_upload(&iso(1024, SHA), &r).unwrap();
    }

    /// Red-first, seen with the checksum comparison left out entirely (which is
    /// how the procedure was run by hand — the operator eyeballed it): a
    /// corrupted upload sailed through, and the failure surfaced fifteen minutes
    /// later as `INSTALLER: FAILED payload`.
    #[test]
    fn a_different_digest_is_refused() {
        let r = json!({"written_bytes": 1024, "sha256sum": "a".repeat(64)});
        let err = verify_upload(&iso(1024, SHA), &r).unwrap_err();
        assert!(err.contains("NOT the file on this disk"), "{err}");
        assert!(err.contains(SHA), "the local digest is quoted: {err}");
        assert!(err.contains("REFUSED"), "{err}");
    }

    #[test]
    fn a_short_upload_is_refused_and_named_as_short() {
        let r = json!({"written_bytes": 512, "sha256sum": SHA});
        let err = verify_upload(&iso(1024, SHA), &r).unwrap_err();
        assert!(err.contains("SHORT"), "{err}");
        assert!(err.contains("512"), "{err}");
    }

    #[test]
    fn a_reply_without_a_digest_is_refused_rather_than_assumed_good() {
        let r = json!({"written_bytes": 1024});
        let err = verify_upload(&iso(1024, SHA), &r).unwrap_err();
        assert!(err.contains("sha256sum"), "{err}");
    }

    #[test]
    fn a_stale_medium_is_refused_with_its_age() {
        let mut i = iso(1024, SHA);
        i.age_s = 50 * 3600;
        let err = staleness_gate(&i, 24).unwrap_err();
        assert!(err.contains("50 h ago"), "{err}");
        assert!(err.contains("--allow-stale-iso"), "{err}");
        i.age_s = 3 * 3600;
        staleness_gate(&i, 24).unwrap();
    }
}