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