use std::path::{Path, PathBuf};
use std::time::SystemTime;
use crate::error::{OcrError, Result};
use crate::models::registry::ModelEntry;
#[cfg(feature = "download")]
use crate::models::registry::effective_repo;
#[cfg(feature = "download")]
pub fn ensure(entry: &ModelEntry, cache_dir_override: Option<&Path>, registry_owner: Option<&str>) -> Result<PathBuf> {
let repo = effective_repo(entry, registry_owner)?;
let root = hf_cache_root(cache_dir_override)?;
if let Some(path) = resolve_cached(&root, &repo, entry.file) {
return Ok(path);
}
let (owner, name) = match repo.split_once('/') {
Some((owner, name)) => (owner, name),
None => ("", repo.as_str()),
};
let client = hf_hub::HFClient::builder()
.cache_dir(root)
.build_sync()
.map_err(|source| model_error(format!("could not create the Hugging Face client for `{repo}`"), source))?;
let path = client
.model(owner, name)
.download_file()
.filename(entry.file.to_string())
.send()
.map_err(|source| model_error(format!("could not download `{}` from `{repo}`", entry.file), source))?;
verify_sha256_file(&path, entry.sha256, entry.name)?;
Ok(path)
}
#[cfg(not(feature = "download"))]
pub fn ensure(
_entry: &ModelEntry,
_cache_dir_override: Option<&Path>,
_registry_owner: Option<&str>,
) -> Result<PathBuf> {
Err(OcrError::model(
"model download requires the `download` feature; provide a local model path instead",
))
}
pub(crate) fn hf_cache_root(override_dir: Option<&Path>) -> Result<PathBuf> {
if let Some(dir) = override_dir {
return Ok(dir.to_path_buf());
}
if let Some(dir) = non_empty_env("HF_HUB_CACHE") {
return Ok(PathBuf::from(dir));
}
if let Some(dir) = non_empty_env("HUGGINGFACE_HUB_CACHE") {
return Ok(PathBuf::from(dir));
}
if let Some(home) = non_empty_env("HF_HOME") {
return Ok(PathBuf::from(home).join("hub"));
}
let home = non_empty_env("HOME")
.ok_or_else(|| OcrError::model("could not determine the Hugging Face cache root: $HOME is unset"))?;
Ok(PathBuf::from(home).join(".cache").join("huggingface").join("hub"))
}
fn non_empty_env(key: &str) -> Option<String> {
std::env::var(key).ok().filter(|value| !value.is_empty())
}
pub(crate) fn repo_cache_dir_name(repo_id: &str) -> String {
format!("models--{}", repo_id.replace('/', "--"))
}
pub(crate) fn resolve_cached(root: &Path, repo_id: &str, file: &str) -> Option<PathBuf> {
let snapshots = root.join(repo_cache_dir_name(repo_id)).join("snapshots");
let mut candidates: Vec<(SystemTime, PathBuf)> = std::fs::read_dir(&snapshots)
.ok()?
.filter_map(std::result::Result::ok)
.filter(|entry| entry.path().is_dir())
.filter_map(|entry| {
let candidate = entry.path().join(file);
if candidate.exists() {
let modified = entry
.metadata()
.and_then(|meta| meta.modified())
.unwrap_or(SystemTime::UNIX_EPOCH);
Some((modified, candidate))
} else {
None
}
})
.collect();
candidates.sort_by_key(|(modified, _)| *modified);
candidates.pop().map(|(_, path)| path)
}
#[cfg(feature = "download")]
const HEX_CHARS_PER_BYTE: usize = 2;
#[cfg(feature = "download")]
const HASH_CHUNK_BYTES: usize = 64 * 1024;
#[cfg(feature = "download")]
fn verify_sha256_file(path: &Path, expected_sha256: &str, model_name: &str) -> Result<()> {
if expected_sha256.is_empty() {
tracing::warn!(
model = model_name,
"model artifact has no pinned sha256; skipping integrity verification"
);
return Ok(());
}
let actual = sha256_file(path)?;
if actual.eq_ignore_ascii_case(expected_sha256) {
Ok(())
} else {
Err(OcrError::model(format!(
"sha256 mismatch for `{model_name}`: expected {expected_sha256}, got {actual}"
)))
}
}
#[cfg(all(test, feature = "download"))]
fn verify_sha256(bytes: &[u8], expected_sha256: &str, model_name: &str) -> Result<()> {
if expected_sha256.is_empty() {
tracing::warn!(
model = model_name,
"model artifact has no pinned sha256; skipping integrity verification"
);
return Ok(());
}
let actual = sha256_hex(bytes);
if actual.eq_ignore_ascii_case(expected_sha256) {
Ok(())
} else {
Err(OcrError::model(format!(
"sha256 mismatch for `{model_name}`: expected {expected_sha256}, got {actual}"
)))
}
}
#[cfg(all(test, feature = "download"))]
fn sha256_hex(bytes: &[u8]) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(bytes);
to_hex(&hasher.finalize())
}
#[cfg(feature = "download")]
fn sha256_file(path: &Path) -> Result<String> {
use std::io::Read as _;
use sha2::{Digest, Sha256};
let mut file = std::fs::File::open(path)?;
let mut hasher = Sha256::new();
let mut buffer = [0_u8; HASH_CHUNK_BYTES];
loop {
let read = file.read(&mut buffer)?;
if read == 0 {
break;
}
hasher.update(&buffer[..read]);
}
Ok(to_hex(&hasher.finalize()))
}
#[cfg(feature = "download")]
fn to_hex(bytes: &[u8]) -> String {
use std::fmt::Write as _;
let mut hex = String::with_capacity(bytes.len() * HEX_CHARS_PER_BYTE);
for byte in bytes {
let _ = write!(hex, "{byte:02x}");
}
hex
}
#[cfg(feature = "download")]
fn model_error(message: String, source: impl std::error::Error + Send + Sync + 'static) -> OcrError {
OcrError::Model {
message,
source: Some(Box::new(source)),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn repo_cache_dir_name_mirrors_the_hugging_face_layout() {
assert_eq!(
repo_cache_dir_name("itextresearch/itext-EasyOCR-english_g2"),
"models--itextresearch--itext-EasyOCR-english_g2"
);
}
#[test]
fn hf_cache_root_returns_the_override_verbatim() {
let override_dir = Path::new("/custom/hub/cache");
assert_eq!(hf_cache_root(Some(override_dir)).unwrap(), override_dir);
}
#[test]
fn resolve_cached_finds_a_file_planted_in_a_snapshot() {
let root = std::env::temp_dir().join(format!("sceptre-resolve-{}", std::process::id()));
let repo = "itextresearch/itext-EasyOCR-english_g2";
let file = "itext-EasyOCR-english_g2.onnx";
let snapshot = root.join(repo_cache_dir_name(repo)).join("snapshots").join("deadbeef");
std::fs::create_dir_all(&snapshot).unwrap();
let planted = snapshot.join(file);
std::fs::write(&planted, b"onnx").unwrap();
assert_eq!(resolve_cached(&root, repo, file), Some(planted));
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn resolve_cached_returns_none_when_the_file_is_absent() {
let root = std::env::temp_dir().join(format!("sceptre-resolve-empty-{}", std::process::id()));
std::fs::create_dir_all(&root).unwrap();
assert_eq!(
resolve_cached(
&root,
"itextresearch/itext-EasyOCR-english_g2",
"itext-EasyOCR-english_g2.onnx"
),
None
);
std::fs::remove_dir_all(&root).ok();
}
}
#[cfg(all(test, feature = "download"))]
mod download_tests {
use super::*;
const EMPTY_SHA256: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
const ABC_SHA256: &str = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";
#[test]
fn sha256_hex_matches_known_test_vectors() {
assert_eq!(sha256_hex(b""), EMPTY_SHA256);
assert_eq!(sha256_hex(b"abc"), ABC_SHA256);
}
#[test]
fn verify_sha256_accepts_a_matching_pin_case_insensitively() {
assert!(verify_sha256(b"abc", ABC_SHA256, "abc_model").is_ok());
assert!(verify_sha256(b"abc", &ABC_SHA256.to_uppercase(), "abc_model").is_ok());
}
#[test]
fn verify_sha256_rejects_a_mismatched_pin() {
let error = verify_sha256(b"abc", EMPTY_SHA256, "abc_model").unwrap_err();
assert!(matches!(error, OcrError::Model { .. }));
}
#[test]
fn verify_sha256_skips_verification_when_the_pin_is_empty() {
assert!(verify_sha256(b"any bytes", "", "unpinned_model").is_ok());
}
#[test]
fn sha256_file_hashes_written_bytes() {
let dir = std::env::temp_dir().join(format!("sceptre-hash-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("abc.bin");
std::fs::write(&file, b"abc").unwrap();
assert_eq!(sha256_file(&file).unwrap(), ABC_SHA256);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn verify_sha256_file_matches_pin_and_rejects_wrong_pin() {
let dir = std::env::temp_dir().join(format!("sceptre-verify-file-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("abc.bin");
std::fs::write(&file, b"abc").unwrap();
assert!(verify_sha256_file(&file, ABC_SHA256, "abc_model").is_ok());
assert!(verify_sha256_file(&file, EMPTY_SHA256, "abc_model").is_err());
assert!(verify_sha256_file(&file, "", "unpinned_model").is_ok());
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn ensure_returns_a_cached_artifact_without_network() {
use crate::models::registry::craft_entry;
let entry = craft_entry();
let root = std::env::temp_dir().join(format!("sceptre-ensure-cache-{}", std::process::id()));
let snapshot = root
.join(repo_cache_dir_name(entry.hf_repo))
.join("snapshots")
.join("rev0");
std::fs::create_dir_all(&snapshot).unwrap();
let planted = snapshot.join(entry.file);
std::fs::write(&planted, b"onnx-bytes").unwrap();
let path = ensure(&entry, Some(root.as_path()), None).expect("cached artifact resolves offline");
assert_eq!(path, planted);
std::fs::remove_dir_all(&root).ok();
}
#[test]
#[ignore = "requires network access to Hugging Face"]
fn ensure_downloads_and_caches_the_craft_model() {
use crate::models::registry::craft_entry;
let cache_dir = std::env::temp_dir().join(format!("sceptre-net-{}", std::process::id()));
let path = ensure(&craft_entry(), Some(cache_dir.as_path()), None).expect("craft download should succeed");
assert!(path.is_file(), "downloaded artifact must exist at {}", path.display());
assert!(path.starts_with(&cache_dir), "artifact must live under the cache dir");
let again = ensure(&craft_entry(), Some(cache_dir.as_path()), None).expect("cached lookup should succeed");
assert_eq!(path, again);
std::fs::remove_dir_all(&cache_dir).ok();
}
}