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