Skip to main content

aurum_core/download/
mod.rs

1//! Shared verify-before-publish artifact downloader (JOE-1591).
2//!
3//! Used by STT and TTS catalogues so download/trust policy is identical.
4
5use crate::error::{EnvironmentError, ProviderError, Result};
6use futures_util::StreamExt;
7use sha2::{Digest, Sha256};
8use std::fs::{self, File, OpenOptions};
9use std::io::Write;
10use std::path::{Path, PathBuf};
11use std::time::Duration;
12
13/// Reviewed artifact identity for a single downloadable file.
14#[derive(Debug, Clone, Copy)]
15pub struct ArtifactSpec {
16    pub id: &'static str,
17    pub filename: &'static str,
18    /// Reviewed content SHA-256 (hex). Required for publish.
19    pub sha256: &'static str,
20    /// Exact expected byte size when known (from reviewed metadata).
21    pub exact_bytes: Option<u64>,
22    /// Soft size used only for progress UX and as floor for the hard cap.
23    pub approx_bytes: u64,
24    /// Absolute or origin-relative download URL (must match policy).
25    pub url: &'static str,
26    pub license: &'static str,
27    pub source_revision: &'static str,
28}
29
30/// Options for a single download.
31#[derive(Debug, Clone)]
32pub struct DownloadOptions {
33    pub show_progress: bool,
34    pub connect_timeout: Duration,
35    pub total_timeout: Duration,
36    /// Hard size cap multiplier over `approx_bytes` / exact (default 3×, floor 1 MiB).
37    pub size_cap_factor: u64,
38}
39
40impl Default for DownloadOptions {
41    fn default() -> Self {
42        Self {
43            show_progress: false,
44            connect_timeout: Duration::from_secs(30),
45            total_timeout: Duration::from_secs(30 * 60),
46            size_cap_factor: 3,
47        }
48    }
49}
50
51/// Hard disk cap derived **only** from reviewed metadata (never raised by Content-Length).
52pub fn download_byte_cap(approx_bytes: u64, exact: Option<u64>, factor: u64) -> u64 {
53    const FLOOR: u64 = 1_000_000;
54    let base = exact.unwrap_or(approx_bytes).max(approx_bytes);
55    base.saturating_mul(factor.max(1)).max(FLOOR)
56}
57
58/// Verify an on-disk file against reviewed identity.
59pub fn verify_artifact(path: &Path, spec: &ArtifactSpec) -> Result<()> {
60    if !path.exists() {
61        return Err(ProviderError::ModelDownload {
62            model: spec.id.to_string(),
63            reason: format!("missing artifact {}", path.display()),
64        }
65        .into());
66    }
67    let meta = fs::metadata(path).map_err(|e| ProviderError::ModelDownload {
68        model: spec.id.to_string(),
69        reason: e.to_string(),
70    })?;
71    if let Some(exact) = spec.exact_bytes {
72        if meta.len() != exact {
73            return Err(ProviderError::ModelDownload {
74                model: spec.id.to_string(),
75                reason: format!(
76                    "size mismatch for {} (got {}, expected {exact})",
77                    spec.filename,
78                    meta.len()
79                ),
80            }
81            .into());
82        }
83    } else if meta.len() < 1_000 {
84        return Err(ProviderError::ModelDownload {
85            model: spec.id.to_string(),
86            reason: format!("artifact too small ({} bytes)", meta.len()),
87        }
88        .into());
89    }
90    let digest = sha256_file(path)?;
91    if digest != spec.sha256 {
92        return Err(ProviderError::ModelDownload {
93            model: spec.id.to_string(),
94            reason: format!(
95                "sha256 mismatch for {} (got {digest}, expected {})",
96                spec.filename, spec.sha256
97            ),
98        }
99        .into());
100    }
101    Ok(())
102}
103
104fn sha256_file(path: &Path) -> Result<String> {
105    use std::io::Read;
106    let mut file = File::open(path).map_err(|e| EnvironmentError::DirectoryAccess {
107        path: path.display().to_string(),
108        reason: e.to_string(),
109    })?;
110    let mut hasher = Sha256::new();
111    let mut buf = [0u8; 64 * 1024];
112    loop {
113        let n = file.read(&mut buf).map_err(EnvironmentError::Io)?;
114        if n == 0 {
115            break;
116        }
117        hasher.update(&buf[..n]);
118    }
119    Ok(hex::encode(hasher.finalize()))
120}
121
122/// Download `spec` to `dest`, verifying before publish. Invisible until success.
123pub async fn download_verified(
124    spec: &ArtifactSpec,
125    dest: &Path,
126    opts: &DownloadOptions,
127) -> Result<()> {
128    if let Some(parent) = dest.parent() {
129        fs::create_dir_all(parent).map_err(|e| EnvironmentError::DirectoryAccess {
130            path: parent.display().to_string(),
131            reason: e.to_string(),
132        })?;
133    }
134
135    let tmp = exclusive_partial_path(dest)?;
136    let result = download_to_partial(spec, &tmp, dest, opts).await;
137    if result.is_err() {
138        let _ = fs::remove_file(&tmp);
139    }
140    result
141}
142
143fn exclusive_partial_path(dest: &Path) -> Result<PathBuf> {
144    let parent = dest.parent().unwrap_or_else(|| Path::new("."));
145    let stem = dest
146        .file_name()
147        .and_then(|s| s.to_str())
148        .unwrap_or("artifact");
149    for _ in 0..32 {
150        let name = format!(
151            ".{}.{}-{}.aurum.partial",
152            stem,
153            std::process::id(),
154            std::time::SystemTime::now()
155                .duration_since(std::time::UNIX_EPOCH)
156                .map(|d| d.as_nanos())
157                .unwrap_or(0)
158        );
159        let path = parent.join(name);
160        match OpenOptions::new().write(true).create_new(true).open(&path) {
161            Ok(f) => {
162                drop(f);
163                #[cfg(unix)]
164                {
165                    use std::os::unix::fs::PermissionsExt;
166                    let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o600));
167                }
168                return Ok(path);
169            }
170            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
171            Err(e) => {
172                return Err(EnvironmentError::DirectoryAccess {
173                    path: parent.display().to_string(),
174                    reason: format!("exclusive partial create failed: {e}"),
175                }
176                .into());
177            }
178        }
179    }
180    Err(EnvironmentError::DirectoryAccess {
181        path: parent.display().to_string(),
182        reason: "could not allocate exclusive partial path".into(),
183    }
184    .into())
185}
186
187async fn download_to_partial(
188    spec: &ArtifactSpec,
189    tmp: &Path,
190    dest: &Path,
191    opts: &DownloadOptions,
192) -> Result<()> {
193    tracing::info!(id = spec.id, url = spec.url, "downloading artifact");
194
195    let client = reqwest::Client::builder()
196        .user_agent(concat!("aurum-core/", env!("CARGO_PKG_VERSION")))
197        .connect_timeout(opts.connect_timeout)
198        .timeout(opts.total_timeout)
199        // Model packs live on HF CDN (302 from resolve/). Allow only HF hosts.
200        .redirect(reqwest::redirect::Policy::custom(|attempt| {
201            let host = attempt.url().host_str().unwrap_or("").to_ascii_lowercase();
202            let ok = host == "huggingface.co"
203                || host.ends_with(".huggingface.co")
204                || host.ends_with(".hf.co")
205                || host == "hf.co"
206                || host.ends_with(".cdn.hf.co");
207            if ok && attempt.previous().len() < 8 {
208                attempt.follow()
209            } else {
210                attempt.stop()
211            }
212        }))
213        .build()
214        .map_err(|e| ProviderError::ModelDownload {
215            model: spec.id.to_string(),
216            reason: format!("http client: {e}"),
217        })?;
218
219    let response = client
220        .get(spec.url)
221        .send()
222        .await
223        .map_err(|e| ProviderError::ModelDownload {
224            model: spec.id.to_string(),
225            reason: format!("request failed: {e}"),
226        })?;
227
228    if !response.status().is_success() {
229        return Err(ProviderError::ModelDownload {
230            model: spec.id.to_string(),
231            reason: format!("HTTP {}", response.status()),
232        }
233        .into());
234    }
235
236    let hard_cap = download_byte_cap(spec.approx_bytes, spec.exact_bytes, opts.size_cap_factor);
237    // Content-Length may only lower the accepted limit (early reject).
238    if let Some(cl) = response.content_length() {
239        if cl > hard_cap {
240            return Err(ProviderError::ModelDownload {
241                model: spec.id.to_string(),
242                reason: format!("Content-Length {cl} exceeds reviewed size cap {hard_cap}"),
243            }
244            .into());
245        }
246    }
247
248    let progress_total = response
249        .content_length()
250        .filter(|&n| n > 0 && n <= hard_cap)
251        .or(spec.exact_bytes)
252        .unwrap_or(spec.approx_bytes);
253
254    let pb = if opts.show_progress {
255        use indicatif::{ProgressBar, ProgressStyle};
256        let pb = ProgressBar::new(progress_total);
257        pb.set_style(
258            ProgressStyle::with_template(
259                "{msg} [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({bytes_per_sec}, {eta})",
260            )
261            .unwrap_or_else(|_| ProgressStyle::default_bar())
262            .progress_chars("=>-"),
263        );
264        pb.set_message(format!("Downloading {}", spec.id));
265        Some(pb)
266    } else {
267        None
268    };
269
270    let mut file = OpenOptions::new().write(true).open(tmp).map_err(|e| {
271        EnvironmentError::DirectoryAccess {
272            path: tmp.display().to_string(),
273            reason: e.to_string(),
274        }
275    })?;
276
277    let mut stream = response.bytes_stream();
278    let mut hasher = Sha256::new();
279    let mut downloaded: u64 = 0;
280
281    while let Some(chunk) = stream.next().await {
282        let chunk = chunk.map_err(|e| ProviderError::ModelDownload {
283            model: spec.id.to_string(),
284            reason: format!("stream error: {e}"),
285        })?;
286        file.write_all(&chunk)
287            .map_err(|e| EnvironmentError::DiskSpace {
288                path: tmp.display().to_string(),
289                reason: e.to_string(),
290            })?;
291        hasher.update(&chunk);
292        downloaded = downloaded.saturating_add(chunk.len() as u64);
293        if downloaded > hard_cap {
294            return Err(ProviderError::ModelDownload {
295                model: spec.id.to_string(),
296                reason: format!("download exceeded size cap ({downloaded} > {hard_cap})"),
297            }
298            .into());
299        }
300        if let Some(pb) = &pb {
301            pb.set_position(downloaded.min(progress_total));
302        }
303    }
304    file.flush().map_err(|e| EnvironmentError::DiskSpace {
305        path: tmp.display().to_string(),
306        reason: e.to_string(),
307    })?;
308    // JOE-1918: durability failures must fail closed, not be ignored.
309    file.sync_all().map_err(|e| EnvironmentError::DiskSpace {
310        path: tmp.display().to_string(),
311        reason: format!("sync partial download: {e}"),
312    })?;
313    drop(file);
314
315    let digest = hex::encode(hasher.finalize());
316    if digest != spec.sha256 {
317        return Err(ProviderError::ModelDownload {
318            model: spec.id.to_string(),
319            reason: format!(
320                "sha256 mismatch (got {digest}, expected {}) — refusing to publish",
321                spec.sha256
322            ),
323        }
324        .into());
325    }
326    if let Some(exact) = spec.exact_bytes {
327        if downloaded != exact {
328            return Err(ProviderError::ModelDownload {
329                model: spec.id.to_string(),
330                reason: format!(
331                    "size mismatch after download (got {downloaded}, expected {exact})"
332                ),
333            }
334            .into());
335        }
336    }
337
338    // Durable publish: rename verified partial into place.
339    // Unix rename replaces atomically; avoid deleting dest first (availability gap).
340    // Windows: rename over existing may fail — fall back to remove+rename.
341    publish_verified_download(tmp, dest)?;
342    if let Some(parent) = dest.parent() {
343        if let Ok(dir) = File::open(parent) {
344            dir.sync_all()
345                .map_err(|e| EnvironmentError::DirectoryAccess {
346                    path: parent.display().to_string(),
347                    reason: format!("sync parent dir after publish: {e}"),
348                })?;
349        }
350    }
351
352    if let Some(pb) = pb {
353        pb.finish_with_message(format!("Downloaded {} ({downloaded} bytes)", spec.id));
354    }
355
356    // Sidecar checksum for operators: `file.bin.sha256`
357    let sidecar = PathBuf::from(format!("{}.sha256", dest.display()));
358    let _ = fs::write(&sidecar, format!("{}  {}\n", digest, spec.filename));
359
360    Ok(())
361}
362
363/// Publish a verified partial into `dest` without a durable-window gap (JOE-1918).
364///
365/// Prefer atomic rename over pre-delete. On platforms where rename cannot
366/// replace, stage the previous file aside and restore it if the new rename fails.
367fn publish_verified_download(tmp: &Path, dest: &Path) -> Result<()> {
368    match fs::rename(tmp, dest) {
369        Ok(()) => Ok(()),
370        Err(e) if dest.exists() => {
371            // Replacement path (common on Windows when dest exists).
372            let backup = dest.with_extension(format!(
373                "aurum.bak.{}-{}",
374                std::process::id(),
375                std::time::SystemTime::now()
376                    .duration_since(std::time::UNIX_EPOCH)
377                    .map(|d| d.as_nanos())
378                    .unwrap_or(0)
379            ));
380            fs::rename(dest, &backup).map_err(|re| EnvironmentError::DirectoryAccess {
381                path: dest.display().to_string(),
382                reason: format!("stage previous artifact: {re} (after rename: {e})"),
383            })?;
384            match fs::rename(tmp, dest) {
385                Ok(()) => {
386                    let _ = fs::remove_file(&backup);
387                    Ok(())
388                }
389                Err(re) => {
390                    let _ = fs::rename(&backup, dest);
391                    Err(EnvironmentError::DirectoryAccess {
392                        path: dest.display().to_string(),
393                        reason: format!("publish verified artifact: {re}"),
394                    }
395                    .into())
396                }
397            }
398        }
399        Err(e) => Err(EnvironmentError::DirectoryAccess {
400            path: dest.display().to_string(),
401            reason: e.to_string(),
402        }
403        .into()),
404    }
405}
406
407/// Sweep only this process-family stale partials (never another live writer's file).
408pub fn sweep_stale_partials(dir: &Path, stale_after: Duration) {
409    let Ok(entries) = fs::read_dir(dir) else {
410        return;
411    };
412    let now = std::time::SystemTime::now();
413    for ent in entries.flatten() {
414        let name = ent.file_name();
415        let name = name.to_string_lossy();
416        if !name.contains(".aurum.partial") && !name.contains(".bin.partial.") {
417            continue;
418        }
419        let Ok(meta) = ent.metadata() else {
420            continue;
421        };
422        let Ok(modified) = meta.modified() else {
423            continue;
424        };
425        if now.duration_since(modified).unwrap_or_default() > stale_after {
426            let _ = fs::remove_file(ent.path());
427        }
428    }
429}
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434
435    #[test]
436    fn cap_never_raised_by_content_length_logic() {
437        // Helper ignores Content-Length; forged CL cannot increase cap.
438        let cap = download_byte_cap(10_000_000, Some(10_000_000), 3);
439        assert_eq!(cap, 30_000_000);
440        let forged = 10_u64.pow(12);
441        assert!(cap < forged);
442    }
443
444    #[test]
445    fn floor_for_tiny_pin() {
446        assert_eq!(download_byte_cap(100, Some(100), 3), 1_000_000);
447    }
448}