Skip to main content

socket_patch_cli/commands/
list.rs

1use clap::Args;
2use socket_patch_core::manifest::operations::read_manifest;
3use socket_patch_core::utils::telemetry::track_patch_listed;
4
5use crate::args::GlobalArgs;
6use crate::json_envelope::{
7    Command, Envelope, EnvelopeError, PatchAction, PatchEvent, PatchEventFile,
8};
9
10#[derive(Args)]
11pub struct ListArgs {
12    #[command(flatten)]
13    pub common: GlobalArgs,
14}
15
16/// Emit the top-level envelope for `list` in error states. Used for the
17/// "manifest not found" and "manifest unreadable" paths so they share
18/// the same JSON shape as a successful list.
19fn emit_error(args: &ListArgs, code: &str, message: String) {
20    if args.common.json {
21        let mut env = Envelope::new(Command::List);
22        env.mark_error(EnvelopeError::new(code, message));
23        println!("{}", env.to_pretty_json());
24    } else {
25        eprintln!("Error: {message}");
26    }
27}
28
29pub async fn run(args: ListArgs) -> i32 {
30    let manifest_path = args.common.resolved_manifest_path();
31
32    if tokio::fs::metadata(&manifest_path).await.is_err() {
33        emit_error(
34            &args,
35            "manifest_not_found",
36            format!("Manifest not found at {}", manifest_path.display()),
37        );
38        return 1;
39    }
40
41    match read_manifest(&manifest_path).await {
42        Ok(Some(manifest)) => {
43            let patch_entries: Vec<_> = manifest.patches.iter().collect();
44            let patches_count = patch_entries.len();
45            track_patch_listed(
46                patches_count,
47                args.common.api_token.as_deref(),
48                args.common.org.as_deref(),
49            )
50            .await;
51
52            if args.common.json {
53                let mut env = Envelope::new(Command::List);
54                for (purl, patch) in &patch_entries {
55                    // `list` emits one `Discovered` event per manifest
56                    // entry. The rich metadata (vulnerabilities, tier,
57                    // license, description, exportedAt) lives under
58                    // `details` per the per-command extension convention.
59                    let files = patch
60                        .files
61                        .keys()
62                        .map(|p| PatchEventFile {
63                            path: p.clone(),
64                            verified: false,
65                            applied_via: None,
66                        })
67                        .collect();
68                    let details = serde_json::json!({
69                        "exportedAt": patch.exported_at,
70                        "tier": patch.tier,
71                        "license": patch.license,
72                        "description": patch.description,
73                        "vulnerabilities": patch.vulnerabilities.iter().map(|(id, vuln)| {
74                            serde_json::json!({
75                                "id": id,
76                                "cves": vuln.cves,
77                                "summary": vuln.summary,
78                                "severity": vuln.severity,
79                                "description": vuln.description,
80                            })
81                        }).collect::<Vec<_>>(),
82                    });
83                    env.record(
84                        PatchEvent::new(PatchAction::Discovered, (*purl).clone())
85                            .with_uuid(patch.uuid.clone())
86                            .with_files(files)
87                            .with_details(details),
88                    );
89                }
90                println!("{}", env.to_pretty_json());
91            } else if patch_entries.is_empty() {
92                println!("No patches found in manifest.");
93            } else {
94                println!("Found {} patch(es):\n", patch_entries.len());
95                for (purl, patch) in &patch_entries {
96                    println!("Package: {purl}");
97                    println!("  UUID: {}", patch.uuid);
98                    println!("  Tier: {}", patch.tier);
99                    println!("  License: {}", patch.license);
100                    println!("  Exported: {}", patch.exported_at);
101
102                    if !patch.description.is_empty() {
103                        println!("  Description: {}", patch.description);
104                    }
105
106                    let vuln_entries: Vec<_> = patch.vulnerabilities.iter().collect();
107                    if !vuln_entries.is_empty() {
108                        println!("  Vulnerabilities ({}):", vuln_entries.len());
109                        for (id, vuln) in &vuln_entries {
110                            let cve_list = if vuln.cves.is_empty() {
111                                String::new()
112                            } else {
113                                format!(" ({})", vuln.cves.join(", "))
114                            };
115                            println!("    - {id}{cve_list}");
116                            println!("      Severity: {}", vuln.severity);
117                            println!("      Summary: {}", vuln.summary);
118                        }
119                    }
120
121                    let file_list: Vec<_> = patch.files.keys().collect();
122                    if !file_list.is_empty() {
123                        println!("  Files patched ({}):", file_list.len());
124                        for file_path in &file_list {
125                            println!("    - {file_path}");
126                        }
127                    }
128
129                    println!();
130                }
131            }
132
133            0
134        }
135        Ok(None) => {
136            emit_error(&args, "manifest_invalid", "Invalid manifest".to_string());
137            1
138        }
139        Err(e) => {
140            emit_error(&args, "manifest_unreadable", e.to_string());
141            1
142        }
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    //! Inline tests for `list` JSON output. Pin the new envelope shape
149    //! so downstream consumers (PR bots, dashboards) can rely on it.
150    use super::*;
151    use socket_patch_core::manifest::schema::{
152        PatchFileInfo, PatchManifest, PatchRecord, VulnerabilityInfo,
153    };
154    use std::collections::HashMap;
155
156    fn sample_manifest() -> PatchManifest {
157        let mut files = HashMap::new();
158        files.insert(
159            "package/index.js".to_string(),
160            PatchFileInfo {
161                before_hash: "b".repeat(64),
162                after_hash: "a".repeat(64),
163            },
164        );
165
166        let mut vulns = HashMap::new();
167        vulns.insert(
168            "GHSA-xyz-1234".to_string(),
169            VulnerabilityInfo {
170                cves: vec!["CVE-2024-12345".to_string()],
171                summary: "Prototype Pollution".to_string(),
172                severity: "high".to_string(),
173                description: "Some description".to_string(),
174            },
175        );
176
177        let mut patches = HashMap::new();
178        patches.insert(
179            "pkg:npm/minimist@1.2.2".to_string(),
180            PatchRecord {
181                uuid: "11111111-1111-4111-8111-111111111111".to_string(),
182                exported_at: "2024-01-01T00:00:00Z".to_string(),
183                files,
184                vulnerabilities: vulns,
185                description: "Fixes prototype pollution".to_string(),
186                license: "MIT".to_string(),
187                tier: "free".to_string(),
188            },
189        );
190
191        PatchManifest { patches }
192    }
193
194    /// Build the envelope the same way `run` would for the given manifest.
195    /// Keeps the test free of binary-spawn overhead while still pinning
196    /// the exact event shape `list --json` produces.
197    fn build_envelope(manifest: &PatchManifest) -> Envelope {
198        let mut env = Envelope::new(Command::List);
199        for (purl, patch) in &manifest.patches {
200            let files = patch
201                .files
202                .keys()
203                .map(|p| PatchEventFile {
204                    path: p.clone(),
205                    verified: false,
206                    applied_via: None,
207                })
208                .collect();
209            let details = serde_json::json!({
210                "exportedAt": patch.exported_at,
211                "tier": patch.tier,
212                "license": patch.license,
213                "description": patch.description,
214                "vulnerabilities": patch.vulnerabilities.iter().map(|(id, vuln)| {
215                    serde_json::json!({
216                        "id": id,
217                        "cves": vuln.cves,
218                        "summary": vuln.summary,
219                        "severity": vuln.severity,
220                        "description": vuln.description,
221                    })
222                }).collect::<Vec<_>>(),
223            });
224            env.record(
225                PatchEvent::new(PatchAction::Discovered, purl.clone())
226                    .with_uuid(patch.uuid.clone())
227                    .with_files(files)
228                    .with_details(details),
229            );
230        }
231        env
232    }
233
234    #[test]
235    fn list_emits_discovered_event_per_patch() {
236        let env = build_envelope(&sample_manifest());
237        let v: serde_json::Value = serde_json::from_str(&env.to_pretty_json()).unwrap();
238        assert_eq!(v["command"], "list");
239        assert_eq!(v["status"], "success");
240        assert_eq!(v["summary"]["discovered"], 1);
241        let events = v["events"].as_array().unwrap();
242        assert_eq!(events.len(), 1);
243        assert_eq!(events[0]["action"], "discovered");
244        assert_eq!(events[0]["purl"], "pkg:npm/minimist@1.2.2");
245        assert_eq!(events[0]["uuid"], "11111111-1111-4111-8111-111111111111");
246    }
247
248    #[test]
249    fn list_event_carries_vulnerability_details() {
250        let env = build_envelope(&sample_manifest());
251        let v: serde_json::Value = serde_json::from_str(&env.to_pretty_json()).unwrap();
252        let event = &v["events"][0];
253        assert_eq!(event["details"]["tier"], "free");
254        assert_eq!(event["details"]["license"], "MIT");
255        let vulns = event["details"]["vulnerabilities"].as_array().unwrap();
256        assert_eq!(vulns.len(), 1);
257        assert_eq!(vulns[0]["id"], "GHSA-xyz-1234");
258        assert_eq!(vulns[0]["severity"], "high");
259        assert_eq!(vulns[0]["cves"][0], "CVE-2024-12345");
260    }
261
262    #[test]
263    fn empty_manifest_emits_empty_events() {
264        let env = build_envelope(&PatchManifest::new());
265        let v: serde_json::Value = serde_json::from_str(&env.to_pretty_json()).unwrap();
266        assert_eq!(v["status"], "success");
267        assert_eq!(v["events"].as_array().unwrap().len(), 0);
268        assert_eq!(v["summary"]["discovered"], 0);
269    }
270}