1use clap::Args;
2use socket_patch_core::api::client::get_api_client_with_overrides;
3use socket_patch_core::manifest::operations::{read_manifest, write_manifest};
4use socket_patch_core::manifest::schema::PatchManifest;
5use socket_patch_core::utils::cleanup_blobs::{cleanup_unused_blobs, format_cleanup_result};
6use socket_patch_core::utils::purl::purl_matches_identifier;
7use socket_patch_core::utils::telemetry::{track_patch_removed, track_patch_remove_failed};
8use std::path::Path;
9use std::time::Duration;
10
11use super::rollback::rollback_patches;
12use crate::args::{apply_env_toggles, GlobalArgs};
13use crate::commands::lock_cli::{acquire_or_emit, lock_broken_event};
14use crate::json_envelope::{
15 Command, Envelope, EnvelopeError, PatchAction, PatchEvent, Status,
16};
17use crate::output::confirm;
18
19fn emit_error_envelope(json: bool, code: &str, message: String) {
22 if json {
23 let mut env = Envelope::new(Command::Remove);
24 env.mark_error(EnvelopeError::new(code, message));
25 println!("{}", env.to_pretty_json());
26 } else {
27 eprintln!("Error: {message}");
28 }
29}
30
31#[derive(Args)]
32pub struct RemoveArgs {
33 pub identifier: String,
35
36 #[command(flatten)]
37 pub common: GlobalArgs,
38
39 #[arg(long = "skip-rollback", env = "SOCKET_SKIP_ROLLBACK", default_value_t = false)]
41 pub skip_rollback: bool,
42}
43
44pub async fn run(args: RemoveArgs) -> i32 {
45 apply_env_toggles(&args.common);
46 let (telemetry_client, _) =
47 get_api_client_with_overrides(args.common.api_client_overrides()).await;
48 let api_token = telemetry_client.api_token().cloned();
49 let org_slug = telemetry_client.org_slug().cloned();
50
51 let manifest_path = args.common.resolved_manifest_path();
52
53 if tokio::fs::metadata(&manifest_path).await.is_err() {
54 emit_error_envelope(
55 args.common.json,
56 "manifest_not_found",
57 format!("Manifest not found at {}", manifest_path.display()),
58 );
59 return 1;
60 }
61
62 let socket_dir = manifest_path.parent().unwrap_or(Path::new("."));
68 let acquired = match acquire_or_emit(
69 socket_dir,
70 Command::Remove,
71 args.common.json,
72 false, false, Duration::from_secs(args.common.lock_timeout.unwrap_or(0)),
75 args.common.break_lock,
76 ) {
77 Ok(acquired) => acquired,
78 Err(code) => return code,
79 };
80 let _lock = acquired.guard;
81 let lock_was_broken = acquired.broke_lock;
82
83 let manifest = match read_manifest(&manifest_path).await {
85 Ok(Some(m)) => m,
86 Ok(None) => {
87 emit_error_envelope(args.common.json, "manifest_invalid", "Invalid manifest".to_string());
88 return 1;
89 }
90 Err(e) => {
91 emit_error_envelope(args.common.json, "manifest_unreadable", e.to_string());
92 return 1;
93 }
94 };
95
96 let matching: Vec<(&String, &socket_patch_core::manifest::schema::PatchRecord)> =
100 if args.identifier.starts_with("pkg:") {
101 manifest
102 .patches
103 .iter()
104 .filter(|(purl, _)| purl_matches_identifier(purl, &args.identifier))
105 .collect()
106 } else {
107 manifest
108 .patches
109 .iter()
110 .filter(|(_, patch)| patch.uuid == args.identifier)
111 .collect()
112 };
113
114 if matching.is_empty() {
115 let msg = format!("No patch found matching identifier: {}", args.identifier);
116 track_patch_remove_failed(&msg, api_token.as_deref(), org_slug.as_deref()).await;
117 if args.common.json {
118 let mut env = Envelope::new(Command::Remove);
119 env.status = Status::NotFound;
120 env.error = Some(EnvelopeError::new("not_found", msg));
121 println!("{}", env.to_pretty_json());
122 } else {
123 eprintln!(
124 "No patch found matching identifier: {}",
125 args.identifier
126 );
127 }
128 return 1;
129 }
130
131 if !args.common.json {
136 if args.identifier.starts_with("pkg:")
137 && !args.identifier.contains('?')
138 && matching.len() > 1
139 {
140 eprintln!(
141 "{} matches {} release variant(s) — all will be removed:",
142 args.identifier,
143 matching.len()
144 );
145 } else {
146 eprintln!("The following patch(es) will be removed:");
147 }
148 for (purl, patch) in &matching {
149 let file_count = patch.files.len();
150 let short_uuid = patch.uuid.get(..8).unwrap_or(patch.uuid.as_str());
154 eprintln!(" - {} (UUID: {}, {} file(s))", purl, short_uuid, file_count);
155 }
156 eprintln!();
157 }
158
159 let prompt = format!(
160 "Remove {} patch(es) and rollback files?",
161 matching.len()
162 );
163 if !confirm(&prompt, true, args.common.yes, args.common.json) {
164 if !args.common.json {
165 println!("Removal cancelled.");
166 }
167 return 0;
168 }
169
170 let mut rollback_count = 0;
172 if !args.skip_rollback {
173 if !args.common.json {
174 println!("Rolling back patch before removal...");
175 }
176 match rollback_patches(
177 &args.common.cwd,
178 &manifest_path,
179 Some(&args.identifier),
180 false,
181 args.common.json, args.common.offline,
183 args.common.global,
184 args.common.global_prefix.clone(),
185 None,
186 )
187 .await
188 {
189 Ok((success, results)) => {
190 if !success {
191 track_patch_remove_failed(
192 "Rollback failed during patch removal",
193 api_token.as_deref(),
194 org_slug.as_deref(),
195 )
196 .await;
197 emit_error_envelope(
198 args.common.json,
199 "rollback_failed",
200 "Rollback failed during patch removal. Use --skip-rollback to remove from manifest without restoring files.".to_string(),
201 );
202 return 1;
203 }
204
205 rollback_count = results
206 .iter()
207 .filter(|r| r.success && !r.files_rolled_back.is_empty())
208 .count();
209 let already_original = results
210 .iter()
211 .filter(|r| {
212 r.success
213 && r.files_verified.iter().all(|f| {
214 f.status
215 == socket_patch_core::patch::rollback::VerifyRollbackStatus::AlreadyOriginal
216 })
217 })
218 .count();
219
220 if !args.common.json {
221 if rollback_count > 0 {
222 println!("Rolled back {rollback_count} package(s)");
223 }
224 if already_original > 0 {
225 println!("{already_original} package(s) already in original state");
226 }
227 if results.is_empty() {
228 println!("No packages found to rollback (not installed)");
229 }
230 println!();
231 }
232 }
233 Err(e) => {
234 track_patch_remove_failed(&e, api_token.as_deref(), org_slug.as_deref()).await;
235 emit_error_envelope(
236 args.common.json,
237 "rollback_failed",
238 format!("Error during rollback: {e}. Use --skip-rollback to remove from manifest without restoring files."),
239 );
240 return 1;
241 }
242 }
243 }
244
245 match remove_patch_from_manifest(&args.identifier, &manifest_path).await {
247 Ok((removed, manifest)) => {
248 if removed.is_empty() {
249 let msg = format!("No patch found matching identifier: {}", args.identifier);
250 track_patch_remove_failed(&msg, api_token.as_deref(), org_slug.as_deref()).await;
251 if args.common.json {
252 let mut env = Envelope::new(Command::Remove);
253 env.status = Status::NotFound;
254 env.error = Some(EnvelopeError::new("not_found", msg));
255 println!("{}", env.to_pretty_json());
256 } else {
257 eprintln!(
258 "No patch found matching identifier: {}",
259 args.identifier
260 );
261 }
262 return 1;
263 }
264
265 if !args.common.json {
266 println!("Removed {} patch(es) from manifest:", removed.len());
267 for purl in &removed {
268 println!(" - {purl}");
269 }
270 println!("\nManifest updated at {}", manifest_path.display());
271 }
272
273 let socket_dir = manifest_path.parent().unwrap();
275 let blobs_path = socket_dir.join("blobs");
276 let mut blobs_removed = 0;
277 if let Ok(cleanup_result) = cleanup_unused_blobs(&manifest, &blobs_path, false).await {
278 blobs_removed = cleanup_result.blobs_removed;
279 if !args.common.json && cleanup_result.blobs_removed > 0 {
280 println!("\n{}", format_cleanup_result(&cleanup_result, false));
281 }
282 }
283
284 if args.common.json {
285 let mut env = Envelope::new(Command::Remove);
286 if lock_was_broken {
287 env.record(lock_broken_event(socket_dir));
288 }
289 for purl in &removed {
291 env.record(PatchEvent::new(PatchAction::Removed, purl.clone()));
292 }
293 if blobs_removed > 0 || rollback_count > 0 {
299 env.record(
300 PatchEvent::artifact(PatchAction::Removed).with_details(serde_json::json!({
301 "blobsRemoved": blobs_removed,
302 "rolledBack": rollback_count,
303 })),
304 );
305 }
306 println!("{}", env.to_pretty_json());
307 }
308
309 track_patch_removed(removed.len(), api_token.as_deref(), org_slug.as_deref()).await;
310 0
311 }
312 Err(e) => {
313 track_patch_remove_failed(&e, api_token.as_deref(), org_slug.as_deref()).await;
314 emit_error_envelope(args.common.json, "remove_failed", e);
315 1
316 }
317 }
318}
319
320async fn remove_patch_from_manifest(
321 identifier: &str,
322 manifest_path: &Path,
323) -> Result<(Vec<String>, PatchManifest), String> {
324 let mut manifest = read_manifest(manifest_path)
325 .await
326 .map_err(|e| e.to_string())?
327 .ok_or_else(|| "Invalid manifest".to_string())?;
328
329 let mut removed = Vec::new();
330
331 let purls_to_remove: Vec<String> = if identifier.starts_with("pkg:") {
332 manifest
334 .patches
335 .keys()
336 .filter(|purl| purl_matches_identifier(purl, identifier))
337 .cloned()
338 .collect()
339 } else {
340 manifest
341 .patches
342 .iter()
343 .filter(|(_, patch)| patch.uuid == identifier)
344 .map(|(purl, _)| purl.clone())
345 .collect()
346 };
347
348 for purl in purls_to_remove {
349 manifest.patches.remove(&purl);
350 removed.push(purl);
351 }
352
353 if !removed.is_empty() {
354 write_manifest(manifest_path, &manifest)
355 .await
356 .map_err(|e| e.to_string())?;
357 }
358
359 Ok((removed, manifest))
360}
361
362#[cfg(test)]
363mod tests {
364 use super::*;
365 use socket_patch_core::manifest::schema::PatchRecord;
366 use std::collections::HashMap;
367
368 fn make_record(uuid: &str) -> PatchRecord {
369 PatchRecord {
370 uuid: uuid.to_string(),
371 exported_at: "2024-01-01T00:00:00Z".to_string(),
372 files: HashMap::new(),
373 vulnerabilities: HashMap::new(),
374 description: "test".to_string(),
375 license: "MIT".to_string(),
376 tier: "free".to_string(),
377 }
378 }
379
380 async fn write_multi_variant(dir: &Path) {
384 let mut patches = HashMap::new();
385 patches.insert(
386 "pkg:pypi/six@1.16.0?artifact_id=wheel-cp311".to_string(),
387 make_record("uuid-cp311"),
388 );
389 patches.insert(
390 "pkg:pypi/six@1.16.0?artifact_id=sdist".to_string(),
391 make_record("uuid-sdist"),
392 );
393 patches.insert(
394 "pkg:pypi/six@1.16.0?artifact_id=wheel-cp312".to_string(),
395 make_record("uuid-cp312"),
396 );
397 patches.insert("pkg:npm/foo@1.0".to_string(), make_record("uuid-foo"));
398 let manifest = PatchManifest { patches };
399 write_manifest(&dir.join("manifest.json"), &manifest)
400 .await
401 .expect("write manifest");
402 }
403
404 #[tokio::test]
405 async fn remove_base_purl_removes_all_variants() {
406 let tmp = tempfile::tempdir().expect("tempdir");
407 write_multi_variant(tmp.path()).await;
408 let manifest_path = tmp.path().join("manifest.json");
409
410 let (removed, manifest) =
411 remove_patch_from_manifest("pkg:pypi/six@1.16.0", &manifest_path)
412 .await
413 .expect("remove ok");
414
415 assert_eq!(removed.len(), 3);
417 assert!(removed.iter().all(|p| p.contains("six@1.16.0")));
418 assert_eq!(manifest.patches.len(), 1);
419 assert!(manifest.patches.contains_key("pkg:npm/foo@1.0"));
420 }
421
422 #[tokio::test]
423 async fn remove_qualified_purl_removes_single_variant() {
424 let tmp = tempfile::tempdir().expect("tempdir");
425 write_multi_variant(tmp.path()).await;
426 let manifest_path = tmp.path().join("manifest.json");
427
428 let (removed, manifest) = remove_patch_from_manifest(
429 "pkg:pypi/six@1.16.0?artifact_id=sdist",
430 &manifest_path,
431 )
432 .await
433 .expect("remove ok");
434
435 assert_eq!(removed, vec!["pkg:pypi/six@1.16.0?artifact_id=sdist"]);
437 assert_eq!(manifest.patches.len(), 3);
438 assert!(!manifest
439 .patches
440 .contains_key("pkg:pypi/six@1.16.0?artifact_id=sdist"));
441 }
442
443 #[tokio::test]
444 async fn remove_by_uuid_removes_single_variant() {
445 let tmp = tempfile::tempdir().expect("tempdir");
446 write_multi_variant(tmp.path()).await;
447 let manifest_path = tmp.path().join("manifest.json");
448
449 let (removed, manifest) =
450 remove_patch_from_manifest("uuid-cp312", &manifest_path)
451 .await
452 .expect("remove ok");
453
454 assert_eq!(removed, vec!["pkg:pypi/six@1.16.0?artifact_id=wheel-cp312"]);
455 assert_eq!(manifest.patches.len(), 3);
456 }
457
458 #[tokio::test]
463 async fn remove_npm_purl_is_exact_and_does_not_prefix_match() {
464 let tmp = tempfile::tempdir().expect("tempdir");
465 let mut patches = HashMap::new();
466 patches.insert("pkg:npm/foo@1.0".to_string(), make_record("uuid-foo"));
467 patches.insert("pkg:npm/foobar@1.0".to_string(), make_record("uuid-foobar"));
468 let manifest = PatchManifest { patches };
469 let manifest_path = tmp.path().join("manifest.json");
470 write_manifest(&manifest_path, &manifest)
471 .await
472 .expect("write manifest");
473
474 let (removed, manifest) =
475 remove_patch_from_manifest("pkg:npm/foo@1.0", &manifest_path)
476 .await
477 .expect("remove ok");
478
479 assert_eq!(removed, vec!["pkg:npm/foo@1.0"]);
480 assert_eq!(manifest.patches.len(), 1);
481 assert!(manifest.patches.contains_key("pkg:npm/foobar@1.0"));
482 }
483
484 #[tokio::test]
489 async fn remove_no_match_leaves_manifest_file_untouched() {
490 let tmp = tempfile::tempdir().expect("tempdir");
491 write_multi_variant(tmp.path()).await;
492 let manifest_path = tmp.path().join("manifest.json");
493 let before_bytes = tokio::fs::read(&manifest_path).await.expect("read before");
494
495 let (removed, manifest) =
496 remove_patch_from_manifest("pkg:npm/not-here@9.9.9", &manifest_path)
497 .await
498 .expect("remove ok");
499
500 assert!(removed.is_empty(), "nothing should match");
501 assert_eq!(manifest.patches.len(), 4, "manifest left intact");
502 let after_bytes = tokio::fs::read(&manifest_path).await.expect("read after");
503 assert_eq!(
504 before_bytes, after_bytes,
505 "a no-op remove must not rewrite the manifest file"
506 );
507 }
508
509 #[tokio::test]
512 async fn remove_base_purl_does_not_touch_other_versions() {
513 let tmp = tempfile::tempdir().expect("tempdir");
514 let mut patches = HashMap::new();
515 patches.insert(
516 "pkg:pypi/six@1.16.0?artifact_id=sdist".to_string(),
517 make_record("uuid-16-sdist"),
518 );
519 patches.insert(
520 "pkg:pypi/six@1.17.0?artifact_id=sdist".to_string(),
521 make_record("uuid-17-sdist"),
522 );
523 let manifest = PatchManifest { patches };
524 let manifest_path = tmp.path().join("manifest.json");
525 write_manifest(&manifest_path, &manifest)
526 .await
527 .expect("write manifest");
528
529 let (removed, manifest) =
530 remove_patch_from_manifest("pkg:pypi/six@1.16.0", &manifest_path)
531 .await
532 .expect("remove ok");
533
534 assert_eq!(removed, vec!["pkg:pypi/six@1.16.0?artifact_id=sdist"]);
535 assert_eq!(manifest.patches.len(), 1);
536 assert!(manifest
537 .patches
538 .contains_key("pkg:pypi/six@1.17.0?artifact_id=sdist"));
539 }
540}