Skip to main content

socket_patch_core/api/
blob_fetcher.rs

1use std::collections::HashSet;
2use std::path::{Path, PathBuf};
3
4use crate::api::client::ApiClient;
5use crate::manifest::operations::get_after_hash_blobs;
6use crate::manifest::schema::PatchManifest;
7use crate::patch::apply::PatchSources;
8
9/// Selects which kind of patch artifact `fetch_missing_sources` downloads.
10///
11/// * `File` — per-file blobs (legacy, largest, always applicable).
12/// * `Diff` — per-patch tar.gz of bsdiff deltas (smallest, only useful
13///   when the original file is on disk).
14/// * `Package` — per-patch tar.gz of patched files (mid-size, applicable
15///   even when the original file is missing).
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum DownloadMode {
18    Diff,
19    Package,
20    File,
21}
22
23impl DownloadMode {
24    /// Short lowercase tag, suitable for JSON output and `--download-mode`
25    /// flag values.
26    pub fn as_tag(&self) -> &'static str {
27        match self {
28            DownloadMode::Diff => "diff",
29            DownloadMode::Package => "package",
30            DownloadMode::File => "file",
31        }
32    }
33
34    /// Parse `--download-mode` flag values.
35    pub fn parse(s: &str) -> Result<Self, String> {
36        match s.to_ascii_lowercase().as_str() {
37            "diff" => Ok(DownloadMode::Diff),
38            "package" => Ok(DownloadMode::Package),
39            "file" | "blob" => Ok(DownloadMode::File),
40            other => Err(format!(
41                "unknown download mode '{}'. Expected diff, package, or file.",
42                other
43            )),
44        }
45    }
46}
47
48/// Result of fetching a single blob.
49#[derive(Debug, Clone)]
50pub struct BlobFetchResult {
51    pub hash: String,
52    pub success: bool,
53    pub error: Option<String>,
54}
55
56/// Aggregate result of a blob-fetch operation.
57#[derive(Debug, Clone, Default)]
58pub struct FetchMissingBlobsResult {
59    pub total: usize,
60    pub downloaded: usize,
61    pub failed: usize,
62    pub skipped: usize,
63    pub results: Vec<BlobFetchResult>,
64}
65
66/// Progress callback signature.
67///
68/// Called with `(hash, one_based_index, total)` for each blob.
69pub type OnProgress = Box<dyn Fn(&str, usize, usize) + Send + Sync>;
70
71// ── Public API ────────────────────────────────────────────────────────
72
73/// Determine which `afterHash` blobs referenced in the manifest are
74/// missing from disk.
75///
76/// Only checks `afterHash` blobs because those are the patched file
77/// contents needed for applying patches. `beforeHash` blobs are
78/// downloaded on-demand during rollback.
79pub async fn get_missing_blobs(manifest: &PatchManifest, blobs_path: &Path) -> HashSet<String> {
80    let after_hash_blobs = get_after_hash_blobs(manifest);
81    let mut missing = HashSet::new();
82
83    for hash in after_hash_blobs {
84        let blob_path = blobs_path.join(&hash);
85        if tokio::fs::metadata(&blob_path).await.is_err() {
86            missing.insert(hash);
87        }
88    }
89
90    missing
91}
92
93/// Download all missing `afterHash` blobs referenced in the manifest.
94///
95/// Creates the `blobs_path` directory if it does not exist.
96///
97/// # Arguments
98///
99/// * `manifest`    – Patch manifest whose `afterHash` blobs to check.
100/// * `blobs_path`  – Directory where blob files are stored (one file per
101///   hash).
102/// * `client`      – [`ApiClient`] used to fetch blobs from the server.
103/// * `on_progress` – Optional callback invoked before each download with
104///   `(hash, 1-based index, total)`.
105pub async fn fetch_missing_blobs(
106    manifest: &PatchManifest,
107    blobs_path: &Path,
108    client: &ApiClient,
109    on_progress: Option<&OnProgress>,
110) -> FetchMissingBlobsResult {
111    let missing = get_missing_blobs(manifest, blobs_path).await;
112
113    if missing.is_empty() {
114        return FetchMissingBlobsResult::default();
115    }
116
117    // Ensure blobs directory exists
118    if let Err(e) = tokio::fs::create_dir_all(blobs_path).await {
119        return all_failed_result(missing.iter(), |h| {
120            (h.clone(), format!("Cannot create blobs directory: {}", e))
121        });
122    }
123
124    let hashes: Vec<String> = missing.into_iter().collect();
125    download_hashes(&hashes, blobs_path, client, on_progress).await
126}
127
128/// Build a [`FetchMissingBlobsResult`] whose entries are all failures
129/// for the same reason. Used by the early-return branches that hit a
130/// blocker (e.g. cannot create blobs dir) before any download attempt.
131fn all_failed_result<'a, I, F>(items: I, mut into_pair: F) -> FetchMissingBlobsResult
132where
133    I: IntoIterator<Item = &'a String>,
134    F: FnMut(&'a String) -> (String, String),
135{
136    let results: Vec<BlobFetchResult> = items
137        .into_iter()
138        .map(|item| {
139            let (hash, error) = into_pair(item);
140            BlobFetchResult {
141                hash,
142                success: false,
143                error: Some(error),
144            }
145        })
146        .collect();
147    let failed = results.len();
148    FetchMissingBlobsResult {
149        total: failed,
150        failed,
151        results,
152        ..FetchMissingBlobsResult::default()
153    }
154}
155
156/// Download specific blobs identified by their hashes.
157///
158/// Useful for fetching `beforeHash` blobs during rollback, where only a
159/// subset of hashes is required.
160///
161/// Blobs that already exist on disk are skipped (counted in `skipped`).
162pub async fn fetch_blobs_by_hash(
163    hashes: &HashSet<String>,
164    blobs_path: &Path,
165    client: &ApiClient,
166    on_progress: Option<&OnProgress>,
167) -> FetchMissingBlobsResult {
168    if hashes.is_empty() {
169        return FetchMissingBlobsResult::default();
170    }
171
172    // Ensure blobs directory exists
173    if let Err(e) = tokio::fs::create_dir_all(blobs_path).await {
174        return all_failed_result(hashes.iter(), |h| {
175            (h.clone(), format!("Cannot create blobs directory: {}", e))
176        });
177    }
178
179    // Filter out hashes that already exist on disk
180    let mut to_download: Vec<String> = Vec::new();
181    let mut skipped: usize = 0;
182    let mut results: Vec<BlobFetchResult> = Vec::new();
183
184    for hash in hashes {
185        let blob_path = blobs_path.join(hash);
186        if tokio::fs::metadata(&blob_path).await.is_ok() {
187            skipped += 1;
188            results.push(BlobFetchResult {
189                hash: hash.clone(),
190                success: true,
191                error: None,
192            });
193        } else {
194            to_download.push(hash.clone());
195        }
196    }
197
198    if to_download.is_empty() {
199        return FetchMissingBlobsResult {
200            total: hashes.len(),
201            downloaded: 0,
202            failed: 0,
203            skipped,
204            results,
205        };
206    }
207
208    let download_result = download_hashes(&to_download, blobs_path, client, on_progress).await;
209
210    FetchMissingBlobsResult {
211        total: hashes.len(),
212        downloaded: download_result.downloaded,
213        failed: download_result.failed,
214        skipped,
215        results: {
216            let mut combined = results;
217            combined.extend(download_result.results);
218            combined
219        },
220    }
221}
222
223/// Return the set of patch UUIDs whose archive at
224/// `<archives_dir>/<uuid>.tar.gz` is missing from disk. Used as the
225/// "what do I need to download" query for diff and package modes.
226pub async fn get_missing_archives(
227    manifest: &PatchManifest,
228    archives_dir: &Path,
229) -> HashSet<String> {
230    let mut missing = HashSet::new();
231    for record in manifest.patches.values() {
232        let archive_path = archives_dir.join(format!("{}.tar.gz", record.uuid));
233        if tokio::fs::metadata(&archive_path).await.is_err() {
234            missing.insert(record.uuid.clone());
235        }
236    }
237    missing
238}
239
240/// Download all missing archives for the chosen [`DownloadMode`].
241///
242/// * [`DownloadMode::File`] delegates to [`fetch_missing_blobs`].
243/// * [`DownloadMode::Diff`] downloads each missing `<uuid>.tar.gz` into
244///   `sources.diffs_path` via [`ApiClient::fetch_diff`].
245/// * [`DownloadMode::Package`] does the same with `sources.packages_path`
246///   and [`ApiClient::fetch_package`].
247///
248/// Returns a [`FetchMissingBlobsResult`] in which each `BlobFetchResult`'s
249/// `hash` field carries the patch UUID (not a blob hash) for diff and
250/// package modes. A `sources.packages_path` / `sources.diffs_path` of
251/// `None` while requesting that mode yields an immediate empty result —
252/// the caller is expected to fall back to a different mode in that case.
253pub async fn fetch_missing_sources(
254    manifest: &PatchManifest,
255    sources: &PatchSources<'_>,
256    mode: DownloadMode,
257    client: &ApiClient,
258    on_progress: Option<&OnProgress>,
259) -> FetchMissingBlobsResult {
260    match mode {
261        DownloadMode::File => {
262            fetch_missing_blobs(manifest, sources.blobs_path, client, on_progress).await
263        }
264        DownloadMode::Diff => match sources.diffs_path {
265            Some(dir) => {
266                fetch_missing_archives_inner(manifest, dir, ArchiveKind::Diff, client, on_progress)
267                    .await
268            }
269            None => FetchMissingBlobsResult::default(),
270        },
271        DownloadMode::Package => match sources.packages_path {
272            Some(dir) => {
273                fetch_missing_archives_inner(
274                    manifest,
275                    dir,
276                    ArchiveKind::Package,
277                    client,
278                    on_progress,
279                )
280                .await
281            }
282            None => FetchMissingBlobsResult::default(),
283        },
284    }
285}
286
287#[derive(Debug, Clone, Copy)]
288enum ArchiveKind {
289    Diff,
290    Package,
291}
292
293async fn fetch_missing_archives_inner(
294    manifest: &PatchManifest,
295    archives_dir: &Path,
296    kind: ArchiveKind,
297    client: &ApiClient,
298    on_progress: Option<&OnProgress>,
299) -> FetchMissingBlobsResult {
300    let missing = get_missing_archives(manifest, archives_dir).await;
301    if missing.is_empty() {
302        return FetchMissingBlobsResult::default();
303    }
304
305    if let Err(e) = tokio::fs::create_dir_all(archives_dir).await {
306        return all_failed_result(missing.iter(), |u| {
307            (
308                u.clone(),
309                format!("Cannot create archives directory: {}", e),
310            )
311        });
312    }
313
314    let uuids: Vec<String> = missing.into_iter().collect();
315    let total = uuids.len();
316    let mut downloaded = 0usize;
317    let mut failed = 0usize;
318    let mut results = Vec::with_capacity(total);
319
320    for (i, uuid) in uuids.iter().enumerate() {
321        if let Some(ref cb) = on_progress {
322            cb(uuid, i + 1, total);
323        }
324
325        let fetch_result = match kind {
326            ArchiveKind::Diff => client.fetch_diff(uuid).await,
327            ArchiveKind::Package => client.fetch_package(uuid).await,
328        };
329
330        match fetch_result {
331            Ok(Some(data)) => {
332                let archive_path: PathBuf = archives_dir.join(format!("{}.tar.gz", uuid));
333                match tokio::fs::write(&archive_path, &data).await {
334                    Ok(()) => {
335                        results.push(BlobFetchResult {
336                            hash: uuid.clone(),
337                            success: true,
338                            error: None,
339                        });
340                        downloaded += 1;
341                    }
342                    Err(e) => {
343                        results.push(BlobFetchResult {
344                            hash: uuid.clone(),
345                            success: false,
346                            error: Some(format!("Failed to write archive to disk: {}", e)),
347                        });
348                        failed += 1;
349                    }
350                }
351            }
352            Ok(None) => {
353                results.push(BlobFetchResult {
354                    hash: uuid.clone(),
355                    success: false,
356                    error: Some(format!(
357                        "{} archive not found on server",
358                        match kind {
359                            ArchiveKind::Diff => "Diff",
360                            ArchiveKind::Package => "Package",
361                        }
362                    )),
363                });
364                failed += 1;
365            }
366            Err(e) => {
367                results.push(BlobFetchResult {
368                    hash: uuid.clone(),
369                    success: false,
370                    error: Some(e.to_string()),
371                });
372                failed += 1;
373            }
374        }
375    }
376
377    FetchMissingBlobsResult {
378        total,
379        downloaded,
380        failed,
381        skipped: 0,
382        results,
383    }
384}
385
386/// Format a [`FetchMissingBlobsResult`] as a human-readable string.
387pub fn format_fetch_result(result: &FetchMissingBlobsResult) -> String {
388    if result.total == 0 {
389        return "All blobs are present locally.".to_string();
390    }
391
392    let mut lines: Vec<String> = Vec::new();
393
394    if result.downloaded > 0 {
395        lines.push(format!("Downloaded {} blob(s)", result.downloaded));
396    }
397
398    if result.skipped > 0 {
399        lines.push(format!(
400            "{} blob(s) already present locally",
401            result.skipped
402        ));
403    }
404
405    if result.failed > 0 {
406        lines.push(format!("Failed to download {} blob(s)", result.failed));
407
408        let failed_results: Vec<&BlobFetchResult> =
409            result.results.iter().filter(|r| !r.success).collect();
410
411        for r in failed_results.iter().take(5) {
412            let short_hash = if r.hash.len() >= 12 {
413                &r.hash[..12]
414            } else {
415                &r.hash
416            };
417            let err = r.error.as_deref().unwrap_or("unknown error");
418            lines.push(format!("  - {}...: {}", short_hash, err));
419        }
420
421        if failed_results.len() > 5 {
422            lines.push(format!("  ... and {} more", failed_results.len() - 5));
423        }
424    }
425
426    // `total > 0` but nothing downloaded, skipped, or failed should not be
427    // reachable, but guard against emitting a misleading blank string.
428    if lines.is_empty() {
429        return "All blobs are present locally.".to_string();
430    }
431
432    lines.join("\n")
433}
434
435// ── Internal helpers ──────────────────────────────────────────────────
436
437/// Compare an expected blob hash against the hash computed from the
438/// downloaded bytes.
439///
440/// Git object hashes are hex, and hex is case-insensitive. The content
441/// hasher ([`compute_git_sha256_from_bytes`]) always emits lowercase, but
442/// [`ApiClient::fetch_blob`]'s validator accepts uppercase hex too — so a
443/// manifest (or server) that uses uppercase would download byte-for-byte
444/// correct content and then be wrongly rejected by a case-sensitive
445/// comparison. Compare ignoring ASCII case to keep the two consistent.
446///
447/// [`compute_git_sha256_from_bytes`]: crate::hash::git_sha256::compute_git_sha256_from_bytes
448fn blob_hash_matches(expected: &str, actual: &str) -> bool {
449    expected.eq_ignore_ascii_case(actual)
450}
451
452/// Download a list of blob hashes sequentially, writing each to
453/// `blobs_path/<hash>`.
454async fn download_hashes(
455    hashes: &[String],
456    blobs_path: &Path,
457    client: &ApiClient,
458    on_progress: Option<&OnProgress>,
459) -> FetchMissingBlobsResult {
460    let total = hashes.len();
461    let mut downloaded: usize = 0;
462    let mut failed: usize = 0;
463    let mut results: Vec<BlobFetchResult> = Vec::with_capacity(total);
464
465    for (i, hash) in hashes.iter().enumerate() {
466        if let Some(ref cb) = on_progress {
467            cb(hash, i + 1, total);
468        }
469
470        match client.fetch_blob(hash).await {
471            Ok(Some(data)) => {
472                // Verify content hash matches expected hash before writing
473                let actual_hash = crate::hash::git_sha256::compute_git_sha256_from_bytes(&data);
474                if !blob_hash_matches(hash, &actual_hash) {
475                    results.push(BlobFetchResult {
476                        hash: hash.clone(),
477                        success: false,
478                        error: Some(format!(
479                            "Content hash mismatch: expected {}, got {}",
480                            hash, actual_hash
481                        )),
482                    });
483                    failed += 1;
484                    continue;
485                }
486
487                let blob_path: PathBuf = blobs_path.join(hash);
488                match tokio::fs::write(&blob_path, &data).await {
489                    Ok(()) => {
490                        results.push(BlobFetchResult {
491                            hash: hash.clone(),
492                            success: true,
493                            error: None,
494                        });
495                        downloaded += 1;
496                    }
497                    Err(e) => {
498                        results.push(BlobFetchResult {
499                            hash: hash.clone(),
500                            success: false,
501                            error: Some(format!("Failed to write blob to disk: {}", e)),
502                        });
503                        failed += 1;
504                    }
505                }
506            }
507            Ok(None) => {
508                results.push(BlobFetchResult {
509                    hash: hash.clone(),
510                    success: false,
511                    error: Some("Blob not found on server".to_string()),
512                });
513                failed += 1;
514            }
515            Err(e) => {
516                results.push(BlobFetchResult {
517                    hash: hash.clone(),
518                    success: false,
519                    error: Some(e.to_string()),
520                });
521                failed += 1;
522            }
523        }
524    }
525
526    FetchMissingBlobsResult {
527        total,
528        downloaded,
529        failed,
530        skipped: 0,
531        results,
532    }
533}
534
535#[cfg(test)]
536mod tests {
537    use super::*;
538    use crate::manifest::schema::{PatchFileInfo, PatchManifest, PatchRecord};
539    use std::collections::HashMap;
540
541    fn make_manifest_with_hashes(after_hashes: &[&str]) -> PatchManifest {
542        let mut files = HashMap::new();
543        for (i, ah) in after_hashes.iter().enumerate() {
544            files.insert(
545                format!("package/file{}.js", i),
546                PatchFileInfo {
547                    before_hash: format!("before{}{}", "0".repeat(58), format!("{:06}", i)),
548                    after_hash: ah.to_string(),
549                },
550            );
551        }
552
553        let mut patches = HashMap::new();
554        patches.insert(
555            "pkg:npm/test@1.0.0".to_string(),
556            PatchRecord {
557                uuid: "test-uuid".to_string(),
558                exported_at: "2024-01-01T00:00:00Z".to_string(),
559                files,
560                vulnerabilities: HashMap::new(),
561                description: "test".to_string(),
562                license: "MIT".to_string(),
563                tier: "free".to_string(),
564            },
565        );
566
567        PatchManifest { patches }
568    }
569
570    #[tokio::test]
571    async fn test_get_missing_blobs_all_missing() {
572        let dir = tempfile::tempdir().unwrap();
573        let blobs_path = dir.path().join("blobs");
574        tokio::fs::create_dir_all(&blobs_path).await.unwrap();
575
576        let h1 = "a".repeat(64);
577        let h2 = "b".repeat(64);
578        let manifest = make_manifest_with_hashes(&[&h1, &h2]);
579
580        let missing = get_missing_blobs(&manifest, &blobs_path).await;
581        assert_eq!(missing.len(), 2);
582        assert!(missing.contains(&h1));
583        assert!(missing.contains(&h2));
584    }
585
586    #[tokio::test]
587    async fn test_get_missing_blobs_some_present() {
588        let dir = tempfile::tempdir().unwrap();
589        let blobs_path = dir.path().join("blobs");
590        tokio::fs::create_dir_all(&blobs_path).await.unwrap();
591
592        let h1 = "a".repeat(64);
593        let h2 = "b".repeat(64);
594
595        // Write h1 to disk so it is NOT missing
596        tokio::fs::write(blobs_path.join(&h1), b"data")
597            .await
598            .unwrap();
599
600        let manifest = make_manifest_with_hashes(&[&h1, &h2]);
601        let missing = get_missing_blobs(&manifest, &blobs_path).await;
602        assert_eq!(missing.len(), 1);
603        assert!(missing.contains(&h2));
604        assert!(!missing.contains(&h1));
605    }
606
607    #[tokio::test]
608    async fn test_get_missing_blobs_empty_manifest() {
609        let dir = tempfile::tempdir().unwrap();
610        let blobs_path = dir.path().join("blobs");
611        tokio::fs::create_dir_all(&blobs_path).await.unwrap();
612
613        let manifest = PatchManifest::new();
614        let missing = get_missing_blobs(&manifest, &blobs_path).await;
615        assert!(missing.is_empty());
616    }
617
618    #[test]
619    fn test_format_fetch_result_all_present() {
620        let result = FetchMissingBlobsResult {
621            total: 0,
622            downloaded: 0,
623            failed: 0,
624            skipped: 0,
625            results: Vec::new(),
626        };
627        assert_eq!(
628            format_fetch_result(&result),
629            "All blobs are present locally."
630        );
631    }
632
633    #[test]
634    fn test_format_fetch_result_some_downloaded() {
635        let result = FetchMissingBlobsResult {
636            total: 3,
637            downloaded: 2,
638            failed: 1,
639            skipped: 0,
640            results: vec![
641                BlobFetchResult {
642                    hash: "a".repeat(64),
643                    success: true,
644                    error: None,
645                },
646                BlobFetchResult {
647                    hash: "b".repeat(64),
648                    success: true,
649                    error: None,
650                },
651                BlobFetchResult {
652                    hash: "c".repeat(64),
653                    success: false,
654                    error: Some("Blob not found on server".to_string()),
655                },
656            ],
657        };
658        let output = format_fetch_result(&result);
659        assert!(output.contains("Downloaded 2 blob(s)"));
660        assert!(output.contains("Failed to download 1 blob(s)"));
661        assert!(output.contains("cccccccccccc..."));
662        assert!(output.contains("Blob not found on server"));
663    }
664
665    #[test]
666    fn test_format_fetch_result_truncates_at_5() {
667        let results: Vec<BlobFetchResult> = (0..8)
668            .map(|i| BlobFetchResult {
669                hash: format!("{:0>64}", i),
670                success: false,
671                error: Some(format!("error {}", i)),
672            })
673            .collect();
674
675        let result = FetchMissingBlobsResult {
676            total: 8,
677            downloaded: 0,
678            failed: 8,
679            skipped: 0,
680            results,
681        };
682        let output = format_fetch_result(&result);
683        assert!(output.contains("... and 3 more"));
684    }
685
686    // ── Group 8: format edge cases ───────────────────────────────────
687
688    #[test]
689    fn test_format_only_downloaded() {
690        let result = FetchMissingBlobsResult {
691            total: 3,
692            downloaded: 3,
693            failed: 0,
694            skipped: 0,
695            results: vec![
696                BlobFetchResult {
697                    hash: "a".repeat(64),
698                    success: true,
699                    error: None,
700                },
701                BlobFetchResult {
702                    hash: "b".repeat(64),
703                    success: true,
704                    error: None,
705                },
706                BlobFetchResult {
707                    hash: "c".repeat(64),
708                    success: true,
709                    error: None,
710                },
711            ],
712        };
713        let output = format_fetch_result(&result);
714        assert!(output.contains("Downloaded 3 blob(s)"));
715        assert!(!output.contains("Failed"));
716    }
717
718    #[test]
719    fn test_format_short_hash() {
720        let result = FetchMissingBlobsResult {
721            total: 1,
722            downloaded: 0,
723            failed: 1,
724            skipped: 0,
725            results: vec![BlobFetchResult {
726                hash: "abc".into(),
727                success: false,
728                error: Some("not found".into()),
729            }],
730        };
731        let output = format_fetch_result(&result);
732        // Hash is < 12 chars, should show full hash
733        assert!(output.contains("abc..."));
734    }
735
736    #[test]
737    fn test_format_error_none() {
738        let result = FetchMissingBlobsResult {
739            total: 1,
740            downloaded: 0,
741            failed: 1,
742            skipped: 0,
743            results: vec![BlobFetchResult {
744                hash: "d".repeat(64),
745                success: false,
746                error: None,
747            }],
748        };
749        let output = format_fetch_result(&result);
750        assert!(output.contains("unknown error"));
751    }
752
753    // ── DownloadMode + archive helpers ──────────────────────────────
754
755    #[test]
756    fn test_download_mode_parse() {
757        assert_eq!(DownloadMode::parse("diff").unwrap(), DownloadMode::Diff);
758        assert_eq!(DownloadMode::parse("DIFF").unwrap(), DownloadMode::Diff);
759        assert_eq!(
760            DownloadMode::parse("package").unwrap(),
761            DownloadMode::Package
762        );
763        assert_eq!(DownloadMode::parse("file").unwrap(), DownloadMode::File);
764        // `blob` aliases to `file` so users can think in pre-2.2 terms.
765        assert_eq!(DownloadMode::parse("blob").unwrap(), DownloadMode::File);
766        assert!(DownloadMode::parse("nope").is_err());
767    }
768
769    #[test]
770    fn test_download_mode_tag() {
771        assert_eq!(DownloadMode::Diff.as_tag(), "diff");
772        assert_eq!(DownloadMode::Package.as_tag(), "package");
773        assert_eq!(DownloadMode::File.as_tag(), "file");
774    }
775
776    fn make_manifest_with_uuids(uuids: &[&str]) -> PatchManifest {
777        let mut patches = HashMap::new();
778        for (i, uuid) in uuids.iter().enumerate() {
779            let key = format!("pkg:npm/test-{}@1.0.0", i);
780            patches.insert(
781                key,
782                PatchRecord {
783                    uuid: (*uuid).to_string(),
784                    exported_at: "2024-01-01T00:00:00Z".to_string(),
785                    files: HashMap::new(),
786                    vulnerabilities: HashMap::new(),
787                    description: "test".to_string(),
788                    license: "MIT".to_string(),
789                    tier: "free".to_string(),
790                },
791            );
792        }
793        PatchManifest { patches }
794    }
795
796    #[tokio::test]
797    async fn test_get_missing_archives_all_missing() {
798        let dir = tempfile::tempdir().unwrap();
799        let archives = dir.path().join("packages");
800        tokio::fs::create_dir_all(&archives).await.unwrap();
801
802        let u1 = "11111111-1111-4111-8111-111111111111";
803        let u2 = "22222222-2222-4222-8222-222222222222";
804        let manifest = make_manifest_with_uuids(&[u1, u2]);
805
806        let missing = get_missing_archives(&manifest, &archives).await;
807        assert_eq!(missing.len(), 2);
808        assert!(missing.contains(u1));
809        assert!(missing.contains(u2));
810    }
811
812    #[tokio::test]
813    async fn test_get_missing_archives_some_present() {
814        let dir = tempfile::tempdir().unwrap();
815        let archives = dir.path().join("packages");
816        tokio::fs::create_dir_all(&archives).await.unwrap();
817
818        let u1 = "11111111-1111-4111-8111-111111111111";
819        let u2 = "22222222-2222-4222-8222-222222222222";
820
821        tokio::fs::write(archives.join(format!("{u1}.tar.gz")), b"data")
822            .await
823            .unwrap();
824
825        let manifest = make_manifest_with_uuids(&[u1, u2]);
826        let missing = get_missing_archives(&manifest, &archives).await;
827        assert_eq!(missing.len(), 1);
828        assert!(missing.contains(u2));
829        assert!(!missing.contains(u1));
830    }
831
832    #[tokio::test]
833    async fn test_fetch_missing_sources_unsupported_mode_returns_empty() {
834        // Asking for Diff mode without a diffs_path yields an empty result
835        // rather than panicking. Same for Package mode.
836        let dir = tempfile::tempdir().unwrap();
837        let blobs = dir.path().join("blobs");
838        tokio::fs::create_dir_all(&blobs).await.unwrap();
839        let sources = PatchSources::blobs_only(&blobs);
840
841        let manifest = make_manifest_with_uuids(&["11111111-1111-4111-8111-111111111111"]);
842        let (client, _) = crate::api::client::get_api_client_from_env(None).await;
843
844        let res =
845            fetch_missing_sources(&manifest, &sources, DownloadMode::Diff, &client, None).await;
846        assert_eq!(res.total, 0);
847        assert_eq!(res.downloaded, 0);
848        assert_eq!(res.failed, 0);
849
850        let res =
851            fetch_missing_sources(&manifest, &sources, DownloadMode::Package, &client, None).await;
852        assert_eq!(res.total, 0);
853    }
854
855    // ── Regression: skipped accounting in format ─────────────────────
856
857    #[test]
858    fn test_format_all_skipped_is_not_blank() {
859        // Regression: `fetch_blobs_by_hash` can return total>0 with every
860        // blob already on disk (downloaded=0, failed=0, skipped=N). The
861        // formatter must surface that rather than returning a blank line.
862        let result = FetchMissingBlobsResult {
863            total: 2,
864            downloaded: 0,
865            failed: 0,
866            skipped: 2,
867            results: vec![
868                BlobFetchResult {
869                    hash: "a".repeat(64),
870                    success: true,
871                    error: None,
872                },
873                BlobFetchResult {
874                    hash: "b".repeat(64),
875                    success: true,
876                    error: None,
877                },
878            ],
879        };
880        let output = format_fetch_result(&result);
881        assert!(!output.trim().is_empty(), "must not be blank: {:?}", output);
882        assert!(output.contains("2 blob(s) already present"));
883        assert!(!output.contains("Downloaded"));
884        assert!(!output.contains("Failed"));
885    }
886
887    #[test]
888    fn test_format_downloaded_and_skipped_mix() {
889        let result = FetchMissingBlobsResult {
890            total: 3,
891            downloaded: 1,
892            failed: 0,
893            skipped: 2,
894            results: vec![
895                BlobFetchResult {
896                    hash: "a".repeat(64),
897                    success: true,
898                    error: None,
899                },
900                BlobFetchResult {
901                    hash: "b".repeat(64),
902                    success: true,
903                    error: None,
904                },
905                BlobFetchResult {
906                    hash: "c".repeat(64),
907                    success: true,
908                    error: None,
909                },
910            ],
911        };
912        let output = format_fetch_result(&result);
913        assert!(output.contains("Downloaded 1 blob(s)"));
914        assert!(output.contains("2 blob(s) already present"));
915    }
916
917    // ── Regression: hash comparison is case-insensitive ──────────────
918
919    #[test]
920    fn test_blob_hash_matches_is_case_insensitive() {
921        // Hex is case-insensitive. `compute_git_sha256_from_bytes` emits
922        // lowercase, but `is_valid_sha256_hex` accepts uppercase, so the
923        // verification must treat the two as equal (otherwise valid
924        // uppercase-hash content is wrongly rejected as a mismatch).
925        let lower = "abc123".to_string() + &"0".repeat(58);
926        let upper = lower.to_ascii_uppercase();
927        assert!(blob_hash_matches(&upper, &lower));
928        assert!(blob_hash_matches(&lower, &upper));
929        assert!(blob_hash_matches(&lower, &lower));
930    }
931
932    #[test]
933    fn test_blob_hash_matches_rejects_genuine_mismatch() {
934        let a = "a".repeat(64);
935        let b = "b".repeat(64);
936        assert!(!blob_hash_matches(&a, &b));
937        // Differing length is still a mismatch.
938        assert!(!blob_hash_matches(&a, "aa"));
939    }
940
941    #[test]
942    fn test_format_only_failed() {
943        let result = FetchMissingBlobsResult {
944            total: 2,
945            downloaded: 0,
946            failed: 2,
947            skipped: 0,
948            results: vec![
949                BlobFetchResult {
950                    hash: "a".repeat(64),
951                    success: false,
952                    error: Some("timeout".into()),
953                },
954                BlobFetchResult {
955                    hash: "b".repeat(64),
956                    success: false,
957                    error: Some("timeout".into()),
958                },
959            ],
960        };
961        let output = format_fetch_result(&result);
962        assert!(!output.contains("Downloaded"));
963        assert!(output.contains("Failed to download 2 blob(s)"));
964    }
965}