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};
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            track_patch_repaired(
95                counts.downloaded,
96                counts.cleaned,
97                0,
98                args.common.api_token.as_deref(),
99                args.common.org.as_deref(),
100            )
101            .await;
102            if args.common.json {
103                println!("{}", env.to_pretty_json());
104            }
105            0
106        }
107        Err(e) => {
108            track_patch_repair_failed(
109                &e,
110                args.common.api_token.as_deref(),
111                args.common.org.as_deref(),
112            )
113            .await;
114            if args.common.json {
115                let mut env = Envelope::new(Command::Repair);
116                env.dry_run = args.common.dry_run;
117                env.mark_error(EnvelopeError::new("repair_failed", e));
118                println!("{}", env.to_pretty_json());
119            } else {
120                eprintln!("Error: {e}");
121            }
122            1
123        }
124    }
125}
126
127/// Aggregate counts surfaced by `repair_inner` for telemetry use.
128struct RepairCounts {
129    downloaded: usize,
130    cleaned: usize,
131}
132
133async fn repair_inner(
134    args: &RepairArgs,
135    manifest_path: &Path,
136) -> Result<(Envelope, RepairCounts), String> {
137    let manifest = read_manifest(manifest_path)
138        .await
139        .map_err(|e| e.to_string())?
140        .ok_or_else(|| "Invalid manifest".to_string())?;
141
142    let socket_dir = manifest_path.parent().unwrap();
143    let blobs_path = socket_dir.join("blobs");
144    let diffs_path = socket_dir.join("diffs");
145    let packages_path = socket_dir.join("packages");
146
147    let download_mode = DownloadMode::parse(&args.common.download_mode).map_err(|e| e.to_string())?;
148
149    let mut downloaded_count = 0usize;
150    let mut download_failed_count = 0usize;
151    let mut blobs_cleaned = 0usize;
152    let mut blobs_checked = 0usize;
153
154    // Step 1: Check for and download missing artifacts in the requested
155    // mode. Counts below refer to whatever kind of artifact was requested
156    // (file blobs, diff archives, or package archives).
157    let missing_artifacts: Vec<String> = match download_mode {
158        DownloadMode::File => get_missing_blobs(&manifest, &blobs_path)
159            .await
160            .into_iter()
161            .collect(),
162        DownloadMode::Diff => get_missing_archives(&manifest, &diffs_path)
163            .await
164            .into_iter()
165            .collect(),
166        DownloadMode::Package => get_missing_archives(&manifest, &packages_path)
167            .await
168            .into_iter()
169            .collect(),
170    };
171    let missing_count = missing_artifacts.len();
172
173    if !args.common.offline {
174        if !missing_artifacts.is_empty() {
175            if !args.common.json {
176                println!(
177                    "Found {} missing {} artifact(s)",
178                    missing_artifacts.len(),
179                    download_mode.as_tag()
180                );
181            }
182
183            if args.common.dry_run {
184                if !args.common.json {
185                    println!("\nDry run - would download:");
186                    for id in missing_artifacts.iter().take(10) {
187                        println!("  - {}...", &id[..12.min(id.len())]);
188                    }
189                    if missing_artifacts.len() > 10 {
190                        println!("  ... and {} more", missing_artifacts.len() - 10);
191                    }
192                }
193            } else {
194                if !args.common.json {
195                    println!("\nDownloading missing {}s...", download_mode.as_tag());
196                }
197                let (client, _) =
198                    get_api_client_with_overrides(args.common.api_client_overrides()).await;
199                let sources = PatchSources {
200                    blobs_path: &blobs_path,
201                    packages_path: Some(&packages_path),
202                    diffs_path: Some(&diffs_path),
203                };
204                let fetch_result =
205                    fetch_missing_sources(&manifest, &sources, download_mode, &client, None).await;
206                downloaded_count = fetch_result.downloaded;
207                download_failed_count = fetch_result.failed;
208                if !args.common.json {
209                    println!("{}", format_fetch_result(&fetch_result));
210                }
211            }
212        } else if !args.common.json {
213            println!(
214                "All {} artifacts are present locally.",
215                download_mode.as_tag()
216            );
217        }
218    } else if !missing_artifacts.is_empty() {
219        if !args.common.json {
220            println!(
221                "Warning: {} {} artifact(s) are missing (offline mode - not downloading)",
222                missing_artifacts.len(),
223                download_mode.as_tag()
224            );
225            for id in missing_artifacts.iter().take(5) {
226                println!("  - {}...", &id[..12.min(id.len())]);
227            }
228            if missing_artifacts.len() > 5 {
229                println!("  ... and {} more", missing_artifacts.len() - 5);
230            }
231        }
232    } else if !args.common.json {
233        println!(
234            "All {} artifacts are present locally.",
235            download_mode.as_tag()
236        );
237    }
238
239    // Step 2: Clean up unused artifacts across all three directories.
240    if !args.download_only {
241        if !args.common.json {
242            println!();
243        }
244        match cleanup_unused_blobs(&manifest, &blobs_path, args.common.dry_run).await {
245            Ok(cleanup_result) => {
246                blobs_checked += cleanup_result.blobs_checked;
247                blobs_cleaned += cleanup_result.blobs_removed;
248                if !args.common.json {
249                    if cleanup_result.blobs_checked == 0 {
250                        println!("No blobs directory found, nothing to clean up.");
251                    } else if cleanup_result.blobs_removed == 0 {
252                        println!(
253                            "Checked {} blob(s), all are in use.",
254                            cleanup_result.blobs_checked
255                        );
256                    } else {
257                        println!("{}", format_cleanup_result(&cleanup_result, args.common.dry_run));
258                    }
259                }
260            }
261            Err(e) => {
262                if !args.common.json {
263                    eprintln!("Warning: blob cleanup failed: {e}");
264                }
265            }
266        }
267
268        // Diff archives.
269        match cleanup_unused_archives(&manifest, &diffs_path, args.common.dry_run).await {
270            Ok(cleanup_result) => {
271                blobs_checked += cleanup_result.blobs_checked;
272                blobs_cleaned += cleanup_result.blobs_removed;
273                if !args.common.json && cleanup_result.blobs_removed > 0 {
274                    println!(
275                        "{}",
276                        format_cleanup_result(&cleanup_result, args.common.dry_run)
277                            .replace("blob(s)", "diff archive(s)")
278                    );
279                }
280            }
281            Err(e) => {
282                if !args.common.json {
283                    eprintln!("Warning: diff cleanup failed: {e}");
284                }
285            }
286        }
287
288        // Package archives.
289        match cleanup_unused_archives(&manifest, &packages_path, args.common.dry_run).await {
290            Ok(cleanup_result) => {
291                blobs_checked += cleanup_result.blobs_checked;
292                blobs_cleaned += cleanup_result.blobs_removed;
293                if !args.common.json && cleanup_result.blobs_removed > 0 {
294                    println!(
295                        "{}",
296                        format_cleanup_result(&cleanup_result, args.common.dry_run)
297                            .replace("blob(s)", "package archive(s)")
298                    );
299                }
300            }
301            Err(e) => {
302                if !args.common.json {
303                    eprintln!("Warning: package cleanup failed: {e}");
304                }
305            }
306        }
307    }
308
309    if !args.common.dry_run && !args.common.json {
310        println!("\nRepair complete.");
311    }
312
313    // Translate the aggregate counts into envelope events. `repair`
314    // operates on artifacts (not specific patches), so events use the
315    // `PatchEvent::artifact` form (no PURL/UUID).
316    let mut env = Envelope::new(Command::Repair);
317    env.dry_run = args.common.dry_run;
318    let action_for_repair = if args.common.dry_run {
319        PatchAction::Verified
320    } else {
321        PatchAction::Downloaded
322    };
323    if downloaded_count > 0 || (args.common.dry_run && missing_count > 0) {
324        let count = if args.common.dry_run {
325            missing_count
326        } else {
327            downloaded_count
328        };
329        env.record(
330            PatchEvent::artifact(action_for_repair).with_details(serde_json::json!({
331                "count": count,
332                "mode": download_mode.as_tag(),
333            })),
334        );
335    }
336    if download_failed_count > 0 {
337        env.record(
338            PatchEvent::artifact(PatchAction::Failed).with_error(
339                "download_failed",
340                format!("{} artifact(s) failed to download", download_failed_count),
341            ),
342        );
343        env.mark_partial_failure();
344    }
345    if blobs_cleaned > 0 {
346        let cleanup_action = if args.common.dry_run {
347            PatchAction::Verified
348        } else {
349            PatchAction::Removed
350        };
351        env.record(PatchEvent::artifact(cleanup_action).with_details(serde_json::json!({
352            "count": blobs_cleaned,
353            "checked": blobs_checked,
354        })));
355    }
356    Ok((
357        env,
358        RepairCounts {
359            downloaded: downloaded_count,
360            cleaned: blobs_cleaned,
361        },
362    ))
363}