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::manifest::schema::PatchManifest;
4use socket_patch_core::utils::telemetry::track_patch_listed;
5
6use crate::args::GlobalArgs;
7use crate::json_envelope::{
8    Command, Envelope, EnvelopeError, PatchAction, PatchEvent, PatchEventFile,
9};
10
11#[derive(Args)]
12pub struct ListArgs {
13    #[command(flatten)]
14    pub common: GlobalArgs,
15}
16
17/// Build the `list --json` envelope: one `Discovered` event per manifest
18/// entry, with the rich metadata (vulnerabilities, tier, license,
19/// description, exportedAt) under `details` per the per-command extension
20/// convention.
21///
22/// Patches, vulnerabilities, and files are each emitted in a stable sorted
23/// order (by PURL / advisory ID / path). `HashMap` iteration is otherwise
24/// nondeterministic, so without this the event/vuln/file ordering would
25/// change run-to-run — breaking consumers that diff this output in CI logs.
26/// Mirrors the stable-ordering guarantee `get` already provides for its
27/// vulnerability lists.
28///
29/// Shared by `run` and the unit tests so the tests exercise the exact code
30/// path `list --json` uses, rather than a hand-copied duplicate.
31fn build_list_envelope(manifest: &PatchManifest) -> Envelope {
32    let mut env = Envelope::new(Command::List);
33
34    let mut patch_entries: Vec<_> = manifest.patches.iter().collect();
35    patch_entries.sort_by(|a, b| a.0.cmp(b.0));
36
37    for (purl, patch) in patch_entries {
38        let mut file_paths: Vec<_> = patch.files.keys().cloned().collect();
39        file_paths.sort();
40        let files = file_paths
41            .into_iter()
42            .map(|path| PatchEventFile {
43                path,
44                verified: false,
45                applied_via: None,
46            })
47            .collect();
48
49        let mut vuln_entries: Vec<_> = patch.vulnerabilities.iter().collect();
50        vuln_entries.sort_by(|a, b| a.0.cmp(b.0));
51        let vulnerabilities: Vec<_> = vuln_entries
52            .iter()
53            .map(|(id, vuln)| {
54                serde_json::json!({
55                    "id": id,
56                    "cves": vuln.cves,
57                    "summary": vuln.summary,
58                    "severity": vuln.severity,
59                    "description": vuln.description,
60                })
61            })
62            .collect();
63
64        let details = serde_json::json!({
65            "exportedAt": patch.exported_at,
66            "tier": patch.tier,
67            "license": patch.license,
68            "description": patch.description,
69            "vulnerabilities": vulnerabilities,
70        });
71
72        env.record(
73            PatchEvent::new(PatchAction::Discovered, purl.clone())
74                .with_uuid(patch.uuid.clone())
75                .with_files(files)
76                .with_details(details),
77        );
78    }
79
80    env
81}
82
83/// Emit the top-level envelope for `list` in error states. Used for the
84/// "manifest not found" and "manifest unreadable" paths so they share
85/// the same JSON shape as a successful list.
86fn emit_error(args: &ListArgs, code: &str, message: String) {
87    if args.common.json {
88        let mut env = Envelope::new(Command::List);
89        env.mark_error(EnvelopeError::new(code, message));
90        println!("{}", env.to_pretty_json());
91    } else {
92        eprintln!("Error: {message}");
93    }
94}
95
96pub async fn run(args: ListArgs) -> i32 {
97    let manifest_path = args.common.resolved_manifest_path();
98
99    if tokio::fs::metadata(&manifest_path).await.is_err() {
100        emit_error(
101            &args,
102            "manifest_not_found",
103            format!("Manifest not found at {}", manifest_path.display()),
104        );
105        return 1;
106    }
107
108    match read_manifest(&manifest_path).await {
109        Ok(Some(manifest)) => {
110            // Sort by PURL so both the JSON envelope and the human-readable
111            // table list packages in a stable order across runs.
112            let mut patch_entries: Vec<_> = manifest.patches.iter().collect();
113            patch_entries.sort_by(|a, b| a.0.cmp(b.0));
114            let patches_count = patch_entries.len();
115            track_patch_listed(
116                patches_count,
117                args.common.api_token.as_deref(),
118                args.common.org.as_deref(),
119            )
120            .await;
121
122            if args.common.json {
123                println!("{}", build_list_envelope(&manifest).to_pretty_json());
124            } else if patch_entries.is_empty() {
125                println!("No patches found in manifest.");
126            } else {
127                println!("Found {} patch(es):\n", patch_entries.len());
128                for (purl, patch) in &patch_entries {
129                    println!("Package: {purl}");
130                    println!("  UUID: {}", patch.uuid);
131                    println!("  Tier: {}", patch.tier);
132                    println!("  License: {}", patch.license);
133                    println!("  Exported: {}", patch.exported_at);
134
135                    if !patch.description.is_empty() {
136                        println!("  Description: {}", patch.description);
137                    }
138
139                    // Sort vulnerabilities by advisory ID for stable output.
140                    let mut vuln_entries: Vec<_> = patch.vulnerabilities.iter().collect();
141                    vuln_entries.sort_by(|a, b| a.0.cmp(b.0));
142                    if !vuln_entries.is_empty() {
143                        println!("  Vulnerabilities ({}):", vuln_entries.len());
144                        for (id, vuln) in &vuln_entries {
145                            let cve_list = if vuln.cves.is_empty() {
146                                String::new()
147                            } else {
148                                format!(" ({})", vuln.cves.join(", "))
149                            };
150                            println!("    - {id}{cve_list}");
151                            println!("      Severity: {}", vuln.severity);
152                            println!("      Summary: {}", vuln.summary);
153                        }
154                    }
155
156                    // Sort patched files by path for stable output.
157                    let mut file_list: Vec<_> = patch.files.keys().collect();
158                    file_list.sort();
159                    if !file_list.is_empty() {
160                        println!("  Files patched ({}):", file_list.len());
161                        for file_path in &file_list {
162                            println!("    - {file_path}");
163                        }
164                    }
165
166                    println!();
167                }
168            }
169
170            0
171        }
172        Ok(None) => {
173            emit_error(&args, "manifest_invalid", "Invalid manifest".to_string());
174            1
175        }
176        Err(e) => {
177            emit_error(&args, "manifest_unreadable", e.to_string());
178            1
179        }
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    //! Inline tests for `list` JSON output. Pin the new envelope shape
186    //! so downstream consumers (PR bots, dashboards) can rely on it.
187    use super::*;
188    use socket_patch_core::manifest::schema::{
189        PatchFileInfo, PatchRecord, VulnerabilityInfo,
190    };
191    use std::collections::HashMap;
192
193    fn sample_manifest() -> PatchManifest {
194        let mut files = HashMap::new();
195        files.insert(
196            "package/index.js".to_string(),
197            PatchFileInfo {
198                before_hash: "b".repeat(64),
199                after_hash: "a".repeat(64),
200            },
201        );
202
203        let mut vulns = HashMap::new();
204        vulns.insert(
205            "GHSA-xyz-1234".to_string(),
206            VulnerabilityInfo {
207                cves: vec!["CVE-2024-12345".to_string()],
208                summary: "Prototype Pollution".to_string(),
209                severity: "high".to_string(),
210                description: "Some description".to_string(),
211            },
212        );
213
214        let mut patches = HashMap::new();
215        patches.insert(
216            "pkg:npm/minimist@1.2.2".to_string(),
217            PatchRecord {
218                uuid: "11111111-1111-4111-8111-111111111111".to_string(),
219                exported_at: "2024-01-01T00:00:00Z".to_string(),
220                files,
221                vulnerabilities: vulns,
222                description: "Fixes prototype pollution".to_string(),
223                license: "MIT".to_string(),
224                tier: "free".to_string(),
225            },
226        );
227
228        PatchManifest { patches }
229    }
230
231    /// A manifest with several patches, each carrying multiple
232    /// vulnerabilities and files, all inserted in deliberately
233    /// non-alphabetical order. Used to pin the stable sort order the
234    /// envelope must impose regardless of HashMap iteration.
235    fn multi_entry_manifest() -> PatchManifest {
236        fn record(uuid: &str, vuln_ids: &[&str], file_paths: &[&str]) -> PatchRecord {
237            let mut files = HashMap::new();
238            for fp in file_paths {
239                files.insert(
240                    fp.to_string(),
241                    PatchFileInfo {
242                        before_hash: "b".repeat(64),
243                        after_hash: "a".repeat(64),
244                    },
245                );
246            }
247            let mut vulns = HashMap::new();
248            for id in vuln_ids {
249                vulns.insert(
250                    id.to_string(),
251                    VulnerabilityInfo {
252                        cves: vec![],
253                        summary: "s".to_string(),
254                        severity: "high".to_string(),
255                        description: "d".to_string(),
256                    },
257                );
258            }
259            PatchRecord {
260                uuid: uuid.to_string(),
261                exported_at: "2024-01-01T00:00:00Z".to_string(),
262                files,
263                vulnerabilities: vulns,
264                description: "desc".to_string(),
265                license: "MIT".to_string(),
266                tier: "free".to_string(),
267            }
268        }
269
270        let mut patches = HashMap::new();
271        patches.insert(
272            "pkg:npm/zeta@1.0.0".to_string(),
273            record("uuid-z", &["GHSA-zzzz-2222-3333", "GHSA-aaaa-2222-3333"], &["z/b.js", "z/a.js"]),
274        );
275        patches.insert(
276            "pkg:npm/alpha@1.0.0".to_string(),
277            record("uuid-a", &["GHSA-mmmm-2222-3333"], &["a/zz.js", "a/aa.js"]),
278        );
279        patches.insert(
280            "pkg:npm/mid@1.0.0".to_string(),
281            record("uuid-m", &["GHSA-cccc-2222-3333"], &["m/x.js"]),
282        );
283        PatchManifest { patches }
284    }
285
286    #[test]
287    fn list_emits_discovered_event_per_patch() {
288        let env = build_list_envelope(&sample_manifest());
289        let v: serde_json::Value = serde_json::from_str(&env.to_pretty_json()).unwrap();
290        assert_eq!(v["command"], "list");
291        assert_eq!(v["status"], "success");
292        assert_eq!(v["summary"]["discovered"], 1);
293        let events = v["events"].as_array().unwrap();
294        assert_eq!(events.len(), 1);
295        assert_eq!(events[0]["action"], "discovered");
296        assert_eq!(events[0]["purl"], "pkg:npm/minimist@1.2.2");
297        assert_eq!(events[0]["uuid"], "11111111-1111-4111-8111-111111111111");
298    }
299
300    #[test]
301    fn list_event_carries_vulnerability_details() {
302        let env = build_list_envelope(&sample_manifest());
303        let v: serde_json::Value = serde_json::from_str(&env.to_pretty_json()).unwrap();
304        let event = &v["events"][0];
305        assert_eq!(event["details"]["tier"], "free");
306        assert_eq!(event["details"]["license"], "MIT");
307        let vulns = event["details"]["vulnerabilities"].as_array().unwrap();
308        assert_eq!(vulns.len(), 1);
309        assert_eq!(vulns[0]["id"], "GHSA-xyz-1234");
310        assert_eq!(vulns[0]["severity"], "high");
311        assert_eq!(vulns[0]["cves"][0], "CVE-2024-12345");
312    }
313
314    #[test]
315    fn empty_manifest_emits_empty_events() {
316        let env = build_list_envelope(&PatchManifest::new());
317        let v: serde_json::Value = serde_json::from_str(&env.to_pretty_json()).unwrap();
318        assert_eq!(v["status"], "success");
319        assert_eq!(v["events"].as_array().unwrap().len(), 0);
320        assert_eq!(v["summary"]["discovered"], 0);
321    }
322
323    // -- Regression: stable ordering -------------------------------------
324    // `HashMap` iteration order is randomized per run, so without explicit
325    // sorting the events / vulnerabilities / files arrays would shuffle
326    // between invocations. These pin the sorted contract so consumers can
327    // diff `list --json` output in CI logs.
328
329    #[test]
330    fn events_are_sorted_by_purl() {
331        let env = build_list_envelope(&multi_entry_manifest());
332        let v: serde_json::Value = serde_json::from_str(&env.to_pretty_json()).unwrap();
333        let purls: Vec<&str> = v["events"]
334            .as_array()
335            .unwrap()
336            .iter()
337            .map(|e| e["purl"].as_str().unwrap())
338            .collect();
339        assert_eq!(
340            purls,
341            vec![
342                "pkg:npm/alpha@1.0.0",
343                "pkg:npm/mid@1.0.0",
344                "pkg:npm/zeta@1.0.0",
345            ]
346        );
347    }
348
349    #[test]
350    fn vulnerabilities_are_sorted_by_id() {
351        let env = build_list_envelope(&multi_entry_manifest());
352        let v: serde_json::Value = serde_json::from_str(&env.to_pretty_json()).unwrap();
353        // The zeta entry carries two advisories inserted out of order.
354        let zeta = v["events"]
355            .as_array()
356            .unwrap()
357            .iter()
358            .find(|e| e["purl"] == "pkg:npm/zeta@1.0.0")
359            .unwrap();
360        let ids: Vec<&str> = zeta["details"]["vulnerabilities"]
361            .as_array()
362            .unwrap()
363            .iter()
364            .map(|vuln| vuln["id"].as_str().unwrap())
365            .collect();
366        assert_eq!(ids, vec!["GHSA-aaaa-2222-3333", "GHSA-zzzz-2222-3333"]);
367    }
368
369    #[test]
370    fn files_are_sorted_by_path() {
371        let env = build_list_envelope(&multi_entry_manifest());
372        let v: serde_json::Value = serde_json::from_str(&env.to_pretty_json()).unwrap();
373        let zeta = v["events"]
374            .as_array()
375            .unwrap()
376            .iter()
377            .find(|e| e["purl"] == "pkg:npm/zeta@1.0.0")
378            .unwrap();
379        let paths: Vec<&str> = zeta["files"]
380            .as_array()
381            .unwrap()
382            .iter()
383            .map(|f| f["path"].as_str().unwrap())
384            .collect();
385        assert_eq!(paths, vec!["z/a.js", "z/b.js"]);
386    }
387
388    #[test]
389    fn ordering_is_deterministic_across_builds() {
390        // Two independent builds of the same manifest must be byte-identical.
391        let manifest = multi_entry_manifest();
392        let a = build_list_envelope(&manifest).to_pretty_json();
393        let b = build_list_envelope(&manifest).to_pretty_json();
394        assert_eq!(a, b);
395    }
396}