1use 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#[derive(Debug, Clone, Copy)]
15pub struct ArtifactSpec {
16 pub id: &'static str,
17 pub filename: &'static str,
18 pub sha256: &'static str,
20 pub exact_bytes: Option<u64>,
22 pub approx_bytes: u64,
24 pub url: &'static str,
26 pub license: &'static str,
27 pub source_revision: &'static str,
28}
29
30#[derive(Debug, Clone)]
32pub struct DownloadOptions {
33 pub show_progress: bool,
34 pub connect_timeout: Duration,
35 pub total_timeout: Duration,
36 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
51pub 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
58pub 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
122pub 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 .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 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 file.sync_all().ok();
309 drop(file);
310
311 let digest = hex::encode(hasher.finalize());
312 if digest != spec.sha256 {
313 return Err(ProviderError::ModelDownload {
314 model: spec.id.to_string(),
315 reason: format!(
316 "sha256 mismatch (got {digest}, expected {}) — refusing to publish",
317 spec.sha256
318 ),
319 }
320 .into());
321 }
322 if let Some(exact) = spec.exact_bytes {
323 if downloaded != exact {
324 return Err(ProviderError::ModelDownload {
325 model: spec.id.to_string(),
326 reason: format!(
327 "size mismatch after download (got {downloaded}, expected {exact})"
328 ),
329 }
330 .into());
331 }
332 }
333
334 if dest.exists() {
336 fs::remove_file(dest).map_err(|e| EnvironmentError::DirectoryAccess {
337 path: dest.display().to_string(),
338 reason: format!("replace existing: {e}"),
339 })?;
340 }
341 fs::rename(tmp, dest).map_err(|e| EnvironmentError::DirectoryAccess {
342 path: dest.display().to_string(),
343 reason: e.to_string(),
344 })?;
345 if let Some(parent) = dest.parent() {
346 if let Ok(dir) = File::open(parent) {
347 let _ = dir.sync_all();
348 }
349 }
350
351 if let Some(pb) = pb {
352 pb.finish_with_message(format!("Downloaded {} ({downloaded} bytes)", spec.id));
353 }
354
355 let sidecar = PathBuf::from(format!("{}.sha256", dest.display()));
357 let _ = fs::write(&sidecar, format!("{} {}\n", digest, spec.filename));
358
359 Ok(())
360}
361
362pub fn sweep_stale_partials(dir: &Path, stale_after: Duration) {
364 let Ok(entries) = fs::read_dir(dir) else {
365 return;
366 };
367 let now = std::time::SystemTime::now();
368 for ent in entries.flatten() {
369 let name = ent.file_name();
370 let name = name.to_string_lossy();
371 if !name.contains(".aurum.partial") && !name.contains(".bin.partial.") {
372 continue;
373 }
374 let Ok(meta) = ent.metadata() else {
375 continue;
376 };
377 let Ok(modified) = meta.modified() else {
378 continue;
379 };
380 if now.duration_since(modified).unwrap_or_default() > stale_after {
381 let _ = fs::remove_file(ent.path());
382 }
383 }
384}
385
386#[cfg(test)]
387mod tests {
388 use super::*;
389
390 #[test]
391 fn cap_never_raised_by_content_length_logic() {
392 let cap = download_byte_cap(10_000_000, Some(10_000_000), 3);
394 assert_eq!(cap, 30_000_000);
395 let forged = 10_u64.pow(12);
396 assert!(cap < forged);
397 }
398
399 #[test]
400 fn floor_for_tiny_pin() {
401 assert_eq!(download_byte_cap(100, Some(100), 3), 1_000_000);
402 }
403}