socket_patch_cli/commands/
list.rs1use 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
16fn 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 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 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 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}