Skip to main content

crossbuild_core/
downloader.rs

1//! Secure downloader with checksum verification.
2
3use 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/// Download request with optional verification.
15#[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    /// Creates a new download request.
27    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    /// Creates a new download request with checksum verification.
39    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    /// Returns the provenance label for logging.
66    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    /// Checks if the request has checksum verification.
75    pub fn is_verified(&self) -> bool {
76        self.expected_checksum.is_some()
77    }
78}
79
80/// Supported checksum algorithms.
81#[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
98/// Download progress callback.
99pub type ProgressCallback = Box<dyn FnMut(DownloadProgress) + Send>;
100
101/// Download progress information.
102#[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/// Download status.
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112pub enum DownloadStatus {
113    Starting,
114    Downloading,
115    Verifying,
116    Complete,
117    Failed,
118}
119
120/// Download result.
121#[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
129/// Secure downloader with checksum verification.
130pub struct Downloader {
131    client: reqwest::blocking::Client,
132    cache_policy: CachePolicy,
133    progress_callback: Option<ProgressCallback>,
134}
135
136impl Downloader {
137    /// Creates a new downloader.
138    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    /// Sets a progress callback.
152    pub fn with_progress_callback(mut self, callback: ProgressCallback) -> Self {
153        self.progress_callback = Some(callback);
154        self
155    }
156
157    /// Downloads a file with verification.
158    pub fn download(&mut self, request: DownloadRequest) -> Result<DownloadResult, CrossBuildError> {
159        // Create destination directory
160        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        // Check if already cached and verified
168        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        // Download
184        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        // Create temp file for atomic write
203        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        // Verify checksum
247        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        // Atomic move
269        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    /// Downloads to cache directory.
298    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}