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/kept.rs

//! **Upload the medium ONCE per sha256, and prove it before reusing it.**
//!
//! # The cost this removes
//!
//! MEASURED on the live go-live, 2026-09-22/23, across five attempts: the same
//! installer ISO was uploaded to UpCloud on **every** attempt, and each upload
//! plus the storage's `syncing` wait cost **165 s or more** (against a 1200 s
//! budget). Nothing about the bytes had changed between attempts — the retries
//! were of the rungs *after* the install.
//!
//! The reason was not the adoption gate, which has always been there
//! ([`crate::plan`] `start_medium` adopts a storage that already carries the
//! title): it was that the run **deleted** the seed storages on its way out, so
//! the next run found nothing to adopt. Giving a gigabyte back to the account
//! cost two and a half minutes of every retry.
//!
//! So the medium is now KEPT by default ([`crate::plan::Plan::keep_medium`]),
//! and `--give-back-medium` is the door back to the old behaviour.
//!
//! # Why a title is not enough to reuse a storage on
//!
//! The storage title carries the ISO's digest — but only its first eight
//! characters ([`crate::upload::Iso::short_sha`]), because an UpCloud title is
//! short and a person has to read it. Eight hex characters is a fine label and
//! a poor proof, and UpCloud's API has no `GET` that answers what a storage's
//! contents hash to: the only place the full digest was ever compared is
//! [`crate::upload::verify_upload`], against the direct-upload reply, at the
//! moment the bytes arrived.
//!
//! This module keeps that proof. When an upload verifies, the run records
//! `(API base, zone, title) → (uuid, the FULL sha256)` in a small JSON file
//! beside the ISO. A later run that finds a storage by title reads the record
//! back and **refuses to adopt** unless the recorded uuid and the recorded full
//! digest are both the ones in front of it. A storage whose sha does not match
//! is not reused.
//!
//! # Why the API base is part of the key
//!
//! Until 2026-09-28 a row was `zone/title` alone, and one `.iso-out/` serves
//! the account AND every mock-upcloud run from the same checkout. A mock mints
//! its uuids from its own counter, so two mocks (and one mock across restarts)
//! hand out the SAME uuids for different storages: a row one mock wrote could
//! "prove" a storage on another, or veto the account's own storage by a uuid
//! the account never issued. The endpoint the bytes were verified against is
//! now part of the key; a row from another endpoint is simply not this
//! endpoint's record ([`Verdict::Unrecorded`]), and a row written before this
//! change (no base) is read the same way.
//!
//! The record is a VETO and not a licence: with no record at all (another box
//! composed the ISO, the file was cleaned away) the title-and-digest gate that
//! was always here still decides, exactly as before. Only a record that
//! DISAGREES stops an adoption — which is the case that could ship the wrong
//! bytes to a box.
//!
//! # Why beside the ISO and not in the account
//!
//! Labels were considered: UpCloud storages take them, and a `sha256=…` label
//! would live with the storage. It was rejected for this change because the
//! label would be written by the same call that creates the storage, i.e.
//! *before* the digest is verified, and a label that can be written before the
//! proof is not the proof. The file is written strictly after
//! [`crate::upload::verify_upload`] returns `Ok`.

use std::collections::BTreeMap;
use std::path::{Path, PathBuf};

use serde_json::Value;

/// What was proven about one storage, the moment its upload verified.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Kept {
    pub uuid: String,
    /// The FULL digest of the bytes UpCloud reported writing.
    pub sha256: String,
}

/// Where the ledger lives: beside the ISO, one file per ISO directory.
///
/// Beside the ISO rather than in `~`: the composed medium and the record of
/// where it was uploaded belong to the same artefact, and `.iso-out` is what a
/// clean run removes when it means to remove both.
pub fn ledger_path(iso: &Path) -> PathBuf {
    iso.parent()
        .unwrap_or_else(|| Path::new("."))
        .join(".upcloud-media.json")
}

/// The key a record is filed under: the API base, the zone AND the title —
/// the same title in another zone is another storage, and the same uuid at
/// another endpoint (a second mock, or the account) is another storage too.
pub fn key(api_base: &str, zone: &str, title: &str) -> String {
    format!("{}|{zone}/{title}", api_base.trim().trim_end_matches('/'))
}

/// Read the whole ledger. A missing, unreadable or malformed file is an EMPTY
/// ledger and never an error: this is an accelerator, and a run that cannot
/// read it must still be able to upload.
pub fn read(path: &Path) -> BTreeMap<String, Kept> {
    let text = match std::fs::read_to_string(path) {
        Ok(t) => t,
        Err(_) => return BTreeMap::new(),
    };
    parse(&text)
}

/// The pure half.
pub fn parse(text: &str) -> BTreeMap<String, Kept> {
    let v: Value = match serde_json::from_str(text) {
        Ok(v) => v,
        Err(_) => return BTreeMap::new(),
    };
    let media = match v.get("media").and_then(Value::as_object) {
        Some(m) => m,
        None => return BTreeMap::new(),
    };
    let mut out = BTreeMap::new();
    for (k, row) in media {
        let uuid = row.get("uuid").and_then(Value::as_str);
        let sha = row.get("sha256").and_then(Value::as_str);
        if let (Some(uuid), Some(sha)) = (uuid, sha) {
            // A short digest in this field would make the veto weaker than the
            // title it is supposed to strengthen. Such a row is dropped.
            if sha.len() == 64 && sha.chars().all(|c| c.is_ascii_hexdigit()) {
                out.insert(
                    k.clone(),
                    Kept {
                        uuid: uuid.to_string(),
                        sha256: sha.to_ascii_lowercase(),
                    },
                );
            }
        }
    }
    out
}

/// Serialise a ledger. Sorted (it is a `BTreeMap`) so two runs that proved the
/// same media write the same bytes.
pub fn render(media: &BTreeMap<String, Kept>) -> String {
    let mut rows = serde_json::Map::new();
    for (k, v) in media {
        rows.insert(
            k.clone(),
            serde_json::json!({"uuid": v.uuid, "sha256": v.sha256}),
        );
    }
    let doc = serde_json::json!({
        "note": "written by gunnar-upcloud after an upload's sha256 was verified against UpCloud's own reply; a row here VETOES adopting a storage whose digest disagrees",
        "media": Value::Object(rows),
    });
    format!(
        "{}\n",
        serde_json::to_string_pretty(&doc).unwrap_or_default()
    )
}

/// Record one proven medium. Best-effort by design — the bytes are already up
/// and attached; a ledger that could not be written costs the NEXT run 165 s,
/// and failing this run instead would cost it the whole install.
pub fn record(
    path: &Path,
    api_base: &str,
    zone: &str,
    title: &str,
    uuid: &str,
    sha256: &str,
) -> Result<(), String> {
    let mut media = read(path);
    media.insert(
        key(api_base, zone, title),
        Kept {
            uuid: uuid.to_string(),
            sha256: sha256.to_ascii_lowercase(),
        },
    );
    std::fs::write(path, render(&media)).map_err(|e| format!("writing {}: {e}", path.display()))
}

/// The verdict on adopting a storage found by title.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Verdict {
    /// The ledger proved it: the uuid and the FULL digest are both the ones in
    /// front of us.
    Proven,
    /// No record — the title-and-digest gate decides, as it always did.
    Unrecorded,
    /// A record that disagrees. The reason is the operator's to read.
    Refused(String),
}

/// Judge a candidate storage against the ledger. Pure.
pub fn judge(
    media: &BTreeMap<String, Kept>,
    api_base: &str,
    zone: &str,
    title: &str,
    uuid: &str,
    iso_sha256: &str,
) -> Verdict {
    let k = key(api_base, zone, title);
    let kept = match media.get(&k) {
        Some(k) => k,
        None => return Verdict::Unrecorded,
    };
    if !kept.sha256.eq_ignore_ascii_case(iso_sha256) {
        return Verdict::Refused(format!(
            "the storage titled {title:?} in {zone} was uploaded from an ISO whose sha256 is \
             {} , and the ISO on this disk is {iso_sha256}. Eight characters of a digest in a \
             storage title is a label, not a proof, and this is the case it cannot catch. \
             NOT reused — delete that storage, or point --iso at the medium it was made from.",
            kept.sha256
        ));
    }
    if kept.uuid != uuid {
        return Verdict::Refused(format!(
            "the storage titled {title:?} in {zone} is {uuid}, and the digest proven for that \
             title was proven against {} — a different storage now carries the name. NOT \
             reused: the bytes behind this uuid were never verified.",
            kept.uuid
        ));
    }
    Verdict::Proven
}

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

    const SHA: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
    const OTHER: &str = "a3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b800";
    const ACCOUNT: &str = "https://api.upcloud.com/1.3";
    const MOCK_A: &str = "http://127.0.0.1:8099/1.3";
    const MOCK_B: &str = "http://127.0.0.1:8098/1.3";

    fn one(sha: &str, uuid: &str) -> BTreeMap<String, Kept> {
        let mut m = BTreeMap::new();
        m.insert(
            key(ACCOUNT, "fi-hel1", "t-cdrom"),
            Kept {
                uuid: uuid.into(),
                sha256: sha.into(),
            },
        );
        m
    }

    /// Kit defect (5), 2026-09-28: the ledger mapped the medium to a uuid
    /// without the endpoint, and mock uuids repeat across mocks. A row is now
    /// the endpoint's own: another mock's row, the account's row, and a row
    /// written before the base was part of the key are all UNRECORDED here —
    /// never Proven and never a veto.
    #[test]
    fn a_record_from_another_endpoint_is_not_this_endpoints_record() {
        let mut m = BTreeMap::new();
        m.insert(
            key(MOCK_A, "se-sto1", "t-cdrom"),
            Kept {
                uuid: "01000000-0000-4000-8000-000000000001".into(),
                sha256: SHA.into(),
            },
        );
        assert_eq!(
            judge(
                &m,
                MOCK_A,
                "se-sto1",
                "t-cdrom",
                "01000000-0000-4000-8000-000000000001",
                SHA
            ),
            Verdict::Proven
        );
        assert_eq!(
            judge(
                &m,
                &format!("{MOCK_A}/"),
                "se-sto1",
                "t-cdrom",
                "01000000-0000-4000-8000-000000000001",
                SHA
            ),
            Verdict::Proven,
            "a trailing slash is the same base"
        );
        // The SAME uuid minted by another mock: not proven by mock A's upload.
        assert_eq!(
            judge(
                &m,
                MOCK_B,
                "se-sto1",
                "t-cdrom",
                "01000000-0000-4000-8000-000000000001",
                SHA
            ),
            Verdict::Unrecorded
        );
        // The account's storage is not vetoed by a mock's row.
        assert_eq!(
            judge(&m, ACCOUNT, "se-sto1", "t-cdrom", "01a", SHA),
            Verdict::Unrecorded
        );
        // A row from before the base was keyed (`zone/title`) matches no endpoint.
        let old = parse(&format!(
            r#"{{"media": {{"se-sto1/t-cdrom": {{"uuid": "01a", "sha256": "{OTHER}"}}}}}}"#
        ));
        assert_eq!(old.len(), 1);
        assert_eq!(
            judge(&old, ACCOUNT, "se-sto1", "t-cdrom", "01a", SHA),
            Verdict::Unrecorded
        );
        assert_eq!(
            key(MOCK_A, "se-sto1", "t"),
            "http://127.0.0.1:8099/1.3|se-sto1/t"
        );
    }

    /// The whole point of the change: the SECOND run of the same bytes reuses
    /// the storage instead of uploading it again.
    #[test]
    fn the_same_bytes_are_proven_and_reused() {
        assert_eq!(
            judge(&one(SHA, "01a"), ACCOUNT, "fi-hel1", "t-cdrom", "01a", SHA),
            Verdict::Proven
        );
    }

    /// Red-first: with only the title's eight characters to go on, two ISOs
    /// that happened to share a prefix would adopt each other's storage. The
    /// ledger carries the full digest, and this is the case it exists for.
    #[test]
    fn a_storage_whose_sha_does_not_match_is_not_reused() {
        let v = judge(
            &one(OTHER, "01a"),
            ACCOUNT,
            "fi-hel1",
            "t-cdrom",
            "01a",
            SHA,
        );
        match v {
            Verdict::Refused(why) => {
                assert!(why.contains("NOT reused"), "{why}");
                assert!(why.contains(OTHER), "the recorded digest is quoted: {why}");
                assert!(why.contains(SHA), "the local digest is quoted: {why}");
            }
            other => panic!("a mismatched digest must be refused, got {other:?}"),
        }
    }

    /// A title that has been re-pointed at a different storage is refused too:
    /// the proof was about bytes behind a uuid, not about a name.
    #[test]
    fn a_title_that_moved_to_another_storage_is_not_reused() {
        let v = judge(&one(SHA, "01a"), ACCOUNT, "fi-hel1", "t-cdrom", "09z", SHA);
        assert!(matches!(v, Verdict::Refused(_)), "{v:?}");
    }

    /// The ledger is a veto, not a licence: an unrecorded storage falls through
    /// to the gate that was always here, so a box that never wrote a ledger
    /// behaves exactly as it did before this module existed.
    #[test]
    fn no_record_leaves_the_old_gate_in_charge() {
        assert_eq!(
            judge(&BTreeMap::new(), ACCOUNT, "fi-hel1", "t-cdrom", "01a", SHA),
            Verdict::Unrecorded
        );
        // …and so does a record for another ZONE, because a title is only
        // unique within one.
        assert_eq!(
            judge(&one(SHA, "01a"), ACCOUNT, "de-fra1", "t-cdrom", "01a", SHA),
            Verdict::Unrecorded
        );
    }

    #[test]
    fn a_ledger_round_trips() {
        let m = one(SHA, "01a");
        assert_eq!(parse(&render(&m)), m);
    }

    /// Every doubt reads as an empty ledger, never as an error: this is an
    /// accelerator, and a run that cannot read it must still be able to upload.
    #[test]
    fn rubbish_is_an_empty_ledger_and_not_a_failure() {
        assert!(parse("not json").is_empty());
        assert!(parse("{}").is_empty());
        assert!(parse(r#"{"media": {"fi-hel1/t": {"uuid": "01a"}}}"#).is_empty());
        // A SHORT digest is dropped rather than trusted — a veto weaker than
        // the title it strengthens is worse than none.
        assert!(
            parse(r#"{"media": {"fi-hel1/t": {"uuid": "01a", "sha256": "e3b0c442"}}}"#).is_empty()
        );
    }

    #[test]
    fn the_ledger_sits_beside_the_iso() {
        assert_eq!(
            ledger_path(Path::new("/x/.iso-out/gunnar-installer-metered.iso")),
            PathBuf::from("/x/.iso-out/.upcloud-media.json")
        );
    }

    #[test]
    fn a_record_is_read_back() {
        let dir = std::env::temp_dir().join(format!("kept-{}", std::process::id()));
        std::fs::create_dir_all(&dir).unwrap();
        let p = dir.join(".upcloud-media.json");
        let _ = std::fs::remove_file(&p);
        record(
            &p,
            ACCOUNT,
            "fi-hel1",
            "t-cdrom",
            "01a",
            &SHA.to_uppercase(),
        )
        .unwrap();
        let m = read(&p);
        assert_eq!(
            m.get(&key(ACCOUNT, "fi-hel1", "t-cdrom")).unwrap().sha256,
            SHA,
            "stored lower-case"
        );
        assert_eq!(
            judge(&m, ACCOUNT, "fi-hel1", "t-cdrom", "01a", SHA),
            Verdict::Proven
        );
        let _ = std::fs::remove_dir_all(&dir);
    }
}