From 290b133cd8a4e7559f5e360406318468e3b6d03d Mon Sep 17 00:00:00 2001 From: Leen Kilani Date: Tue, 4 Aug 2026 17:43:45 +0300 Subject: [PATCH 1/2] COR-1633: POL-02: wait for blocking rules --- src/scanners/blast.rs | 62 +++++++++++++++++++++++++++++++++++++------ src/utils/api.rs | 35 ++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 8 deletions(-) diff --git a/src/scanners/blast.rs b/src/scanners/blast.rs index 193d408..35f2e8d 100644 --- a/src/scanners/blast.rs +++ b/src/scanners/blast.rs @@ -304,14 +304,7 @@ pub fn run( } }; if *fail { - let blocking_rules = - match utils::api::check_blocking_rules(&config.get_url(), &scan_id, None) { - Ok(rules) => rules, - Err(e) => { - log::error!("Failed to check blocking rules: {}", e); - std::process::exit(1); - } - }; + let blocking_rules = wait_for_blocking_rules(config, &scan_id); if blocking_rules.block { println!("\nExiting with error code 1 due to some issues violating some blocking rules defined for this project.\nfor more details, please check the scan results at the link: {}\nAlternatively, you can run {} to view the issues list on your local machine.", utils::terminal::set_text_color(&scan_url, utils::terminal::TerminalColor::Green), @@ -610,6 +603,59 @@ pub fn wait_for_scan(config: &Config, scan_id: &str) { ); } +/// Match doghouse `LICENSE_DEPS_WAIT_TIMEOUT` (15 minutes). +const BLOCKING_RULES_WAIT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15 * 60); +const BLOCKING_RULES_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_secs(2); + +fn stop_blocking_rules_spinner(stop_signal: &Arc>, spinner: thread::JoinHandle<()>) { + *stop_signal.lock().unwrap() = true; + let _ = spinner.join(); + print!( + "{}", + utils::terminal::set_text_color("", utils::terminal::TerminalColor::Reset) + ); +} + +/// Poll until blocking-rules status is no longer pending (or 15m timeout). +/// Older backends omit status (serde defaults to complete: one-shot). +fn wait_for_blocking_rules(config: &Config, scan_id: &str) -> utils::api::BlockingRuleResponse { + let stop_signal = Arc::new(Mutex::new(false)); + let stop_signal_clone = Arc::clone(&stop_signal); + let spinner = thread::spawn(move || { + utils::terminal::show_loading_message( + "Checking blocking rules... ([T]s)", + stop_signal_clone, + ); + }); + + let started = std::time::Instant::now(); + loop { + match utils::api::check_blocking_rules(&config.get_url(), scan_id, None) { + Ok(rules) if !rules.is_pending() => { + stop_blocking_rules_spinner(&stop_signal, spinner); + return rules; + } + Ok(_) => { + if started.elapsed() >= BLOCKING_RULES_WAIT_TIMEOUT { + stop_blocking_rules_spinner(&stop_signal, spinner); + log::error!( + "\nTimed out waiting for blocking rules for scan '{}'. \ + Failing closed.", + scan_id + ); + std::process::exit(1); + } + } + Err(e) => { + stop_blocking_rules_spinner(&stop_signal, spinner); + log::error!("Failed to check blocking rules: {}", e); + std::process::exit(1); + } + } + std::thread::sleep(BLOCKING_RULES_POLL_INTERVAL); + } +} + pub fn check_scan_status(scan_id: &str, url: &str) -> Result> { match utils::api::get_scan(url, scan_id) { Ok(scan) => Ok(scan.status == "complete"), diff --git a/src/utils/api.rs b/src/utils/api.rs index ae164cd..963f470 100644 --- a/src/utils/api.rs +++ b/src/utils/api.rs @@ -1386,11 +1386,28 @@ pub struct FalsePositiveDetection { pub reasoning: Option, } +/// Doghouse `check_blocking_rules` readiness. Missing on older backends. +pub const BLOCKING_RULES_STATUS_COMPLETE: &str = "complete"; +pub const BLOCKING_RULES_STATUS_PENDING: &str = "pending"; + +fn default_blocking_rules_status() -> String { + BLOCKING_RULES_STATUS_COMPLETE.to_string() +} + #[derive(Deserialize, Debug, Clone)] pub struct BlockingRuleResponse { pub block: bool, pub blocking_issues: Vec, pub total_pages: u32, + /// License-deps readiness. Omitted on older backends: treated as complete. + #[serde(default = "default_blocking_rules_status")] + pub status: String, +} + +impl BlockingRuleResponse { + pub fn is_pending(&self) -> bool { + self.status == BLOCKING_RULES_STATUS_PENDING + } } #[derive(Deserialize, Debug, Clone)] @@ -1439,6 +1456,24 @@ mod tests { use super::*; use reqwest::header::{HeaderMap, HeaderValue}; + #[test] + fn blocking_rule_response_defaults_status_when_missing() { + let legacy = r#"{"block":false,"blocking_issues":[],"total_pages":1}"#; + let parsed: BlockingRuleResponse = serde_json::from_str(legacy).unwrap(); + assert_eq!(parsed.status, BLOCKING_RULES_STATUS_COMPLETE); + assert!(!parsed.is_pending()); + + let pending = r#"{"block":false,"blocking_issues":[],"total_pages":1,"status":"pending"}"#; + let parsed: BlockingRuleResponse = serde_json::from_str(pending).unwrap(); + assert!(parsed.is_pending()); + + let complete = r#"{"block":true,"blocking_issues":[],"total_pages":1,"status":"complete"}"#; + let parsed: BlockingRuleResponse = serde_json::from_str(complete).unwrap(); + assert_eq!(parsed.status, BLOCKING_RULES_STATUS_COMPLETE); + assert!(!parsed.is_pending()); + assert!(parsed.block); + } + #[test] fn scan_response_deserializes_git_sha_and_defaults_when_missing() { let with_sha = r#"{ From a47b8d08eebffc6386ade22f5ba4a7200304a0fc Mon Sep 17 00:00:00 2001 From: Leen Kilani Date: Wed, 5 Aug 2026 16:23:04 +0300 Subject: [PATCH 2/2] COR-1633: address comments --- src/scanners/blast.rs | 193 ++++++++++++++++++++++++++++++++++++++---- src/utils/api.rs | 54 ++++++++++-- 2 files changed, 223 insertions(+), 24 deletions(-) diff --git a/src/scanners/blast.rs b/src/scanners/blast.rs index 35f2e8d..e35b4ad 100644 --- a/src/scanners/blast.rs +++ b/src/scanners/blast.rs @@ -616,7 +616,58 @@ fn stop_blocking_rules_spinner(stop_signal: &Arc>, spinner: thread:: ); } -/// Poll until blocking-rules status is no longer pending (or 15m timeout). +#[derive(Debug)] +enum BlockingRulesPollDecision { + Complete(utils::api::BlockingRuleResponse), + KeepWaiting, + FailClosed { message: String }, +} + +const BLOCKING_RULES_TIMEOUT_MESSAGE: &str = + "Timed out waiting for blocking rules to finish. Failing closed."; + +/// Only explicit `complete` is terminal. Pending/unknown wait; transient errors +/// retry until timeout; permanent errors fail immediately. +fn decide_blocking_rules_poll( + result: Result, + elapsed: std::time::Duration, + timeout: std::time::Duration, +) -> BlockingRulesPollDecision { + match result { + Ok(rules) if rules.is_complete() => BlockingRulesPollDecision::Complete(rules), + Ok(rules) => { + if elapsed >= timeout { + log::debug!( + "Blocking-rules wait timed out; last status was '{}'.", + rules.status + ); + BlockingRulesPollDecision::FailClosed { + message: BLOCKING_RULES_TIMEOUT_MESSAGE.to_string(), + } + } else { + if rules.status == utils::api::BLOCKING_RULES_STATUS_PENDING { + log::debug!("Blocking rules still pending; waiting."); + } else { + log::debug!( + "Unexpected blocking-rules status '{}'; waiting for complete.", + rules.status + ); + } + BlockingRulesPollDecision::KeepWaiting + } + } + Err(e) => { + if !utils::api::is_retryable_blocking_rules_error_message(&e) || elapsed >= timeout { + BlockingRulesPollDecision::FailClosed { message: e } + } else { + log::debug!("Transient blocking-rules error; will retry: {}", e); + BlockingRulesPollDecision::KeepWaiting + } + } + } +} + +/// Poll until blocking-rules status is `complete` (or 15m timeout). /// Older backends omit status (serde defaults to complete: one-shot). fn wait_for_blocking_rules(config: &Config, scan_id: &str) -> utils::api::BlockingRuleResponse { let stop_signal = Arc::new(Mutex::new(false)); @@ -630,25 +681,28 @@ fn wait_for_blocking_rules(config: &Config, scan_id: &str) -> utils::api::Blocki let started = std::time::Instant::now(); loop { - match utils::api::check_blocking_rules(&config.get_url(), scan_id, None) { - Ok(rules) if !rules.is_pending() => { + // Do not start another request after the deadline. + if started.elapsed() >= BLOCKING_RULES_WAIT_TIMEOUT { + stop_blocking_rules_spinner(&stop_signal, spinner); + log::error!("\n{} (scan '{}')", BLOCKING_RULES_TIMEOUT_MESSAGE, scan_id); + std::process::exit(1); + } + + let result = utils::api::check_blocking_rules(&config.get_url(), scan_id, None) + .map_err(|e| e.to_string()); + match decide_blocking_rules_poll(result, started.elapsed(), BLOCKING_RULES_WAIT_TIMEOUT) { + BlockingRulesPollDecision::Complete(rules) => { stop_blocking_rules_spinner(&stop_signal, spinner); return rules; } - Ok(_) => { - if started.elapsed() >= BLOCKING_RULES_WAIT_TIMEOUT { - stop_blocking_rules_spinner(&stop_signal, spinner); - log::error!( - "\nTimed out waiting for blocking rules for scan '{}'. \ - Failing closed.", - scan_id - ); - std::process::exit(1); - } - } - Err(e) => { + BlockingRulesPollDecision::KeepWaiting => {} + BlockingRulesPollDecision::FailClosed { message } => { stop_blocking_rules_spinner(&stop_signal, spinner); - log::error!("Failed to check blocking rules: {}", e); + if message == BLOCKING_RULES_TIMEOUT_MESSAGE { + log::error!("\n{} (scan '{}')", message, scan_id); + } else { + log::error!("Failed to check blocking rules: {}", message); + } std::process::exit(1); } } @@ -746,12 +800,117 @@ pub fn metadata_json_from_pairs(pairs: &[String]) -> Result, Stri #[cfg(test)] mod tests { use super::*; - use crate::utils::api::{SCAIssue, SCALocation, SCAPackage}; + use crate::utils::api::{ + BlockingRuleResponse, SCAIssue, SCALocation, SCAPackage, BLOCKING_RULES_STATUS_COMPLETE, + BLOCKING_RULES_STATUS_PENDING, + }; + use std::time::Duration; fn counts(pairs: &[(&str, usize)]) -> HashMap { pairs.iter().map(|(k, v)| (k.to_string(), *v)).collect() } + fn sample_rules(status: &str, block: bool) -> BlockingRuleResponse { + BlockingRuleResponse { + block, + blocking_issues: vec![], + total_pages: 1, + status: status.to_string(), + } + } + + #[test] + fn poll_only_complete_is_terminal_unknown_keeps_waiting() { + let timeout = Duration::from_secs(60); + match decide_blocking_rules_poll( + Ok(sample_rules(BLOCKING_RULES_STATUS_COMPLETE, true)), + Duration::ZERO, + timeout, + ) { + BlockingRulesPollDecision::Complete(r) => assert!(r.block), + other => panic!("expected Complete, got {other:?}"), + } + assert!(matches!( + decide_blocking_rules_poll( + Ok(sample_rules(BLOCKING_RULES_STATUS_PENDING, false)), + Duration::ZERO, + timeout + ), + BlockingRulesPollDecision::KeepWaiting + )); + assert!(matches!( + decide_blocking_rules_poll( + Ok(sample_rules("processing", false)), + Duration::ZERO, + timeout + ), + BlockingRulesPollDecision::KeepWaiting + )); + assert!(matches!( + decide_blocking_rules_poll(Ok(sample_rules("", false)), Duration::ZERO, timeout), + BlockingRulesPollDecision::KeepWaiting + )); + assert!(matches!( + decide_blocking_rules_poll(Ok(sample_rules("processing", false)), timeout, timeout), + BlockingRulesPollDecision::FailClosed { .. } + )); + } + + #[test] + fn poll_retries_transient_error_then_accepts_complete() { + let timeout = Duration::from_secs(60); + assert!(matches!( + decide_blocking_rules_poll( + Err("API request failed with status: 503 Service Unavailable".into()), + Duration::from_secs(1), + timeout, + ), + BlockingRulesPollDecision::KeepWaiting + )); + // Still fail closed once the overall deadline is hit. + assert!(matches!( + decide_blocking_rules_poll( + Err("API request failed with status: 503 Service Unavailable".into()), + timeout, + timeout, + ), + BlockingRulesPollDecision::FailClosed { .. } + )); + match decide_blocking_rules_poll( + Ok(sample_rules(BLOCKING_RULES_STATUS_COMPLETE, false)), + Duration::from_secs(2), + timeout, + ) { + BlockingRulesPollDecision::Complete(r) => assert!(!r.block), + other => panic!("expected Complete after retry, got {other:?}"), + } + } + + #[test] + fn poll_fails_fast_on_permanent_auth_error() { + assert!(matches!( + decide_blocking_rules_poll( + Err("API request failed with status: 401 Unauthorized".into()), + Duration::ZERO, + Duration::from_secs(60), + ), + BlockingRulesPollDecision::FailClosed { .. } + )); + } + + #[test] + fn poll_accepts_complete_even_after_deadline_overrun() { + // Request started under budget; late response still honored. + match decide_blocking_rules_poll( + Ok(sample_rules(BLOCKING_RULES_STATUS_COMPLETE, true)), + Duration::from_secs(16 * 60), + Duration::from_secs(15 * 60), + ) { + BlockingRulesPollDecision::Complete(r) => assert!(r.block), + other => panic!("expected late Complete to be accepted, got {other:?}"), + } + } + fn sca_issue(classification: Option<&str>) -> SCAIssue { SCAIssue { id: "issue-1".to_string(), diff --git a/src/utils/api.rs b/src/utils/api.rs index 963f470..c94cf8a 100644 --- a/src/utils/api.rs +++ b/src/utils/api.rs @@ -1114,7 +1114,7 @@ pub fn check_blocking_rules( debug(&format!("Sending request to URL: {}", url)); debug(&format!("Query params: {:?}", query_params)); - let response = match client.get(url).query(&query_params).send() { + let response = match client.get(&url).query(&query_params).send() { Ok(res) => { check_for_warnings(res.headers(), res.status()); debug(&format!("Response status: {}", res.status())); @@ -1405,11 +1405,24 @@ pub struct BlockingRuleResponse { } impl BlockingRuleResponse { - pub fn is_pending(&self) -> bool { - self.status == BLOCKING_RULES_STATUS_PENDING + pub fn is_complete(&self) -> bool { + self.status == BLOCKING_RULES_STATUS_COMPLETE } } +/// Retryable during `--fail` wait: transport errors and HTTP 429/5xx. +/// Permanent 4xx (auth, not found, bad request) stay fail-fast. +pub fn is_retryable_blocking_rules_error_message(message: &str) -> bool { + if let Some(rest) = message.strip_prefix("API request failed with status: ") { + return rest + .split_whitespace() + .next() + .and_then(|code| code.parse::().ok()) + .is_some_and(|code| code == 429 || (500..600).contains(&code)); + } + message.starts_with("API request failed:") +} + #[derive(Deserialize, Debug, Clone)] pub struct BlockingIssue { pub id: String, @@ -1461,17 +1474,44 @@ mod tests { let legacy = r#"{"block":false,"blocking_issues":[],"total_pages":1}"#; let parsed: BlockingRuleResponse = serde_json::from_str(legacy).unwrap(); assert_eq!(parsed.status, BLOCKING_RULES_STATUS_COMPLETE); - assert!(!parsed.is_pending()); + assert!(parsed.is_complete()); let pending = r#"{"block":false,"blocking_issues":[],"total_pages":1,"status":"pending"}"#; let parsed: BlockingRuleResponse = serde_json::from_str(pending).unwrap(); - assert!(parsed.is_pending()); + assert_eq!(parsed.status, BLOCKING_RULES_STATUS_PENDING); + assert!(!parsed.is_complete()); let complete = r#"{"block":true,"blocking_issues":[],"total_pages":1,"status":"complete"}"#; let parsed: BlockingRuleResponse = serde_json::from_str(complete).unwrap(); - assert_eq!(parsed.status, BLOCKING_RULES_STATUS_COMPLETE); - assert!(!parsed.is_pending()); + assert!(parsed.is_complete()); assert!(parsed.block); + + let unexpected = + r#"{"block":false,"blocking_issues":[],"total_pages":1,"status":"processing"}"#; + let parsed: BlockingRuleResponse = serde_json::from_str(unexpected).unwrap(); + assert!(!parsed.is_complete()); + } + + #[test] + fn retryable_blocking_rules_errors_cover_transport_429_and_5xx() { + assert!(is_retryable_blocking_rules_error_message( + "API request failed: connection reset" + )); + assert!(is_retryable_blocking_rules_error_message( + "API request failed with status: 429 Too Many Requests" + )); + assert!(is_retryable_blocking_rules_error_message( + "API request failed with status: 503 Service Unavailable" + )); + assert!(!is_retryable_blocking_rules_error_message( + "API request failed with status: 401 Unauthorized" + )); + assert!(!is_retryable_blocking_rules_error_message( + "API request failed with status: 404 Not Found" + )); + assert!(!is_retryable_blocking_rules_error_message( + "Failed to parse response: expected value" + )); } #[test]