1use std::io::Write;
9use std::path::Path;
10use std::time::Duration;
11
12use serde::Deserialize;
13use sha2::{Digest, Sha256};
14
15const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
19const READ_TIMEOUT: Duration = Duration::from_secs(60);
20const TOTAL_TIMEOUT: Duration = Duration::from_secs(2 * 60 * 60);
21const MAX_TREE_PAGES: usize = 20;
22
23#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct HubBlob {
26 pub path: String,
27 pub size: u64,
28 pub sha256: Option<String>,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct SpeakingFiles {
36 pub model: HubBlob,
37 pub voices: Vec<HubBlob>,
38}
39
40pub struct Hub {
42 client: reqwest::Client,
43 token: Option<String>,
44}
45
46impl Hub {
47 pub fn new(token: Option<String>) -> Result<Self, String> {
48 let client = forge_agent::http_trust::with_extra_roots(
53 reqwest::Client::builder()
54 .connect_timeout(CONNECT_TIMEOUT)
55 .read_timeout(READ_TIMEOUT)
56 .timeout(TOTAL_TIMEOUT),
57 )
58 .build()
59 .map_err(|error| format!("the Hub client did not build: {error}"))?;
60 Ok(Self { client, token })
61 }
62
63 async fn get(&self, url: &str) -> Result<reqwest::Response, String> {
65 let request = self.client.get(url);
66 let request = match self.token.as_deref() {
67 Some(token) => request.bearer_auth(token),
68 None => request,
69 };
70 let response = request
71 .send()
72 .await
73 .map_err(|error| format!("the Hub did not answer for {url}: {error}"))?;
74 if !response.status().is_success() {
75 return Err(format!("the Hub answered {} for {url}", response.status()));
76 }
77 Ok(response)
78 }
79
80 pub async fn revision(&self, repo: &str) -> Result<String, String> {
83 let url = format!("https://huggingface.co/api/models/{repo}");
84 let response = self.get(&url).await?;
85 let info: ModelInfo = response
86 .json()
87 .await
88 .map_err(|error| format!("the Hub's answer for {repo} did not parse: {error}"))?;
89 Ok(info.sha)
90 }
91
92 pub async fn blobs(&self, repo: &str, revision: &str) -> Result<Vec<HubBlob>, String> {
99 let mut url = tree_url(repo, revision);
100 let mut blobs = Vec::new();
101 for _ in 0..MAX_TREE_PAGES {
102 let response = self.get(&url).await?;
103 let next = response
104 .headers()
105 .get(reqwest::header::LINK)
106 .and_then(|link| link.to_str().ok())
107 .and_then(next_page);
108 let raw = response
109 .bytes()
110 .await
111 .map_err(|error| format!("the Hub's listing for {repo} did not read: {error}"))?;
112 blobs.extend(parse_blobs(&raw)?);
113 match next {
114 Some(next) => url = next,
115 None => return Ok(blobs),
116 }
117 }
118 Err(format!("the Hub's listing for {repo} did not finish within {MAX_TREE_PAGES} pages"))
119 }
120
121 pub async fn download(
127 &self,
128 repo: &str,
129 revision: &str,
130 blob: &HubBlob,
131 dest: &Path,
132 mut on_bytes: impl FnMut(u64),
133 ) -> Result<(), String> {
134 let url = format!("https://huggingface.co/{repo}/resolve/{revision}/{}", blob.path);
135 let request = self.client.get(&url);
136 let request = match self.token.as_deref() {
137 Some(token) => request.bearer_auth(token),
138 None => request,
139 };
140 let mut response = request
141 .send()
142 .await
143 .map_err(|error| format!("{} did not fetch: {error}", blob.path))?;
144 if !response.status().is_success() {
145 return Err(format!("{} answered {}", blob.path, response.status()));
146 }
147 if let Some(parent) = dest.parent() {
148 std::fs::create_dir_all(parent)
149 .map_err(|error| format!("{}: {error}", parent.display()))?;
150 }
151 let part = part_path(dest);
152 let mut file =
153 std::fs::File::create(&part).map_err(|error| format!("{}: {error}", part.display()))?;
154 let mut hasher = Sha256::new();
155 let mut written = 0_u64;
156 loop {
157 let chunk = match response.chunk().await {
158 Ok(Some(chunk)) => chunk,
159 Ok(None) => break,
160 Err(error) => {
161 let _ = std::fs::remove_file(&part);
162 return Err(format!("{} did not finish: {error}", blob.path));
163 }
164 };
165 if let Err(error) = file.write_all(&chunk) {
166 let _ = std::fs::remove_file(&part);
167 return Err(format!("{}: {error}", part.display()));
168 }
169 hasher.update(&chunk);
170 written += chunk.len() as u64;
171 on_bytes(written);
172 }
173 drop(file);
174 finish_file(&part, dest, blob, written, &hex::encode(hasher.finalize()))
175 }
176}
177
178fn part_path(dest: &Path) -> std::path::PathBuf {
180 let name =
181 dest.file_name().map_or_else(String::new, |name| name.to_string_lossy().into_owned());
182 dest.with_file_name(format!("{name}.part"))
183}
184
185pub fn finish_file(
187 part: &Path,
188 dest: &Path,
189 blob: &HubBlob,
190 written: u64,
191 digest: &str,
192) -> Result<(), String> {
193 if written != blob.size {
194 let _ = std::fs::remove_file(part);
195 return Err(format!(
196 "{} is {written} bytes and the entry declares {}",
197 blob.path, blob.size
198 ));
199 }
200 if let Some(expected) = blob.sha256.as_deref()
201 && !digest.eq_ignore_ascii_case(expected)
202 {
203 let _ = std::fs::remove_file(part);
204 return Err(format!("{} declares sha256 {expected}, and the file is {digest}", blob.path));
205 }
206 std::fs::rename(part, dest).map_err(|error| format!("{}: {error}", dest.display()))
207}
208
209pub fn tree_url(repo: &str, revision: &str) -> String {
212 format!("https://huggingface.co/api/models/{repo}/tree/{revision}?recursive=true&limit=1000")
213}
214
215pub fn next_page(link: &str) -> Option<String> {
217 for part in link.split(',') {
218 if !part.contains("rel=\"next\"") {
219 continue;
220 }
221 let start = part.find('<')?;
222 let end = start + 1 + part[start + 1..].find('>')?;
223 return Some(part[start + 1..end].to_owned());
224 }
225 None
226}
227
228pub fn verify_release(
235 variant: &crate::tts::SpeakingVariant,
236 model: &HubBlob,
237) -> Result<(), String> {
238 if model.size != variant.size {
239 return Err(format!(
240 "{} is {} bytes at this revision and the compiled entry declares {}",
241 variant.path, model.size, variant.size
242 ));
243 }
244 match model.sha256.as_deref() {
245 Some(digest) if digest.eq_ignore_ascii_case(variant.sha256) => Ok(()),
246 Some(digest) => Err(format!(
247 "{} declares sha256 {digest} at this revision and the compiled entry declares {}",
248 variant.path, variant.sha256
249 )),
250 None => Err(format!(
251 "{} publishes no digest at this revision, so the compiled one cannot be checked",
252 variant.path
253 )),
254 }
255}
256
257#[derive(Deserialize)]
258struct ModelInfo {
259 sha: String,
260}
261
262#[derive(Deserialize)]
263struct TreeEntry {
264 #[serde(rename = "type")]
265 kind: String,
266 path: String,
267 size: Option<u64>,
268 lfs: Option<Lfs>,
269}
270
271#[derive(Deserialize)]
272struct Lfs {
273 oid: String,
274}
275
276pub fn parse_blobs(raw: &[u8]) -> Result<Vec<HubBlob>, String> {
278 let entries: Vec<TreeEntry> = serde_json::from_slice(raw)
279 .map_err(|error| format!("the Hub's listing did not parse: {error}"))?;
280 Ok(entries
281 .into_iter()
282 .filter(|entry| entry.kind == "file")
283 .map(|entry| HubBlob {
284 size: entry.size.unwrap_or(0),
285 sha256: entry.lfs.map(|lfs| lfs.oid),
286 path: entry.path,
287 })
288 .collect())
289}
290
291pub fn voice_id(path: &str) -> Option<&str> {
295 let file = std::path::Path::new(path.strip_prefix("voices/")?);
296 if !file.extension().is_some_and(|extension| extension.eq_ignore_ascii_case("bin")) {
297 return None;
298 }
299 let name = file.file_stem()?.to_str()?;
300 name.contains('_').then_some(name)
301}
302
303pub fn speaking_files(blobs: &[HubBlob], variant_path: &str) -> Result<SpeakingFiles, String> {
305 let model = blobs
306 .iter()
307 .find(|blob| blob.path == variant_path)
308 .cloned()
309 .ok_or_else(|| format!("the repository publishes no {variant_path}"))?;
310 let voices: Vec<HubBlob> =
311 blobs.iter().filter(|blob| voice_id(&blob.path).is_some()).cloned().collect();
312 if voices.is_empty() {
313 return Err("the repository publishes no voice files".to_owned());
314 }
315 Ok(SpeakingFiles { model, voices })
316}
317
318#[cfg(test)]
319mod tests {
320 use std::path::Path;
321
322 use super::{HubBlob, finish_file, parse_blobs, speaking_files, voice_id};
323
324 fn listing() -> Vec<HubBlob> {
325 parse_blobs(
326 br#"[
327 {"type":"file","path":"onnx/model_quantized.onnx","size":92,"lfs":{"oid":"aa"}},
328 {"type":"directory","path":"voices","size":0},
329 {"type":"file","path":"voices/af_heart.bin","size":5,"lfs":{"oid":"bb"}},
330 {"type":"file","path":"voices/af.bin","size":9,"lfs":{"oid":"cc"}},
331 {"type":"file","path":"README.md","size":10}
332 ]"#,
333 )
334 .expect("the listing parses")
335 }
336
337 #[test]
340 fn the_tree_url_is_recursive() {
341 let url = super::tree_url("onnx-community/Kokoro-82M-v1.0-ONNX", "abc123");
342 assert!(url.contains("/tree/abc123?recursive=true"), "got: {url}");
343 }
344
345 #[test]
346 fn a_link_header_names_the_next_page_or_none() {
347 let next = super::next_page(
348 "<https://huggingface.co/api/models/o/r/tree/abc?recursive=true&cursor=xyz>; rel=\"next\"",
349 );
350 assert_eq!(
351 next.as_deref(),
352 Some("https://huggingface.co/api/models/o/r/tree/abc?recursive=true&cursor=xyz")
353 );
354 assert_eq!(super::next_page("<https://x>; rel=\"prev\""), None);
355 }
356
357 #[test]
360 fn a_recursive_listing_finds_the_nested_model_and_voices() {
361 let blobs = parse_blobs(
362 br#"[
363 {"type":"directory","path":"onnx","size":0},
364 {"type":"directory","path":"voices","size":0},
365 {"type":"file","path":"onnx/model_quantized.onnx","size":92,"lfs":{"oid":"aa"}},
366 {"type":"file","path":"voices/af_heart.bin","size":5,"lfs":{"oid":"bb"}},
367 {"type":"file","path":"voices/bf_emma.bin","size":5,"lfs":{"oid":"cc"}}
368 ]"#,
369 )
370 .expect("the listing parses");
371 let files = speaking_files(&blobs, "onnx/model_quantized.onnx").expect("the set");
372 assert_eq!(files.model.path, "onnx/model_quantized.onnx");
373 assert_eq!(files.voices.len(), 2);
374 }
375
376 #[test]
379 fn a_release_that_disagrees_with_the_compiled_pin_is_refused() {
380 let variant = crate::tts::SpeakingVariant::named(crate::tts::PINNED_VARIANT)
381 .expect("the pinned variant exists");
382 let pinned = HubBlob {
383 path: variant.path.to_owned(),
384 size: variant.size,
385 sha256: Some(variant.sha256.to_owned()),
386 };
387 super::verify_release(&variant, &pinned).expect("the compiled release is accepted");
388
389 let resized = HubBlob { size: variant.size + 1, ..pinned.clone() };
390 let error = super::verify_release(&variant, &resized).expect_err("a different size");
391 assert!(error.contains(variant.path), "got: {error}");
392
393 let rehashed = HubBlob { sha256: Some("00".repeat(32)), ..pinned.clone() };
394 let error = super::verify_release(&variant, &rehashed).expect_err("a different digest");
395 assert!(error.contains("sha256"), "got: {error}");
396
397 let undigested = HubBlob { sha256: None, ..pinned };
398 let error = super::verify_release(&variant, &undigested).expect_err("no digest");
399 assert!(error.contains("no digest"), "got: {error}");
400 }
401
402 #[test]
403 fn a_tree_listing_drops_directories_and_keeps_lfs_digests() {
404 let blobs = listing();
405 assert_eq!(blobs.len(), 4, "the directory is not a file");
406 let model = blobs
407 .iter()
408 .find(|blob| {
409 std::path::Path::new(&blob.path)
410 .extension()
411 .is_some_and(|extension| extension.eq_ignore_ascii_case("onnx"))
412 })
413 .expect("the model");
414 assert_eq!(model.size, 92);
415 assert_eq!(model.sha256.as_deref(), Some("aa"));
416 let readme = blobs.iter().find(|blob| blob.path == "README.md").expect("the readme");
417 assert_eq!(readme.sha256, None, "a file outside LFS has no digest");
418 }
419
420 #[test]
421 fn a_named_voice_is_a_stem_with_a_language_gender_prefix() {
422 assert_eq!(voice_id("voices/af_heart.bin"), Some("af_heart"));
423 assert_eq!(voice_id("voices/af.bin"), None, "the blend is not a named voice");
424 assert_eq!(voice_id("onnx/model.onnx"), None);
425 }
426
427 #[test]
428 fn a_variant_is_its_model_file_and_every_named_voice() {
429 let files = speaking_files(&listing(), "onnx/model_quantized.onnx").expect("the set");
430 assert_eq!(files.model.path, "onnx/model_quantized.onnx");
431 assert_eq!(files.voices.len(), 1, "only the named voice, not the blend");
432 assert_eq!(files.voices[0].path, "voices/af_heart.bin");
433 }
434
435 #[test]
436 fn a_variant_the_repository_does_not_publish_is_refused_by_name() {
437 let error = speaking_files(&listing(), "onnx/model_q4.onnx").expect_err("refused");
438 assert!(error.contains("onnx/model_q4.onnx"), "got: {error}");
439 }
440
441 #[test]
442 fn a_download_whose_digest_disagrees_is_taken_back() {
443 let dir = tempfile::tempdir().expect("a temp dir");
444 let part = dir.path().join("model.onnx.part");
445 let dest = dir.path().join("model.onnx");
446 std::fs::write(&part, b"weights").expect("the part is written");
447 let blob =
448 HubBlob { path: "onnx/model.onnx".to_owned(), size: 7, sha256: Some("00".repeat(32)) };
449 let error =
450 finish_file(&part, &dest, &blob, 7, "11".repeat(32).as_str()).expect_err("refused");
451 assert!(error.contains("sha256"), "got: {error}");
452 assert!(!part.exists(), "the partial file goes");
453 assert!(!dest.exists(), "nothing lands under the model's name");
454 }
455
456 #[test]
457 fn a_download_of_the_wrong_length_is_taken_back() {
458 let dir = tempfile::tempdir().expect("a temp dir");
459 let part = dir.path().join("model.onnx.part");
460 let dest = dir.path().join("model.onnx");
461 std::fs::write(&part, b"weights").expect("the part is written");
462 let blob = HubBlob { path: "onnx/model.onnx".to_owned(), size: 99, sha256: None };
463 let error =
464 finish_file(&part, &dest, &blob, 7, "11".repeat(32).as_str()).expect_err("refused");
465 assert!(error.contains("99"), "got: {error}");
466 assert!(!dest.exists());
467 }
468
469 #[test]
470 fn a_verified_download_lands_under_its_name() {
471 let dir = tempfile::tempdir().expect("a temp dir");
472 let part = dir.path().join("model.onnx.part");
473 let dest = dir.path().join("model.onnx");
474 std::fs::write(&part, b"weights").expect("the part is written");
475 let digest = {
478 use sha2::{Digest as _, Sha256};
479 let mut hasher = Sha256::new();
480 hasher.update(b"weights");
481 hex::encode(hasher.finalize())
482 };
483 let blob =
484 HubBlob { path: "onnx/model.onnx".to_owned(), size: 7, sha256: Some(digest.clone()) };
485 finish_file(&part, &dest, &blob, 7, digest.as_str()).expect("it lands");
486 assert_eq!(std::fs::read(&dest).expect("reads"), b"weights");
487 assert!(!Path::new(&part).exists());
488 }
489}