From bc2c169d7e65f1dcc5c88cdd30bd71e1a8cbf4cf Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 9 Aug 2026 09:39:10 +0000 Subject: [PATCH 1/3] Warn when the Corgea webapp is older than this CLI requires Every authenticated command now reads GET /api/version before running and warns when the deployment is behind MIN_WEBAPP_VERSION (v1.71.3), so users learn why a command may misbehave instead of hitting an opaque failure. The check is best effort and never blocks: a 404 (webapp predating the endpoint), a null version, an unreachable endpoint, or a version with no numbers in it all leave the command to run silently. Deployment versions carry suffixes (v1.71.3-beta, v1.71.3-client-a), so only the leading major.minor.patch is compared -- a suffixed build counts as the release it was cut from rather than sorting below it the way semver pre-release ordering would. CORGEA_MIN_WEBAPP_VERSION overrides the floor and CORGEA_SKIP_WEBAPP_VERSION_CHECK skips the check entirely. Co-authored-by: ibrahim --- src/main.rs | 3 +- src/utils/api.rs | 54 +++++++ src/version_check.rs | 190 ++++++++++++++++++++++++ tests/cli_webapp_version.rs | 149 +++++++++++++++++++ tests/cloud_commands_e2e/common/mod.rs | 14 ++ tests/cloud_commands_e2e/inspect.rs | 4 + tests/cloud_commands_e2e/scan_list.rs | 3 + tests/cloud_commands_e2e/upload_wait.rs | 4 +- tests/common/mod.rs | 13 ++ 9 files changed, 432 insertions(+), 2 deletions(-) create mode 100644 src/version_check.rs create mode 100644 tests/cli_webapp_version.rs diff --git a/src/main.rs b/src/main.rs index 57cac78..618e368 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,6 +19,7 @@ mod utils { pub mod terminal; } mod targets; +mod version_check; use clap::{CommandFactory, Parser, Subcommand}; use config::Config; @@ -520,7 +521,7 @@ fn main() { } utils::api::set_auth_token(&config.get_token()); match utils::api::verify_token(config.get_url().as_str()) { - Ok(true) => {} + Ok(true) => version_check::warn_if_webapp_outdated(config.get_url().as_str()), Ok(false) => { println!("Invalid token provided.\nPlease run 'corgea login' to authenticate.\nFor more info checkout our docs at Check out our docs at https://docs.corgea.app/install_cli#login-with-the-cli"); std::process::exit(1); diff --git a/src/utils/api.rs b/src/utils/api.rs index 6bfc784..615bdd1 100644 --- a/src/utils/api.rs +++ b/src/utils/api.rs @@ -18,6 +18,9 @@ use std::path::Path; const CHUNK_SIZE: usize = 50 * 1024 * 1024; // 50 MB const API_BASE: &str = "/api/v1"; +/// Unversioned on purpose: the webapp serves its own version outside the +/// versioned API so clients can read it before agreeing on an API version. +const WEBAPP_VERSION_PATH: &str = "/api/version"; fn auth_headers(token: &str) -> HeaderMap { let mut headers = HeaderMap::new(); @@ -1181,6 +1184,50 @@ pub fn verify_token(corgea_url: &str) -> Result> { } } +/// The version the webapp at `corgea_url` reports for itself. +/// +/// `Ok(None)` means the deployment did not tell us: either it predates +/// `/api/version` (404) or it could not determine its own version (null). +/// Callers treat both the same way. +/// +/// Deliberately skips `check_for_warnings`: this runs as a pre-flight before +/// the command the user asked for, and it must not be what terminates the +/// process. The command's own requests still surface deprecation signals. +pub fn get_webapp_version(corgea_url: &str) -> Result, Box> { + let url = format!("{}{}", corgea_url, WEBAPP_VERSION_PATH); + let client = http_client(); + debug(&format!("Sending request to URL: {}", url)); + + let response = client.get(&url).send()?; + let status = response.status(); + + if status == StatusCode::NOT_FOUND { + debug("Webapp does not serve /api/version"); + return Ok(None); + } + + if !status.is_success() { + return Err(format!("Request failed with status: {}", status).into()); + } + + let body_text = response.text()?; + let body: WebappVersionResponse = match serde_json::from_str(&body_text) { + Ok(json) => json, + Err(e) => { + debug(&format!( + "Failed to parse response as JSON: {}. Response body: {}", + e, body_text + )); + return Err("Failed to parse response".to_string().into()); + } + }; + + Ok(body + .version + .map(|version| version.trim().to_string()) + .filter(|version| !version.is_empty())) +} + /// Evaluate a scan against blocking rules. /// /// `block_on` is a comma-separated list of CI rule slugs. When omitted the @@ -1351,6 +1398,13 @@ pub fn get_all_sca_issues( Ok(all_issues) } +#[derive(Deserialize, Serialize, Debug)] +pub struct WebappVersionResponse { + /// Null on deployments that cannot determine their own version. + #[serde(default)] + pub version: Option, +} + #[derive(Deserialize, Serialize, Debug)] pub struct ScanResponse { pub id: String, diff --git a/src/version_check.rs b/src/version_check.rs new file mode 100644 index 0000000..ff5d9a5 --- /dev/null +++ b/src/version_check.rs @@ -0,0 +1,190 @@ +//! Compatibility pre-flight: warn when the Corgea webapp a command is about to +//! talk to is older than this CLI expects. +//! +//! Best effort throughout. A webapp that does not report its version, an +//! unreachable endpoint, or an unparsable version all leave the command to run +//! exactly as before — the check only ever adds a warning. + +use crate::log::debug; +use crate::utils::api; +use crate::utils::generic::get_env_var_if_exists; +use regex::Regex; +use semver::Version; +use std::sync::LazyLock; + +/// Oldest webapp release this CLI is built against. Raise it whenever the CLI +/// starts depending on a webapp change. +pub const MIN_WEBAPP_VERSION: &str = "v1.71.3"; + +/// Overrides `MIN_WEBAPP_VERSION`, for testing a different floor without a +/// rebuild. +const MIN_VERSION_ENV_VAR: &str = "CORGEA_MIN_WEBAPP_VERSION"; + +/// Escape hatch for anyone deliberately pinned to an older self-hosted webapp +/// who does not want the warning on every command. +const SKIP_ENV_VAR: &str = "CORGEA_SKIP_WEBAPP_VERSION_CHECK"; + +/// Deployment versions are not plain semver: releases ship as `v1.71.3`, while +/// pre-release and per-customer builds add a suffix (`v1.71.3-beta`, +/// `v1.71.3-client-a`). Only the leading numeric part is comparable, so a +/// suffixed build counts as the release it was cut from rather than sorting +/// below it the way semver pre-release ordering would. +static NUMERIC_VERSION: LazyLock = + LazyLock::new(|| Regex::new(r"(\d+)\.(\d+)(?:\.(\d+))?").expect("valid version regex")); + +/// The `major.minor.patch` numbers in `raw`, or `None` when it carries none. +/// A missing patch reads as `0`, so `v1.71` and `v1.71.0` compare equal. +pub fn extract_version(raw: &str) -> Option { + let captures = NUMERIC_VERSION.captures(raw)?; + let number = |group: usize| match captures.get(group) { + Some(digits) => digits.as_str().parse::().ok(), + None => Some(0), + }; + Some(Version::new(number(1)?, number(2)?, number(3)?)) +} + +/// The warning to show for a webapp running `webapp_version`, or `None` when it +/// is new enough or when either version has no numbers to compare. +pub fn outdated_warning(corgea_url: &str, webapp_version: &str, minimum: &str) -> Option { + let running = extract_version(webapp_version)?; + let required = extract_version(minimum)?; + + if running >= required { + return None; + } + + Some(format!( + "Warning: this Corgea CLI (v{cli}) requires Corgea webapp {minimum} or newer, \ + but {corgea_url} is running {webapp_version}. Commands may fail or return \ + incomplete results until the webapp is upgraded. \ + Set {SKIP_ENV_VAR}=1 to silence this warning.", + cli = env!("CARGO_PKG_VERSION"), + )) +} + +/// The version floor to enforce: `CORGEA_MIN_WEBAPP_VERSION`, else the built-in +/// `MIN_WEBAPP_VERSION`. +fn min_webapp_version() -> String { + get_env_var_if_exists(MIN_VERSION_ENV_VAR).unwrap_or_else(|| MIN_WEBAPP_VERSION.to_string()) +} + +fn check_disabled() -> bool { + matches!( + get_env_var_if_exists(SKIP_ENV_VAR) + .map(|value| value.trim().to_ascii_lowercase()) + .as_deref(), + Some("1") | Some("true") | Some("yes") + ) +} + +/// Warn on stderr when the webapp at `corgea_url` is older than this CLI needs. +/// Call before running the command the user asked for. +pub fn warn_if_webapp_outdated(corgea_url: &str) { + if check_disabled() { + debug("Webapp version check disabled"); + return; + } + + let webapp_version = match api::get_webapp_version(corgea_url) { + Ok(Some(version)) => version, + Ok(None) => { + debug("Webapp did not report a version; skipping compatibility check"); + return; + } + Err(e) => { + debug(&format!("Failed to read the webapp version: {}", e)); + return; + } + }; + + debug(&format!("Webapp reports version {}", webapp_version)); + + if let Some(warning) = outdated_warning(corgea_url, &webapp_version, &min_webapp_version()) { + log::warn!("{}", warning); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extracts_the_numbers_from_release_and_suffixed_versions() { + assert_eq!(extract_version("v1.71.3"), Some(Version::new(1, 71, 3))); + assert_eq!(extract_version("1.71.3"), Some(Version::new(1, 71, 3))); + assert_eq!( + extract_version("v1.71.3-beta"), + Some(Version::new(1, 71, 3)) + ); + assert_eq!( + extract_version("v1.71.3-client-a"), + Some(Version::new(1, 71, 3)) + ); + assert_eq!( + extract_version("v1.71.3-main-a1b2c3d"), + Some(Version::new(1, 71, 3)) + ); + } + + #[test] + fn a_missing_patch_reads_as_zero() { + assert_eq!(extract_version("v1.71"), Some(Version::new(1, 71, 0))); + assert_eq!(extract_version("v2"), None); + } + + #[test] + fn versions_without_numbers_are_not_comparable() { + assert_eq!(extract_version(""), None); + assert_eq!(extract_version("unknown"), None); + assert_eq!(extract_version("main"), None); + } + + #[test] + fn warns_only_when_the_webapp_is_behind_the_minimum() { + let warn = |version: &str| outdated_warning("https://corgea.test", version, "v1.71.3"); + + assert!(warn("v1.71.2").is_some()); + assert!(warn("v1.70.9").is_some()); + assert!(warn("v0.9.0").is_some()); + assert!(warn("v1.71.3").is_none()); + assert!(warn("v1.71.4").is_none()); + assert!(warn("v2.0.0").is_none()); + } + + #[test] + fn a_suffixed_build_counts_as_the_release_it_was_cut_from() { + // Plain semver sorts `1.71.3-beta` below `1.71.3`; comparing only the + // numeric part deliberately does not. + let warn = |version: &str| outdated_warning("https://corgea.test", version, "v1.71.3"); + + assert!(warn("v1.71.3-beta").is_none()); + assert!(warn("v1.71.3-client-a").is_none()); + assert!(warn("v1.71.2-client-a").is_some()); + } + + #[test] + fn unreadable_versions_never_warn() { + assert!(outdated_warning("https://corgea.test", "unknown", "v1.71.3").is_none()); + assert!(outdated_warning("https://corgea.test", "v1.0.0", "not-a-version").is_none()); + } + + #[test] + fn the_warning_names_the_instance_the_minimum_and_the_escape_hatch() { + let warning = outdated_warning("https://corgea.test", "v1.70.0", "v1.71.3") + .expect("an outdated webapp warns"); + + assert!(warning.contains("v1.71.3"), "{warning}"); + assert!(warning.contains("v1.70.0"), "{warning}"); + assert!(warning.contains("https://corgea.test"), "{warning}"); + assert!(warning.contains(env!("CARGO_PKG_VERSION")), "{warning}"); + assert!(warning.contains(SKIP_ENV_VAR), "{warning}"); + } + + #[test] + fn the_default_minimum_is_a_readable_version() { + assert_eq!( + extract_version(MIN_WEBAPP_VERSION), + Some(Version::new(1, 71, 3)) + ); + } +} diff --git a/tests/cli_webapp_version.rs b/tests/cli_webapp_version.rs new file mode 100644 index 0000000..5aae67d --- /dev/null +++ b/tests/cli_webapp_version.rs @@ -0,0 +1,149 @@ +//! End-to-end tests for the webapp compatibility pre-flight: before running a +//! command, `corgea` reads `GET /api/version` and warns when the webapp is +//! older than this CLI requires. The warning never blocks the command. + +mod common; + +use common::{corgea_isolated, scans_empty, temp_plain_dir, webapp_version, Hits, Routes}; +use std::process::Output; + +const MINIMUM: &str = "v1.71.3"; + +fn spawn_stub(version: Option) -> (String, Hits) { + common::spawn_resolution_stub(Routes { + scans: Some(scans_empty()), + version, + ..Default::default() + }) +} + +/// `corgea list` against `url` from a throwaway non-git dir, with the version +/// floor pinned so the test does not shift when `MIN_WEBAPP_VERSION` is bumped. +/// `--project-name` keeps an empty scan page from being a resolution failure. +fn run_list(url: &str, extra_env: &[(&str, &str)]) -> Output { + let (tmp, cwd) = temp_plain_dir("proj"); + let (mut cmd, _home) = corgea_isolated(); + cmd.args(["list", "--project-name", "demo"]) + .current_dir(&cwd) + .env("CORGEA_URL", url) + .env("CORGEA_TOKEN", "test-token") + .env("CORGEA_MIN_WEBAPP_VERSION", MINIMUM); + for (key, value) in extra_env { + cmd.env(key, value); + } + let output = cmd.output().expect("spawn corgea"); + drop(tmp); + output +} + +fn stderr(out: &Output) -> String { + String::from_utf8_lossy(&out.stderr).to_string() +} + +#[test] +fn an_outdated_webapp_warns_and_still_runs_the_command() { + let (url, hits) = spawn_stub(Some(webapp_version("v1.70.0"))); + + let out = run_list(&url, &[]); + let stderr = stderr(&out); + + assert!( + stderr.contains(MINIMUM) && stderr.contains("v1.70.0"), + "expected a version warning; stderr: {stderr}" + ); + assert!( + out.status.success(), + "the warning must not fail the command" + ); + assert!( + hits.lock() + .unwrap() + .iter() + .any(|h| h.starts_with("/api/v1/scans")), + "the command must still run; hits: {:?}", + hits.lock().unwrap() + ); +} + +#[test] +fn a_current_webapp_is_silent() { + let (url, _hits) = spawn_stub(Some(webapp_version(MINIMUM))); + + let stderr = stderr(&run_list(&url, &[])); + + assert!( + !stderr.contains("requires Corgea webapp"), + "an up-to-date webapp must not warn; stderr: {stderr}" + ); +} + +#[test] +fn suffixed_builds_compare_on_their_numbers() { + for version in ["v1.71.3-beta", "v1.71.3-client-a", "v1.71.4-client-a"] { + let (url, _hits) = spawn_stub(Some(webapp_version(version))); + let stderr = stderr(&run_list(&url, &[])); + assert!( + !stderr.contains("requires Corgea webapp"), + "{version} satisfies {MINIMUM}; stderr: {stderr}" + ); + } + + let (url, _hits) = spawn_stub(Some(webapp_version("v1.71.2-client-a"))); + let stderr = stderr(&run_list(&url, &[])); + assert!( + stderr.contains("v1.71.2-client-a"), + "a suffixed build below the floor still warns; stderr: {stderr}" + ); +} + +#[test] +fn a_webapp_without_the_endpoint_is_silent() { + // `version: None` makes the stub 404 `/api/version`, exactly as a webapp + // released before the endpoint existed answers. + let (url, hits) = spawn_stub(None); + + let out = run_list(&url, &[]); + let stderr = stderr(&out); + + assert!( + hits.lock().unwrap().iter().any(|h| h == "/api/version"), + "the endpoint must be dialed; hits: {:?}", + hits.lock().unwrap() + ); + assert!( + !stderr.contains("requires Corgea webapp"), + "a 404 must be treated as unknown, not warned about; stderr: {stderr}" + ); + assert!(out.status.success()); +} + +#[test] +fn a_webapp_reporting_a_null_version_is_silent() { + let (url, _hits) = spawn_stub(Some(r#"{"status":"ok","version":null}"#.to_string())); + + let stderr = stderr(&run_list(&url, &[])); + + assert!( + !stderr.contains("requires Corgea webapp"), + "an unknown version must not warn; stderr: {stderr}" + ); +} + +#[test] +fn the_skip_env_var_silences_the_warning_and_the_request() { + let (url, hits) = spawn_stub(Some(webapp_version("v1.70.0"))); + + let out = run_list(&url, &[("CORGEA_SKIP_WEBAPP_VERSION_CHECK", "1")]); + let stderr = stderr(&out); + + assert!( + !stderr.contains("requires Corgea webapp"), + "the skip flag must silence the warning; stderr: {stderr}" + ); + assert!( + !hits.lock().unwrap().iter().any(|h| h == "/api/version"), + "the skip flag must also skip the request; hits: {:?}", + hits.lock().unwrap() + ); + assert!(out.status.success()); +} diff --git a/tests/cloud_commands_e2e/common/mod.rs b/tests/cloud_commands_e2e/common/mod.rs index 3cd26a8..8d38258 100644 --- a/tests/cloud_commands_e2e/common/mod.rs +++ b/tests/cloud_commands_e2e/common/mod.rs @@ -559,6 +559,18 @@ pub(crate) fn verify_request() -> ExpectedRequest { ) } +/// The compatibility pre-flight every authenticated command makes right after +/// verifying the token. It answers with a null version — "this deployment does +/// not know" — so these contracts stay independent of the CLI's minimum-version +/// floor. `cli_webapp_version.rs` covers the comparison itself. +pub(crate) fn webapp_version_request() -> ExpectedRequest { + expected_request( + "read webapp version", + |request| assert_authenticated_request(request, Method::GET, "/api/version"), + json_response(json!({"status": "ok", "version": null})), + ) +} + pub(crate) fn scan_response(scan_id: &str, project: &str, status: &str) -> Value { json!({ "id": scan_id, @@ -676,6 +688,7 @@ pub(crate) fn upload_plan(scan_id: &str, project_id: i64) -> Vec Vec { let sca_path = "/api/v1/scan/blast-scan-123/issues/sca".to_string(); vec![ verify_request(), + webapp_version_request(), expected_request( "start BLAST upload", |request| { diff --git a/tests/cloud_commands_e2e/inspect.rs b/tests/cloud_commands_e2e/inspect.rs index b29b24b..480a55c 100644 --- a/tests/cloud_commands_e2e/inspect.rs +++ b/tests/cloud_commands_e2e/inspect.rs @@ -10,6 +10,7 @@ fn inspect_scan_json_returns_requested_scan() { let scan_path = format!("/api/v1/scan/{scan_id}"); let api = ApiStub::start(vec![ verify_request(), + webapp_version_request(), expected_request( "inspect scan as JSON", move |request| assert_authenticated_request(request, Method::GET, &scan_path), @@ -50,6 +51,7 @@ fn inspect_issue_json_returns_requested_issue() { let issue_path = format!("/api/v1/issue/{issue_id}"); let api = ApiStub::start(vec![ verify_request(), + webapp_version_request(), expected_request( "inspect issue as JSON", move |request| assert_authenticated_request(request, Method::GET, &issue_path), @@ -94,6 +96,7 @@ fn inspect_scan_exits_one_on_server_error() { let scan_path = format!("/api/v1/scan/{scan_id}"); let api = ApiStub::start(vec![ verify_request(), + webapp_version_request(), expected_request( "reject scan inspection", move |request| assert_authenticated_request(request, Method::GET, &scan_path), @@ -121,6 +124,7 @@ fn inspect_issue_exits_one_on_invalid_contract() { let issue_path = format!("/api/v1/issue/{issue_id}"); let api = ApiStub::start(vec![ verify_request(), + webapp_version_request(), expected_request( "return invalid issue contract", move |request| assert_authenticated_request(request, Method::GET, &issue_path), diff --git a/tests/cloud_commands_e2e/scan_list.rs b/tests/cloud_commands_e2e/scan_list.rs index 9632472..ab7f3c3 100644 --- a/tests/cloud_commands_e2e/scan_list.rs +++ b/tests/cloud_commands_e2e/scan_list.rs @@ -30,6 +30,7 @@ fn scan_fail_on_malicious_sends_sha_and_list_renders_it() { let list_response_sha = project.sha.clone(); let list_api = ApiStub::start(vec![ verify_request(), + webapp_version_request(), expected_request( "resolve Git project", |request| { @@ -80,6 +81,7 @@ fn list_json_returns_filtered_scan_contract() { let query_project = local_project.clone(); let api = ApiStub::start(vec![ verify_request(), + webapp_version_request(), expected_request( "list filtered scans as JSON", move |request| assert_scan_list_request(request, &query_project), @@ -127,6 +129,7 @@ fn list_exits_one_on_server_error() { let local_project = temp_project_name(project.path()); let api = ApiStub::start(vec![ verify_request(), + webapp_version_request(), expected_request( "reject scan list", move |request| assert_scan_list_request(request, &local_project), diff --git a/tests/cloud_commands_e2e/upload_wait.rs b/tests/cloud_commands_e2e/upload_wait.rs index 994ff77..4a5f629 100644 --- a/tests/cloud_commands_e2e/upload_wait.rs +++ b/tests/cloud_commands_e2e/upload_wait.rs @@ -68,7 +68,7 @@ fn upload_wait_uses_returned_ids_and_stops_at_complete() { fn wait_reports_an_immediately_complete_scan() { let project = TempDir::new().expect("create wait project"); let local_project = temp_project_name(project.path()); - let mut plan = vec![verify_request()]; + let mut plan = vec![verify_request(), webapp_version_request()]; append_wait_plan( &mut plan, &local_project, @@ -98,6 +98,7 @@ fn wait_exits_one_when_scan_list_fails() { let local_project = temp_project_name(project.path()); let api = ApiStub::start(vec![ verify_request(), + webapp_version_request(), expected_request( "reject scan list", move |request| assert_scan_list_request(request, &local_project), @@ -128,6 +129,7 @@ fn wait_exits_one_when_scan_detail_fails() { let detail_path = format!("/api/v1/scan/{scan_id}"); let api = ApiStub::start(vec![ verify_request(), + webapp_version_request(), expected_request( "reject scan detail", move |request| assert_authenticated_request(request, Method::GET, &detail_path), diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 77d4be4..f361d88 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -37,6 +37,8 @@ pub fn corgea_isolated() -> (Command, TempDir) { .env_remove("CORGEA_PYPI_REGISTRY") .env_remove("CORGEA_VULN_API_URL") .env_remove("CORGEA_VULN_API_SEND_TOKEN_TO_CUSTOM_URL") + .env_remove("CORGEA_MIN_WEBAPP_VERSION") + .env_remove("CORGEA_SKIP_WEBAPP_VERSION_CHECK") .env_remove("AI_AGENT") .env_remove("CODEX_SANDBOX") .env_remove("CLAUDECODE") @@ -568,6 +570,9 @@ pub struct Routes { pub scan_issues: Option, /// `GET /scan/{id}/issues/quality` — the `--code-quality --scan-id` route. pub scan_quality_issues: Option, + /// `GET /api/version` — the webapp compatibility pre-flight. Left unset it + /// 404s, which is how a webapp predating the endpoint answers. + pub version: Option, } #[allow(dead_code)] @@ -577,6 +582,8 @@ impl Routes { pub fn answer(&self, path: &str) -> (&'static str, String) { let body = if path.starts_with("/api/v1/verify") { Some(r#"{"status":"ok"}"#.to_string()) + } else if path == "/api/version" { + self.version.clone() } else if path.starts_with("/api/v1/projects?repo_url=") { self.projects.clone() } else if path.starts_with("/api/v1/scans?") { @@ -623,6 +630,12 @@ pub fn scan_issues_empty() -> String { r#"{"status":"ok","page":1,"total_pages":1,"total_issues":0,"issues":[]}"#.to_string() } +/// `GET /api/version` reporting `version`. +#[allow(dead_code)] +pub fn webapp_version(version: &str) -> String { + format!(r#"{{"status":"ok","version":"{version}"}}"#) +} + /// `GET /api/v1/scan/{id}` returning a completed scan (`check_scan_status` /// checks the lowercase `complete`). #[allow(dead_code)] From df1b5a3d9175de4fd58c6d51569ebbae4435a3ea Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 9 Aug 2026 10:08:58 +0000 Subject: [PATCH 2/3] Cap the webapp version pre-flight at its own short timeout The pre-flight inherited the shared client's 150s timeout, so a deployment that answered /api/v1/verify but stalled /api/version delayed the command the user actually asked for by two and a half minutes -- for a check that is advisory and whose failure is meant to cost nothing. Give it 5s of its own. Measured against a stub that accepts /api/version and never replies: 150.02s before, 5.02s after. Co-authored-by: ibrahim --- src/utils/api.rs | 22 ++++++++++-- tests/cli_webapp_version.rs | 68 +++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 2 deletions(-) diff --git a/src/utils/api.rs b/src/utils/api.rs index 615bdd1..c89dd84 100644 --- a/src/utils/api.rs +++ b/src/utils/api.rs @@ -22,6 +22,11 @@ const API_BASE: &str = "/api/v1"; /// versioned API so clients can read it before agreeing on an API version. const WEBAPP_VERSION_PATH: &str = "/api/version"; +/// Far below the shared client's timeout, because this request is advisory and +/// runs ahead of every authenticated command: a deployment that stalls this one +/// route must not hold the command the user actually asked for. +const WEBAPP_VERSION_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); + fn auth_headers(token: &str) -> HeaderMap { let mut headers = HeaderMap::new(); let (name, value) = auth_header(token); @@ -125,6 +130,14 @@ impl DebugRequestBuilder { } } + /// Override the shared client's timeout for this request alone. + pub fn timeout(self, timeout: std::time::Duration) -> Self { + Self { + inner: self.inner.timeout(timeout), + client: self.client, + } + } + pub fn send(self) -> reqwest::Result { use reqwest::cookie::CookieStore; @@ -1188,7 +1201,12 @@ pub fn verify_token(corgea_url: &str) -> Result> { /// /// `Ok(None)` means the deployment did not tell us: either it predates /// `/api/version` (404) or it could not determine its own version (null). -/// Callers treat both the same way. +/// +/// A 404 is not evidence of an outdated webapp and callers must not treat it as +/// one. The endpoint ships in a release *above* `MIN_WEBAPP_VERSION`, so every +/// deployment sitting exactly at the floor answers 404 while being perfectly +/// compatible. A 404 only narrows the version to "older than the release that +/// added this route", which spans both sides of the floor. /// /// Deliberately skips `check_for_warnings`: this runs as a pre-flight before /// the command the user asked for, and it must not be what terminates the @@ -1198,7 +1216,7 @@ pub fn get_webapp_version(corgea_url: &str) -> Result, Box String { + use std::io::Write; + use std::net::TcpListener; + + let routes = Routes { + scans: Some(scans_empty()), + ..Default::default() + }; + let listener = TcpListener::bind("127.0.0.1:0").expect("bind stub"); + let base_url = format!("http://127.0.0.1:{}", listener.local_addr().unwrap().port()); + std::thread::spawn(move || { + for stream in listener.incoming() { + let Ok(mut stream) = stream else { continue }; + let routes = routes.clone(); + std::thread::spawn(move || { + let buf = corgea::vuln_api_stub::read_http_request(&mut stream); + let request = String::from_utf8_lossy(&buf); + let path = request + .lines() + .next() + .and_then(|line| line.split_whitespace().nth(1)) + .unwrap_or("") + .to_string(); + if path == "/api/version" { + // Long enough that answering at all would fail the elapsed + // assertion, so only the per-request timeout can save it. + std::thread::sleep(Duration::from_secs(600)); + return; + } + let (status, body) = routes.answer(&path); + let response = corgea::vuln_api_stub::http_response(status, "", &body); + let _ = stream.write_all(response.as_bytes()); + }); + } + }); + base_url +} + +#[test] +fn a_stalled_version_endpoint_does_not_hold_up_the_command() { + // Without a timeout of its own the pre-flight inherits the shared client's + // 150s, delaying a command that was ready to run. + let url = spawn_stalling_version_stub(); + + let started = Instant::now(); + let out = run_list(&url, &[]); + let elapsed = started.elapsed(); + + assert!( + elapsed < Duration::from_secs(60), + "the advisory pre-flight held the command for {elapsed:?}" + ); + assert!( + out.status.success(), + "the command must still run; stderr: {}", + stderr(&out) + ); + assert!( + !stderr(&out).contains("requires Corgea webapp"), + "an unreachable endpoint must not warn; stderr: {}", + stderr(&out) + ); +} + #[test] fn the_skip_env_var_silences_the_warning_and_the_request() { let (url, hits) = spawn_stub(Some(webapp_version("v1.70.0"))); From bb7481c22187ffe06de27c236cb877405ef46084 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 9 Aug 2026 10:09:04 +0000 Subject: [PATCH 3/3] Only read a version anchored at the start of the string Matching a dotted number anywhere let a decorated value such as build-2026.08-v1.70.0 parse as 2026.8.0, which silently suppressed the warning an outdated webapp should have produced. Anchor the pattern and require the numbers to end the string or start a suffix, so a shape we do not recognize is unknown rather than optimistically new. Also record why a 404 stays silent: the endpoint ships in a release above MIN_WEBAPP_VERSION, so warning on it would flag every deployment sitting exactly at the floor. Co-authored-by: ibrahim --- src/version_check.rs | 36 ++++++++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/src/version_check.rs b/src/version_check.rs index ff5d9a5..5a6e6de 100644 --- a/src/version_check.rs +++ b/src/version_check.rs @@ -29,13 +29,20 @@ const SKIP_ENV_VAR: &str = "CORGEA_SKIP_WEBAPP_VERSION_CHECK"; /// `v1.71.3-client-a`). Only the leading numeric part is comparable, so a /// suffixed build counts as the release it was cut from rather than sorting /// below it the way semver pre-release ordering would. -static NUMERIC_VERSION: LazyLock = - LazyLock::new(|| Regex::new(r"(\d+)\.(\d+)(?:\.(\d+))?").expect("valid version regex")); - -/// The `major.minor.patch` numbers in `raw`, or `None` when it carries none. -/// A missing patch reads as `0`, so `v1.71` and `v1.71.0` compare equal. +/// +/// Anchored, and the numbers must end the string or start a suffix. Matching a +/// dotted number anywhere would read something like `build-2026.08-v1.70.0` as +/// `2026.8.0` and call an outdated webapp current; a shape we do not recognize +/// has to be unknown instead. +static NUMERIC_VERSION: LazyLock = LazyLock::new(|| { + Regex::new(r"^v?(\d+)\.(\d+)(?:\.(\d+))?(?:$|[-+])").expect("valid version regex") +}); + +/// The `major.minor.patch` numbers leading `raw`, or `None` when it is not a +/// shape we recognize. A missing patch reads as `0`, so `v1.71` and `v1.71.0` +/// compare equal. pub fn extract_version(raw: &str) -> Option { - let captures = NUMERIC_VERSION.captures(raw)?; + let captures = NUMERIC_VERSION.captures(raw.trim())?; let number = |group: usize| match captures.get(group) { Some(digits) => digits.as_str().parse::().ok(), None => Some(0), @@ -87,6 +94,9 @@ pub fn warn_if_webapp_outdated(corgea_url: &str) { let webapp_version = match api::get_webapp_version(corgea_url) { Ok(Some(version)) => version, + // Includes a 404 from a webapp predating the endpoint. Warning on that + // would flag every deployment sitting exactly at the floor, since the + // endpoint ships above it -- see `api::get_webapp_version`. Ok(None) => { debug("Webapp did not report a version; skipping compatibility check"); return; @@ -139,6 +149,20 @@ mod tests { assert_eq!(extract_version("main"), None); } + #[test] + fn a_version_buried_in_another_string_is_unknown() { + // Matching a dotted number anywhere would read the date here as + // 2026.8.0 and call this outdated webapp current. + assert_eq!(extract_version("build-2026.08-v1.70.0"), None); + assert_eq!(extract_version("corgea 1.70.0"), None); + assert_eq!(extract_version("1.71.3.4"), None); + } + + #[test] + fn surrounding_whitespace_is_tolerated() { + assert_eq!(extract_version(" v1.71.3\n"), Some(Version::new(1, 71, 3))); + } + #[test] fn warns_only_when_the_webapp_is_behind_the_minimum() { let warn = |version: &str| outdated_warning("https://corgea.test", version, "v1.71.3");