use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use tracing::debug;
use crate::core::sha256::compute_file_sha256;
#[derive(Error, Debug)]
pub enum IntegrityError {
#[error("Failed to query HuggingFace metadata for {repo}@{revision}: {reason}")]
MetadataFetchFailed {
repo: String,
revision: String,
reason: String,
},
#[error("Invalid remote conversion source manifest: {reason}")]
InvalidSourceManifest { reason: String },
#[error("Required source shard '{filename}' is absent from the HuggingFace manifest")]
RequiredShardMissing { filename: String },
#[error(
"Required source shard '{filename}' has no HuggingFace LFS SHA-256 identity (etag: {etag})"
)]
RequiredShardNotLfs { filename: String, etag: String },
#[error("Duplicate source-manifest entry for '{filename}'")]
DuplicateManifestEntry { filename: String },
#[error(
"Local file missing during integrity check: shard '{filename}' \
expected at {path}"
)]
LocalFileMissing { filename: String, path: String },
#[error(
"Integrity check failed for shard '{filename}' \
(repo {repo}@{revision}): \
expected SHA-256 {expected}, computed {actual}. \
The downloaded file does not match HuggingFace's recorded hash. \
Possible causes: corrupted download, MITM, or the source repo \
was force-pushed since the last cache. \
Re-run after `rm -rf {local_path}` to refetch, or pass \
--no-integrity to skip (NOT recommended)."
)]
ShardMismatch {
repo: String,
revision: String,
filename: String,
expected: String,
actual: String,
local_path: String,
},
#[error(
"Integrity check failed for shard '{filename}': \
expected size {expected_bytes} bytes, file on disk is {actual_bytes} bytes. \
File is truncated or has trailing data."
)]
SizeMismatch {
filename: String,
expected_bytes: u64,
actual_bytes: u64,
},
#[error("I/O error during integrity check: {0}")]
Io(#[from] std::io::Error),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ShardIntegrity {
pub filename: String,
pub bytes: u64,
pub sha256: Option<String>,
pub hf_etag: String,
pub is_lfs: bool,
}
impl ShardIntegrity {
pub fn from_metadata(filename: &str, etag: &str, size: u64) -> Self {
let trimmed = etag.trim().trim_matches('"');
let is_lfs = trimmed.len() == 64 && trimmed.chars().all(|c| c.is_ascii_hexdigit());
Self {
filename: filename.to_string(),
bytes: size,
sha256: if is_lfs {
Some(trimmed.to_lowercase())
} else {
None
},
hf_etag: trimmed.to_string(),
is_lfs,
}
}
}
pub fn verify_shard(
repo: &str,
revision: &str,
local_path: &Path,
expected: &ShardIntegrity,
) -> Result<(), IntegrityError> {
let metadata = match std::fs::metadata(local_path) {
Ok(m) => m,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Err(IntegrityError::LocalFileMissing {
filename: expected.filename.clone(),
path: local_path.display().to_string(),
});
}
Err(e) => return Err(IntegrityError::Io(e)),
};
let actual_bytes = metadata.len();
if actual_bytes != expected.bytes {
return Err(IntegrityError::SizeMismatch {
filename: expected.filename.clone(),
expected_bytes: expected.bytes,
actual_bytes,
});
}
let Some(expected_sha) = expected.sha256.as_ref() else {
debug!(
filename = %expected.filename,
"size match (non-LFS file; no sha256 verification possible)"
);
return Ok(());
};
let actual_sha = compute_file_sha256(local_path)?;
if actual_sha.eq_ignore_ascii_case(expected_sha) {
debug!(filename = %expected.filename, "sha256 match");
Ok(())
} else {
Err(IntegrityError::ShardMismatch {
repo: repo.to_string(),
revision: revision.to_string(),
filename: expected.filename.clone(),
expected: expected_sha.clone(),
actual: actual_sha,
local_path: local_path.display().to_string(),
})
}
}
#[inline]
pub fn shard_path(local_dir: &Path, filename: &str) -> PathBuf {
local_dir.join(filename)
}
#[cfg(test)]
mod tests {
use super::*;
use sha2::{Digest, Sha256};
use std::fs;
use tempfile::TempDir;
#[test]
fn from_metadata_lfs_etag_marks_lfs_and_records_sha256() {
let etag = "f9343d7d7ec5c3d8bcced056c438fc9f1d3819e9ca3d42418a40857050e10e20";
let s = ShardIntegrity::from_metadata("model.safetensors", etag, 12345);
assert!(s.is_lfs);
assert_eq!(s.sha256.as_deref(), Some(etag));
assert_eq!(s.hf_etag, etag);
assert_eq!(s.bytes, 12345);
}
#[test]
fn from_metadata_lfs_etag_uppercase_normalized_to_lowercase() {
let etag = "F9343D7D7EC5C3D8BCCED056C438FC9F1D3819E9CA3D42418A40857050E10E20";
let s = ShardIntegrity::from_metadata("model.safetensors", etag, 1);
assert!(s.is_lfs);
assert_eq!(
s.sha256.as_deref(),
Some("f9343d7d7ec5c3d8bcced056c438fc9f1d3819e9ca3d42418a40857050e10e20")
);
}
#[test]
fn from_metadata_quoted_etag_unwrapped() {
let etag = "\"f9343d7d7ec5c3d8bcced056c438fc9f1d3819e9ca3d42418a40857050e10e20\"";
let s = ShardIntegrity::from_metadata("model.safetensors", etag, 1);
assert!(s.is_lfs);
}
#[test]
fn from_metadata_git_blob_etag_marks_non_lfs() {
let etag = "0123456789abcdef0123456789abcdef01234567";
let s = ShardIntegrity::from_metadata("config.json", etag, 1024);
assert!(!s.is_lfs);
assert!(s.sha256.is_none());
assert_eq!(s.hf_etag, etag);
}
#[test]
fn from_metadata_garbage_etag_marks_non_lfs() {
let s = ShardIntegrity::from_metadata("foo", "not-a-hash", 0);
assert!(!s.is_lfs);
assert!(s.sha256.is_none());
}
fn make_shard_file(tmp: &Path, name: &str, contents: &[u8]) -> PathBuf {
let p = tmp.join(name);
if let Some(parent) = p.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(&p, contents).unwrap();
p
}
fn lfs_record(name: &str, contents: &[u8]) -> ShardIntegrity {
let mut h = Sha256::new();
h.update(contents);
let sha = hex::encode(h.finalize());
ShardIntegrity::from_metadata(name, &sha, contents.len() as u64)
}
#[test]
fn verify_shard_pass_lfs() {
let tmp = TempDir::new().unwrap();
let contents = b"the actual safetensors bytes";
let path = make_shard_file(tmp.path(), "model-00001.safetensors", contents);
let expected = lfs_record("model-00001.safetensors", contents);
verify_shard("org/repo", "main", &path, &expected).expect("hash matches");
}
#[test]
fn verify_shard_fail_sha_mismatch_names_filename_and_hashes() {
let tmp = TempDir::new().unwrap();
let contents = b"good bytes";
let path = make_shard_file(tmp.path(), "model.safetensors", contents);
let bad = b"different bytes that hash differently";
let expected = lfs_record("model.safetensors", bad);
let expected = ShardIntegrity {
bytes: contents.len() as u64,
..expected
};
let err = verify_shard("org/repo", "main", &path, &expected).expect_err("should mismatch");
let msg = format!("{err}");
assert!(msg.contains("model.safetensors"), "msg: {msg}");
assert!(msg.contains("expected SHA-256"), "msg: {msg}");
assert!(msg.contains("--no-integrity"), "msg: {msg}");
assert!(matches!(err, IntegrityError::ShardMismatch { .. }));
}
#[test]
fn verify_shard_fail_size_mismatch_short_circuits_before_hashing() {
let tmp = TempDir::new().unwrap();
let contents = b"only 12 bytes";
let path = make_shard_file(tmp.path(), "model.safetensors", contents);
let expected = ShardIntegrity {
filename: "model.safetensors".into(),
bytes: 9999,
sha256: Some("0".repeat(64)),
hf_etag: "0".repeat(64),
is_lfs: true,
};
let err = verify_shard("org/repo", "main", &path, &expected).expect_err("size mismatch");
assert!(matches!(err, IntegrityError::SizeMismatch { .. }));
let msg = format!("{err}");
assert!(msg.contains("9999"), "msg: {msg}");
assert!(msg.contains("13"), "msg: {msg}");
}
#[test]
fn verify_shard_missing_file_named_in_error() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("nope.safetensors");
let expected = ShardIntegrity {
filename: "nope.safetensors".into(),
bytes: 100,
sha256: Some("0".repeat(64)),
hf_etag: "0".repeat(64),
is_lfs: true,
};
let err = verify_shard("org/repo", "main", &path, &expected).expect_err("missing");
assert!(matches!(err, IntegrityError::LocalFileMissing { .. }));
let msg = format!("{err}");
assert!(msg.contains("nope.safetensors"), "msg: {msg}");
}
#[test]
fn verify_shard_non_lfs_skips_hash_after_size_match() {
let tmp = TempDir::new().unwrap();
let contents = br#"{"hidden_size": 4096}"#;
let path = make_shard_file(tmp.path(), "config.json", contents);
let expected = ShardIntegrity {
filename: "config.json".into(),
bytes: contents.len() as u64,
sha256: None,
hf_etag: "0123456789abcdef0123456789abcdef01234567".into(),
is_lfs: false,
};
verify_shard("org/repo", "main", &path, &expected)
.expect("non-LFS files only need size to match");
}
}