use std::path::{Path, PathBuf};
use std::time::Duration;
use sha2::{Digest, Sha256};
pub(crate) fn hf_download(
repo_id: &str,
filename: &str,
revision: &str,
cache_dir: Option<&Path>,
expected_sha256: &str,
) -> Result<PathBuf, String> {
let mut builder = hf_hub::HFClientBuilder::new();
if let Some(cache_dir) = cache_dir {
builder = builder.cache_dir(cache_dir);
}
let api = builder
.build_sync()
.map_err(|error| format!("HF API init failed: {error}"))?;
if let Some(path) = cached_file(&api, repo_id, filename, revision)?
&& verify_sha256(&path, expected_sha256)?
{
return Ok(path);
}
if hf_offline_mode() {
return Err(format!(
"Hugging Face offline mode is enabled and '{filename}' from {repo_id}@{revision} is not available in the local cache"
));
}
let key = format!("{repo_id}/{filename}@{revision}");
let lock = download_lock(&key);
let repo_id = repo_id.to_string();
let filename = filename.to_string();
let revision = revision.to_string();
let expected_sha256 = expected_sha256.to_string();
let lock_file = artifact_lock_file(cache_dir, &key)?;
with_download_deadline(&key, move || {
let _guard = lock.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
fs2::FileExt::lock_exclusive(&lock_file)
.map_err(|error| format!("Failed to lock Hugging Face cache artifact: {error}"))?;
if let Some(path) = cached_file(&api, &repo_id, &filename, &revision)? {
if verify_sha256(&path, &expected_sha256)? {
return Ok(path);
}
remove_corrupt_cache_entry(&path)?;
}
let (owner, name) = hf_hub::split_id(&repo_id);
let path = api
.model(owner, name)
.download_file()
.filename(filename.clone())
.revision(revision.clone())
.force_download(true)
.send()
.map_err(|error| format!("Failed to download '{filename}' from {repo_id}@{revision}: {error}"))?;
if !verify_sha256(&path, &expected_sha256)? {
remove_corrupt_cache_entry(&path)?;
return Err(format!(
"SHA-256 mismatch for '{filename}' from {repo_id}@{revision} after refresh"
));
}
Ok(path)
})
}
fn verify_sha256(path: &Path, expected: &str) -> Result<bool, String> {
use std::io::Read;
let mut file = std::fs::File::open(path)
.map_err(|error| format!("Failed to open cached model '{}': {error}", path.display()))?;
let mut hasher = Sha256::new();
let mut buffer = [0u8; 1024 * 1024];
loop {
let read = file
.read(&mut buffer)
.map_err(|error| format!("Failed to hash cached model '{}': {error}", path.display()))?;
if read == 0 {
break;
}
hasher.update(&buffer[..read]);
}
Ok(hex_digest(hasher.finalize().as_ref()) == expected)
}
fn hex_digest(bytes: &[u8]) -> String {
use std::fmt::Write;
let mut output = String::with_capacity(bytes.len() * 2);
for byte in bytes {
write!(&mut output, "{byte:02x}").expect("writing to a String cannot fail");
}
output
}
fn artifact_lock_file(cache_dir: Option<&Path>, key: &str) -> Result<std::fs::File, String> {
let cache_dir = cache_dir
.map(Path::to_path_buf)
.unwrap_or_else(hf_hub::resolve_cache_dir);
let lock_dir = cache_dir.join(".locks").join("xberg-candle-ocr");
std::fs::create_dir_all(&lock_dir)
.map_err(|error| format!("Failed to create Hugging Face lock directory: {error}"))?;
let digest_bytes = Sha256::digest(key.as_bytes());
let digest = hex_digest(digest_bytes.as_ref());
std::fs::OpenOptions::new()
.create(true)
.truncate(false)
.read(true)
.write(true)
.open(lock_dir.join(format!("{digest}.lock")))
.map_err(|error| format!("Failed to open Hugging Face artifact lock: {error}"))
}
fn remove_corrupt_cache_entry(path: &Path) -> Result<(), String> {
let target = std::fs::canonicalize(path).ok();
if path.exists() || path.symlink_metadata().is_ok() {
std::fs::remove_file(path)
.map_err(|error| format!("Failed to remove corrupt cache entry '{}': {error}", path.display()))?;
}
if let Some(target) = target
&& target != path
&& target.exists()
{
std::fs::remove_file(&target)
.map_err(|error| format!("Failed to remove corrupt cache blob '{}': {error}", target.display()))?;
}
Ok(())
}
fn cached_file(
api: &hf_hub::HFClientSync,
repo_id: &str,
filename: &str,
revision: &str,
) -> Result<Option<std::path::PathBuf>, String> {
let (owner, name) = hf_hub::split_id(repo_id);
match api
.model(owner, name)
.download_file()
.filename(filename)
.revision(revision)
.local_files_only(true)
.send()
{
Ok(path) => Ok(Some(path)),
Err(hf_hub::HFError::LocalEntryNotFound { .. } | hf_hub::HFError::EntryNotFound { .. }) => Ok(None),
Err(error) => Err(format!(
"Failed to inspect Hugging Face cache for {repo_id}/{filename}: {error}"
)),
}
}
fn hf_offline_mode() -> bool {
["HF_HUB_OFFLINE", "HUGGINGFACE_HUB_OFFLINE"]
.iter()
.filter_map(std::env::var_os)
.any(|value| {
value
.to_str()
.is_some_and(|value| matches!(value.trim().to_ascii_lowercase().as_str(), "1" | "on" | "yes" | "true"))
})
}
fn download_lock(key: &str) -> std::sync::Arc<std::sync::Mutex<()>> {
use std::collections::HashMap;
use std::sync::{Arc, Mutex, OnceLock};
static LOCKS: OnceLock<Mutex<HashMap<String, Arc<Mutex<()>>>>> = OnceLock::new();
let mut locks = LOCKS
.get_or_init(|| Mutex::new(HashMap::new()))
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
Arc::clone(locks.entry(key.to_string()).or_default())
}
const DEFAULT_MODEL_DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(300);
pub(crate) fn model_download_timeout() -> Duration {
std::env::var("XBERG_MODEL_DOWNLOAD_TIMEOUT_SECS")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.filter(|&s| s > 0)
.map(Duration::from_secs)
.unwrap_or(DEFAULT_MODEL_DOWNLOAD_TIMEOUT)
}
pub(crate) fn with_download_deadline<T, F>(label: &str, f: F) -> Result<T, String>
where
F: FnOnce() -> Result<T, String> + Send + 'static,
T: Send + 'static,
{
let deadline = model_download_timeout();
let (tx, rx) = std::sync::mpsc::sync_channel::<Result<T, String>>(1);
std::thread::Builder::new()
.name("xberg-model-download".into())
.spawn(move || {
let _ = tx.send(f());
})
.map_err(|e| format!("failed to spawn model-download thread: {e}"))?;
match rx.recv_timeout(deadline) {
Ok(result) => result,
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
tracing::warn!(
target: "xberg::model_download",
label = %label,
timeout_secs = deadline.as_secs(),
"model download exceeded deadline (network unreachable / firewalled?); aborting so \
the extraction pipeline does not hang. Set XBERG_MODEL_DOWNLOAD_TIMEOUT_SECS to adjust."
);
Err(format!(
"model download '{label}' timed out after {}s (HuggingFace unreachable?)",
deadline.as_secs()
))
}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
Err(format!("model-download thread for '{label}' died unexpectedly"))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{Duration, Instant};
#[test]
fn with_download_deadline_returns_ok_for_fast_closure() {
let result = with_download_deadline("fast", || Ok::<i32, String>(7));
assert_eq!(result, Ok(7), "fast closure must return its Ok value verbatim");
}
#[test]
fn deadline_reads_env_override_and_aborts_a_hung_closure() {
#[allow(unsafe_code)]
unsafe {
std::env::set_var("XBERG_MODEL_DOWNLOAD_TIMEOUT_SECS", "1");
}
assert_eq!(
model_download_timeout(),
Duration::from_secs(1),
"explicit override must win"
);
let started = Instant::now();
let result = with_download_deadline("hung", || {
std::thread::sleep(Duration::from_secs(10));
Ok::<(), String>(())
});
let elapsed = started.elapsed();
#[allow(unsafe_code)]
unsafe {
std::env::remove_var("XBERG_MODEL_DOWNLOAD_TIMEOUT_SECS");
}
let err = result.expect_err("a closure that outlives the deadline must return Err");
assert!(err.contains("timed out"), "error must mention the timeout, got: {err}");
assert!(
elapsed < Duration::from_secs(3),
"guard must fire near the 1s deadline, not wait out the 10s sleep (took {elapsed:?})"
);
}
}