Skip to main content

socket_patch_cli/commands/
remove.rs

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
19/// Emit a `remove` error envelope and return. Used by the many error
20/// paths in `run` so they all share the same JSON shape.
21fn 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    /// Package PURL or patch UUID.
34    pub identifier: String,
35
36    #[command(flatten)]
37    pub common: GlobalArgs,
38
39    /// Skip rolling back files before removing (only update manifest).
40    #[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    // Serialize against concurrent socket-patch runs targeting the
63    // same `.socket/` directory. Note: `rollback_patches` (which
64    // `remove` calls into) does NOT acquire the lock — that would
65    // self-deadlock — so the outer remove invocation holds it for
66    // both the rollback and the manifest mutation.
67    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, // remove has no --silent on its own; use false
73        false, // remove has no --dry-run
74        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    // Read manifest to show what will be removed and confirm
84    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    // Find matching patches to show what will be removed. A base PURL
97    // (no `?`) matches every release variant of that package@version; a
98    // qualified PURL or a UUID targets a single patch.
99    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    // Show what will be removed and confirm. When a base PURL expanded
132    // to multiple manifest entries (PyPI release variants), make the
133    // blast radius explicit so the user understands why a single
134    // `remove pkg:pypi/foo@1.0` is removing several variants.
135    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            // Short-UUID for display only. Slice on a char boundary and
151            // tolerate UUIDs shorter than 8 chars — a malformed manifest
152            // must not panic the whole command in the display path.
153            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    // First, rollback the patch if not skipped
171    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, // silent when JSON
182            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    // Now remove from manifest
246    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            // Clean up unused blobs
274            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                // One Removed event per purl whose manifest entry was deleted.
290                for purl in &removed {
291                    env.record(PatchEvent::new(PatchAction::Removed, purl.clone()));
292                }
293                // One artifact-level Removed event carrying the
294                // blob-sweep and rollback counts. Emitted whenever either
295                // is non-zero so the `rolledBack` count is still reported
296                // even when no blobs happened to be swept (e.g. the removed
297                // patch's afterHash blobs are still referenced elsewhere).
298                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        // Base PURL removes every release variant; qualified PURL removes one.
333        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    /// Write a manifest with three PyPI release variants of one
381    /// package@version plus an unrelated npm package, returning the
382    /// temp dir (kept alive) and the manifest path.
383    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        // All three release variants removed; the npm package untouched.
416        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        // Only the sdist variant removed; the two wheels + npm remain.
436        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    /// A plain (qualifier-free) npm PURL removes exactly its own entry and
459    /// must not accidentally match same-prefix neighbours like
460    /// `foobar@1.0`. Guards the `strip_purl_qualifiers == identifier`
461    /// exact-equality path for non-PyPI keys.
462    #[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    /// An identifier that matches nothing removes nothing and — crucially
485    /// — must NOT rewrite the manifest file. We assert byte-identity of
486    /// the on-disk manifest before/after so a future change that always
487    /// re-serializes (churning mtime / formatting) is caught.
488    #[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    /// A base PURL must not bleed across versions: removing `six@1.16.0`
510    /// leaves `six@1.17.0` (and its variants) in place.
511    #[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}