Skip to content
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
248 changes: 248 additions & 0 deletions crates/socket-patch-cli/src/ecosystem_dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -55,12 +67,63 @@ 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 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)
.await
.unwrap_or_default()
.is_empty()
}

/// 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)
.await
.unwrap_or_default()
.is_empty()
}
Comment thread
mikolalysenko marked this conversation as resolved.

/// 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:<type>/` (a newer CLI's ecosystem in a committed
Expand Down Expand Up @@ -454,10 +517,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);

Expand Down Expand Up @@ -1041,4 +1111,182 @@ 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"), "<project/>").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"), "<Project/>").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"
);
}

// 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"
);
}
}
Loading