//! What bytes exist: the per-file manifest and the digest that names it. use crate::ContractError; use sha2::{Digest, Sha256}; /// One file in a bundle: its sha256 and its path relative to the bundle root. #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct ManifestEntry { pub sha256: String, pub path: String, } /// Every file in a bundle, sorted by path. /// /// Sorted, and constructible only through checks: a manifest is what the digest /// is computed over, so two producers listing the same files must produce the /// same text or the digest stops being an identity. Sorting is the ordering /// half of that. Rejecting duplicate paths is the other half, since a repeated /// path with two different hashes is a manifest that describes two bundles. #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(transparent)] pub struct Manifest { entries: Vec, } impl Manifest { /// Build a manifest from `(path, sha256)` pairs in any order. pub fn new(files: I) -> Result where I: IntoIterator, P: Into, S: Into, { let mut entries: Vec = files .into_iter() .map(|(path, sha256)| ManifestEntry { path: path.into(), sha256: sha256.into(), }) .collect(); for e in &entries { check_sha256(&e.sha256)?; check_path(&e.path)?; } entries.sort_by(|a, b| a.path.cmp(&b.path)); if let Some(dup) = entries.windows(2).find(|w| w[0].path == w[1].path) { return Err(ContractError::DuplicatePath(dup[0].path.clone())); } if entries.is_empty() { return Err(ContractError::EmptyManifest); } Ok(Self { entries }) } pub fn entries(&self) -> &[ManifestEntry] { &self.entries } /// The `MANIFEST` file's contents: ` `, one per line, /// trailing newline. Deliberately the shape `sha256sum -c` reads, so the /// file is checkable on a node with no tooling of ours installed. pub fn to_text(&self) -> String { let mut s = String::new(); for e in &self.entries { s.push_str(&e.sha256); s.push_str(" "); s.push_str(&e.path); s.push('\n'); } s } /// Parse a `MANIFEST` back. Round-trips [`Manifest::to_text`]. pub fn parse(text: &str) -> Result { let mut files = Vec::new(); for (n, line) in text.lines().enumerate() { if line.trim().is_empty() { continue; } // Split on the two-space separator rather than on whitespace: a path // may contain single spaces, and splitting greedily would silently // truncate one. `sha256sum` has the same convention. let (sha, path) = line .split_once(" ") .ok_or(ContractError::MalformedManifestLine(n + 1))?; files.push((path.to_string(), sha.to_string())); } Self::new(files) } /// The bundle digest: sha256 of the manifest text. /// /// Hashing the manifest rather than the primary binary is the point. Two /// bundles whose binaries are identical and whose assets differ hash the /// same if you hash only the binary, so an asset-only change ships /// unnoticed. pub fn digest(&self) -> BundleDigest { let mut hasher = Sha256::new(); hasher.update(self.to_text().as_bytes()); BundleDigest(hex_lower(&hasher.finalize())) } } /// The identity of a set of bytes: 64 lowercase hex, the sha256 of a manifest. /// /// Not a substitute for the git sha, and neither is a substitute for it. The /// sha says what source; the digest says what bytes. Cargo builds are not /// bit-reproducible by default, so one commit built twice gives two digests: /// a digest match across hosts proves more than a sha match, and a digest /// cannot answer "have we already built this commit". #[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] #[serde(transparent)] pub struct BundleDigest(String); impl BundleDigest { pub fn parse(s: &str) -> Result { check_sha256(s)?; Ok(Self(s.to_string())) } pub fn as_str(&self) -> &str { &self.0 } /// The first 16 hex characters, which is what a content-addressed directory /// is named. The full 64 is what gets stored and compared. pub fn short(&self) -> &str { &self.0[..16] } } impl std::fmt::Display for BundleDigest { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { self.0.fmt(f) } } fn check_sha256(s: &str) -> Result<(), ContractError> { if s.len() == 64 && s.bytes() .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) { Ok(()) } else { Err(ContractError::NotASha256(s.to_string())) } } /// A manifest path is relative, forward-slashed, and does not walk upward. /// /// The node verifies a bundle by joining these onto a release directory, so a /// path that escapes it would have the verifier hash a file the bundle does not /// contain, and a deploy write one there. fn check_path(p: &str) -> Result<(), ContractError> { let bad = p.is_empty() || p.starts_with('/') || p.contains('\\') || p.split('/').any(|c| c == ".." || c == ".") || p.contains('\n'); if bad { return Err(ContractError::UnsafePath(p.to_string())); } Ok(()) } fn hex_lower(bytes: &[u8]) -> String { use std::fmt::Write as _; let mut s = String::with_capacity(bytes.len() * 2); for b in bytes { let _ = write!(s, "{b:02x}"); } s } #[cfg(test)] mod tests { use super::*; fn sha(byte: u8) -> String { std::iter::repeat_n(format!("{byte:02x}"), 32).collect() } #[test] fn entries_sort_by_path_so_input_order_cannot_change_the_digest() { let a = Manifest::new([ ("static/app.css", sha(1)), ("makenotwork", sha(2)), ("companions/mnw-cli", sha(3)), ]) .unwrap(); let b = Manifest::new([ ("companions/mnw-cli", sha(3)), ("static/app.css", sha(1)), ("makenotwork", sha(2)), ]) .unwrap(); assert_eq!(a.to_text(), b.to_text()); assert_eq!(a.digest(), b.digest()); let paths: Vec<&str> = a.entries().iter().map(|e| e.path.as_str()).collect(); assert_eq!( paths, ["companions/mnw-cli", "makenotwork", "static/app.css"] ); } #[test] fn text_round_trips() { let m = Manifest::new([("docs/index.html", sha(9)), ("makenotwork", sha(10))]).unwrap(); let back = Manifest::parse(&m.to_text()).unwrap(); assert_eq!(m, back); assert_eq!(m.digest(), back.digest()); } #[test] fn a_path_containing_a_space_survives_the_round_trip() { // Splitting on whitespace rather than on the two-space separator would // truncate this to "Release" and quietly describe a different file. let m = Manifest::new([("docs/Release Notes.html", sha(4))]).unwrap(); let back = Manifest::parse(&m.to_text()).unwrap(); assert_eq!(back.entries()[0].path, "docs/Release Notes.html"); } #[test] fn the_digest_is_the_sha256_of_the_manifest_text() { let m = Manifest::new([("a", sha(0))]).unwrap(); let mut hasher = Sha256::new(); hasher.update(m.to_text().as_bytes()); assert_eq!(m.digest().as_str(), hex_lower(&hasher.finalize())); assert_eq!(m.digest().short().len(), 16); } #[test] fn a_changed_asset_changes_the_digest_even_with_the_binary_untouched() { // Hashing only the binary would call these two bundles the same thing. let before = Manifest::new([("makenotwork", sha(7)), ("static/app.css", sha(1))]).unwrap(); let after = Manifest::new([("makenotwork", sha(7)), ("static/app.css", sha(2))]).unwrap(); assert_ne!(before.digest(), after.digest()); } #[test] fn a_duplicate_path_is_refused() { let err = Manifest::new([("makenotwork", sha(1)), ("makenotwork", sha(2))]).unwrap_err(); assert!(matches!(err, ContractError::DuplicatePath(p) if p == "makenotwork")); } #[test] fn an_empty_manifest_is_refused() { // A bundle with no files has a perfectly good digest (the hash of the // empty string), which is the problem: it is a stable identity for // nothing at all, and every empty bundle shares it. let files: Vec<(String, String)> = Vec::new(); assert!(matches!( Manifest::new(files).unwrap_err(), ContractError::EmptyManifest )); } #[test] fn paths_that_escape_the_bundle_are_refused() { for p in ["/etc/passwd", "../secrets", "a/../../b", "c:\\windows"] { assert!( matches!( Manifest::new([(p, sha(1))]).unwrap_err(), ContractError::UnsafePath(_) ), "{p} should be refused" ); } } #[test] fn a_hash_that_is_not_a_sha256_is_refused() { for h in ["", "abc", &"g".repeat(64), &"AB".repeat(32)] { assert!( matches!( Manifest::new([("a", h)]).unwrap_err(), ContractError::NotASha256(_) ), "{h} should be refused" ); } } #[test] fn a_malformed_line_names_its_line_number() { let text = format!("{} ok\nnot-a-manifest-line\n", sha(1)); assert!(matches!( Manifest::parse(&text).unwrap_err(), ContractError::MalformedManifestLine(2) )); } }