Skip to main content

socket_patch_cli/commands/
repair.rs

1use clap::Args;
2use socket_patch_core::api::blob_fetcher::{
3    fetch_missing_sources, format_fetch_result, get_missing_archives, get_missing_blobs,
4    DownloadMode,
5};
6use socket_patch_core::api::client::get_api_client_with_overrides;
7use socket_patch_core::manifest::operations::read_manifest;
8use socket_patch_core::patch::apply::PatchSources;
9use socket_patch_core::utils::cleanup_blobs::{
10    cleanup_unused_archives, cleanup_unused_blobs, format_cleanup_result,
11};
12use socket_patch_core::utils::telemetry::{track_patch_repair_failed, track_patch_repaired};
13use std::path::Path;
14use std::time::Duration;
15
16use crate::args::{apply_env_toggles, GlobalArgs};
17use crate::commands::lock_cli::{acquire_or_emit, lock_broken_event};
18use crate::json_envelope::{Command, Envelope, EnvelopeError, PatchAction, PatchEvent, Status};
19
20#[derive(Args)]
21pub struct RepairArgs {
22    #[command(flatten)]
23    pub common: GlobalArgs,
24
25    /// Only download missing artifacts; skip the cleanup phase.
26    /// Incompatible with `--offline`.
27    #[arg(long = "download-only", env = "SOCKET_DOWNLOAD_ONLY", default_value_t = false)]
28    pub download_only: bool,
29}
30
31pub async fn run(args: RepairArgs) -> i32 {
32    apply_env_toggles(&args.common);
33
34    // --offline implies strict airgap: no network calls. `--download-only`
35    // is the inverse (network-only). The two are now mutually exclusive.
36    if args.common.offline && args.download_only {
37        let msg =
38            "--offline and --download-only are mutually exclusive".to_string();
39        if args.common.json {
40            let mut env = Envelope::new(Command::Repair);
41            env.dry_run = args.common.dry_run;
42            env.mark_error(EnvelopeError::new("invalid_args", msg));
43            println!("{}", env.to_pretty_json());
44        } else {
45            eprintln!("Error: {msg}");
46        }
47        return 2;
48    }
49
50    let manifest_path = args.common.resolved_manifest_path();
51
52    if tokio::fs::metadata(&manifest_path).await.is_err() {
53        if args.common.json {
54            let mut env = Envelope::new(Command::Repair);
55            env.dry_run = args.common.dry_run;
56            env.mark_error(EnvelopeError::new(
57                "manifest_not_found",
58                format!("Manifest not found at {}", manifest_path.display()),
59            ));
60            println!("{}", env.to_pretty_json());
61        } else {
62            eprintln!("Manifest not found at {}", manifest_path.display());
63        }
64        return 1;
65    }
66
67    // Serialize against concurrent socket-patch runs targeting the
68    // same `.socket/` directory. See `apply_lock`.
69    let socket_dir = manifest_path.parent().unwrap_or(Path::new("."));
70    let acquired = match acquire_or_emit(
71        socket_dir,
72        Command::Repair,
73        args.common.json,
74        args.common.silent,
75        args.common.dry_run,
76        Duration::from_secs(args.common.lock_timeout.unwrap_or(0)),
77        args.common.break_lock,
78    ) {
79        Ok(acquired) => acquired,
80        Err(code) => return code,
81    };
82    let _lock = acquired.guard;
83    let lock_was_broken = acquired.broke_lock;
84
85    match repair_inner(&args, &manifest_path).await {
86        Ok((mut env, counts)) => {
87            if lock_was_broken {
88                // Audit trail for `--break-lock`. Event ordering is
89                // documented as best-effort; appending keeps the
90                // `Envelope::record` invariant intact (events + summary
91                // stay in sync).
92                env.record(lock_broken_event(socket_dir));
93            }
94            // A repair where some artifacts failed to download is marked a
95            // partial failure inside `repair_inner` (a `Failed` event plus
96            // `mark_partial_failure`). Mirror `apply`: surface that as a
97            // non-zero exit and the failure telemetry, so a CI guarding on
98            // the exit code doesn't treat a half-finished repair as success.
99            let had_failure = matches!(env.status, Status::PartialFailure | Status::Error);
100            if had_failure {
101                track_patch_repair_failed(
102                    "One or more artifacts failed to download",
103                    args.common.api_token.as_deref(),
104                    args.common.org.as_deref(),
105                )
106                .await;
107            } else {
108                track_patch_repaired(
109                    counts.downloaded,
110                    counts.cleaned,
111                    counts.bytes_freed,
112                    args.common.api_token.as_deref(),
113                    args.common.org.as_deref(),
114                )
115                .await;
116            }
117            if args.common.json {
118                println!("{}", env.to_pretty_json());
119            }
120            if had_failure {
121                1
122            } else {
123                0
124            }
125        }
126        Err(e) => {
127            track_patch_repair_failed(
128                &e,
129                args.common.api_token.as_deref(),
130                args.common.org.as_deref(),
131            )
132            .await;
133            if args.common.json {
134                let mut env = Envelope::new(Command::Repair);
135                env.dry_run = args.common.dry_run;
136                env.mark_error(EnvelopeError::new("repair_failed", e));
137                println!("{}", env.to_pretty_json());
138            } else {
139                eprintln!("Error: {e}");
140            }
141            1
142        }
143    }
144}
145
146/// Aggregate counts surfaced by `repair_inner` for telemetry use.
147pub(crate) struct RepairCounts {
148    downloaded: usize,
149    cleaned: usize,
150    bytes_freed: u64,
151}
152
153pub(crate) async fn repair_inner(
154    args: &RepairArgs,
155    manifest_path: &Path,
156) -> Result<(Envelope, RepairCounts), String> {
157    let manifest = read_manifest(manifest_path)
158        .await
159        .map_err(|e| e.to_string())?
160        .ok_or_else(|| "Invalid manifest".to_string())?;
161
162    let socket_dir = manifest_path.parent().unwrap();
163    let blobs_path = socket_dir.join("blobs");
164    let diffs_path = socket_dir.join("diffs");
165    let packages_path = socket_dir.join("packages");
166
167    let download_mode = DownloadMode::parse(&args.common.download_mode).map_err(|e| e.to_string())?;
168
169    let mut downloaded_count = 0usize;
170    let mut download_failed_count = 0usize;
171    let mut blobs_cleaned = 0usize;
172    let mut blobs_checked = 0usize;
173    let mut bytes_freed = 0u64;
174
175    // Step 1: Check for and download missing artifacts in the requested
176    // mode. Counts below refer to whatever kind of artifact was requested
177    // (file blobs, diff archives, or package archives).
178    let missing_artifacts: Vec<String> = match download_mode {
179        DownloadMode::File => get_missing_blobs(&manifest, &blobs_path)
180            .await
181            .into_iter()
182            .collect(),
183        DownloadMode::Diff => get_missing_archives(&manifest, &diffs_path)
184            .await
185            .into_iter()
186            .collect(),
187        DownloadMode::Package => get_missing_archives(&manifest, &packages_path)
188            .await
189            .into_iter()
190            .collect(),
191    };
192    let missing_count = missing_artifacts.len();
193
194    if !args.common.offline {
195        if !missing_artifacts.is_empty() {
196            if !args.common.json {
197                println!(
198                    "Found {} missing {} artifact(s)",
199                    missing_artifacts.len(),
200                    download_mode.as_tag()
201                );
202            }
203
204            if args.common.dry_run {
205                if !args.common.json {
206                    println!("\nDry run - would download:");
207                    for id in missing_artifacts.iter().take(10) {
208                        println!("  - {}...", &id[..12.min(id.len())]);
209                    }
210                    if missing_artifacts.len() > 10 {
211                        println!("  ... and {} more", missing_artifacts.len() - 10);
212                    }
213                }
214            } else {
215                if !args.common.json {
216                    println!("\nDownloading missing {}s...", download_mode.as_tag());
217                }
218                let (client, _) =
219                    get_api_client_with_overrides(args.common.api_client_overrides()).await;
220                let sources = PatchSources {
221                    blobs_path: &blobs_path,
222                    packages_path: Some(&packages_path),
223                    diffs_path: Some(&diffs_path),
224                };
225                let fetch_result =
226                    fetch_missing_sources(&manifest, &sources, download_mode, &client, None).await;
227                downloaded_count = fetch_result.downloaded;
228                download_failed_count = fetch_result.failed;
229                if !args.common.json {
230                    println!("{}", format_fetch_result(&fetch_result));
231                }
232            }
233        } else if !args.common.json {
234            println!(
235                "All {} artifacts are present locally.",
236                download_mode.as_tag()
237            );
238        }
239    } else if !missing_artifacts.is_empty() {
240        if !args.common.json {
241            println!(
242                "Warning: {} {} artifact(s) are missing (offline mode - not downloading)",
243                missing_artifacts.len(),
244                download_mode.as_tag()
245            );
246            for id in missing_artifacts.iter().take(5) {
247                println!("  - {}...", &id[..12.min(id.len())]);
248            }
249            if missing_artifacts.len() > 5 {
250                println!("  ... and {} more", missing_artifacts.len() - 5);
251            }
252        }
253    } else if !args.common.json {
254        println!(
255            "All {} artifacts are present locally.",
256            download_mode.as_tag()
257        );
258    }
259
260    // Step 2: Clean up unused artifacts across all three directories.
261    if !args.download_only {
262        if !args.common.json {
263            println!();
264        }
265        match cleanup_unused_blobs(&manifest, &blobs_path, args.common.dry_run).await {
266            Ok(cleanup_result) => {
267                blobs_checked += cleanup_result.blobs_checked;
268                blobs_cleaned += cleanup_result.blobs_removed;
269                bytes_freed += cleanup_result.bytes_freed;
270                if !args.common.json {
271                    if cleanup_result.blobs_checked == 0 {
272                        println!("No blobs directory found, nothing to clean up.");
273                    } else if cleanup_result.blobs_removed == 0 {
274                        println!(
275                            "Checked {} blob(s), all are in use.",
276                            cleanup_result.blobs_checked
277                        );
278                    } else {
279                        println!("{}", format_cleanup_result(&cleanup_result, args.common.dry_run));
280                    }
281                }
282            }
283            Err(e) => {
284                if !args.common.json {
285                    eprintln!("Warning: blob cleanup failed: {e}");
286                }
287            }
288        }
289
290        // Diff archives.
291        match cleanup_unused_archives(&manifest, &diffs_path, args.common.dry_run).await {
292            Ok(cleanup_result) => {
293                blobs_checked += cleanup_result.blobs_checked;
294                blobs_cleaned += cleanup_result.blobs_removed;
295                bytes_freed += cleanup_result.bytes_freed;
296                if !args.common.json && cleanup_result.blobs_removed > 0 {
297                    println!(
298                        "{}",
299                        format_cleanup_result(&cleanup_result, args.common.dry_run)
300                            .replace("blob(s)", "diff archive(s)")
301                    );
302                }
303            }
304            Err(e) => {
305                if !args.common.json {
306                    eprintln!("Warning: diff cleanup failed: {e}");
307                }
308            }
309        }
310
311        // Package archives.
312        match cleanup_unused_archives(&manifest, &packages_path, args.common.dry_run).await {
313            Ok(cleanup_result) => {
314                blobs_checked += cleanup_result.blobs_checked;
315                blobs_cleaned += cleanup_result.blobs_removed;
316                bytes_freed += cleanup_result.bytes_freed;
317                if !args.common.json && cleanup_result.blobs_removed > 0 {
318                    println!(
319                        "{}",
320                        format_cleanup_result(&cleanup_result, args.common.dry_run)
321                            .replace("blob(s)", "package archive(s)")
322                    );
323                }
324            }
325            Err(e) => {
326                if !args.common.json {
327                    eprintln!("Warning: package cleanup failed: {e}");
328                }
329            }
330        }
331    }
332
333    if !args.common.dry_run && !args.common.json {
334        println!("\nRepair complete.");
335    }
336
337    // Translate the aggregate counts into envelope events. `repair`
338    // operates on artifacts (not specific patches), so events use the
339    // `PatchEvent::artifact` form (no PURL/UUID).
340    let mut env = Envelope::new(Command::Repair);
341    env.dry_run = args.common.dry_run;
342    let action_for_repair = if args.common.dry_run {
343        PatchAction::Verified
344    } else {
345        PatchAction::Downloaded
346    };
347    // Only the online path downloads (or, in dry-run, *would* download).
348    // In offline mode nothing is fetched even when artifacts are missing,
349    // so don't record a download/would-download event there — that would
350    // contradict the human-readable path, which only prints a warning.
351    if downloaded_count > 0 || (!args.common.offline && args.common.dry_run && missing_count > 0) {
352        let count = if args.common.dry_run {
353            missing_count
354        } else {
355            downloaded_count
356        };
357        env.record(
358            PatchEvent::artifact(action_for_repair).with_details(serde_json::json!({
359                "count": count,
360                "mode": download_mode.as_tag(),
361            })),
362        );
363    }
364    if download_failed_count > 0 {
365        env.record(
366            PatchEvent::artifact(PatchAction::Failed).with_error(
367                "download_failed",
368                format!("{} artifact(s) failed to download", download_failed_count),
369            ),
370        );
371        env.mark_partial_failure();
372    }
373    if blobs_cleaned > 0 {
374        let cleanup_action = if args.common.dry_run {
375            PatchAction::Verified
376        } else {
377            PatchAction::Removed
378        };
379        env.record(PatchEvent::artifact(cleanup_action).with_details(serde_json::json!({
380            "count": blobs_cleaned,
381            "checked": blobs_checked,
382        })));
383    }
384    Ok((
385        env,
386        RepairCounts {
387            downloaded: downloaded_count,
388            cleaned: blobs_cleaned,
389            bytes_freed,
390        },
391    ))
392}
393
394#[cfg(test)]
395mod tests {
396    //! Unit tests for `repair_inner` — the offline cleanup / event-recording
397    //! core. These run without a network (all use `--offline`), exercising
398    //! the orphan-cleanup and envelope-building paths directly so the
399    //! contract is pinned independently of the binary harness.
400    use super::*;
401    use crate::args::GlobalArgs;
402    use std::path::PathBuf;
403
404    const MANIFEST_JSON: &str = r#"{
405      "patches": {
406        "pkg:npm/__repair_unit__@1.0.0": {
407          "uuid": "11111111-1111-4111-8111-111111111111",
408          "exportedAt": "2024-01-01T00:00:00Z",
409          "files": {
410            "package/index.js": {
411              "beforeHash": "0000000000000000000000000000000000000000000000000000000000000000",
412              "afterHash":  "1111111111111111111111111111111111111111111111111111111111111111"
413            }
414          },
415          "vulnerabilities": {},
416          "description": "unit test patch",
417          "license": "MIT",
418          "tier": "free"
419        }
420      }
421    }"#;
422
423    const REFERENCED_HASH: &str =
424        "1111111111111111111111111111111111111111111111111111111111111111";
425
426    /// Write a `.socket/manifest.json` under `root` and return the socket dir.
427    fn make_socket(root: &Path) -> PathBuf {
428        let socket = root.join(".socket");
429        std::fs::create_dir_all(&socket).unwrap();
430        std::fs::write(socket.join("manifest.json"), MANIFEST_JSON).unwrap();
431        socket
432    }
433
434    fn write_blob(socket: &Path, hash: &str, content: &[u8]) {
435        let blobs = socket.join("blobs");
436        std::fs::create_dir_all(&blobs).unwrap();
437        std::fs::write(blobs.join(hash), content).unwrap();
438    }
439
440    fn offline_args(cwd: &Path) -> RepairArgs {
441        RepairArgs {
442            common: GlobalArgs {
443                cwd: cwd.to_path_buf(),
444                manifest_path: ".socket/manifest.json".to_string(),
445                offline: true,
446                json: true,
447                download_mode: "file".to_string(),
448                ..GlobalArgs::default()
449            },
450            download_only: false,
451        }
452    }
453
454    /// True when `env` carries the download / would-download artifact event
455    /// (identified by its `details.mode` field, unique to that event).
456    fn has_download_event(env: &Envelope) -> bool {
457        env.events.iter().any(|e| {
458            e.details
459                .as_ref()
460                .and_then(|d| d.get("mode"))
461                .is_some()
462        })
463    }
464
465    /// Regression for the offline + dry-run leak: with `--offline` set, the
466    /// download phase is skipped entirely, so even in dry-run mode a missing
467    /// artifact must NOT produce a "would-download" (verified) event. Before
468    /// the fix the event was recorded unconditionally on `dry_run &&
469    /// missing > 0`, contradicting the human-readable path (which only warns).
470    #[tokio::test]
471    async fn offline_dry_run_does_not_record_download_event() {
472        let tmp = tempfile::tempdir().unwrap();
473        let socket = make_socket(tmp.path());
474        // No blob on disk → the manifest's afterHash is "missing".
475        let mut args = offline_args(tmp.path());
476        args.common.dry_run = true;
477
478        let (env, counts) = repair_inner(&args, &socket.join("manifest.json"))
479            .await
480            .expect("repair_inner");
481
482        assert!(
483            !has_download_event(&env),
484            "offline dry-run must not emit a download/would-download event; events={:?}",
485            env.events
486        );
487        assert_eq!(counts.downloaded, 0);
488        assert_eq!(env.status, Status::Success);
489    }
490
491    /// The online dry-run path *should* still preview the download — this
492    /// pins that the offline gate didn't over-correct. We can't hit the
493    /// network here, but `repair_inner`'s dry-run branch records the event
494    /// from the missing-artifact list without contacting the server.
495    #[tokio::test]
496    async fn online_dry_run_records_would_download_event() {
497        let tmp = tempfile::tempdir().unwrap();
498        let socket = make_socket(tmp.path());
499        let mut args = offline_args(tmp.path());
500        args.common.offline = false;
501        args.common.dry_run = true;
502
503        let (env, _counts) = repair_inner(&args, &socket.join("manifest.json"))
504            .await
505            .expect("repair_inner");
506
507        assert!(
508            has_download_event(&env),
509            "online dry-run must preview the download; events={:?}",
510            env.events
511        );
512    }
513
514    /// Regression for the dropped `bytes_freed`: cleanup of an orphan blob
515    /// must report the reclaimed byte count up through `RepairCounts` so the
516    /// telemetry `bytes_freed` field is non-zero (it was hardcoded to 0).
517    #[tokio::test]
518    async fn cleanup_reports_bytes_freed_and_removed_count() {
519        let tmp = tempfile::tempdir().unwrap();
520        let socket = make_socket(tmp.path());
521        write_blob(&socket, REFERENCED_HASH, b"kept");
522        let orphan_hash = "deadbeef".repeat(8); // 64 hex chars
523        let orphan_bytes = b"orphaned content bytes";
524        write_blob(&socket, &orphan_hash, orphan_bytes);
525
526        let args = offline_args(tmp.path());
527        let (env, counts) = repair_inner(&args, &socket.join("manifest.json"))
528            .await
529            .expect("repair_inner");
530
531        assert_eq!(counts.cleaned, 1, "one orphan should be cleaned");
532        assert_eq!(
533            counts.bytes_freed,
534            orphan_bytes.len() as u64,
535            "bytes_freed must reflect the reclaimed orphan size"
536        );
537        // The referenced blob survives; the orphan is gone.
538        assert!(socket.join("blobs").join(REFERENCED_HASH).exists());
539        assert!(!socket.join("blobs").join(&orphan_hash).exists());
540        // A Removed event is recorded for the swept orphan.
541        assert_eq!(env.summary.removed, 1);
542    }
543
544    /// `--download-only` skips the cleanup pass, so an orphan blob survives
545    /// and `bytes_freed` stays zero. (Run without `--offline`, which is
546    /// mutually exclusive; the manifest's blob is present so the online
547    /// download phase has nothing to fetch and never touches the network.)
548    #[tokio::test]
549    async fn download_only_skips_cleanup() {
550        let tmp = tempfile::tempdir().unwrap();
551        let socket = make_socket(tmp.path());
552        write_blob(&socket, REFERENCED_HASH, b"kept");
553        let orphan_hash = "feedface".repeat(8);
554        write_blob(&socket, &orphan_hash, b"orphan");
555
556        let mut args = offline_args(tmp.path());
557        args.common.offline = false;
558        args.download_only = true;
559
560        let (_env, counts) = repair_inner(&args, &socket.join("manifest.json"))
561            .await
562            .expect("repair_inner");
563
564        assert_eq!(counts.cleaned, 0, "download-only must skip cleanup");
565        assert_eq!(counts.bytes_freed, 0);
566        assert!(
567            socket.join("blobs").join(&orphan_hash).exists(),
568            "orphan must survive when cleanup is skipped"
569        );
570    }
571}