use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::time::SystemTime;
use crate::core::integrity::ShardIntegrity;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SourceShard {
pub filename: String,
pub bytes: u64,
pub sha256: Option<String>,
pub hf_etag: String,
pub is_lfs: bool,
pub verified_at_secs: u64,
}
impl SourceShard {
pub fn from_integrity(value: &ShardIntegrity) -> Self {
Self {
filename: value.filename.clone(),
bytes: value.bytes,
sha256: value.sha256.clone(),
hf_etag: value.hf_etag.clone(),
is_lfs: value.is_lfs,
verified_at_secs: secs_since_epoch(),
}
}
}
fn secs_since_epoch() -> u64 {
SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
pub fn compute_source_bundle_sha256(shards: &[SourceShard]) -> Option<String> {
let mut entries: Vec<(&str, String)> = shards
.iter()
.filter_map(|s| {
s.sha256
.as_ref()
.map(|h| (s.filename.as_str(), h.to_ascii_lowercase()))
})
.collect();
if entries.is_empty() {
return None;
}
entries.sort_by(|a, b| a.0.cmp(b.0));
let mut hasher = Sha256::new();
for (filename, sha) in &entries {
hasher.update(filename.as_bytes());
hasher.update(b":");
hasher.update(sha.as_bytes());
hasher.update(b"\n");
}
Some(hex::encode(hasher.finalize()))
}
#[cfg(test)]
mod tests {
use super::*;
fn shard(filename: &str, sha: Option<&str>) -> SourceShard {
SourceShard {
filename: filename.to_string(),
bytes: 1,
sha256: sha.map(|s| s.to_string()),
hf_etag: sha.map(|s| s.to_string()).unwrap_or_default(),
is_lfs: sha.is_some(),
verified_at_secs: 1,
}
}
#[test]
fn source_shard_adapter_copies_all_fields_and_stamps_timestamp() {
let integ = ShardIntegrity {
filename: "model.safetensors".into(),
bytes: 4096,
sha256: Some("d".repeat(64)),
hf_etag: "d".repeat(64),
is_lfs: true,
};
let s = SourceShard::from_integrity(&integ);
assert_eq!(s.filename, integ.filename);
assert_eq!(s.bytes, integ.bytes);
assert_eq!(s.sha256, integ.sha256);
assert_eq!(s.hf_etag, integ.hf_etag);
assert_eq!(s.is_lfs, integ.is_lfs);
assert!(s.verified_at_secs > 0);
}
#[test]
fn bundle_sha_returns_none_for_empty_list() {
assert!(compute_source_bundle_sha256(&[]).is_none());
}
#[test]
fn bundle_sha_returns_none_when_all_shards_lack_sha() {
let shards = vec![shard("config.json", None), shard("tokenizer.json", None)];
assert!(compute_source_bundle_sha256(&shards).is_none());
}
#[test]
fn bundle_sha_is_deterministic_under_input_reordering() {
let a = shard("a.safetensors", Some(&"a".repeat(64)));
let b = shard("b.safetensors", Some(&"b".repeat(64)));
let c = shard("c.safetensors", Some(&"c".repeat(64)));
let h1 = compute_source_bundle_sha256(&[a.clone(), b.clone(), c.clone()]).unwrap();
let h2 = compute_source_bundle_sha256(&[c, a, b]).unwrap();
assert_eq!(h1, h2, "bundle SHA must be order-independent");
assert_eq!(h1.len(), 64, "must be 64-hex SHA-256");
assert!(
h1.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
"must be lowercase hex"
);
}
#[test]
fn bundle_sha_skips_shards_without_sha_but_includes_others() {
let s = vec![
shard("model.safetensors", Some(&"e".repeat(64))),
shard("config.json", None),
shard("tokenizer.json", None),
];
let h_full = compute_source_bundle_sha256(&s).unwrap();
let h_only_lfs =
compute_source_bundle_sha256(&[shard("model.safetensors", Some(&"e".repeat(64)))])
.unwrap();
assert_eq!(h_full, h_only_lfs);
}
#[test]
fn bundle_sha_normalizes_uppercase_hex() {
let lower = compute_source_bundle_sha256(&[shard("a", Some(&"a".repeat(64)))]).unwrap();
let upper = compute_source_bundle_sha256(&[shard("a", Some(&"A".repeat(64)))]).unwrap();
assert_eq!(lower, upper, "uppercase shard SHA must match lowercase");
}
#[test]
fn bundle_sha_distinct_for_distinct_inputs() {
let h1 = compute_source_bundle_sha256(&[shard("a", Some(&"a".repeat(64)))]).unwrap();
let h2 = compute_source_bundle_sha256(&[shard("a", Some(&"b".repeat(64)))]).unwrap();
let h3 = compute_source_bundle_sha256(&[shard("b", Some(&"a".repeat(64)))]).unwrap();
assert_ne!(h1, h2);
assert_ne!(h1, h3);
}
}