use clap::Args;
use socket_patch_core::manifest::operations::read_manifest;
use socket_patch_core::manifest::schema::PatchManifest;
use socket_patch_core::utils::telemetry::track_patch_listed;
use crate::args::GlobalArgs;
use crate::json_envelope::{
Command, Envelope, EnvelopeError, PatchAction, PatchEvent, PatchEventFile,
};
#[derive(Args)]
pub struct ListArgs {
#[command(flatten)]
pub common: GlobalArgs,
}
fn build_list_envelope(manifest: &PatchManifest) -> Envelope {
let mut env = Envelope::new(Command::List);
let mut patch_entries: Vec<_> = manifest.patches.iter().collect();
patch_entries.sort_by(|a, b| a.0.cmp(b.0));
for (purl, patch) in patch_entries {
let mut file_paths: Vec<_> = patch.files.keys().cloned().collect();
file_paths.sort();
let files = file_paths
.into_iter()
.map(|path| PatchEventFile {
path,
verified: false,
applied_via: None,
})
.collect();
let mut vuln_entries: Vec<_> = patch.vulnerabilities.iter().collect();
vuln_entries.sort_by(|a, b| a.0.cmp(b.0));
let vulnerabilities: Vec<_> = vuln_entries
.iter()
.map(|(id, vuln)| {
serde_json::json!({
"id": id,
"cves": vuln.cves,
"summary": vuln.summary,
"severity": vuln.severity,
"description": vuln.description,
})
})
.collect();
let details = serde_json::json!({
"exportedAt": patch.exported_at,
"tier": patch.tier,
"license": patch.license,
"description": patch.description,
"vulnerabilities": vulnerabilities,
});
env.record(
PatchEvent::new(PatchAction::Discovered, purl.clone())
.with_uuid(patch.uuid.clone())
.with_files(files)
.with_details(details),
);
}
env
}
fn emit_error(args: &ListArgs, code: &str, message: String) {
if args.common.json {
let mut env = Envelope::new(Command::List);
env.mark_error(EnvelopeError::new(code, message));
println!("{}", env.to_pretty_json());
} else {
eprintln!("Error: {message}");
}
}
pub async fn run(args: ListArgs) -> i32 {
let manifest_path = args.common.resolved_manifest_path();
if tokio::fs::metadata(&manifest_path).await.is_err() {
emit_error(
&args,
"manifest_not_found",
format!("Manifest not found at {}", manifest_path.display()),
);
return 1;
}
match read_manifest(&manifest_path).await {
Ok(Some(manifest)) => {
let mut patch_entries: Vec<_> = manifest.patches.iter().collect();
patch_entries.sort_by(|a, b| a.0.cmp(b.0));
let patches_count = patch_entries.len();
track_patch_listed(
patches_count,
args.common.api_token.as_deref(),
args.common.org.as_deref(),
)
.await;
if args.common.json {
println!("{}", build_list_envelope(&manifest).to_pretty_json());
} else if patch_entries.is_empty() {
println!("No patches found in manifest.");
} else {
println!("Found {} patch(es):\n", patch_entries.len());
for (purl, patch) in &patch_entries {
println!("Package: {purl}");
println!(" UUID: {}", patch.uuid);
println!(" Tier: {}", patch.tier);
println!(" License: {}", patch.license);
println!(" Exported: {}", patch.exported_at);
if !patch.description.is_empty() {
println!(" Description: {}", patch.description);
}
let mut vuln_entries: Vec<_> = patch.vulnerabilities.iter().collect();
vuln_entries.sort_by(|a, b| a.0.cmp(b.0));
if !vuln_entries.is_empty() {
println!(" Vulnerabilities ({}):", vuln_entries.len());
for (id, vuln) in &vuln_entries {
let cve_list = if vuln.cves.is_empty() {
String::new()
} else {
format!(" ({})", vuln.cves.join(", "))
};
println!(" - {id}{cve_list}");
println!(" Severity: {}", vuln.severity);
println!(" Summary: {}", vuln.summary);
}
}
let mut file_list: Vec<_> = patch.files.keys().collect();
file_list.sort();
if !file_list.is_empty() {
println!(" Files patched ({}):", file_list.len());
for file_path in &file_list {
println!(" - {file_path}");
}
}
println!();
}
}
0
}
Ok(None) => {
emit_error(&args, "manifest_invalid", "Invalid manifest".to_string());
1
}
Err(e) => {
emit_error(&args, "manifest_unreadable", e.to_string());
1
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use socket_patch_core::manifest::schema::{
PatchFileInfo, PatchRecord, VulnerabilityInfo,
};
use std::collections::HashMap;
fn sample_manifest() -> PatchManifest {
let mut files = HashMap::new();
files.insert(
"package/index.js".to_string(),
PatchFileInfo {
before_hash: "b".repeat(64),
after_hash: "a".repeat(64),
},
);
let mut vulns = HashMap::new();
vulns.insert(
"GHSA-xyz-1234".to_string(),
VulnerabilityInfo {
cves: vec!["CVE-2024-12345".to_string()],
summary: "Prototype Pollution".to_string(),
severity: "high".to_string(),
description: "Some description".to_string(),
},
);
let mut patches = HashMap::new();
patches.insert(
"pkg:npm/minimist@1.2.2".to_string(),
PatchRecord {
uuid: "11111111-1111-4111-8111-111111111111".to_string(),
exported_at: "2024-01-01T00:00:00Z".to_string(),
files,
vulnerabilities: vulns,
description: "Fixes prototype pollution".to_string(),
license: "MIT".to_string(),
tier: "free".to_string(),
},
);
PatchManifest { patches }
}
fn multi_entry_manifest() -> PatchManifest {
fn record(uuid: &str, vuln_ids: &[&str], file_paths: &[&str]) -> PatchRecord {
let mut files = HashMap::new();
for fp in file_paths {
files.insert(
fp.to_string(),
PatchFileInfo {
before_hash: "b".repeat(64),
after_hash: "a".repeat(64),
},
);
}
let mut vulns = HashMap::new();
for id in vuln_ids {
vulns.insert(
id.to_string(),
VulnerabilityInfo {
cves: vec![],
summary: "s".to_string(),
severity: "high".to_string(),
description: "d".to_string(),
},
);
}
PatchRecord {
uuid: uuid.to_string(),
exported_at: "2024-01-01T00:00:00Z".to_string(),
files,
vulnerabilities: vulns,
description: "desc".to_string(),
license: "MIT".to_string(),
tier: "free".to_string(),
}
}
let mut patches = HashMap::new();
patches.insert(
"pkg:npm/zeta@1.0.0".to_string(),
record("uuid-z", &["GHSA-zzzz-2222-3333", "GHSA-aaaa-2222-3333"], &["z/b.js", "z/a.js"]),
);
patches.insert(
"pkg:npm/alpha@1.0.0".to_string(),
record("uuid-a", &["GHSA-mmmm-2222-3333"], &["a/zz.js", "a/aa.js"]),
);
patches.insert(
"pkg:npm/mid@1.0.0".to_string(),
record("uuid-m", &["GHSA-cccc-2222-3333"], &["m/x.js"]),
);
PatchManifest { patches }
}
#[test]
fn list_emits_discovered_event_per_patch() {
let env = build_list_envelope(&sample_manifest());
let v: serde_json::Value = serde_json::from_str(&env.to_pretty_json()).unwrap();
assert_eq!(v["command"], "list");
assert_eq!(v["status"], "success");
assert_eq!(v["summary"]["discovered"], 1);
let events = v["events"].as_array().unwrap();
assert_eq!(events.len(), 1);
assert_eq!(events[0]["action"], "discovered");
assert_eq!(events[0]["purl"], "pkg:npm/minimist@1.2.2");
assert_eq!(events[0]["uuid"], "11111111-1111-4111-8111-111111111111");
}
#[test]
fn list_event_carries_vulnerability_details() {
let env = build_list_envelope(&sample_manifest());
let v: serde_json::Value = serde_json::from_str(&env.to_pretty_json()).unwrap();
let event = &v["events"][0];
assert_eq!(event["details"]["tier"], "free");
assert_eq!(event["details"]["license"], "MIT");
let vulns = event["details"]["vulnerabilities"].as_array().unwrap();
assert_eq!(vulns.len(), 1);
assert_eq!(vulns[0]["id"], "GHSA-xyz-1234");
assert_eq!(vulns[0]["severity"], "high");
assert_eq!(vulns[0]["cves"][0], "CVE-2024-12345");
}
#[test]
fn empty_manifest_emits_empty_events() {
let env = build_list_envelope(&PatchManifest::new());
let v: serde_json::Value = serde_json::from_str(&env.to_pretty_json()).unwrap();
assert_eq!(v["status"], "success");
assert_eq!(v["events"].as_array().unwrap().len(), 0);
assert_eq!(v["summary"]["discovered"], 0);
}
#[test]
fn events_are_sorted_by_purl() {
let env = build_list_envelope(&multi_entry_manifest());
let v: serde_json::Value = serde_json::from_str(&env.to_pretty_json()).unwrap();
let purls: Vec<&str> = v["events"]
.as_array()
.unwrap()
.iter()
.map(|e| e["purl"].as_str().unwrap())
.collect();
assert_eq!(
purls,
vec![
"pkg:npm/alpha@1.0.0",
"pkg:npm/mid@1.0.0",
"pkg:npm/zeta@1.0.0",
]
);
}
#[test]
fn vulnerabilities_are_sorted_by_id() {
let env = build_list_envelope(&multi_entry_manifest());
let v: serde_json::Value = serde_json::from_str(&env.to_pretty_json()).unwrap();
let zeta = v["events"]
.as_array()
.unwrap()
.iter()
.find(|e| e["purl"] == "pkg:npm/zeta@1.0.0")
.unwrap();
let ids: Vec<&str> = zeta["details"]["vulnerabilities"]
.as_array()
.unwrap()
.iter()
.map(|vuln| vuln["id"].as_str().unwrap())
.collect();
assert_eq!(ids, vec!["GHSA-aaaa-2222-3333", "GHSA-zzzz-2222-3333"]);
}
#[test]
fn files_are_sorted_by_path() {
let env = build_list_envelope(&multi_entry_manifest());
let v: serde_json::Value = serde_json::from_str(&env.to_pretty_json()).unwrap();
let zeta = v["events"]
.as_array()
.unwrap()
.iter()
.find(|e| e["purl"] == "pkg:npm/zeta@1.0.0")
.unwrap();
let paths: Vec<&str> = zeta["files"]
.as_array()
.unwrap()
.iter()
.map(|f| f["path"].as_str().unwrap())
.collect();
assert_eq!(paths, vec!["z/a.js", "z/b.js"]);
}
#[test]
fn ordering_is_deterministic_across_builds() {
let manifest = multi_entry_manifest();
let a = build_list_envelope(&manifest).to_pretty_json();
let b = build_list_envelope(&manifest).to_pretty_json();
assert_eq!(a, b);
}
}