From edbf7f368064617746d0bdf3a522bad0c37ee3d7 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 12 Aug 2026 16:37:35 -0700 Subject: [PATCH 1/2] fix(scan): warn when Maven/NuGet discovery is gated off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `scan --mode vendored`/`hosted` discovers packages through `crawl_all_ecosystems`, which silently skips Maven/NuGet when `SOCKET_EXPERIMENTAL_MAVEN` / `SOCKET_EXPERIMENTAL_NUGET` is off. The existing `warn_maven_disabled` / `warn_nuget_disabled` warnings only fire on the partitioned-apply path, so a Maven/NuGet project scanned in vendored/hosted mode with the flag off reported `scannedPackages: 0` and success with zero explanation. Add discovery-path warnings that fire once per run, only when a Maven/NuGet project is actually present on disk (reusing the crawler's own `get_maven_repo_paths` / `get_nuget_package_paths` discovery so the signal matches exactly what an opted-in scan would crawl). The gate itself is unchanged — Maven/NuGet remain opt-in; only the silent skip is surfaced. Co-Authored-By: Claude Opus 4.8 --- .../src/ecosystem_dispatch.rs | 152 ++++++++++++++++++ 1 file changed, 152 insertions(+) diff --git a/crates/socket-patch-cli/src/ecosystem_dispatch.rs b/crates/socket-patch-cli/src/ecosystem_dispatch.rs index d2b44ea4..80252be1 100644 --- a/crates/socket-patch-cli/src/ecosystem_dispatch.rs +++ b/crates/socket-patch-cli/src/ecosystem_dispatch.rs @@ -34,6 +34,18 @@ fn warn_maven_disabled(skipped: usize) { eprintln!(" Set SOCKET_EXPERIMENTAL_MAVEN=1 to enable at your own risk."); } +/// Discovery-path counterpart to [`warn_maven_disabled`]. `scan --mode +/// vendored`/`hosted` reaches the Maven repo through `crawl_all_ecosystems`, +/// not the partitioned-apply path, so the apply-time warning never fires +/// there. When a Maven project is present but the experimental flag is off, +/// discovery is skipped silently and the scan reports zero Maven packages +/// with no explanation — surface the skip so the user knows why. +fn warn_maven_discovery_gated() { + eprintln!("Warning: Maven project detected but not scanned — Maven support is experimental."); + eprintln!(" Maven patches corrupt jar sidecar checksums (sha1/md5)."); + eprintln!(" Set SOCKET_EXPERIMENTAL_MAVEN=1 to enable Maven discovery."); +} + /// Runtime opt-in gate for experimental NuGet support. Same shape as /// the Maven gate. Even with the sidecar fixup deleting /// `.nupkg.metadata`, signed packages still carry a `.nupkg.sha512` @@ -55,12 +67,48 @@ fn warn_nuget_disabled(skipped: usize) { eprintln!(" Set SOCKET_EXPERIMENTAL_NUGET=1 to enable at your own risk."); } +/// Discovery-path counterpart to [`warn_nuget_disabled`]; see +/// [`warn_maven_discovery_gated`] for why the apply-time warning is not +/// enough on the crawl path. +fn warn_nuget_discovery_gated() { + eprintln!("Warning: NuGet project detected but not scanned — NuGet support is experimental."); + eprintln!(" NuGet patches corrupt the .nupkg.sha512 signature sidecar that"); + eprintln!(" `dotnet restore` reads as tamper-evidence."); + eprintln!(" Set SOCKET_EXPERIMENTAL_NUGET=1 to enable NuGet discovery."); +} + fn env_truthy(name: &str) -> bool { std::env::var(name) .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) .unwrap_or(false) } +/// Should [`crawl_all_ecosystems`] warn that a Maven project on disk was +/// skipped? True only when the experimental runtime flag is OFF *and* the +/// crawler would actually have found Maven sources (a Maven/Gradle project +/// plus its local repository). Reusing the crawler's own path discovery +/// keeps the signal aligned with exactly what an opted-in scan would crawl, +/// and guards the warning against firing on non-Maven projects (one warning +/// per run, only when something was really skipped). +async fn maven_discovery_gated_off(options: &CrawlerOptions) -> bool { + !maven_runtime_enabled() + && !MavenCrawler + .get_maven_repo_paths(options) + .await + .unwrap_or_default() + .is_empty() +} + +/// NuGet counterpart to [`maven_discovery_gated_off`]. +async fn nuget_discovery_gated_off(options: &CrawlerOptions) -> bool { + !nuget_runtime_enabled() + && !NuGetCrawler + .get_nuget_package_paths(options) + .await + .unwrap_or_default() + .is_empty() +} + /// Whether [`crawl_all_ecosystems`] actually visits this PURL's ecosystem /// in THIS process. Maven and NuGet sit behind runtime opt-in gates, and an /// unrecognized `pkg:/` (a newer CLI's ecosystem in a committed @@ -454,10 +502,17 @@ pub async fn crawl_all_ecosystems( // walks the Maven repo only when the operator has explicitly // opted into experimental support. crawl!(Ecosystem::Maven, MavenCrawler); + } else if maven_discovery_gated_off(options).await { + // A Maven/Gradle project is present on disk but discovery is gated + // off; without this the scan silently reports zero Maven packages. + warn_maven_discovery_gated(); } crawl!(Ecosystem::Composer, ComposerCrawler); if nuget_runtime_enabled() { crawl!(Ecosystem::Nuget, NuGetCrawler); + } else if nuget_discovery_gated_off(options).await { + // A .NET project is present on disk but discovery is gated off. + warn_nuget_discovery_gated(); } crawl!(Ecosystem::Deno, DenoCrawler); @@ -1041,4 +1096,101 @@ mod tests { assert!(counts.contains_key(&Ecosystem::Pypi)); assert!(counts.contains_key(&Ecosystem::Gem)); } + + // ---- gated-off discovery warning signal ------------------------------ + // + // `scan --mode vendored`/`hosted` reaches Maven/NuGet through + // `crawl_all_ecosystems`, which skips them when the experimental flag is + // off. `*_discovery_gated_off` is the decision that drives the one-time + // "project detected but not scanned" warning, so a project on disk with + // the flag off no longer produces a silent `scannedPackages: 0`. These + // assert the decision fires exactly when a project IS present and the + // flag IS off — and NOT otherwise (no warning noise). + + #[tokio::test] + #[serial_test::serial(experimental_gate_env)] + async fn maven_discovery_gated_off_warns_for_pom_project_when_flag_off() { + let cwd = tempfile::tempdir().unwrap(); + std::fs::write(cwd.path().join("pom.xml"), "").unwrap(); + // A real (existing) local Maven repo so discovery yields a path. + let repo = tempfile::tempdir().unwrap(); + std::env::set_var("MAVEN_REPO_LOCAL", repo.path()); + let opts = local_options(cwd.path().to_path_buf()); + + std::env::remove_var("SOCKET_EXPERIMENTAL_MAVEN"); + let gated_off = maven_discovery_gated_off(&opts).await; + + // With the flag ON the same project is crawled, not warned about. + std::env::set_var("SOCKET_EXPERIMENTAL_MAVEN", "1"); + let gated_on = maven_discovery_gated_off(&opts).await; + + std::env::remove_var("SOCKET_EXPERIMENTAL_MAVEN"); + std::env::remove_var("MAVEN_REPO_LOCAL"); + + assert!( + gated_off, + "a Maven project with the experimental flag off must be flagged as gated" + ); + assert!( + !gated_on, + "with the experimental flag on the project is crawled, so no warning" + ); + } + + #[tokio::test] + #[serial_test::serial(experimental_gate_env)] + async fn maven_discovery_not_gated_without_java_project() { + // No pom.xml/build.gradle marker: nothing to scan, so no warning even + // though the flag is off (guards against warning noise). + let cwd = tempfile::tempdir().unwrap(); + std::env::remove_var("SOCKET_EXPERIMENTAL_MAVEN"); + let gated = maven_discovery_gated_off(&local_options(cwd.path().to_path_buf())).await; + assert!( + !gated, + "a non-Maven project must not produce a gated-discovery warning" + ); + } + + #[tokio::test] + #[serial_test::serial(experimental_gate_env)] + async fn nuget_discovery_gated_off_warns_for_dotnet_project_when_flag_off() { + let cwd = tempfile::tempdir().unwrap(); + std::fs::write(cwd.path().join("App.csproj"), "").unwrap(); + // A real (existing) global NuGet cache so discovery yields a path. + let cache = tempfile::tempdir().unwrap(); + std::env::set_var("NUGET_PACKAGES", cache.path()); + let opts = local_options(cwd.path().to_path_buf()); + + std::env::remove_var("SOCKET_EXPERIMENTAL_NUGET"); + let gated_off = nuget_discovery_gated_off(&opts).await; + + std::env::set_var("SOCKET_EXPERIMENTAL_NUGET", "1"); + let gated_on = nuget_discovery_gated_off(&opts).await; + + std::env::remove_var("SOCKET_EXPERIMENTAL_NUGET"); + std::env::remove_var("NUGET_PACKAGES"); + + assert!( + gated_off, + "a .NET project with the experimental flag off must be flagged as gated" + ); + assert!( + !gated_on, + "with the experimental flag on the project is crawled, so no warning" + ); + } + + #[tokio::test] + #[serial_test::serial(experimental_gate_env)] + async fn nuget_discovery_not_gated_without_dotnet_project() { + // No .csproj/.sln/packages.config marker: nothing to scan, so no + // warning even though the flag is off (guards against warning noise). + let cwd = tempfile::tempdir().unwrap(); + std::env::remove_var("SOCKET_EXPERIMENTAL_NUGET"); + let gated = nuget_discovery_gated_off(&local_options(cwd.path().to_path_buf())).await; + assert!( + !gated, + "a non-.NET project must not produce a gated-discovery warning" + ); + } } From fb5057e46a13ad06b460ca2caf3c75da046701dd Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Thu, 13 Aug 2026 10:37:55 -0700 Subject: [PATCH 2/2] fix(scan): suppress gated-off Maven/NuGet warning in global scans `maven_discovery_gated_off` / `nuget_discovery_gated_off` treated any non-empty `get_maven_repo_paths` / `get_nuget_package_paths` result as "a local project was detected". But in `--global` / `--global-prefix` mode those helpers return the shared cache path with NO project-marker check (`global_prefix` skips even the `is_dir` check), so an ordinary global scan emitted a false "Maven/NuGet project detected but gated" warning for a project that was never there. Return early (never gated) in global / global-prefix mode, matching how the crawlers themselves only apply the project-marker gate on the local path. Local project scans (a real `pom.xml` / `.csproj` with the flag off) still warn. The experimental gate itself is unchanged. Adds hermetic unit tests asserting the decision stays FALSE in global and global-prefix mode even with a populated cache present. Co-Authored-By: Claude Opus 4.8 --- .../src/ecosystem_dispatch.rs | 108 +++++++++++++++++- 1 file changed, 102 insertions(+), 6 deletions(-) diff --git a/crates/socket-patch-cli/src/ecosystem_dispatch.rs b/crates/socket-patch-cli/src/ecosystem_dispatch.rs index 80252be1..96c88317 100644 --- a/crates/socket-patch-cli/src/ecosystem_dispatch.rs +++ b/crates/socket-patch-cli/src/ecosystem_dispatch.rs @@ -85,12 +85,22 @@ fn env_truthy(name: &str) -> bool { /// Should [`crawl_all_ecosystems`] warn that a Maven project on disk was /// skipped? True only when the experimental runtime flag is OFF *and* the -/// crawler would actually have found Maven sources (a Maven/Gradle project -/// plus its local repository). Reusing the crawler's own path discovery -/// keeps the signal aligned with exactly what an opted-in scan would crawl, -/// and guards the warning against firing on non-Maven projects (one warning -/// per run, only when something was really skipped). +/// crawler would actually have found a *local* Maven project (a Maven/Gradle +/// marker plus its local repository). Reusing the crawler's own path +/// discovery keeps the signal aligned with exactly what an opted-in scan +/// would crawl, and guards the warning against firing on non-Maven projects +/// (one warning per run, only when something was really skipped). +/// +/// The warning only makes sense for a *local project* scan. In `--global` / +/// `--global-prefix` mode `get_maven_repo_paths` returns the shared cache +/// path with NO project-marker check (`global_prefix` skips even the `is_dir` +/// check), so a non-empty result there says nothing about a project being +/// present — a global scan of the `~/.m2` cache would otherwise falsely warn +/// "Maven project detected but gated". Suppress it in those modes. async fn maven_discovery_gated_off(options: &CrawlerOptions) -> bool { + if options.global || options.global_prefix.is_some() { + return false; + } !maven_runtime_enabled() && !MavenCrawler .get_maven_repo_paths(options) @@ -99,8 +109,13 @@ async fn maven_discovery_gated_off(options: &CrawlerOptions) -> bool { .is_empty() } -/// NuGet counterpart to [`maven_discovery_gated_off`]. +/// NuGet counterpart to [`maven_discovery_gated_off`]. Likewise suppressed in +/// `--global` / `--global-prefix` mode, where `get_nuget_package_paths` +/// returns the shared cache path without a .NET-project-marker check. async fn nuget_discovery_gated_off(options: &CrawlerOptions) -> bool { + if options.global || options.global_prefix.is_some() { + return false; + } !nuget_runtime_enabled() && !NuGetCrawler .get_nuget_package_paths(options) @@ -1193,4 +1208,85 @@ mod tests { "a non-.NET project must not produce a gated-discovery warning" ); } + + // The gated-off warning is about a LOCAL project being silently skipped. + // A `--global` / `--global-prefix` scan reads the shared cache directly: + // `get_maven_repo_paths` / `get_nuget_package_paths` return the cache path + // with NO project-marker check (`global_prefix` skips even the `is_dir` + // check), so a non-empty result there says nothing about a local project + // being present. The decision must stay FALSE in those modes — otherwise + // an ordinary global scan emits a false "project detected but gated" + // warning for a project that was never there. + + #[tokio::test] + #[serial_test::serial(experimental_gate_env)] + async fn maven_discovery_not_gated_in_global_or_prefix_mode() { + // A real (existing) local Maven repo so global discovery yields a path. + let repo = tempfile::tempdir().unwrap(); + std::env::set_var("MAVEN_REPO_LOCAL", repo.path()); + std::env::remove_var("SOCKET_EXPERIMENTAL_MAVEN"); + // A cwd with no Java project marker anywhere. + let cwd = tempfile::tempdir().unwrap(); + + let global = CrawlerOptions { + cwd: cwd.path().to_path_buf(), + global: true, + global_prefix: None, + }; + let global_gated = maven_discovery_gated_off(&global).await; + + let prefix = CrawlerOptions { + cwd: cwd.path().to_path_buf(), + global: false, + global_prefix: Some(repo.path().to_path_buf()), + }; + let prefix_gated = maven_discovery_gated_off(&prefix).await; + + std::env::remove_var("MAVEN_REPO_LOCAL"); + + assert!( + !global_gated, + "--global scan hits the shared cache, not a local project — must not warn as gated" + ); + assert!( + !prefix_gated, + "--global-prefix scan targets an explicit cache path — must not warn as gated" + ); + } + + #[tokio::test] + #[serial_test::serial(experimental_gate_env)] + async fn nuget_discovery_not_gated_in_global_or_prefix_mode() { + // A real (existing) global NuGet cache so global discovery yields a path. + let cache = tempfile::tempdir().unwrap(); + std::env::set_var("NUGET_PACKAGES", cache.path()); + std::env::remove_var("SOCKET_EXPERIMENTAL_NUGET"); + // A cwd with no .NET project marker anywhere. + let cwd = tempfile::tempdir().unwrap(); + + let global = CrawlerOptions { + cwd: cwd.path().to_path_buf(), + global: true, + global_prefix: None, + }; + let global_gated = nuget_discovery_gated_off(&global).await; + + let prefix = CrawlerOptions { + cwd: cwd.path().to_path_buf(), + global: false, + global_prefix: Some(cache.path().to_path_buf()), + }; + let prefix_gated = nuget_discovery_gated_off(&prefix).await; + + std::env::remove_var("NUGET_PACKAGES"); + + assert!( + !global_gated, + "--global scan hits the shared cache, not a local project — must not warn as gated" + ); + assert!( + !prefix_gated, + "--global-prefix scan targets an explicit cache path — must not warn as gated" + ); + } }