1use std::collections::HashMap;
4use std::fmt::{self, Display, Formatter};
5use std::fs;
6use std::io::{Read, Write};
7use std::path::{Path, PathBuf};
8use std::time::Duration;
9
10use anyhow::Result;
11use crate::cache::CachePolicy;
12use crate::error::CrossBuildError;
13
14#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct DownloadRequest {
17 pub url: String,
18 pub destination: PathBuf,
19 pub expected_checksum: Option<String>,
20 pub checksum_algorithm: ChecksumAlgorithm,
21 pub timeout: Duration,
22 pub headers: HashMap<String, String>,
23}
24
25impl DownloadRequest {
26 pub fn new(url: impl Into<String>, destination: impl Into<PathBuf>) -> Self {
28 Self {
29 url: url.into(),
30 destination: destination.into(),
31 expected_checksum: None,
32 checksum_algorithm: ChecksumAlgorithm::Sha256,
33 timeout: Duration::from_secs(300),
34 headers: HashMap::new(),
35 }
36 }
37
38 pub fn with_checksum(
40 url: impl Into<String>,
41 destination: impl Into<PathBuf>,
42 checksum: impl Into<String>,
43 algorithm: ChecksumAlgorithm,
44 ) -> Self {
45 Self {
46 url: url.into(),
47 destination: destination.into(),
48 expected_checksum: Some(checksum.into()),
49 checksum_algorithm: algorithm,
50 timeout: Duration::from_secs(300),
51 headers: HashMap::new(),
52 }
53 }
54
55 pub fn with_timeout(mut self, timeout: Duration) -> Self {
56 self.timeout = timeout;
57 self
58 }
59
60 pub fn with_header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
61 self.headers.insert(key.into(), value.into());
62 self
63 }
64
65 pub fn provenance_label(&self) -> String {
67 if let Some(checksum) = &self.expected_checksum {
68 format!("{} ({}:{})", self.url, self.checksum_algorithm, checksum)
69 } else {
70 self.url.clone()
71 }
72 }
73
74 pub fn is_verified(&self) -> bool {
76 self.expected_checksum.is_some()
77 }
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub enum ChecksumAlgorithm {
83 Sha256,
84 Sha512,
85 Blake3,
86}
87
88impl Display for ChecksumAlgorithm {
89 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
90 match self {
91 ChecksumAlgorithm::Sha256 => f.write_str("sha256"),
92 ChecksumAlgorithm::Sha512 => f.write_str("sha512"),
93 ChecksumAlgorithm::Blake3 => f.write_str("blake3"),
94 }
95 }
96}
97
98pub type ProgressCallback = Box<dyn FnMut(DownloadProgress) + Send>;
100
101#[derive(Debug, Clone)]
103pub struct DownloadProgress {
104 pub url: String,
105 pub downloaded_bytes: u64,
106 pub total_bytes: Option<u64>,
107 pub status: DownloadStatus,
108}
109
110#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112pub enum DownloadStatus {
113 Starting,
114 Downloading,
115 Verifying,
116 Complete,
117 Failed,
118}
119
120#[derive(Debug, Clone)]
122pub struct DownloadResult {
123 pub path: PathBuf,
124 pub size_bytes: u64,
125 pub checksum: String,
126 pub algorithm: ChecksumAlgorithm,
127}
128
129pub struct Downloader {
131 client: reqwest::blocking::Client,
132 cache_policy: CachePolicy,
133 progress_callback: Option<ProgressCallback>,
134}
135
136impl Downloader {
137 pub fn new(cache_policy: CachePolicy) -> Result<Self, CrossBuildError> {
139 let client = reqwest::blocking::Client::builder()
140 .timeout(Duration::from_secs(300))
141 .build()
142 .map_err(|e| CrossBuildError::configuration(format!("Failed to create HTTP client: {e}").to_string()))?;
143
144 Ok(Self {
145 client,
146 cache_policy,
147 progress_callback: None,
148 })
149 }
150
151 pub fn with_progress_callback(mut self, callback: ProgressCallback) -> Self {
153 self.progress_callback = Some(callback);
154 self
155 }
156
157 pub fn download(&mut self, request: DownloadRequest) -> Result<DownloadResult, CrossBuildError> {
159 if let Some(parent) = request.destination.parent() {
161 fs::create_dir_all(parent).map_err(|source| CrossBuildError::Io {
162 path: Some(parent.to_path_buf()),
163 source,
164 })?;
165 }
166
167 if request.destination.exists() && request.is_verified() {
169 if let Ok(checksum) = self.compute_checksum(&request.destination, request.checksum_algorithm) {
170 if checksum == request.expected_checksum.as_deref().unwrap_or("") {
171 return Ok(DownloadResult {
172 path: request.destination.clone(),
173 size_bytes: fs::metadata(&request.destination)
174 .map(|m| m.len())
175 .unwrap_or(0),
176 checksum,
177 algorithm: request.checksum_algorithm,
178 });
179 }
180 }
181 }
182
183 let mut response = self.client
185 .get(&request.url)
186 .timeout(request.timeout)
187 .send()
188 .map_err(|e| CrossBuildError::DownloadFailed {
189 url: request.url.clone(),
190 reason: e.to_string(),
191 })?;
192
193 if !response.status().is_success() {
194 return Err(CrossBuildError::DownloadFailed {
195 url: request.url.clone(),
196 reason: format!("HTTP {}", response.status()),
197 });
198 }
199
200 let total_size = response.content_length();
201
202 let temp_path = request.destination.with_extension("tmp.part");
204 let mut file = fs::File::create(&temp_path).map_err(|source| CrossBuildError::Io {
205 path: Some(temp_path.clone()),
206 source,
207 })?;
208
209 let mut downloaded = 0u64;
210 let mut buffer = vec![0u8; 8192];
211 let mut hasher = self.create_hasher(request.checksum_algorithm);
212
213 loop {
214 let bytes_read = response.read(&mut buffer).map_err(|e| CrossBuildError::DownloadFailed {
215 url: request.url.clone(),
216 reason: e.to_string(),
217 })?;
218
219 if bytes_read == 0 {
220 break;
221 }
222
223 file.write_all(&buffer[..bytes_read]).map_err(|source| CrossBuildError::Io {
224 path: Some(temp_path.clone()),
225 source,
226 })?;
227
228 hasher.update(&buffer[..bytes_read]);
229 downloaded += bytes_read as u64;
230
231 if let Some(ref mut callback) = self.progress_callback {
232 callback(DownloadProgress {
233 url: request.url.clone(),
234 downloaded_bytes: downloaded,
235 total_bytes: total_size,
236 status: DownloadStatus::Downloading,
237 });
238 }
239 }
240
241 file.flush().map_err(|source| CrossBuildError::Io {
242 path: Some(temp_path.clone()),
243 source,
244 })?;
245
246 if let Some(expected) = &request.expected_checksum {
248 if let Some(ref mut callback) = self.progress_callback {
249 callback(DownloadProgress {
250 url: request.url.clone(),
251 downloaded_bytes: downloaded,
252 total_bytes: total_size,
253 status: DownloadStatus::Verifying,
254 });
255 }
256
257 let actual = self.finalize_checksum(hasher, request.checksum_algorithm);
258 if actual != *expected {
259 let _ = fs::remove_file(&temp_path);
260 return Err(CrossBuildError::ChecksumMismatch {
261 url: request.url,
262 expected: expected.clone(),
263 actual,
264 });
265 }
266 }
267
268 fs::rename(&temp_path, &request.destination).map_err(|source| CrossBuildError::Io {
270 path: Some(request.destination.clone()),
271 source,
272 })?;
273
274 let final_checksum = if request.expected_checksum.is_some() {
275 self.compute_checksum(&request.destination, request.checksum_algorithm)?
276 } else {
277 self.compute_checksum(&request.destination, request.checksum_algorithm)?
278 };
279
280 if let Some(ref mut callback) = self.progress_callback {
281 callback(DownloadProgress {
282 url: request.url.clone(),
283 downloaded_bytes: downloaded,
284 total_bytes: total_size,
285 status: DownloadStatus::Complete,
286 });
287 }
288
289 Ok(DownloadResult {
290 path: request.destination,
291 size_bytes: downloaded,
292 checksum: final_checksum,
293 algorithm: request.checksum_algorithm,
294 })
295 }
296
297 pub fn download_to_cache(
299 &mut self,
300 url: &str,
301 cache_key: &str,
302 expected_checksum: Option<&str>,
303 _algorithm: ChecksumAlgorithm,
304 ) -> Result<DownloadResult, CrossBuildError> {
305 let dest = self.cache_policy.download_dir(&PathBuf::from("."))
306 .join(cache_key);
307 self.download(DownloadRequest::with_checksum(
308 url,
309 dest,
310 expected_checksum.unwrap_or(""),
311 ChecksumAlgorithm::Sha256,
312 ))
313 }
314
315 fn create_hasher(&self, algorithm: ChecksumAlgorithm) -> Box<dyn ChecksumHasher> {
316 match algorithm {
317 ChecksumAlgorithm::Sha256 => Box::new(Sha256Hasher::new()),
318 ChecksumAlgorithm::Sha512 => Box::new(Sha512Hasher::new()),
319 ChecksumAlgorithm::Blake3 => Box::new(Blake3Hasher::new()),
320 }
321 }
322
323 fn finalize_checksum(&self, hasher: Box<dyn ChecksumHasher>, _algorithm: ChecksumAlgorithm) -> String {
324 hasher.finalize()
325 }
326
327 fn compute_checksum(&self, path: &Path, algorithm: ChecksumAlgorithm) -> Result<String, CrossBuildError> {
328 let mut file = fs::File::open(path).map_err(|source| CrossBuildError::Io {
329 path: Some(path.to_path_buf()),
330 source,
331 })?;
332
333 let mut hasher = self.create_hasher(algorithm);
334 let mut buffer = vec![0u8; 8192];
335
336 loop {
337 let bytes_read = file.read(&mut buffer).map_err(|source| CrossBuildError::Io {
338 path: Some(path.to_path_buf()),
339 source,
340 })?;
341 if bytes_read == 0 {
342 break;
343 }
344 hasher.update(&buffer[..bytes_read]);
345 }
346
347 Ok(hasher.finalize())
348 }
349}
350
351trait ChecksumHasher: Send {
352 fn update(&mut self, data: &[u8]);
353 fn finalize(self: Box<Self>) -> String;
354}
355
356struct Sha256Hasher(sha2::Sha256);
357
358impl Sha256Hasher {
359 fn new() -> Self {
360 use sha2::Digest;
361 Self(sha2::Sha256::new())
362 }
363}
364
365impl ChecksumHasher for Sha256Hasher {
366 fn update(&mut self, data: &[u8]) {
367 use sha2::Digest;
368 self.0.update(data);
369 }
370
371 fn finalize(self: Box<Self>) -> String {
372 use sha2::Digest;
373 hex::encode(self.0.finalize())
374 }
375}
376
377struct Sha512Hasher(sha2::Sha512);
378
379impl Sha512Hasher {
380 fn new() -> Self {
381 use sha2::Digest;
382 Self(sha2::Sha512::new())
383 }
384}
385
386impl ChecksumHasher for Sha512Hasher {
387 fn update(&mut self, data: &[u8]) {
388 use sha2::Digest;
389 self.0.update(data);
390 }
391
392 fn finalize(self: Box<Self>) -> String {
393 use sha2::Digest;
394 hex::encode(self.0.finalize())
395 }
396}
397
398struct Blake3Hasher(blake3::Hasher);
399
400impl Blake3Hasher {
401 fn new() -> Self {
402 Self(blake3::Hasher::new())
403 }
404}
405
406impl ChecksumHasher for Blake3Hasher {
407 fn update(&mut self, data: &[u8]) {
408 self.0.update(data);
409 }
410
411 fn finalize(self: Box<Self>) -> String {
412 self.0.finalize().to_hex().to_string()
413 }
414}
415
416#[cfg(test)]
417mod tests {
418 use super::*;
419 #[test]
420 fn download_request_creation() {
421 let req = DownloadRequest::new("https://example.com/file", "/tmp/file");
422 assert_eq!(req.url, "https://example.com/file");
423 assert!(!req.is_verified());
424 }
425
426 #[test]
427 fn download_request_with_checksum() {
428 let req = DownloadRequest::with_checksum(
429 "https://example.com/file",
430 "/tmp/file",
431 "abc123",
432 ChecksumAlgorithm::Sha256,
433 );
434 assert!(req.is_verified());
435 assert_eq!(req.expected_checksum, Some("abc123".to_string()));
436 }
437
438 #[test]
439 fn provenance_label() {
440 let req = DownloadRequest::with_checksum(
441 "https://example.com/file",
442 "/tmp/file",
443 "sha256:abc123",
444 ChecksumAlgorithm::Sha256,
445 );
446 assert!(req.provenance_label().contains("sha256:abc123"));
447 }
448
449 #[test]
450 fn checksum_algorithms() {
451 assert_eq!(ChecksumAlgorithm::Sha256.to_string(), "sha256");
452 assert_eq!(ChecksumAlgorithm::Sha512.to_string(), "sha512");
453 assert_eq!(ChecksumAlgorithm::Blake3.to_string(), "blake3");
454 }
455}