pub(crate) fn sha256_hex(bytes: &[u8]) -> String {
use sha2::{Digest, Sha256};
use std::fmt::Write as _;
let digest = Sha256::digest(bytes);
let mut hex = String::with_capacity(digest.len() * 2);
for byte in digest {
let _ = write!(hex, "{byte:02x}");
}
hex
}
pub(crate) fn verify_sha256(bytes: &[u8], expected_hex: &str) -> Result<(), String> {
let expected = expected_hex.trim();
let actual = sha256_hex(bytes);
if actual.eq_ignore_ascii_case(expected) {
Ok(())
} else {
Err(format!("expected sha256 {expected}, got {actual}"))
}
}
pub(crate) fn lookup_checksum(
json: &str,
version: &str,
asset: &str,
) -> Result<Option<String>, String> {
let map: serde_json::Value =
serde_json::from_str(json).map_err(|e| format!("malformed JSON: {e}"))?;
let by_version = map.get(version).or_else(|| map.get(format!("v{version}")));
let checksum = by_version
.and_then(|entry| entry.get(asset))
.and_then(serde_json::Value::as_str)
.map(str::to_string);
Ok(checksum)
}
#[cfg(test)]
mod tests {
use super::{lookup_checksum, sha256_hex, verify_sha256};
const HELLO_SHA256: &str = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824";
#[test]
fn sha256_hex_known_vector() {
assert_eq!(sha256_hex(b"hello"), HELLO_SHA256);
}
#[test]
fn verify_accepts_matching_bytes() {
assert!(verify_sha256(b"hello", HELLO_SHA256).is_ok());
assert!(verify_sha256(b"hello", &HELLO_SHA256.to_uppercase()).is_ok());
}
#[test]
fn verify_rejects_tampered_bytes() {
let err = verify_sha256(b"hello tampered", HELLO_SHA256)
.expect_err("tampered bytes must not verify against the hello digest");
assert!(err.contains(HELLO_SHA256), "missing expected hex: {err}");
assert!(
err.contains(&sha256_hex(b"hello tampered")),
"missing actual hex: {err}"
);
}
#[test]
fn lookup_finds_entry() {
let got = lookup_checksum(r#"{"0.4.8": {"a": "x"}}"#, "0.4.8", "a").unwrap();
assert_eq!(got.as_deref(), Some("x"));
}
#[test]
fn lookup_accepts_v_prefixed_version_key() {
let got = lookup_checksum(r#"{"v0.4.8": {"a": "x"}}"#, "0.4.8", "a").unwrap();
assert_eq!(got.as_deref(), Some("x"));
}
#[test]
fn lookup_missing_version_or_asset_is_none() {
let json = r#"{"0.4.8": {"a": "x"}}"#;
assert_eq!(lookup_checksum(json, "9.9.9", "a").unwrap(), None);
assert_eq!(lookup_checksum(json, "0.4.8", "b").unwrap(), None);
}
#[test]
fn lookup_malformed_json_is_err() {
assert!(lookup_checksum("not json", "0.4.8", "a").is_err());
}
#[test]
fn committed_checksums_file_is_well_formed() {
let raw = include_str!("../sidecar-checksums.json");
let map: serde_json::Value = serde_json::from_str(raw).expect("valid JSON");
let obj = map.as_object().expect("top level is an object");
for (version, assets) in obj {
if version.starts_with('_') {
continue; }
let assets = assets
.as_object()
.unwrap_or_else(|| panic!("version {version} must map to an object"));
for (asset, digest) in assets {
let digest = digest
.as_str()
.unwrap_or_else(|| panic!("{version}/{asset} must be a string"));
assert_eq!(
digest.len(),
64,
"{version}/{asset} must be 64 hex chars, got {digest:?}"
);
assert!(
digest.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f')),
"{version}/{asset} must be lowercase hex, got {digest:?}"
);
}
}
}
}