From c3ad51a307b9ac4bc3ad545704dd4056a64ad88f Mon Sep 17 00:00:00 2001 From: Leen Kilani Date: Mon, 13 Jul 2026 14:50:22 +0300 Subject: [PATCH 1/2] COR-1641: CLI-02: Scan-Level Metadata Tagging --- src/main.rs | 27 ++++++++++++++-- src/scanners/blast.rs | 75 +++++++++++++++++++++++++++++++++++++++++++ src/utils/api.rs | 6 ++++ 3 files changed, 106 insertions(+), 2 deletions(-) diff --git a/src/main.rs b/src/main.rs index 873dc19..a42890b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -83,10 +83,17 @@ enum Commands { #[arg( long, - help = "Skip the scan if this commit already has a completed scan for the same project and branch within the last 24h. Only for a default blast scan (not with --fail, --fail-on, --only-uncommitted, --target, --exclude, --scan-type, --policy, or --out-file/--out-format)." + help = "Skip the scan if this commit already has a completed scan for the same project and branch within the last 24h. Only for a default blast scan (not with --fail, --fail-on, --only-uncommitted, --target, --exclude, --scan-type, --policy, --metadata, or --out-file/--out-format)." )] skip_if_scanned: bool, + #[arg( + long = "metadata", + value_name = "KEY=VALUE", + help = "Attach scan-level metadata (repeatable), e.g. --metadata pipeline_url=... --metadata artifact_version=1.2.3" + )] + metadata: Vec, + #[arg( short, long, @@ -493,6 +500,7 @@ fn main() { fail, only_uncommitted, skip_if_scanned, + metadata, scan_type, policy, out_format, @@ -530,6 +538,19 @@ fn main() { std::process::exit(1); } + if !metadata.is_empty() && *scanner != Scanner::Blast { + ::log::error!("--metadata is only supported with the blast scanner."); + std::process::exit(1); + } + + let metadata_json = match scanners::blast::metadata_json_from_pairs(metadata) { + Ok(json) => json, + Err(e) => { + ::log::error!("{}", e); + std::process::exit(1); + } + }; + if *skip_if_scanned && (*fail || fail_on.is_some() @@ -538,11 +559,12 @@ fn main() { || exclude.is_some() || scan_type.is_some() || policy.is_some() + || metadata_json.is_some() || out_file.is_some() || out_format.is_some()) { ::log::error!( - "--skip-if-scanned only applies to a default blast scan. It cannot be combined with --fail, --fail-on, --only-uncommitted, --target, --exclude, --scan-type, --policy, or --out-file/--out-format." + "--skip-if-scanned only applies to a default blast scan. It cannot be combined with --fail, --fail-on, --only-uncommitted, --target, --exclude, --scan-type, --policy, --metadata, or --out-file/--out-format." ); std::process::exit(1); } @@ -620,6 +642,7 @@ fn main() { fail, only_uncommitted, skip_if_scanned, + metadata_json, scan_type.clone(), policy.clone(), out_format.clone(), diff --git a/src/scanners/blast.rs b/src/scanners/blast.rs index b81b79f..524782f 100644 --- a/src/scanners/blast.rs +++ b/src/scanners/blast.rs @@ -18,6 +18,7 @@ pub fn run( fail: &bool, only_uncommitted: &bool, skip_if_scanned: &bool, + metadata: Option, scan_type: Option, policy: Option, out_format: Option, @@ -248,6 +249,7 @@ pub fn run( repo_info, scan_type, policy, + metadata, ) { Ok(result) => result, Err(e) => { @@ -595,6 +597,32 @@ pub fn report_scan_status( Ok(classification_counts) } +/// Parse `--metadata KEY=VALUE` flags into a JSON object string. +/// Splits on the first `=`; duplicate keys last-wins. Empty input -> `Ok(None)`. +pub fn metadata_json_from_pairs(pairs: &[String]) -> Result, String> { + let mut map = serde_json::Map::new(); + for entry in pairs { + match entry.split_once('=') { + Some((key, value)) if !key.is_empty() => { + map.insert( + key.to_string(), + serde_json::Value::String(value.to_string()), + ); + } + _ => { + return Err( + "Invalid --metadata value. Use KEY=VALUE with a non-empty key, e.g. --metadata pipeline_url=https://...".to_string(), + ); + } + } + } + if map.is_empty() { + Ok(None) + } else { + Ok(Some(serde_json::Value::Object(map).to_string())) + } +} + fn find_recent_matching_scan<'a>( scans: &'a [ScanResponse], local_sha: &str, @@ -661,9 +689,56 @@ mod tests { engine: "blast".to_string(), created_at: created_at.to_string(), git_sha: sha.map(str::to_string), + metadata: None, } } + #[test] + fn parse_metadata_valid_pairs() { + let pairs = vec![ + "pipeline_url=https://ci.example/run/1".to_string(), + "artifact_version=1.2.3".to_string(), + "note=a=b=c".to_string(), + ]; + let json = metadata_json_from_pairs(&pairs).unwrap().unwrap(); + let map: serde_json::Map = serde_json::from_str(&json).unwrap(); + assert_eq!(map.len(), 3); + assert_eq!( + map.get("pipeline_url").and_then(|v| v.as_str()), + Some("https://ci.example/run/1") + ); + assert_eq!( + map.get("artifact_version").and_then(|v| v.as_str()), + Some("1.2.3") + ); + assert_eq!(map.get("note").and_then(|v| v.as_str()), Some("a=b=c")); + } + + #[test] + fn parse_metadata_rejects_missing_eq_or_empty_key() { + assert!(metadata_json_from_pairs(&["novalue".to_string()]).is_err()); + assert!(metadata_json_from_pairs(&["=value".to_string()]).is_err()); + assert!(metadata_json_from_pairs(&["".to_string()]).is_err()); + } + + #[test] + fn parse_metadata_duplicate_keys_last_wins() { + let pairs = vec!["k=first".to_string(), "k=second".to_string()]; + let json = metadata_json_from_pairs(&pairs).unwrap().unwrap(); + let map: serde_json::Map = serde_json::from_str(&json).unwrap(); + assert_eq!(map.get("k").and_then(|v| v.as_str()), Some("second")); + } + + #[test] + fn parse_metadata_empty_value_and_empty_list_ok() { + let json = metadata_json_from_pairs(&["k=".to_string()]) + .unwrap() + .unwrap(); + let map: serde_json::Map = serde_json::from_str(&json).unwrap(); + assert_eq!(map.get("k").and_then(|v| v.as_str()), Some("")); + assert!(metadata_json_from_pairs(&[]).unwrap().is_none()); + } + #[test] fn skips_matching_complete_scan_within_24h() { let now = Utc::now(); diff --git a/src/utils/api.rs b/src/utils/api.rs index 3a5ae86..6a1093c 100644 --- a/src/utils/api.rs +++ b/src/utils/api.rs @@ -226,6 +226,7 @@ pub fn upload_zip( repo_info: Option, scan_type: Option, policy: Option, + metadata: Option, ) -> Result> { let client = http_client(); let file_size = std::fs::metadata(file_path)?.len(); @@ -347,6 +348,9 @@ pub fn upload_zip( if let Some(policy) = policy.clone() { form = form.part("target_policies", multipart::Part::text(policy.to_string())); } + if let Some(meta) = &metadata { + form = form.part("metadata", multipart::Part::text(meta.clone())); + } let response = match client .patch(format!("{}{}/start-scan/{}/", url, API_BASE, transfer_id)) @@ -950,6 +954,8 @@ pub struct ScanResponse { pub created_at: String, #[serde(default)] pub git_sha: Option, + #[serde(default)] + pub metadata: Option, } #[derive(Serialize, Deserialize, Debug)] From 5d35509b8fe5e47c14c720e9ec39b1361bd6d7c4 Mon Sep 17 00:00:00 2001 From: Leen Kilani Date: Mon, 13 Jul 2026 15:15:39 +0300 Subject: [PATCH 2/2] COR-1641: serde skip if none --- src/utils/api.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/api.rs b/src/utils/api.rs index 6a1093c..7bc6267 100644 --- a/src/utils/api.rs +++ b/src/utils/api.rs @@ -954,7 +954,7 @@ pub struct ScanResponse { pub created_at: String, #[serde(default)] pub git_sha: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub metadata: Option, }