socket_patch_cli/commands/
list.rs1use 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
17fn 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
83fn 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 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 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 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 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 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 #[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 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 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}