1use crate::error::{EnvironmentError, ProviderError, Result};
9use futures_util::StreamExt;
10use sha2::{Digest, Sha256};
11use std::fs::{self, File, OpenOptions};
12use std::io::Write;
13use std::path::{Path, PathBuf};
14use std::sync::Arc;
15use std::time::Duration;
16
17#[derive(Debug, Clone, Copy)]
19pub struct ArtifactSpec {
20 pub id: &'static str,
21 pub filename: &'static str,
22 pub sha256: &'static str,
24 pub exact_bytes: Option<u64>,
26 pub approx_bytes: u64,
28 pub url: &'static str,
30 pub license: &'static str,
31 pub source_revision: &'static str,
32}
33
34#[derive(Debug, Clone, Copy)]
36pub struct DownloadRequest<'a> {
37 pub id: &'a str,
38 pub filename: &'a str,
39 pub sha256: &'a str,
40 pub exact_bytes: Option<u64>,
41 pub approx_bytes: u64,
42 pub url: &'a str,
43}
44
45impl ArtifactSpec {
46 pub fn request(&self) -> DownloadRequest<'_> {
47 DownloadRequest {
48 id: self.id,
49 filename: self.filename,
50 sha256: self.sha256,
51 exact_bytes: self.exact_bytes,
52 approx_bytes: self.approx_bytes,
53 url: self.url,
54 }
55 }
56}
57
58pub type DownloadByteProgress = Arc<dyn Fn(u64, u64) + Send + Sync>;
60
61#[derive(Clone)]
63pub struct DownloadOptions {
64 pub show_progress: bool,
65 pub connect_timeout: Duration,
66 pub total_timeout: Duration,
67 pub size_cap_factor: u64,
69 pub on_progress: Option<DownloadByteProgress>,
71}
72
73impl Default for DownloadOptions {
74 fn default() -> Self {
75 Self {
76 show_progress: false,
77 connect_timeout: Duration::from_secs(30),
78 total_timeout: Duration::from_secs(30 * 60),
79 size_cap_factor: 3,
80 on_progress: None,
81 }
82 }
83}
84
85impl std::fmt::Debug for DownloadOptions {
86 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87 f.debug_struct("DownloadOptions")
88 .field("show_progress", &self.show_progress)
89 .field("connect_timeout", &self.connect_timeout)
90 .field("total_timeout", &self.total_timeout)
91 .field("size_cap_factor", &self.size_cap_factor)
92 .field("on_progress", &self.on_progress.is_some())
93 .finish()
94 }
95}
96
97pub fn download_byte_cap(approx_bytes: u64, exact: Option<u64>, factor: u64) -> u64 {
99 const FLOOR: u64 = 1_000_000;
100 let base = exact.unwrap_or(approx_bytes).max(approx_bytes);
101 base.saturating_mul(factor.max(1)).max(FLOOR)
102}
103
104const DISK_HEADROOM_BYTES: u64 = 64 * 1024 * 1024;
106
107pub fn available_disk_bytes(path: &Path) -> Option<u64> {
112 available_disk_bytes_inner(path)
113}
114
115#[cfg(unix)]
116fn available_disk_bytes_inner(path: &Path) -> Option<u64> {
117 use std::ffi::CString;
118 use std::os::unix::ffi::OsStrExt;
119
120 let c_path = CString::new(path.as_os_str().as_bytes()).ok()?;
121 let mut buf: libc::statvfs = unsafe { std::mem::zeroed() };
122 let rc = unsafe { libc::statvfs(c_path.as_ptr(), &mut buf) };
123 if rc != 0 {
124 return None;
125 }
126 #[allow(clippy::unnecessary_cast)]
129 let fr = {
130 let frsize = buf.f_frsize as u64;
131 let bsize = buf.f_bsize as u64;
132 if frsize > 0 {
133 frsize
134 } else {
135 bsize
136 }
137 };
138 if fr == 0 {
139 return None;
140 }
141 #[allow(clippy::unnecessary_cast)]
142 let avail = buf.f_bavail as u64;
143 Some(avail.saturating_mul(fr))
144}
145
146#[cfg(windows)]
147fn available_disk_bytes_inner(path: &Path) -> Option<u64> {
148 use std::os::windows::ffi::OsStrExt;
149 use std::ptr;
150
151 #[link(name = "kernel32")]
152 extern "system" {
153 fn GetDiskFreeSpaceExW(
154 lp_directory_name: *const u16,
155 lp_free_bytes_available_to_caller: *mut u64,
156 lp_total_number_of_bytes: *mut u64,
157 lp_total_number_of_free_bytes: *mut u64,
158 ) -> i32;
159 }
160
161 let wide: Vec<u16> = path
162 .as_os_str()
163 .encode_wide()
164 .chain(std::iter::once(0))
165 .collect();
166 let mut free_to_caller: u64 = 0;
167 let ok = unsafe {
168 GetDiskFreeSpaceExW(
169 wide.as_ptr(),
170 &mut free_to_caller,
171 ptr::null_mut(),
172 ptr::null_mut(),
173 )
174 };
175 if ok == 0 {
176 None
177 } else {
178 Some(free_to_caller)
179 }
180}
181
182#[cfg(not(any(unix, windows)))]
183fn available_disk_bytes_inner(_path: &Path) -> Option<u64> {
184 None
185}
186
187pub fn ensure_disk_budget(path: &Path, need_bytes: u64) -> Result<()> {
189 let parent = path
190 .parent()
191 .filter(|p| !p.as_os_str().is_empty())
192 .unwrap_or(path);
193 if !parent.exists() {
195 let _ = fs::create_dir_all(parent);
196 }
197 let probe = if parent.exists() { parent } else { path };
198 let Some(free) = available_disk_bytes(probe) else {
199 return Ok(());
200 };
201 let required = need_bytes.saturating_add(DISK_HEADROOM_BYTES);
202 if free < required {
203 return Err(EnvironmentError::DiskSpace {
204 path: probe.display().to_string(),
205 reason: format!(
206 "insufficient free space: have {free} bytes, need ~{required} \
207 (download budget {need_bytes} + {DISK_HEADROOM_BYTES} headroom)"
208 ),
209 }
210 .into());
211 }
212 Ok(())
213}
214
215pub fn verify_artifact(path: &Path, spec: &ArtifactSpec) -> Result<()> {
217 verify_artifact_request(path, &spec.request())
218}
219
220pub fn verify_artifact_request(path: &Path, req: &DownloadRequest<'_>) -> Result<()> {
222 if !path.exists() {
223 return Err(ProviderError::ModelDownload {
224 model: req.id.to_string(),
225 reason: format!("missing artifact {}", path.display()),
226 }
227 .into());
228 }
229 let meta = fs::metadata(path).map_err(|e| ProviderError::ModelDownload {
230 model: req.id.to_string(),
231 reason: e.to_string(),
232 })?;
233 if let Some(exact) = req.exact_bytes {
234 if meta.len() != exact {
235 return Err(ProviderError::ModelDownload {
236 model: req.id.to_string(),
237 reason: format!(
238 "size mismatch for {} (got {}, expected {exact})",
239 req.filename,
240 meta.len()
241 ),
242 }
243 .into());
244 }
245 } else if meta.len() < 1_000 {
246 return Err(ProviderError::ModelDownload {
247 model: req.id.to_string(),
248 reason: format!("artifact too small ({} bytes)", meta.len()),
249 }
250 .into());
251 }
252 let digest = sha256_file(path)?;
253 if digest != req.sha256 {
254 return Err(ProviderError::ModelDownload {
255 model: req.id.to_string(),
256 reason: format!(
257 "sha256 mismatch for {} (got {digest}, expected {})",
258 req.filename, req.sha256
259 ),
260 }
261 .into());
262 }
263 Ok(())
264}
265
266fn sha256_file(path: &Path) -> Result<String> {
267 use std::io::Read;
268 let mut file = File::open(path).map_err(|e| EnvironmentError::DirectoryAccess {
269 path: path.display().to_string(),
270 reason: e.to_string(),
271 })?;
272 let mut hasher = Sha256::new();
273 let mut buf = [0u8; 64 * 1024];
274 loop {
275 let n = file.read(&mut buf).map_err(EnvironmentError::Io)?;
276 if n == 0 {
277 break;
278 }
279 hasher.update(&buf[..n]);
280 }
281 Ok(hex::encode(hasher.finalize()))
282}
283
284pub async fn download_verified(
286 spec: &ArtifactSpec,
287 dest: &Path,
288 opts: &DownloadOptions,
289) -> Result<()> {
290 download_verified_request(&spec.request(), dest, opts).await
291}
292
293pub async fn download_verified_request(
295 req: &DownloadRequest<'_>,
296 dest: &Path,
297 opts: &DownloadOptions,
298) -> Result<()> {
299 if let Some(parent) = dest.parent() {
300 fs::create_dir_all(parent).map_err(|e| EnvironmentError::DirectoryAccess {
301 path: parent.display().to_string(),
302 reason: e.to_string(),
303 })?;
304 }
305
306 let hard_cap = download_byte_cap(req.approx_bytes, req.exact_bytes, opts.size_cap_factor);
307 ensure_disk_budget(dest, hard_cap)?;
308
309 let tmp = exclusive_partial_path(dest)?;
310 let result = download_to_partial(req, &tmp, dest, opts, hard_cap).await;
311 if result.is_err() {
312 let _ = fs::remove_file(&tmp);
313 }
314 result
315}
316
317fn exclusive_partial_path(dest: &Path) -> Result<PathBuf> {
318 let parent = dest.parent().unwrap_or_else(|| Path::new("."));
319 let stem = dest
320 .file_name()
321 .and_then(|s| s.to_str())
322 .unwrap_or("artifact");
323 for _ in 0..32 {
324 let name = format!(
325 ".{}.{}-{}.aurum.partial",
326 stem,
327 std::process::id(),
328 std::time::SystemTime::now()
329 .duration_since(std::time::UNIX_EPOCH)
330 .map(|d| d.as_nanos())
331 .unwrap_or(0)
332 );
333 let path = parent.join(name);
334 match OpenOptions::new().write(true).create_new(true).open(&path) {
335 Ok(f) => {
336 drop(f);
337 #[cfg(unix)]
338 {
339 use std::os::unix::fs::PermissionsExt;
340 let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o600));
341 }
342 return Ok(path);
343 }
344 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
345 Err(e) => {
346 return Err(EnvironmentError::DirectoryAccess {
347 path: parent.display().to_string(),
348 reason: format!("exclusive partial create failed: {e}"),
349 }
350 .into());
351 }
352 }
353 }
354 Err(EnvironmentError::DirectoryAccess {
355 path: parent.display().to_string(),
356 reason: "could not allocate exclusive partial path".into(),
357 }
358 .into())
359}
360
361fn artifact_redirect_policy(attempt: reqwest::redirect::Attempt<'_>) -> reqwest::redirect::Action {
363 let host = attempt.url().host_str().unwrap_or("").to_ascii_lowercase();
364 let ok = host == "huggingface.co"
365 || host.ends_with(".huggingface.co")
366 || host.ends_with(".hf.co")
367 || host == "hf.co"
368 || host.ends_with(".cdn.hf.co")
369 || host == "github.com"
370 || host == "www.github.com"
371 || host.ends_with(".github.com")
372 || host == "objects.githubusercontent.com"
373 || host.ends_with(".githubusercontent.com")
374 || host == "release-assets.githubusercontent.com";
375 if ok && attempt.previous().len() < 8 {
376 attempt.follow()
377 } else {
378 attempt.stop()
379 }
380}
381
382async fn download_to_partial(
383 req: &DownloadRequest<'_>,
384 tmp: &Path,
385 dest: &Path,
386 opts: &DownloadOptions,
387 hard_cap: u64,
388) -> Result<()> {
389 tracing::info!(id = req.id, url = req.url, "downloading artifact");
390
391 let client = reqwest::Client::builder()
392 .user_agent(concat!("aurum-core/", env!("CARGO_PKG_VERSION")))
393 .connect_timeout(opts.connect_timeout)
394 .timeout(opts.total_timeout)
395 .redirect(reqwest::redirect::Policy::custom(artifact_redirect_policy))
396 .build()
397 .map_err(|e| ProviderError::ModelDownload {
398 model: req.id.to_string(),
399 reason: format!("http client: {e}"),
400 })?;
401
402 let response = client
403 .get(req.url)
404 .send()
405 .await
406 .map_err(|e| ProviderError::ModelDownload {
407 model: req.id.to_string(),
408 reason: format!("request failed: {e}"),
409 })?;
410
411 if !response.status().is_success() {
412 return Err(ProviderError::ModelDownload {
413 model: req.id.to_string(),
414 reason: format!("HTTP {}", response.status()),
415 }
416 .into());
417 }
418
419 if let Some(cl) = response.content_length() {
421 if cl > hard_cap {
422 return Err(ProviderError::ModelDownload {
423 model: req.id.to_string(),
424 reason: format!("Content-Length {cl} exceeds reviewed size cap {hard_cap}"),
425 }
426 .into());
427 }
428 }
429
430 let progress_total = response
431 .content_length()
432 .filter(|&n| n > 0 && n <= hard_cap)
433 .or(req.exact_bytes)
434 .unwrap_or(req.approx_bytes);
435
436 let pb = if opts.show_progress {
437 use indicatif::{ProgressBar, ProgressStyle};
438 let pb = ProgressBar::new(progress_total);
439 pb.set_style(
440 ProgressStyle::with_template(
441 "{msg} [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({bytes_per_sec}, {eta})",
442 )
443 .unwrap_or_else(|_| ProgressStyle::default_bar())
444 .progress_chars("=>-"),
445 );
446 pb.set_message(format!("Downloading {}", req.id));
447 Some(pb)
448 } else {
449 None
450 };
451
452 let mut file = OpenOptions::new().write(true).open(tmp).map_err(|e| {
453 EnvironmentError::DirectoryAccess {
454 path: tmp.display().to_string(),
455 reason: e.to_string(),
456 }
457 })?;
458
459 let mut stream = response.bytes_stream();
460 let mut hasher = Sha256::new();
461 let mut downloaded: u64 = 0;
462
463 while let Some(chunk) = stream.next().await {
464 let chunk = chunk.map_err(|e| ProviderError::ModelDownload {
465 model: req.id.to_string(),
466 reason: format!("stream error: {e}"),
467 })?;
468 file.write_all(&chunk)
469 .map_err(|e| EnvironmentError::DiskSpace {
470 path: tmp.display().to_string(),
471 reason: e.to_string(),
472 })?;
473 hasher.update(&chunk);
474 downloaded = downloaded.saturating_add(chunk.len() as u64);
475 if downloaded > hard_cap {
476 return Err(ProviderError::ModelDownload {
477 model: req.id.to_string(),
478 reason: format!("download exceeded size cap ({downloaded} > {hard_cap})"),
479 }
480 .into());
481 }
482 if let Some(pb) = &pb {
483 pb.set_position(downloaded.min(progress_total));
484 }
485 if let Some(cb) = &opts.on_progress {
486 cb(downloaded, progress_total);
487 }
488 }
489 file.flush().map_err(|e| EnvironmentError::DiskSpace {
490 path: tmp.display().to_string(),
491 reason: e.to_string(),
492 })?;
493 file.sync_all().map_err(|e| EnvironmentError::DiskSpace {
495 path: tmp.display().to_string(),
496 reason: format!("sync partial download: {e}"),
497 })?;
498 drop(file);
499
500 let digest = hex::encode(hasher.finalize());
501 if digest != req.sha256 {
502 return Err(ProviderError::ModelDownload {
503 model: req.id.to_string(),
504 reason: format!(
505 "sha256 mismatch (got {digest}, expected {}) — refusing to publish",
506 req.sha256
507 ),
508 }
509 .into());
510 }
511 if let Some(exact) = req.exact_bytes {
512 if downloaded != exact {
513 return Err(ProviderError::ModelDownload {
514 model: req.id.to_string(),
515 reason: format!(
516 "size mismatch after download (got {downloaded}, expected {exact})"
517 ),
518 }
519 .into());
520 }
521 }
522
523 publish_verified_download(tmp, dest)?;
525 if let Some(parent) = dest.parent() {
526 if let Ok(dir) = File::open(parent) {
527 dir.sync_all()
528 .map_err(|e| EnvironmentError::DirectoryAccess {
529 path: parent.display().to_string(),
530 reason: format!("sync parent dir after publish: {e}"),
531 })?;
532 }
533 }
534
535 if let Some(pb) = pb {
536 pb.finish_with_message(format!("Downloaded {} ({downloaded} bytes)", req.id));
537 }
538
539 let sidecar = PathBuf::from(format!("{}.sha256", dest.display()));
541 let _ = fs::write(&sidecar, format!("{} {}\n", digest, req.filename));
542
543 Ok(())
544}
545
546fn publish_verified_download(tmp: &Path, dest: &Path) -> Result<()> {
551 match fs::rename(tmp, dest) {
552 Ok(()) => Ok(()),
553 Err(e) if dest.exists() => {
554 let backup = dest.with_extension(format!(
556 "aurum.bak.{}-{}",
557 std::process::id(),
558 std::time::SystemTime::now()
559 .duration_since(std::time::UNIX_EPOCH)
560 .map(|d| d.as_nanos())
561 .unwrap_or(0)
562 ));
563 fs::rename(dest, &backup).map_err(|re| EnvironmentError::DirectoryAccess {
564 path: dest.display().to_string(),
565 reason: format!("stage previous artifact: {re} (after rename: {e})"),
566 })?;
567 match fs::rename(tmp, dest) {
568 Ok(()) => {
569 let _ = fs::remove_file(&backup);
570 Ok(())
571 }
572 Err(re) => {
573 let _ = fs::rename(&backup, dest);
574 Err(EnvironmentError::DirectoryAccess {
575 path: dest.display().to_string(),
576 reason: format!("publish verified artifact: {re}"),
577 }
578 .into())
579 }
580 }
581 }
582 Err(e) => Err(EnvironmentError::DirectoryAccess {
583 path: dest.display().to_string(),
584 reason: e.to_string(),
585 }
586 .into()),
587 }
588}
589
590pub fn sweep_stale_partials(dir: &Path, stale_after: Duration) {
592 let Ok(entries) = fs::read_dir(dir) else {
593 return;
594 };
595 let now = std::time::SystemTime::now();
596 for ent in entries.flatten() {
597 let name = ent.file_name();
598 let name = name.to_string_lossy();
599 if !name.contains(".aurum.partial") {
600 continue;
601 }
602 let Ok(meta) = ent.metadata() else {
603 continue;
604 };
605 let Ok(modified) = meta.modified() else {
606 continue;
607 };
608 if now.duration_since(modified).unwrap_or_default() > stale_after {
609 let _ = fs::remove_file(ent.path());
610 }
611 }
612}
613
614#[cfg(test)]
615mod tests {
616 use super::*;
617
618 #[test]
619 fn cap_never_raised_by_content_length_logic() {
620 let cap = download_byte_cap(10_000_000, Some(10_000_000), 3);
622 assert_eq!(cap, 30_000_000);
623 let forged = 10_u64.pow(12);
624 assert!(cap < forged);
625 }
626
627 #[test]
628 fn floor_for_tiny_pin() {
629 assert_eq!(download_byte_cap(100, Some(100), 3), 1_000_000);
630 }
631
632 #[test]
633 fn disk_budget_ok_when_space_unknown_or_ample() {
634 let dir = tempfile::tempdir().unwrap();
635 ensure_disk_budget(dir.path(), 1).unwrap();
637 }
638
639 #[test]
640 fn disk_budget_fails_when_need_exceeds_free() {
641 let dir = tempfile::tempdir().unwrap();
642 if available_disk_bytes(dir.path()).is_some() {
644 let err = ensure_disk_budget(dir.path(), u64::MAX / 4).unwrap_err();
645 let s = err.to_string();
646 assert!(
647 s.contains("insufficient free space")
648 || s.contains("DiskSpace")
649 || s.contains("disk"),
650 "unexpected err: {s}"
651 );
652 }
653 }
654}