pub(crate) mod download;
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use reqwest::blocking::Client;
use crate::session::CeraError;
pub trait DownloadProgress: Send + Sync + std::fmt::Debug {
fn on_progress(&self, url: &str, bytes_downloaded: u64, total_bytes: Option<u64>);
}
#[derive(Clone, Debug)]
pub struct BundleRepo {
store_dir: PathBuf,
http_client: Client,
head_client: Client,
progress: Option<Arc<dyn DownloadProgress>>,
}
impl BundleRepo {
pub fn new(store_dir: impl Into<PathBuf>) -> Self {
let head_client = Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()
.expect("build no-redirect reqwest client");
Self {
store_dir: store_dir.into(),
http_client: Client::new(),
head_client,
progress: None,
}
}
pub fn with_progress(
store_dir: impl Into<PathBuf>,
progress: Arc<dyn DownloadProgress>,
) -> Self {
let mut repo = Self::new(store_dir);
repo.progress = Some(progress);
repo
}
pub fn store_dir(&self) -> &Path {
&self.store_dir
}
pub fn cache_size(&self) -> Result<u64, CeraError> {
let mut total = 0u64;
Self::walk_dir_size(&self.store_dir, &mut total)?;
Ok(total)
}
fn walk_dir_size(dir: &Path, total: &mut u64) -> Result<(), CeraError> {
let entries = match fs::read_dir(dir) {
Ok(e) => e,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(e.into()),
};
for entry in entries.flatten() {
let Ok(file_type) = entry.file_type() else {
continue;
};
if file_type.is_dir() {
Self::walk_dir_size(&entry.path(), total)?;
} else if file_type.is_file()
&& let Ok(meta) = entry.metadata()
{
*total = total.saturating_add(meta.len());
}
}
Ok(())
}
pub fn clear_cache(&self) -> Result<(), CeraError> {
match fs::remove_dir_all(&self.store_dir) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(e.into()),
}
fs::create_dir_all(&self.store_dir)?;
Ok(())
}
pub fn resolve_url(
&self,
url: &str,
expected_sha256: Option<&str>,
) -> Result<PathBuf, CeraError> {
let dest = self.path_for_url(url)?;
let head = if expected_sha256.is_some() {
download::HeadInfo {
content_length: None,
linked_sha256: None,
}
} else {
download::head_info(&self.head_client, url)
};
if dest.exists() && self.cache_hit_valid(&dest, url, expected_sha256, &head) {
return Ok(dest);
}
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent)?;
}
let download_hash = expected_sha256
.map(|s| s.to_ascii_lowercase())
.or_else(|| head.linked_sha256.clone());
tracing::info!(
target: "cera::bundle",
url,
dest = %dest.display(),
hash_source = match (expected_sha256.is_some(), head.linked_sha256.is_some()) {
(true, _) => "caller",
(false, true) => "x-linked-etag",
(false, false) => "unverified",
},
"downloading bundle file"
);
download::download_to(
&self.http_client,
url,
&dest,
download_hash.as_deref(),
head.content_length,
self.progress.as_deref(),
)?;
Ok(dest)
}
fn cache_hit_valid(
&self,
dest: &Path,
url: &str,
expected_sha256: Option<&str>,
head: &download::HeadInfo,
) -> bool {
let expected_hash = expected_sha256
.map(|s| s.to_ascii_lowercase())
.or_else(|| head.linked_sha256.clone());
if let Some(exp_hash) = expected_hash {
return hash_matches(dest, url, &exp_hash);
}
if let Some(exp_len) = head.content_length {
let actual = fs::metadata(dest).map(|m| m.len()).unwrap_or(0);
if actual == exp_len {
return true;
}
tracing::info!(
target: "cera::bundle",
url,
expected = exp_len,
actual,
"cached file size mismatch; re-downloading"
);
return false;
}
true
}
fn path_for_url(&self, url: &str) -> Result<PathBuf, CeraError> {
let (host, path) = split_url(url)?;
let host_lower = host.to_ascii_lowercase();
validate_path_segment("url host", &host_lower)?;
let path_no_qs = path
.trim_start_matches('/')
.split(['?', '#'])
.next()
.unwrap_or("");
if path_no_qs.is_empty() {
return Err(CeraError::Backend(format!(
"url `{url}` has no path component"
)));
}
let mut out = self.store_dir.clone();
out.push(&host_lower);
for segment in path_no_qs.split('/') {
validate_path_segment("url path segment", segment)?;
out.push(segment);
}
Ok(out)
}
}
fn hash_matches(dest: &Path, url: &str, expected_hash: &str) -> bool {
let expected = expected_hash.to_ascii_lowercase();
if let Some(cached) = download::read_sidecar(dest) {
if cached == expected {
return true;
}
tracing::info!(
target: "cera::bundle",
url,
expected = %expected,
actual = %cached,
"cached file sidecar hash mismatch; re-downloading"
);
return false;
}
match download::sha256_file(dest) {
Ok(actual) if actual == expected => {
download::write_sidecar(dest, &actual);
true
}
Ok(actual) => {
tracing::info!(
target: "cera::bundle",
url,
expected = %expected,
actual = %actual,
"cached file hash mismatch; re-downloading"
);
false
}
Err(e) => {
tracing::warn!(
target: "cera::bundle",
url,
error = %e,
"failed to hash cached file; re-downloading"
);
false
}
}
}
const LEAP_BUNDLES_API_URL: &str = "https://huggingface.co/api/models/LiquidAI/LeapBundles";
const LIST_BUNDLES_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LeapBundleEntry {
pub name: String,
pub quants: Vec<String>,
}
pub fn list_leap_bundles() -> Result<Vec<LeapBundleEntry>, CeraError> {
let client = Client::builder()
.timeout(LIST_BUNDLES_TIMEOUT)
.build()
.map_err(|e| CeraError::Backend(format!("list-bundles client build failed: {e}")))?;
let body = client
.get(LEAP_BUNDLES_API_URL)
.send()
.and_then(|r| r.error_for_status())
.and_then(|r| r.text())
.map_err(|e| CeraError::Backend(format!("list-bundles HTTP failed: {e}")))?;
parse_leap_bundles(&body)
}
fn parse_leap_bundles(body: &str) -> Result<Vec<LeapBundleEntry>, CeraError> {
#[derive(serde::Deserialize)]
struct Sibling {
rfilename: String,
}
#[derive(serde::Deserialize)]
struct Resp {
siblings: Vec<Sibling>,
}
let resp: Resp = serde_json::from_str(body)
.map_err(|e| CeraError::Backend(format!("list-bundles JSON parse failed: {e}")))?;
let mut by_bundle: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
for sib in resp.siblings {
let Some((dir, file)) = sib.rfilename.split_once('/') else {
continue;
};
let Some(quant) = file.strip_suffix(".json") else {
continue;
};
if quant.contains('/') {
continue;
}
if validate_path_segment("bundle_id", dir).is_err()
|| validate_path_segment("quant", quant).is_err()
{
continue;
}
by_bundle
.entry(dir.to_string())
.or_default()
.insert(quant.to_string());
}
Ok(by_bundle
.into_iter()
.map(|(name, quants)| LeapBundleEntry {
name,
quants: quants.into_iter().collect(),
})
.collect())
}
pub fn leap_bundles_manifest_url(bundle_id: &str, quant: &str) -> Result<String, CeraError> {
validate_path_segment("bundle_id", bundle_id)?;
validate_path_segment("quant", quant)?;
Ok(format!(
"https://huggingface.co/LiquidAI/LeapBundles/resolve/main/{bundle_id}/{quant}.json"
))
}
fn validate_path_segment(kind: &str, segment: &str) -> Result<(), CeraError> {
if segment.is_empty() {
return Err(CeraError::Backend(format!("{kind} must not be empty")));
}
if segment == "." || segment == ".." {
return Err(CeraError::Backend(format!(
"{kind} `{segment}` is not a valid path component"
)));
}
for ch in segment.chars() {
let ok = ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.');
if !ok {
return Err(CeraError::Backend(format!(
"{kind} `{segment}` contains forbidden character {ch:?}"
)));
}
}
Ok(())
}
fn split_url(url: &str) -> Result<(&str, &str), CeraError> {
let scheme_end = url.find("://").ok_or_else(|| {
CeraError::Backend(format!("url `{url}` must start with https:// or http://"))
})?;
let scheme = &url[..scheme_end];
let lower = scheme.to_ascii_lowercase();
if lower != "http" && lower != "https" {
return Err(CeraError::Backend(format!(
"url `{url}` must start with https:// or http://"
)));
}
let after_scheme = &url[scheme_end + 3..]; let (host, path) = after_scheme
.split_once('/')
.ok_or_else(|| CeraError::Backend(format!("url `{url}` has no path component")))?;
if host.is_empty() {
return Err(CeraError::Backend(format!(
"url `{url}` has empty host component"
)));
}
let path_start = url.len() - path.len() - 1;
Ok((host, &url[path_start..]))
}
#[cfg(test)]
mod tests {
use super::*;
fn unique_test_dir(name: &str) -> PathBuf {
let dir =
std::env::temp_dir().join(format!("cera-bundle-test-{}-{}", name, std::process::id()));
let _ = fs::remove_dir_all(&dir);
dir
}
#[test]
fn cache_size_is_zero_when_store_dir_missing() {
let dir = unique_test_dir("size-empty");
let repo = BundleRepo::new(&dir);
assert!(
!dir.exists(),
"BundleRepo::new must not eagerly create store_dir"
);
assert_eq!(repo.cache_size().unwrap(), 0);
}
#[test]
fn cache_size_sums_nested_files() {
let dir = unique_test_dir("size-sum");
fs::create_dir_all(dir.join("huggingface.co/LiquidAI/A")).unwrap();
fs::create_dir_all(dir.join("huggingface.co/LiquidAI/B")).unwrap();
fs::write(dir.join("huggingface.co/LiquidAI/A/file1"), vec![0u8; 1024]).unwrap();
fs::write(
dir.join("huggingface.co/LiquidAI/A/file1.sha256"),
b"deadbeef",
)
.unwrap();
fs::write(dir.join("huggingface.co/LiquidAI/B/file2"), vec![0u8; 4096]).unwrap();
let repo = BundleRepo::new(&dir);
assert_eq!(repo.cache_size().unwrap(), 1024 + 8 + 4096);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn clear_cache_is_idempotent_on_missing_store_dir() {
let dir = unique_test_dir("clear-empty");
let repo = BundleRepo::new(&dir);
assert!(!dir.exists());
repo.clear_cache().unwrap();
assert!(!dir.exists());
}
#[test]
fn clear_cache_wipes_files_but_keeps_store_dir() {
let dir = unique_test_dir("clear-wipe");
fs::create_dir_all(dir.join("huggingface.co/LiquidAI/A")).unwrap();
fs::write(dir.join("huggingface.co/LiquidAI/A/file"), vec![0u8; 100]).unwrap();
let repo = BundleRepo::new(&dir);
assert_eq!(repo.cache_size().unwrap(), 100);
repo.clear_cache().unwrap();
assert!(dir.exists(), "store_dir must survive clear_cache");
assert_eq!(repo.cache_size().unwrap(), 0);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn path_for_url_mirrors_host_and_path() {
let repo = BundleRepo::new("/tmp/store");
let p = repo
.path_for_url("https://huggingface.co/LiquidAI/LFM2-1.2B-GGUF/resolve/main/x.gguf")
.unwrap();
assert_eq!(
p,
PathBuf::from("/tmp/store/huggingface.co/LiquidAI/LFM2-1.2B-GGUF/resolve/main/x.gguf")
);
}
#[test]
fn split_url_rejects_missing_scheme() {
assert!(split_url("huggingface.co/x").is_err());
}
#[test]
fn split_url_rejects_missing_path() {
assert!(split_url("https://huggingface.co").is_err());
}
#[test]
fn split_url_accepts_http_and_https() {
assert!(split_url("http://example.com/x").is_ok());
assert!(split_url("https://example.com/x").is_ok());
}
#[test]
fn split_url_scheme_is_case_insensitive() {
assert!(split_url("HTTPS://example.com/x").is_ok());
assert!(split_url("Http://example.com/x").is_ok());
assert!(split_url("HTTP://example.com/x").is_ok());
}
#[test]
fn path_for_url_lowercases_host_for_cache_consistency() {
let repo = BundleRepo::new("/tmp/store");
let a = repo
.path_for_url("https://HuggingFace.co/LiquidAI/M/x.gguf")
.unwrap();
let b = repo
.path_for_url("https://huggingface.co/LiquidAI/M/x.gguf")
.unwrap();
assert_eq!(a, b);
assert_eq!(
a,
PathBuf::from("/tmp/store/huggingface.co/LiquidAI/M/x.gguf")
);
}
#[test]
fn path_for_url_rejects_parent_dir_segment() {
let repo = BundleRepo::new("/tmp/store");
let e = repo
.path_for_url("https://evil.example.com/a/../../etc/passwd")
.expect_err("`..` segment must be rejected");
assert!(format!("{e}").contains("not a valid path component"));
}
#[test]
fn path_for_url_rejects_windows_reserved_chars() {
let repo = BundleRepo::new("/tmp/store");
for bad in ["a*b", "a\"b", "a<b", "a>b", "a|b"] {
let url = format!("https://example.com/{bad}");
let e = repo
.path_for_url(&url)
.expect_err(&format!("{bad:?} must be rejected"));
let msg = format!("{e}");
assert!(
msg.contains("forbidden"),
"unexpected error for {bad:?}: {msg}"
);
}
}
#[test]
fn path_for_url_rejects_empty_segment() {
let repo = BundleRepo::new("/tmp/store");
let e = repo
.path_for_url("https://example.com/a//b")
.expect_err("empty path segment must be rejected");
assert!(format!("{e}").contains("must not be empty"));
}
#[test]
fn path_for_url_strips_query_and_fragment() {
let repo = BundleRepo::new("/tmp/store");
let p = repo
.path_for_url("https://example.com/model.gguf?foo=bar#frag")
.unwrap();
assert_eq!(p, PathBuf::from("/tmp/store/example.com/model.gguf"));
}
#[test]
fn path_for_url_rejects_null_byte_in_path() {
let repo = BundleRepo::new("/tmp/store");
let e = repo
.path_for_url("https://example.com/a\0b")
.expect_err("null byte in path must be rejected");
assert!(format!("{e}").contains("forbidden"));
}
#[test]
fn hash_matches_uses_sidecar_fast_path() {
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("x.gguf");
std::fs::write(&dest, b"").unwrap();
let hex = "0123456789abcdef".repeat(4);
assert_eq!(hex.len(), 64);
std::fs::write(download::sidecar_path(&dest), &hex).unwrap();
assert!(hash_matches(&dest, "https://example.com/x", &hex));
assert!(hash_matches(
&dest,
"https://example.com/x",
&hex.to_uppercase()
));
let wrong = "f".repeat(64);
assert!(!hash_matches(&dest, "https://example.com/x", &wrong));
}
#[test]
fn hash_matches_full_rehash_when_no_sidecar() {
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("x.bin");
std::fs::write(&dest, b"hello").unwrap();
let correct = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824";
let wrong = "0".repeat(64);
assert!(hash_matches(&dest, "https://example.com/x", correct));
assert!(!hash_matches(&dest, "https://example.com/x", &wrong));
}
#[test]
fn leap_manifest_url_happy_path() {
let url = leap_bundles_manifest_url("LFM2-1.2B-GGUF", "Q4_0").unwrap();
assert_eq!(
url,
"https://huggingface.co/LiquidAI/LeapBundles/resolve/main/LFM2-1.2B-GGUF/Q4_0.json"
);
}
#[test]
fn leap_manifest_url_rejects_empty() {
assert!(leap_bundles_manifest_url("", "Q4_0").is_err());
assert!(leap_bundles_manifest_url("LFM2-1.2B-GGUF", "").is_err());
}
#[test]
fn leap_manifest_url_rejects_path_separators() {
assert!(leap_bundles_manifest_url("LFM2/GGUF", "Q4_0").is_err());
assert!(leap_bundles_manifest_url("LFM2-1.2B-GGUF", "sub/Q4_0").is_err());
assert!(leap_bundles_manifest_url("LFM2\\GGUF", "Q4_0").is_err());
}
#[test]
fn leap_manifest_url_rejects_parent_dir() {
assert!(leap_bundles_manifest_url("..", "Q4_0").is_err());
assert!(leap_bundles_manifest_url("LFM2-1.2B-GGUF", "..").is_err());
}
#[test]
fn leap_manifest_url_rejects_whitespace_and_url_reserved() {
assert!(leap_bundles_manifest_url("LFM2 GGUF", "Q4_0").is_err());
assert!(leap_bundles_manifest_url("LFM2-1.2B-GGUF", "Q4 0").is_err());
assert!(leap_bundles_manifest_url("LFM2-1.2B-GGUF", "Q4_0\n").is_err());
assert!(leap_bundles_manifest_url("LFM2?x", "Q4_0").is_err());
assert!(leap_bundles_manifest_url("LFM2#x", "Q4_0").is_err());
assert!(leap_bundles_manifest_url("LFM2%2E", "Q4_0").is_err());
}
#[test]
fn parse_leap_bundles_groups_siblings() {
let body = r#"{
"siblings": [
{"rfilename": ".gitattributes"},
{"rfilename": "README.md"},
{"rfilename": "LFM2-1.2B-8da4w_output_8da8w-seq_4096.bundle"},
{"rfilename": "LFM2-1.2B-GGUF/Q8_0.json"},
{"rfilename": "LFM2-1.2B-GGUF/Q4_0.json"},
{"rfilename": "LFM2-1.2B-GGUF/Q4_K_M.json"},
{"rfilename": "LFM2-2.6B-GGUF/Q4_0.json"},
{"rfilename": "LFM2-2.6B-GGUF/notes/extra.json"},
{"rfilename": "LFM2-2.6B-GGUF/extras.txt"}
]
}"#;
let entries = parse_leap_bundles(body).unwrap();
assert_eq!(entries.len(), 2, "expected 2 bundles, got {entries:?}");
assert_eq!(entries[0].name, "LFM2-1.2B-GGUF");
assert_eq!(entries[0].quants, vec!["Q4_0", "Q4_K_M", "Q8_0"]);
assert_eq!(entries[1].name, "LFM2-2.6B-GGUF");
assert_eq!(entries[1].quants, vec!["Q4_0"]);
}
#[test]
fn parse_leap_bundles_rejects_malformed_json() {
let err = parse_leap_bundles("not json at all").unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("JSON parse failed"), "got: {msg}");
}
#[test]
fn parse_leap_bundles_empty_siblings_is_ok() {
let entries = parse_leap_bundles(r#"{"siblings": []}"#).unwrap();
assert!(entries.is_empty());
}
#[test]
fn parse_leap_bundles_drops_invalid_path_segments() {
let body = r#"{
"siblings": [
{"rfilename": "Good-Bundle-GGUF/Q4_0.json"},
{"rfilename": "Has Space-GGUF/Q4_0.json"},
{"rfilename": "Good-Bundle-GGUF/Q 0.json"},
{"rfilename": "Has?Reserved/Q4_0.json"},
{"rfilename": "Café-GGUF/Q4_0.json"}
]
}"#;
let entries = parse_leap_bundles(body).unwrap();
assert_eq!(entries.len(), 1, "expected only the valid entry");
assert_eq!(entries[0].name, "Good-Bundle-GGUF");
assert_eq!(entries[0].quants, vec!["Q4_0"]);
}
}