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 #[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 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 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 env.record(lock_broken_event(socket_dir));
93 }
94 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
146pub(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 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 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 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 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 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 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 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 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 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 #[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 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 #[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 #[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); 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 assert!(socket.join("blobs").join(REFERENCED_HASH).exists());
539 assert!(!socket.join("blobs").join(&orphan_hash).exists());
540 assert_eq!(env.summary.removed, 1);
542 }
543
544 #[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}