Skip to main content

aurum_core/download/
mod.rs

1//! Shared verify-before-publish artifact downloader (JOE-1591).
2//!
3//! STT (`model/`) and TTS (`tts/catalogue.rs`) both route through
4//! [`download_verified_request`]. This module owns: exclusive partials, size
5//! caps from reviewed metadata only, HF/GitHub redirect policy, disk-budget
6//! preflight, streaming SHA-256, and durable publish.
7
8use 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/// Reviewed artifact identity for a single downloadable file (static catalogue).
18#[derive(Debug, Clone, Copy)]
19pub struct ArtifactSpec {
20    pub id: &'static str,
21    pub filename: &'static str,
22    /// Reviewed content SHA-256 (hex). Required for publish.
23    pub sha256: &'static str,
24    /// Exact expected byte size when known (from reviewed metadata).
25    pub exact_bytes: Option<u64>,
26    /// Soft size used only for progress UX and as floor for the hard cap.
27    pub approx_bytes: u64,
28    /// Absolute or origin-relative download URL (must match policy).
29    pub url: &'static str,
30    pub license: &'static str,
31    pub source_revision: &'static str,
32}
33
34/// Runtime download identity (STT/TTS map pins into this).
35#[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
58/// Byte progress callback: `(downloaded_bytes, advisory_total_bytes)`.
59pub type DownloadByteProgress = Arc<dyn Fn(u64, u64) + Send + Sync>;
60
61/// Options for a single download.
62#[derive(Clone)]
63pub struct DownloadOptions {
64    pub show_progress: bool,
65    pub connect_timeout: Duration,
66    pub total_timeout: Duration,
67    /// Hard size cap multiplier over `approx_bytes` / exact (default 3×, floor 1 MiB).
68    pub size_cap_factor: u64,
69    /// Optional progress hook (CLI/library progress bars).
70    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
97/// Hard disk cap derived **only** from reviewed metadata (never raised by Content-Length).
98pub 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
104/// Extra free-space headroom beyond the hard download cap (disk pressure).
105const DISK_HEADROOM_BYTES: u64 = 64 * 1024 * 1024;
106
107/// Best-effort free bytes for the filesystem containing `path`.
108///
109/// Returns `None` when the platform probe is unavailable — callers must not
110/// treat that as infinite space; they still rely on write failures + size caps.
111pub 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    // f_frsize / f_bsize / f_bavail are `u64` on some platforms and `c_ulong` on
127    // others — cast through u64 for a single portable product.
128    #[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
187/// Fail closed when free space is known and below `need_bytes` + headroom.
188pub 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    // Ensure the parent exists so the probe hits the right volume.
194    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
215/// Verify an on-disk file against reviewed identity.
216pub fn verify_artifact(path: &Path, spec: &ArtifactSpec) -> Result<()> {
217    verify_artifact_request(path, &spec.request())
218}
219
220/// Verify an on-disk file against a runtime download request.
221pub 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
284/// Download static [`ArtifactSpec`] to `dest`, verifying before publish.
285pub 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
293/// Download `req` to `dest`, verifying before publish. Invisible until success.
294pub 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
361/// Allow HuggingFace CDN and GitHub release asset redirects; stop otherwise.
362fn 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    // Content-Length may only lower the accepted limit (early reject).
420    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    // JOE-1918: durability failures must fail closed, not be ignored.
494    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    // Durable publish: rename verified partial into place.
524    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    // Sidecar checksum for operators: `file.bin.sha256`
540    let sidecar = PathBuf::from(format!("{}.sha256", dest.display()));
541    let _ = fs::write(&sidecar, format!("{}  {}\n", digest, req.filename));
542
543    Ok(())
544}
545
546/// Publish a verified partial into `dest` without a durable-window gap (JOE-1918).
547///
548/// Prefer atomic rename over pre-delete. On platforms where rename cannot
549/// replace, stage the previous file aside and restore it if the new rename fails.
550fn publish_verified_download(tmp: &Path, dest: &Path) -> Result<()> {
551    match fs::rename(tmp, dest) {
552        Ok(()) => Ok(()),
553        Err(e) if dest.exists() => {
554            // Replacement path (common on Windows when dest exists).
555            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
590/// Sweep only this process-family stale partials (never another live writer's file).
591pub 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        // Helper ignores Content-Length; forged CL cannot increase cap.
621        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        // Tiny need should pass on any real volume; unknown probe also Ok.
636        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        // Only assert when probe works — otherwise skip.
643        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}