From 6df756e1f979b0ff6b7f57eb2da548d4540fdc84 Mon Sep 17 00:00:00 2001 From: "Martin Kolinek (from Dev Box)" Date: Fri, 11 Sep 2026 20:20:07 +0200 Subject: [PATCH 01/13] feat(cargo-aprz): discover tokens from GitHub CLI Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a9fc919b-99f7-4134-aad1-2116321b4e0c --- crates/cargo-aprz-lib/src/commands/common.rs | 17 +- .../src/commands/github_credentials.rs | 526 ++++++++++++++++++ crates/cargo-aprz-lib/src/commands/mod.rs | 1 + .../tests/hosting_provider_integration.rs | 60 +- .../tests/process_globals_integration.rs | 2 + crates/cargo-aprz-lib/tests/support/mod.rs | 2 + crates/cargo-aprz/README.md | 15 +- crates/cargo-aprz/docs/DESIGN.md | 32 ++ crates/cargo-aprz/src/main.rs | 15 +- 9 files changed, 660 insertions(+), 10 deletions(-) create mode 100644 crates/cargo-aprz-lib/src/commands/github_credentials.rs diff --git a/crates/cargo-aprz-lib/src/commands/common.rs b/crates/cargo-aprz-lib/src/commands/common.rs index ccd6c4529..ca51d26a5 100644 --- a/crates/cargo-aprz-lib/src/commands/common.rs +++ b/crates/cargo-aprz-lib/src/commands/common.rs @@ -19,6 +19,7 @@ use ohno::IntoAppError; use super::ProgressReporter; use super::cache_dir::platform_cache_dir; use super::config::Config; +use super::github_credentials::{GitHubToken, discover}; use crate::Result; use crate::expr::{ExpressionDisposition, ExpressionOutcome, Risk, evaluate}; use crate::facts::{Collector, CrateFacts, CrateRef, Endpoints, ProviderResult}; @@ -76,9 +77,12 @@ pub enum ConsoleSection { /// Common arguments shared between crates and deps commands #[derive(Args, Debug)] pub struct CommonArgs { - /// GitHub personal access token - #[arg(long, value_name = "TOKEN", env = "GITHUB_TOKEN")] - pub github_token: Option, + /// GitHub token. + /// + /// Defaults to `GITHUB_TOKEN`, then the authenticated `gh` token for the + /// configured GitHub host. If none is available, GitHub access is anonymous. + #[arg(long, value_name = "TOKEN")] + pub github_token: Option, /// Codeberg personal access token #[arg(long, value_name = "TOKEN", env = "CODEBERG_TOKEN")] @@ -260,8 +264,11 @@ impl<'a, H: super::Host> Common<'a, H> { let progress_reporter = ProgressReporter::new(delay, use_colors_for_progress); + let endpoints = args.endpoints(); + let github_token = discover(args.github_token.as_ref(), &endpoints).await; + let collector = Collector::new( - args.github_token.as_deref(), + github_token.as_ref().map(GitHubToken::expose_secret), args.codeberg_token.as_deref(), &cache_dir, config.crates_cache_ttl, @@ -272,7 +279,7 @@ impl<'a, H: super::Host> Common<'a, H> { args.ignore_cached, config.bug_label_matcher()?.into(), progress_reporter, - &args.endpoints(), + &endpoints, ) .await?; diff --git a/crates/cargo-aprz-lib/src/commands/github_credentials.rs b/crates/cargo-aprz-lib/src/commands/github_credentials.rs new file mode 100644 index 000000000..4e18e53fa --- /dev/null +++ b/crates/cargo-aprz-lib/src/commands/github_credentials.rs @@ -0,0 +1,526 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Best-effort GitHub credential discovery. + +use std::convert::Infallible; +use std::ffi::{OsStr, OsString}; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::str::FromStr; +use std::{fmt, io}; + +use tokio::process::Command; +use url::Url; + +use crate::facts::Endpoints; + +const GITHUB_TOKEN_ENV: &str = "GITHUB_TOKEN"; +const LOG_TARGET: &str = "credentials"; + +/// A GitHub token whose debug representation never exposes its value. +#[derive(Clone, PartialEq, Eq)] +pub struct GitHubToken(String); + +impl GitHubToken { + /// Expose the token only where it is attached to the GitHub HTTP client. + pub fn expose_secret(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for GitHubToken { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("GitHubToken([REDACTED])") + } +} + +impl FromStr for GitHubToken { + type Err = Infallible; + + fn from_str(value: &str) -> Result { + Ok(Self(value.to_owned())) + } +} + +struct GhCommandOutput { + success: bool, + stdout: Vec, +} + +/// Resolve the GitHub credential used by the hosting provider. +pub(super) async fn discover(explicit: Option<&GitHubToken>, endpoints: &Endpoints) -> Option { + discover_with(explicit, endpoints, || std::env::var_os(GITHUB_TOKEN_ENV), query_gh).await +} + +async fn discover_with( + explicit: Option<&GitHubToken>, + endpoints: &Endpoints, + read_environment: impl FnOnce() -> Option, + query_gh: impl FnOnce(String) -> OutputFuture, +) -> Option +where + OutputFuture: Future>, +{ + if let Some(token) = explicit { + log::trace!(target: LOG_TARGET, "GitHub credential source: --github-token"); + return Some(token.clone()); + } + + if let Some(token) = read_environment() { + return if let Ok(token) = token.into_string() { + log::trace!(target: LOG_TARGET, "GitHub credential source: {GITHUB_TOKEN_ENV}"); + Some(GitHubToken(token)) + } else { + log::trace!( + target: LOG_TARGET, + "GitHub credential source {GITHUB_TOKEN_ENV} is not valid UTF-8; using anonymous access" + ); + None + }; + } + + let Some(hostname) = github_hostname(endpoints) else { + log::trace!( + target: LOG_TARGET, + "Effective GitHub service URL has no usable hostname; using anonymous access" + ); + return None; + }; + + let output = match query_gh(hostname.clone()).await { + Ok(output) => output, + Err(error) => { + log::trace!( + target: LOG_TARGET, + "GitHub credential source gh could not be executed for host '{hostname}' ({}); using anonymous access", + error.kind() + ); + return None; + } + }; + + if !output.success { + log::trace!( + target: LOG_TARGET, + "GitHub credential source gh did not return a token for host '{hostname}'; using anonymous access" + ); + return None; + } + + let Ok(stdout) = String::from_utf8(output.stdout) else { + log::trace!( + target: LOG_TARGET, + "GitHub credential source gh returned non-UTF-8 output for host '{hostname}'; using anonymous access" + ); + return None; + }; + + let token = stdout.trim(); + if token.is_empty() { + log::trace!( + target: LOG_TARGET, + "GitHub credential source gh returned a blank token for host '{hostname}'; using anonymous access" + ); + return None; + } + + log::trace!(target: LOG_TARGET, "GitHub credential source: gh for host '{hostname}'"); + Some(GitHubToken(token.to_owned())) +} + +fn github_hostname(endpoints: &Endpoints) -> Option { + let url = Url::parse(endpoints.host_url("github.com")?).ok()?; + let hostname = url.host_str()?; + + Some(if hostname.eq_ignore_ascii_case("api.github.com") { + "github.com".to_owned() + } else { + hostname.to_owned() + }) +} + +async fn query_gh(hostname: String) -> io::Result { + let executable = resolve_gh_executable().await?; + let output = Command::new(executable) + .args(["auth", "token", "--hostname"]) + .arg(hostname) + .stdin(Stdio::null()) + .stderr(Stdio::null()) + .output() + .await?; + + Ok(GhCommandOutput { + success: output.status.success(), + stdout: output.stdout, + }) +} + +async fn resolve_gh_executable() -> io::Result { + resolve_executable_async(resolve_gh_from_environment).await +} + +async fn resolve_executable_async(resolver: impl FnOnce() -> Option + Send + 'static) -> io::Result { + tokio::task::spawn_blocking(resolver) + .await + .map_err(io::Error::other)? + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "gh was not found on PATH")) +} + +fn resolve_gh_from_environment() -> Option { + let path = std::env::var_os("PATH")?; + let current_dir = std::env::current_dir().ok()?; + let path_ext = std::env::var_os("PATHEXT"); + + resolve_executable(OsStr::new("gh"), &path, path_ext.as_deref(), ¤t_dir) +} + +/// Resolves `program` only from explicit, non-empty PATH entries. +fn resolve_executable(program: &OsStr, path: &OsStr, path_ext: Option<&OsStr>, current_dir: &Path) -> Option { + debug_assert!(current_dir.is_absolute(), "the process current directory is absolute"); + let executable_names = executable_names(program, path_ext); + + std::env::split_paths(path) + // An empty component can make OS command lookup search CWD implicitly. Requiring an + // actual entry such as `.` keeps repository-local executables opt-in. + .filter(|directory| !directory.as_os_str().is_empty()) + .map(|directory| { + if directory.is_absolute() { + directory + } else { + current_dir.join(directory) + } + }) + .flat_map(|directory| executable_names.iter().map(move |name| directory.join(name))) + .find(|candidate| is_executable(candidate)) +} + +#[cfg(windows)] +fn executable_names(program: &OsStr, path_ext: Option<&OsStr>) -> Vec { + const DEFAULT_PATH_EXT: &str = ".COM;.EXE;.BAT;.CMD"; + + if Path::new(program).extension().is_some() { + return vec![program.to_owned()]; + } + + let path_ext = path_ext + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| OsStr::new(DEFAULT_PATH_EXT)); + std::env::split_paths(path_ext) + .filter(|extension| !extension.as_os_str().is_empty()) + .map(|extension| { + let extension = extension.as_os_str(); + let mut executable = program.to_owned(); + if !extension.to_string_lossy().starts_with('.') { + executable.push("."); + } + executable.push(extension); + executable + }) + .collect() +} + +#[cfg(not(windows))] +fn executable_names(program: &OsStr, _path_ext: Option<&OsStr>) -> Vec { + vec![program.to_owned()] +} + +#[cfg(unix)] +fn is_executable(candidate: &Path) -> bool { + use std::os::unix::fs::PermissionsExt as _; + + candidate + .metadata() + .is_ok_and(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0) +} + +#[cfg(not(unix))] +fn is_executable(candidate: &Path) -> bool { + candidate.is_file() +} + +#[cfg(test)] +#[cfg(not(miri))] +mod tests { + use std::cell::{Cell, RefCell}; + use std::fs; + use std::time::{Duration, Instant}; + + use super::*; + + fn token(value: &str) -> GitHubToken { + value.parse().expect("GitHubToken parsing is infallible") + } + + fn successful(stdout: &[u8]) -> GhCommandOutput { + GhCommandOutput { + success: true, + stdout: stdout.to_vec(), + } + } + + #[tokio::test] + async fn explicit_token_precedes_environment_and_gh() { + let environment_read = Cell::new(false); + let gh_called = Cell::new(false); + let explicit = token("explicit-secret"); + + let selected = discover_with( + Some(&explicit), + &Endpoints::default(), + || { + environment_read.set(true); + Some(OsString::from("environment-secret")) + }, + |_| { + gh_called.set(true); + std::future::ready(Ok(successful(b"gh-secret"))) + }, + ) + .await + .expect("the explicit token is selected"); + + assert_eq!(selected.expose_secret(), "explicit-secret"); + assert!(!environment_read.get(), "an explicit token suppresses environment lookup"); + assert!(!gh_called.get(), "an explicit token suppresses gh"); + } + + #[tokio::test] + async fn environment_token_precedes_gh() { + let gh_called = Cell::new(false); + + let selected = discover_with( + None, + &Endpoints::default(), + || Some(OsString::from("environment-secret")), + |_| { + gh_called.set(true); + std::future::ready(Ok(successful(b"gh-secret"))) + }, + ) + .await + .expect("the environment token is selected"); + + assert_eq!(selected.expose_secret(), "environment-secret"); + assert!(!gh_called.get(), "an environment token suppresses gh"); + } + + #[tokio::test] + async fn gh_uses_the_enterprise_hostname_and_trims_stdout() { + let requested_hostname = RefCell::new(None); + let endpoints = Endpoints::default().with_github_url("https://github.example.test/api/v3"); + + let selected = discover_with( + None, + &endpoints, + || None, + |hostname| { + requested_hostname.replace(Some(hostname)); + std::future::ready(Ok(successful(b" gh-secret\r\n"))) + }, + ) + .await + .expect("gh returned a token"); + + assert_eq!(requested_hostname.borrow().as_deref(), Some("github.example.test")); + assert_eq!(selected.expose_secret(), "gh-secret"); + } + + #[tokio::test] + async fn public_api_uses_the_github_com_login() { + let requested_hostname = RefCell::new(None); + + let selected = discover_with( + None, + &Endpoints::default(), + || None, + |hostname| { + requested_hostname.replace(Some(hostname)); + std::future::ready(Ok(successful(b"gh-secret"))) + }, + ) + .await; + + assert!(selected.is_some()); + assert_eq!(requested_hostname.borrow().as_deref(), Some("github.com")); + } + + #[tokio::test] + async fn command_not_found_continues_anonymously() { + let selected = discover_with( + None, + &Endpoints::default(), + || None, + |_| std::future::ready(Err(io::Error::new(io::ErrorKind::NotFound, "test gh is absent"))), + ) + .await; + + assert!(selected.is_none()); + } + + #[tokio::test] + async fn unsuccessful_command_continues_anonymously_without_exposing_stdout() { + let secret = "failed-command-secret"; + let selected = discover_with( + None, + &Endpoints::default(), + || None, + |_| { + std::future::ready(Ok(GhCommandOutput { + success: false, + stdout: secret.as_bytes().to_vec(), + })) + }, + ) + .await; + + assert!(selected.is_none()); + } + + #[tokio::test] + async fn blank_command_output_continues_anonymously() { + let selected = discover_with( + None, + &Endpoints::default(), + || None, + |_| std::future::ready(Ok(successful(b" \r\n\t "))), + ) + .await; + + assert!(selected.is_none()); + } + + #[tokio::test] + async fn non_utf8_command_output_continues_anonymously() { + let selected = discover_with( + None, + &Endpoints::default(), + || None, + |_| std::future::ready(Ok(successful(&[0xff, 0xfe]))), + ) + .await; + + assert!(selected.is_none()); + } + + #[test] + fn path_resolution_prefers_path_over_a_planted_cwd_executable() { + let root = tempfile::tempdir().expect("creating a resolver fixture"); + let current_dir = root.path().join("project"); + let path_dir = root.path().join("bin"); + fs::create_dir_all(¤t_dir).expect("creating the project directory"); + fs::create_dir_all(&path_dir).expect("creating the PATH directory"); + write_test_executable(¤t_dir.join(test_gh_name())); + let expected = path_dir.join(test_gh_name()); + write_test_executable(&expected); + let path = std::env::join_paths([&path_dir]).expect("the fixture PATH is valid"); + + let resolved = resolve_test_executable(&path, ¤t_dir); + + assert_eq!(resolved.as_deref(), Some(expected.as_path())); + } + + #[test] + fn path_resolution_does_not_search_cwd_implicitly() { + let root = tempfile::tempdir().expect("creating a resolver fixture"); + let current_dir = root.path().join("project"); + fs::create_dir_all(¤t_dir).expect("creating the project directory"); + write_test_executable(¤t_dir.join(test_gh_name())); + + let resolved = resolve_test_executable(OsStr::new(""), ¤t_dir); + + assert!(resolved.is_none()); + } + + #[test] + fn path_resolution_honors_an_explicit_cwd_entry() { + let root = tempfile::tempdir().expect("creating a resolver fixture"); + let current_dir = root.path().join("project"); + fs::create_dir_all(¤t_dir).expect("creating the project directory"); + let expected = current_dir.join(test_gh_name()); + write_test_executable(&expected); + + let resolved = resolve_test_executable(OsStr::new("."), ¤t_dir); + + assert_eq!(resolved.as_deref(), Some(current_dir.join(".").join(test_gh_name()).as_path())); + } + + #[cfg(windows)] + #[test] + fn path_resolution_uses_pathext_order() { + let root = tempfile::tempdir().expect("creating a resolver fixture"); + let current_dir = root.path().join("project"); + let path_dir = root.path().join("bin"); + fs::create_dir_all(¤t_dir).expect("creating the project directory"); + fs::create_dir_all(&path_dir).expect("creating the PATH directory"); + write_test_executable(&path_dir.join("gh.EXE")); + let expected = path_dir.join("gh.CMD"); + write_test_executable(&expected); + let path = std::env::join_paths([&path_dir]).expect("the fixture PATH is valid"); + + let resolved = resolve_executable(OsStr::new("gh"), &path, Some(OsStr::new(".CMD;.EXE")), ¤t_dir); + + assert_eq!(resolved.as_deref(), Some(expected.as_path())); + } + + #[tokio::test(flavor = "current_thread")] + async fn executable_resolution_does_not_block_the_async_worker() { + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let resolution = tokio::spawn(resolve_executable_async(move || { + let _ = started_tx.send(Instant::now()); + std::thread::sleep(Duration::from_millis(400)); + None + })); + + let started_at = started_rx.await.expect("the blocking resolver starts"); + assert!( + started_at.elapsed() < Duration::from_millis(200), + "the async worker was blocked by executable resolution" + ); + + let error = resolution + .await + .expect("the resolution task does not panic") + .expect_err("the fixture resolver returns no executable"); + assert_eq!(error.kind(), io::ErrorKind::NotFound); + } + + #[test] + fn token_debug_output_is_redacted() { + let secret = "diagnostic-secret"; + let diagnostic = format!("{:?}", token(secret)); + + assert!(!diagnostic.contains(secret)); + assert_eq!(diagnostic, "GitHubToken([REDACTED])"); + } + + #[cfg(windows)] + fn test_gh_name() -> &'static str { + "gh.EXE" + } + + #[cfg(not(windows))] + fn test_gh_name() -> &'static str { + "gh" + } + + fn resolve_test_executable(path: &OsStr, current_dir: &Path) -> Option { + #[cfg(windows)] + let path_ext = Some(OsStr::new(".EXE")); + #[cfg(not(windows))] + let path_ext = None; + + resolve_executable(OsStr::new("gh"), path, path_ext, current_dir) + } + + fn write_test_executable(path: &Path) { + fs::write(path, b"resolver fixture").expect("writing the resolver fixture"); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + + fs::set_permissions(path, fs::Permissions::from_mode(0o755)).expect("making the resolver fixture executable"); + } + } +} diff --git a/crates/cargo-aprz-lib/src/commands/mod.rs b/crates/cargo-aprz-lib/src/commands/mod.rs index 7ff0cc73b..364835d0d 100644 --- a/crates/cargo-aprz-lib/src/commands/mod.rs +++ b/crates/cargo-aprz-lib/src/commands/mod.rs @@ -44,6 +44,7 @@ mod config; mod crates; mod deps; mod duration; +mod github_credentials; mod host; mod init; mod progress_reporter; diff --git a/crates/cargo-aprz-lib/tests/hosting_provider_integration.rs b/crates/cargo-aprz-lib/tests/hosting_provider_integration.rs index a58a96360..f8ae37f9c 100644 --- a/crates/cargo-aprz-lib/tests/hosting_provider_integration.rs +++ b/crates/cargo-aprz-lib/tests/hosting_provider_integration.rs @@ -19,7 +19,7 @@ use chrono::{DateTime, SecondsFormat, Utc}; use semver::Version; use serde_json::json; use url::Url; -use wiremock::matchers::{method, path, query_param}; +use wiremock::matchers::{header, method, path, query_param}; use wiremock::{Mock, MockServer, ResponseTemplate}; /// No-op progress reporter for testing @@ -210,6 +210,64 @@ async fn derives_hosting_data_from_a_github_repository() { assert_eq!(data.open_pr_age.p50, 5); } +#[tokio::test] +#[cfg_attr(miri, ignore = "Miri cannot call CreateIoCompletionPort")] +async fn scopes_credentials_to_their_host_without_leaking_them_in_errors() { + let server = MockServer::start().await; + let github_token = "github-diagnostic-secret"; + let codeberg_token = "codeberg-diagnostic-secret"; + + Mock::given(method("GET")) + .and(path("/repos/github-owner/private")) + .and(header("authorization", format!("token {github_token}"))) + .respond_with(ResponseTemplate::new(400)) + .expect(1) + .mount(&server) + .await; + + Mock::given(method("GET")) + .and(path("/repos/codeberg-owner/public")) + .and(header("authorization", format!("token {codeberg_token}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "stars_count": 3, + "forks_count": 2, + "watchers_count": 1, + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/repos/codeberg-owner/public/issues")) + .and(header("authorization", format!("token {codeberg_token}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(Vec::::new())) + .expect(1) + .mount(&server) + .await; + + let endpoints = Endpoints::default().with_github_url(server.uri()).with_codeberg_url(server.uri()); + let cache_dir = tempfile::tempdir().expect("creating a cache directory"); + let provider = Provider::new( + Some(github_token), + Some(codeberg_token), + cache_in(cache_dir.path(), false), + bug_matcher(), + &endpoints, + ) + .expect("credential headers are valid"); + + let github_error = expect_error(fetch_one(&provider, spec_for("private", "github.com", "github-owner", "private")).await); + assert!(!github_error.contains(github_token), "GitHub errors must redact credentials"); + assert!( + !github_error.contains(codeberg_token), + "GitHub errors must not contain Codeberg credentials" + ); + + let codeberg_data = expect_found(fetch_one(&provider, spec_for("public", "codeberg.org", "codeberg-owner", "public")).await); + assert_eq!(codeberg_data.stars, 3); + assert_eq!(codeberg_data.forks, 2); + assert_eq!(codeberg_data.subscribers, 1); +} + #[tokio::test] #[cfg_attr(miri, ignore = "Miri cannot call CreateIoCompletionPort")] async fn consumes_every_page_advertised_by_the_link_header() { diff --git a/crates/cargo-aprz-lib/tests/process_globals_integration.rs b/crates/cargo-aprz-lib/tests/process_globals_integration.rs index 6344de153..a1dd0f341 100644 --- a/crates/cargo-aprz-lib/tests/process_globals_integration.rs +++ b/crates/cargo-aprz-lib/tests/process_globals_integration.rs @@ -65,6 +65,8 @@ async fn cli_uses_the_platform_cache_directory_and_installs_a_logger() { &service_uri, "--github-url", &service_uri, + "--github-token", + "integration-github-token", "--codeberg-url", &service_uri, "--advisory-url", diff --git a/crates/cargo-aprz-lib/tests/support/mod.rs b/crates/cargo-aprz-lib/tests/support/mod.rs index 8d5c4658c..7db865350 100644 --- a/crates/cargo-aprz-lib/tests/support/mod.rs +++ b/crates/cargo-aprz-lib/tests/support/mod.rs @@ -159,6 +159,8 @@ impl MockWorld { service_uri.clone(), "--github-url".to_owned(), service_uri.clone(), + "--github-token".to_owned(), + "integration-github-token".to_owned(), "--codeberg-url".to_owned(), service_uri, "--color".to_owned(), diff --git a/crates/cargo-aprz/README.md b/crates/cargo-aprz/README.md index c36d11763..09eb39599 100644 --- a/crates/cargo-aprz/README.md +++ b/crates/cargo-aprz/README.md @@ -167,14 +167,25 @@ results in very low rate limits. If `cargo-aprz` detects it is being throttled b to try the operation again. When using the `deps` command on a large project, it’s likely you’ll hit these rate limits, which can make the process take hours to complete fully. -In such a case, you can provide a GitHub or Codeberg token on the command-line or through environment variables, which gives you substantially higher +In such a case, you can provide a GitHub or Codeberg token on the command line or through environment variables, which gives you substantially higher rate limits. ```bash cargo aprz deps --github-token --codeberg-token ``` -You can also set the `GITHUB_TOKEN` and `CODEBERG_TOKEN` environment variables, which `cargo-aprz` will automatically pick up. +GitHub credentials are discovered in this order: + +1. `--github-token` +1. `GITHUB_TOKEN` +1. the token reported by `gh auth token --hostname ` +1. anonymous access + +The host passed to `gh` comes from the effective GitHub service URL, including +`--github-url` or `APRZ_GITHUB_URL` overrides for GitHub Enterprise. Missing +`gh` authentication is ignored and retains the existing anonymous rate-limit +behavior. Codeberg credentials continue to use `--codeberg-token` or +`CODEBERG_TOKEN`. ### Reports diff --git a/crates/cargo-aprz/docs/DESIGN.md b/crates/cargo-aprz/docs/DESIGN.md index 8e07da68b..40b18383f 100644 --- a/crates/cargo-aprz/docs/DESIGN.md +++ b/crates/cargo-aprz/docs/DESIGN.md @@ -59,6 +59,38 @@ service continues to use its default. Overriding an address does not change how the corresponding provider interprets the responses it receives, so a substitute service must speak the same protocol as the one it replaces. +### GitHub credential discovery + +GitHub fact collection accepts anonymous access, but a dependency appraisal can +exhaust anonymous API limits quickly. Credential resolution follows this order: + +1. the explicit `--github-token` command-line option; +2. the `GITHUB_TOKEN` environment variable; +3. the output of `gh auth token --hostname ` when the `gh` executable is + available and has an authenticated account for the configured GitHub host; +4. anonymous access. + +`` comes from the effective GitHub service address, so a +`--github-url`/`APRZ_GITHUB_URL` override can use the matching GitHub Enterprise +login rather than accidentally querying `github.com`. + +The `gh` fallback is convenience, not a prerequisite. A missing executable, +missing login, nonzero `gh auth token` result, blank output, or non-UTF-8 output +continues anonymously and retains the provider's existing rate-limit behavior. +An explicit option or environment token is authoritative; cargo-aprz never +invokes `gh` when either is present. + +The command is spawned directly without a shell. Its stdout is trimmed and used +only as the request credential; it is never logged, cached, included in an +error, or inherited by unrelated child processes. Stderr from a failed +best-effort lookup is suppressed. Diagnostic tracing reports only the credential +source, never the token. Before spawning, cargo-aprz resolves `gh` to an absolute +path by scanning only explicit `PATH` entries; it does not use the process +current directory unless that directory appears in `PATH`. On Windows, +resolution follows `PATHEXT` ordering. Filesystem resolution runs on a blocking +worker and the child process is awaited asynchronously so credential discovery +does not block an async runtime worker. + ## Cache storage Provider data is stored beneath a platform-specific cache root, partitioned by diff --git a/crates/cargo-aprz/src/main.rs b/crates/cargo-aprz/src/main.rs index dd5ee27f7..3684a3b36 100644 --- a/crates/cargo-aprz/src/main.rs +++ b/crates/cargo-aprz/src/main.rs @@ -148,14 +148,25 @@ //! to try the operation again. //! //! When using the `deps` command on a large project, it's likely you'll hit these rate limits, which can make the process take hours to complete fully. -//! In such a case, you can provide a GitHub or Codeberg token on the command-line or through environment variables, which gives you substantially higher +//! In such a case, you can provide a GitHub or Codeberg token on the command line or through environment variables, which gives you substantially higher //! rate limits. //! //! ```bash //! cargo aprz deps --github-token --codeberg-token //! ``` //! -//! You can also set the `GITHUB_TOKEN` and `CODEBERG_TOKEN` environment variables, which `cargo-aprz` will automatically pick up. +//! GitHub credentials are discovered in this order: +//! +//! 1. `--github-token` +//! 2. `GITHUB_TOKEN` +//! 3. the token reported by `gh auth token --hostname ` +//! 4. anonymous access +//! +//! The host passed to `gh` comes from the effective GitHub service URL, including +//! `--github-url` or `APRZ_GITHUB_URL` overrides for GitHub Enterprise. Missing +//! `gh` authentication is ignored and retains the existing anonymous rate-limit +//! behavior. Codeberg credentials continue to use `--codeberg-token` or +//! `CODEBERG_TOKEN`. //! //! ## Reports //! From 9f37740e373892716ef3d71a9ab7923e77019930 Mon Sep 17 00:00:00 2001 From: "Martin Kolinek (from Dev Box)" Date: Tue, 15 Sep 2026 16:36:39 +0200 Subject: [PATCH 02/13] fix(cargo-aprz): harden GitHub credential discovery Keep native Anvil runs host-aware, bound and directly test the gh process bridge, ignore blank environment tokens, and reject Windows batch shims. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a9fc919b-99e3-4546-847a-e30dd5cb18a4 --- .anvil.lock | 4 +- crates/cargo-anvil/docs/design/containers.md | 10 +- .../src/anvil/artifacts/justfile.rs | 10 +- .../justfiles/anvil/checks/aprz.just | 23 +- crates/cargo-anvil/tests/recipe_contracts.rs | 41 +-- .../src/commands/github_credentials.rs | 249 ++++++++++++++++-- crates/cargo-aprz/README.md | 6 +- crates/cargo-aprz/docs/DESIGN.md | 17 +- crates/cargo-aprz/src/main.rs | 6 +- justfiles/anvil/checks/aprz.just | 23 +- 10 files changed, 277 insertions(+), 112 deletions(-) diff --git a/.anvil.lock b/.anvil.lock index 9f8d24363..42553bda6 100644 --- a/.anvil.lock +++ b/.anvil.lock @@ -1,7 +1,7 @@ version = 1 tool = "anvil" tool_version = "0.9.0" -catalog_checksum = "sha256:8daaee8ac9a6ceb2c71b002ff6eb85d49057ae8df7c56f2c4c9a2805de3288bb" +catalog_checksum = "sha256:28e0d00fd960d23f25da98b05e271ec031959c9736c2ec7d1c11d658a69d1723" [[file]] path = ".anvil/container/Dockerfile.dockerignore" @@ -57,7 +57,7 @@ checksum = "sha256:d4d3bd645a5586e9a1cc3a5fc27e38c93e59b1e29f83ede0ccd610273eec0 [[file]] path = "justfiles/anvil/checks/aprz.just" -checksum = "sha256:e10edbbe60358ec930241cd5d4e681a84f30f9d5278e652696e2f149e112d066" +checksum = "sha256:5811524f5b9433a570de936a5a3d4593d17e2e4b93f164039a9f9160620de267" [[file]] path = "justfiles/anvil/checks/audit.just" diff --git a/crates/cargo-anvil/docs/design/containers.md b/crates/cargo-anvil/docs/design/containers.md index 92e5c9252..c7ac785e7 100644 --- a/crates/cargo-anvil/docs/design/containers.md +++ b/crates/cargo-anvil/docs/design/containers.md @@ -396,10 +396,12 @@ otherwise the engine leaves it as `/`, and anything falling back to `$HOME` writ ### 5.3 Environment -The run passes `ANVIL_IN_CONTAINER=1` (§5.4) and forwards `GITHUB_TOKEN` by name, resolved the way the recipe resolves -it natively: the environment first, then the gh CLI's stored token. `anvil-aprz` runs in the `scheduled-advisories` group and -queries the GitHub advisory API, which allows 60 requests an hour unauthenticated and then sleeps until the quota -resets, so a tier needs the token to terminate rather than merely to run quickly. +The run passes `ANVIL_IN_CONTAINER=1` (§5.4) and forwards `GITHUB_TOKEN` by name, resolved from the environment first +and then the gh CLI's stored `github.com` token. This wrapper lookup is only for container execution, because the image +has no gh CLI of its own. Native `anvil-aprz` leaves discovery to cargo-aprz, which selects the gh login from its +effective GitHub endpoint and therefore respects GitHub Enterprise overrides. `anvil-aprz` runs in the +`scheduled-advisories` group and queries the GitHub advisory API, which allows 60 requests an hour unauthenticated and +then sleeps until the quota resets, so a tier needs the token to terminate rather than merely to run quickly. The two sources are not treated alike. An **exported** `GITHUB_TOKEN` is forwarded whatever the target is — that is exact parity, since a native run exposes it to every process the shell spawns too. A token **derived** from the gh CLI diff --git a/crates/cargo-anvil/src/anvil/artifacts/justfile.rs b/crates/cargo-anvil/src/anvil/artifacts/justfile.rs index dc4777691..445e01ff7 100644 --- a/crates/cargo-anvil/src/anvil/artifacts/justfile.rs +++ b/crates/cargo-anvil/src/anvil/artifacts/justfile.rs @@ -78,16 +78,14 @@ macro_rules! split_recipe_files { const DEV_FILES: &[(&str, &str)] = split_recipe_files!("dev", ["build"]); #[test] -fn aprz_forwards_a_github_token_into_the_container() { +fn aprz_leaves_native_github_credential_discovery_to_cargo_aprz() { let aprz = CHECK_FILES .iter() .find_map(|(path, body)| path.ends_with("/aprz.just").then_some(*body)) .expect("aprz.just is registered in CHECK_FILES below"); - // The container driver forwards GITHUB_TOKEN by name, so the check reads - // the variable and says how to obtain one rather than reaching for a - // mounted secret path. - assert!(aprz.contains("GITHUB_TOKEN")); - assert!(aprz.contains("gh auth")); + assert!(!aprz.contains("Get-Command gh")); + assert!(!aprz.contains("$env:GITHUB_TOKEN =")); + assert!(aprz.contains("cargo {{_anvil_stable_toolchain_args}} aprz deps")); } /// One `justfiles/anvil/checks/.just` file per catalog check diff --git a/crates/cargo-anvil/templates/justfiles/anvil/checks/aprz.just b/crates/cargo-anvil/templates/justfiles/anvil/checks/aprz.just index e068388cd..170e5b325 100644 --- a/crates/cargo-anvil/templates/justfiles/anvil/checks/aprz.just +++ b/crates/cargo-anvil/templates/justfiles/anvil/checks/aprz.just @@ -10,12 +10,10 @@ # capped at 60 requests an hour, and on a full workspace it exhausts that # and then waits for the quota to reset rather than failing; an # authenticated token raises the cap to 5000/hour. CI injects GITHUB_TOKEN -# (github.token). For local runs, if GITHUB_TOKEN is unset we borrow the -# gh CLI's stored token (non-interactive: `gh auth token` prints the -# active account's token for github.com and never opens a browser/auth -# prompt). If neither is available we warn with instructions and proceed -# unauthenticated. In a container the driver resolves the token the same -# way and forwards it by name, because the image has no gh CLI of its own. +# (github.token). For native local runs, cargo-aprz performs host-aware +# discovery from GITHUB_TOKEN and the gh CLI. In a container the driver +# resolves and forwards GITHUB_TOKEN by name because the image has no gh CLI +# of its own. # # Unscoped (consults external risk DB). @@ -23,19 +21,6 @@ [script("pwsh", "-NoProfile")] anvil-aprz: anvil-aprz-validate-prereqs $ErrorActionPreference = 'Stop' - if (-not $env:GITHUB_TOKEN) { - $tok = $null - if (Get-Command gh -ErrorAction SilentlyContinue) { - try { $tok = (gh auth token --hostname github.com 2>$null) } catch { $tok = $null } - } - if ($tok) { - $env:GITHUB_TOKEN = $tok.Trim() - } else { - Write-Warning 'anvil-aprz: GITHUB_TOKEN is not set and no token could be obtained from the gh CLI.' - Write-Warning 'cargo-aprz will use the unauthenticated GitHub API, which allows 60 requests an hour. On a full workspace it exhausts that and then blocks, for up to an hour, waiting for the quota to reset.' - Write-Warning 'To fix: run `gh auth login` (recommended), or set $env:GITHUB_TOKEN to a GitHub token, then re-run.' - } - } & cargo {{_anvil_stable_toolchain_args}} aprz deps --error-if-high-risk --console appraisal if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/crates/cargo-anvil/tests/recipe_contracts.rs b/crates/cargo-anvil/tests/recipe_contracts.rs index be7d85a44..4c1ca8f5d 100644 --- a/crates/cargo-anvil/tests/recipe_contracts.rs +++ b/crates/cargo-anvil/tests/recipe_contracts.rs @@ -2408,14 +2408,12 @@ fn windows_arm64_fallback_accepts_empty_nextest_sets_in_both_configurations() { assert!(!calls.contains("llvm-cov"), "coverage commands must not run:\n{calls}"); } -// --- container-specific behaviour ------------------------------------------ +// --- credential-specific behaviour ----------------------------------------- -/// `anvil-aprz` warns and proceeds when it cannot obtain a token, rather than -/// throwing. That change exists so a containerized tier is not aborted by a -/// missing credential, and nothing else covers it: the dogfood run normally has -/// a host token, and the tokenless container E2E case runs a custom echo recipe. +/// Native `anvil-aprz` must not preempt cargo-aprz's host-aware credential +/// discovery with a token hard-coded for github.com. #[test] -fn aprz_without_a_token_warns_and_still_runs() { +fn aprz_leaves_native_credential_discovery_to_cargo_aprz() { if !tools_available() { return; } @@ -2426,24 +2424,6 @@ fn aprz_without_a_token_warns_and_still_runs() { "anvil-tool-cargo-aprz-install installer=\"install\"", ], ); - // A gh that yields no token: the recipe must fall through to the warnings - // rather than treating a failed lookup as fatal. - // - // Three stubs because command lookup differs by platform and the fallback - // is the developer's real, signed-in `gh`: on Windows only `.cmd` is in - // PATHEXT, so a `.ps1` stub is skipped; on Unix a bare `gh` must exist and - // be executable. Getting this wrong does not fail the test -- it makes it - // pass while exercising the authenticated path, which is the opposite of - // what the name claims. - write(&tmp.path().join("fake-bin/gh.cmd"), "@exit /b 1\r\n"); - write(&tmp.path().join("fake-bin/gh.ps1"), "exit 1\n"); - let unix_stub = tmp.path().join("fake-bin/gh"); - write(&unix_stub, "#!/bin/sh\nexit 1\n"); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt as _; - std::fs::set_permissions(&unix_stub, std::fs::Permissions::from_mode(0o755)).unwrap(); - } let log = tmp.path().join("cargo.log"); let output = run_just( @@ -2452,31 +2432,26 @@ fn aprz_without_a_token_warns_and_still_runs() { &[ ("FAKE_CARGO_LOG", log.as_os_str()), ("GITHUB_TOKEN", OsStr::new("")), - ("ANVIL_IN_CONTAINER", OsStr::new("1")), + ("APRZ_GITHUB_URL", OsStr::new("https://github.example.test/api/v3")), ], ); assert!( output.status.success(), - "a missing token must not fail the check\nstdout:\n{}\nstderr:\n{}", + "credential discovery must be left to cargo-aprz\nstdout:\n{}\nstderr:\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); - // PowerShell's warning stream surfaces on stdout once `just` has run the - // script, so assert on what the developer actually sees rather than on a - // particular stream. let seen = format!( "{}{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); assert!( - seen.contains("GITHUB_TOKEN is not set"), - "the warning must name the variable:\n{seen}" + !seen.contains("gh auth token"), + "the wrapper must not perform its own GitHub CLI lookup:\n{seen}" ); - assert!(seen.contains("gh auth login"), "the warning must say how to fix it:\n{seen}"); - // The point of warning rather than throwing: the check still runs. let calls = std::fs::read_to_string(&log).unwrap_or_default(); assert!(calls.contains("aprz deps"), "cargo aprz must still be invoked:\n{calls}"); } diff --git a/crates/cargo-aprz-lib/src/commands/github_credentials.rs b/crates/cargo-aprz-lib/src/commands/github_credentials.rs index 4e18e53fa..01668a39a 100644 --- a/crates/cargo-aprz-lib/src/commands/github_credentials.rs +++ b/crates/cargo-aprz-lib/src/commands/github_credentials.rs @@ -8,6 +8,7 @@ use std::ffi::{OsStr, OsString}; use std::path::{Path, PathBuf}; use std::process::Stdio; use std::str::FromStr; +use std::time::Duration; use std::{fmt, io}; use tokio::process::Command; @@ -16,6 +17,7 @@ use url::Url; use crate::facts::Endpoints; const GITHUB_TOKEN_ENV: &str = "GITHUB_TOKEN"; +const GH_COMMAND_TIMEOUT: Duration = Duration::from_secs(10); const LOG_TARGET: &str = "credentials"; /// A GitHub token whose debug representation never exposes its value. @@ -48,6 +50,31 @@ struct GhCommandOutput { stdout: Vec, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum GhStdio { + Null, + Capture, +} + +impl GhStdio { + fn into_stdio(self) -> Stdio { + match self { + Self::Null => Stdio::null(), + Self::Capture => Stdio::piped(), + } + } +} + +#[derive(Debug, PartialEq, Eq)] +struct GhCommandRequest { + executable: PathBuf, + args: Vec, + stdin: GhStdio, + stdout: GhStdio, + stderr: GhStdio, + timeout: Duration, +} + /// Resolve the GitHub credential used by the hosting provider. pub(super) async fn discover(explicit: Option<&GitHubToken>, endpoints: &Endpoints) -> Option { discover_with(explicit, endpoints, || std::env::var_os(GITHUB_TOKEN_ENV), query_gh).await @@ -68,16 +95,22 @@ where } if let Some(token) = read_environment() { - return if let Ok(token) = token.into_string() { - log::trace!(target: LOG_TARGET, "GitHub credential source: {GITHUB_TOKEN_ENV}"); - Some(GitHubToken(token)) - } else { + let Ok(token) = token.into_string() else { log::trace!( target: LOG_TARGET, "GitHub credential source {GITHUB_TOKEN_ENV} is not valid UTF-8; using anonymous access" ); - None + return None; }; + let token = token.trim(); + if !token.is_empty() { + log::trace!(target: LOG_TARGET, "GitHub credential source: {GITHUB_TOKEN_ENV}"); + return Some(GitHubToken(token.to_owned())); + } + log::trace!( + target: LOG_TARGET, + "GitHub credential source {GITHUB_TOKEN_ENV} is blank; continuing credential discovery" + ); } let Some(hostname) = github_hostname(endpoints) else { @@ -141,14 +174,60 @@ fn github_hostname(endpoints: &Endpoints) -> Option { } async fn query_gh(hostname: String) -> io::Result { - let executable = resolve_gh_executable().await?; - let output = Command::new(executable) - .args(["auth", "token", "--hostname"]) - .arg(hostname) - .stdin(Stdio::null()) - .stderr(Stdio::null()) - .output() - .await?; + query_gh_with(hostname, resolve_gh_executable, run_gh_command).await +} + +async fn query_gh_with( + hostname: String, + resolve_executable: impl FnOnce() -> ResolveFuture, + run_command: impl FnOnce(GhCommandRequest) -> RunFuture, +) -> io::Result +where + ResolveFuture: Future>, + RunFuture: Future>, +{ + let executable = resolve_executable().await?; + if !executable.is_absolute() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "resolved gh executable path was not absolute", + )); + } + + run_command(GhCommandRequest { + executable, + args: ["auth", "token", "--hostname"] + .into_iter() + .map(OsString::from) + .chain(std::iter::once(OsString::from(hostname))) + .collect(), + stdin: GhStdio::Null, + stdout: GhStdio::Capture, + stderr: GhStdio::Null, + timeout: GH_COMMAND_TIMEOUT, + }) + .await +} + +async fn run_gh_command(request: GhCommandRequest) -> io::Result { + let GhCommandRequest { + executable, + args, + stdin, + stdout, + stderr, + timeout, + } = request; + let child = Command::new(executable) + .args(args) + .stdin(stdin.into_stdio()) + .stdout(stdout.into_stdio()) + .stderr(stderr.into_stdio()) + .kill_on_drop(true) + .spawn()?; + let output = tokio::time::timeout(timeout, child.wait_with_output()) + .await + .map_err(|elapsed| io::Error::new(io::ErrorKind::TimedOut, elapsed))??; Ok(GhCommandOutput { success: output.status.success(), @@ -200,7 +279,10 @@ fn executable_names(program: &OsStr, path_ext: Option<&OsStr>) -> Vec const DEFAULT_PATH_EXT: &str = ".COM;.EXE;.BAT;.CMD"; if Path::new(program).extension().is_some() { - return vec![program.to_owned()]; + return windows_executable_image_extension(Path::new(program).extension().unwrap_or_default()) + .then(|| program.to_owned()) + .into_iter() + .collect(); } let path_ext = path_ext @@ -208,6 +290,7 @@ fn executable_names(program: &OsStr, path_ext: Option<&OsStr>) -> Vec .unwrap_or_else(|| OsStr::new(DEFAULT_PATH_EXT)); std::env::split_paths(path_ext) .filter(|extension| !extension.as_os_str().is_empty()) + .filter(|extension| windows_executable_image_extension(extension.as_os_str())) .map(|extension| { let extension = extension.as_os_str(); let mut executable = program.to_owned(); @@ -220,6 +303,13 @@ fn executable_names(program: &OsStr, path_ext: Option<&OsStr>) -> Vec .collect() } +#[cfg(windows)] +fn windows_executable_image_extension(extension: &OsStr) -> bool { + let extension = extension.to_string_lossy(); + let extension = extension.strip_prefix('.').unwrap_or_else(|| extension.as_ref()); + extension.eq_ignore_ascii_case("com") || extension.eq_ignore_ascii_case("exe") +} + #[cfg(not(windows))] fn executable_names(program: &OsStr, _path_ext: Option<&OsStr>) -> Vec { vec![program.to_owned()] @@ -244,7 +334,7 @@ fn is_executable(candidate: &Path) -> bool { mod tests { use std::cell::{Cell, RefCell}; use std::fs; - use std::time::{Duration, Instant}; + use std::time::Instant; use super::*; @@ -305,6 +395,28 @@ mod tests { assert!(!gh_called.get(), "an environment token suppresses gh"); } + #[tokio::test] + async fn blank_environment_tokens_continue_to_gh() { + for environment in ["", " \r\n\t "] { + let gh_called = Cell::new(false); + + let selected = discover_with( + None, + &Endpoints::default(), + || Some(OsString::from(environment)), + |_| { + gh_called.set(true); + std::future::ready(Ok(successful(b"gh-secret"))) + }, + ) + .await + .expect("a blank environment token falls through to gh"); + + assert_eq!(selected.expose_secret(), "gh-secret"); + assert!(gh_called.get(), "gh is queried for a blank environment token"); + } + } + #[tokio::test] async fn gh_uses_the_enterprise_hostname_and_trims_stdout() { let requested_hostname = RefCell::new(None); @@ -358,6 +470,19 @@ mod tests { assert!(selected.is_none()); } + #[tokio::test] + async fn timed_out_command_continues_anonymously() { + let selected = discover_with( + None, + &Endpoints::default(), + || None, + |_| std::future::ready(Err(io::Error::new(io::ErrorKind::TimedOut, "test gh lookup expired"))), + ) + .await; + + assert!(selected.is_none()); + } + #[tokio::test] async fn unsuccessful_command_continues_anonymously_without_exposing_stdout() { let secret = "failed-command-secret"; @@ -403,6 +528,79 @@ mod tests { assert!(selected.is_none()); } + #[tokio::test] + async fn query_gh_builds_the_production_process_request() { + let root = tempfile::tempdir().expect("creating a command fixture"); + let executable = root.path().join(test_gh_name()); + let observed = RefCell::new(None); + + let output = query_gh_with( + "github.example.test".to_owned(), + || std::future::ready(Ok(executable.clone())), + |request| { + observed.replace(Some(request)); + std::future::ready(Ok(successful(b"fixture-token\n"))) + }, + ) + .await + .expect("the injected command runner succeeds"); + + assert!(output.success); + assert_eq!(output.stdout, b"fixture-token\n"); + assert_eq!( + observed.into_inner(), + Some(GhCommandRequest { + executable, + args: ["auth", "token", "--hostname", "github.example.test"] + .into_iter() + .map(OsString::from) + .collect(), + stdin: GhStdio::Null, + stdout: GhStdio::Capture, + stderr: GhStdio::Null, + timeout: GH_COMMAND_TIMEOUT, + }) + ); + } + + #[tokio::test] + async fn command_timeout_is_reported_and_does_not_wait_for_the_child() { + let executable = std::env::current_exe().expect("the test harness executable has an absolute path"); + let module = module_path!().split_once("::").map_or(module_path!(), |(_, module)| module); + let fixture = format!("{module}::command_timeout_child_fixture"); + let started = Instant::now(); + + let Err(error) = run_gh_command(GhCommandRequest { + executable, + args: ["--ignored", "--exact"] + .into_iter() + .map(OsString::from) + .chain(std::iter::once(OsString::from(fixture))) + .collect(), + stdin: GhStdio::Null, + stdout: GhStdio::Capture, + stderr: GhStdio::Null, + timeout: Duration::from_millis(50), + }) + .await + else { + panic!("the child exceeds the test timeout"); + }; + + assert_eq!(error.kind(), io::ErrorKind::TimedOut); + assert!( + started.elapsed() < Duration::from_secs(5), + "the timed-out child was awaited instead of terminated" + ); + } + + #[test] + #[ignore = "subprocess fixture for command_timeout_is_reported_and_does_not_wait_for_the_child"] + #[cfg_attr(coverage_nightly, coverage(off))] + fn command_timeout_child_fixture() { + std::thread::sleep(Duration::from_secs(30)); + } + #[test] fn path_resolution_prefers_path_over_a_planted_cwd_executable() { let root = tempfile::tempdir().expect("creating a resolver fixture"); @@ -454,15 +652,32 @@ mod tests { fs::create_dir_all(¤t_dir).expect("creating the project directory"); fs::create_dir_all(&path_dir).expect("creating the PATH directory"); write_test_executable(&path_dir.join("gh.EXE")); - let expected = path_dir.join("gh.CMD"); + let expected = path_dir.join("gh.COM"); write_test_executable(&expected); let path = std::env::join_paths([&path_dir]).expect("the fixture PATH is valid"); - let resolved = resolve_executable(OsStr::new("gh"), &path, Some(OsStr::new(".CMD;.EXE")), ¤t_dir); + let resolved = resolve_executable(OsStr::new("gh"), &path, Some(OsStr::new(".CMD;.COM;.EXE")), ¤t_dir); assert_eq!(resolved.as_deref(), Some(expected.as_path())); } + #[cfg(windows)] + #[test] + fn path_resolution_rejects_batch_scripts() { + let root = tempfile::tempdir().expect("creating a resolver fixture"); + let current_dir = root.path().join("project"); + let path_dir = root.path().join("bin"); + fs::create_dir_all(¤t_dir).expect("creating the project directory"); + fs::create_dir_all(&path_dir).expect("creating the PATH directory"); + write_test_executable(&path_dir.join("gh.CMD")); + write_test_executable(&path_dir.join("gh.BAT")); + let path = std::env::join_paths([&path_dir]).expect("the fixture PATH is valid"); + + let resolved = resolve_executable(OsStr::new("gh"), &path, Some(OsStr::new(".CMD;.BAT")), ¤t_dir); + + assert!(resolved.is_none()); + } + #[tokio::test(flavor = "current_thread")] async fn executable_resolution_does_not_block_the_async_worker() { let (started_tx, started_rx) = tokio::sync::oneshot::channel(); diff --git a/crates/cargo-aprz/README.md b/crates/cargo-aprz/README.md index 09eb39599..31e8d20ff 100644 --- a/crates/cargo-aprz/README.md +++ b/crates/cargo-aprz/README.md @@ -183,9 +183,9 @@ GitHub credentials are discovered in this order: The host passed to `gh` comes from the effective GitHub service URL, including `--github-url` or `APRZ_GITHUB_URL` overrides for GitHub Enterprise. Missing -`gh` authentication is ignored and retains the existing anonymous rate-limit -behavior. Codeberg credentials continue to use `--codeberg-token` or -`CODEBERG_TOKEN`. +or blank credentials and a `gh` lookup that exceeds its finite deadline are +ignored and retain the existing anonymous rate-limit behavior. Codeberg +credentials continue to use `--codeberg-token` or `CODEBERG_TOKEN`. ### Reports diff --git a/crates/cargo-aprz/docs/DESIGN.md b/crates/cargo-aprz/docs/DESIGN.md index 40b18383f..4aa2c1b93 100644 --- a/crates/cargo-aprz/docs/DESIGN.md +++ b/crates/cargo-aprz/docs/DESIGN.md @@ -77,8 +77,10 @@ login rather than accidentally querying `github.com`. The `gh` fallback is convenience, not a prerequisite. A missing executable, missing login, nonzero `gh auth token` result, blank output, or non-UTF-8 output continues anonymously and retains the provider's existing rate-limit behavior. -An explicit option or environment token is authoritative; cargo-aprz never -invokes `gh` when either is present. +An explicit option is authoritative, as is a nonblank environment token; +cargo-aprz never invokes `gh` when either applies. Environment values are +trimmed; an empty or whitespace-only `GITHUB_TOKEN` is treated as absent so +host-aware `gh` discovery can continue. The command is spawned directly without a shell. Its stdout is trimmed and used only as the request credential; it is never logged, cached, included in an @@ -86,10 +88,13 @@ error, or inherited by unrelated child processes. Stderr from a failed best-effort lookup is suppressed. Diagnostic tracing reports only the credential source, never the token. Before spawning, cargo-aprz resolves `gh` to an absolute path by scanning only explicit `PATH` entries; it does not use the process -current directory unless that directory appears in `PATH`. On Windows, -resolution follows `PATHEXT` ordering. Filesystem resolution runs on a blocking -worker and the child process is awaited asynchronously so credential discovery -does not block an async runtime worker. +current directory unless that directory appears in `PATH`. On Windows, only +directly executable `.COM` and `.EXE` images are considered, in `PATHEXT` +ordering; `.BAT` and `.CMD` shims are excluded so lookup never delegates +argument parsing to `cmd.exe`. Filesystem resolution runs on a blocking worker +and the child process is awaited asynchronously with a ten-second deadline. +Expiry terminates the child and continues anonymously, so credential discovery +does not block an async runtime worker indefinitely. ## Cache storage diff --git a/crates/cargo-aprz/src/main.rs b/crates/cargo-aprz/src/main.rs index 3684a3b36..5ef5f9996 100644 --- a/crates/cargo-aprz/src/main.rs +++ b/crates/cargo-aprz/src/main.rs @@ -164,9 +164,9 @@ //! //! The host passed to `gh` comes from the effective GitHub service URL, including //! `--github-url` or `APRZ_GITHUB_URL` overrides for GitHub Enterprise. Missing -//! `gh` authentication is ignored and retains the existing anonymous rate-limit -//! behavior. Codeberg credentials continue to use `--codeberg-token` or -//! `CODEBERG_TOKEN`. +//! or blank credentials and a `gh` lookup that exceeds its finite deadline are +//! ignored and retain the existing anonymous rate-limit behavior. Codeberg +//! credentials continue to use `--codeberg-token` or `CODEBERG_TOKEN`. //! //! ## Reports //! diff --git a/justfiles/anvil/checks/aprz.just b/justfiles/anvil/checks/aprz.just index e068388cd..170e5b325 100644 --- a/justfiles/anvil/checks/aprz.just +++ b/justfiles/anvil/checks/aprz.just @@ -10,12 +10,10 @@ # capped at 60 requests an hour, and on a full workspace it exhausts that # and then waits for the quota to reset rather than failing; an # authenticated token raises the cap to 5000/hour. CI injects GITHUB_TOKEN -# (github.token). For local runs, if GITHUB_TOKEN is unset we borrow the -# gh CLI's stored token (non-interactive: `gh auth token` prints the -# active account's token for github.com and never opens a browser/auth -# prompt). If neither is available we warn with instructions and proceed -# unauthenticated. In a container the driver resolves the token the same -# way and forwards it by name, because the image has no gh CLI of its own. +# (github.token). For native local runs, cargo-aprz performs host-aware +# discovery from GITHUB_TOKEN and the gh CLI. In a container the driver +# resolves and forwards GITHUB_TOKEN by name because the image has no gh CLI +# of its own. # # Unscoped (consults external risk DB). @@ -23,19 +21,6 @@ [script("pwsh", "-NoProfile")] anvil-aprz: anvil-aprz-validate-prereqs $ErrorActionPreference = 'Stop' - if (-not $env:GITHUB_TOKEN) { - $tok = $null - if (Get-Command gh -ErrorAction SilentlyContinue) { - try { $tok = (gh auth token --hostname github.com 2>$null) } catch { $tok = $null } - } - if ($tok) { - $env:GITHUB_TOKEN = $tok.Trim() - } else { - Write-Warning 'anvil-aprz: GITHUB_TOKEN is not set and no token could be obtained from the gh CLI.' - Write-Warning 'cargo-aprz will use the unauthenticated GitHub API, which allows 60 requests an hour. On a full workspace it exhausts that and then blocks, for up to an hour, waiting for the quota to reset.' - Write-Warning 'To fix: run `gh auth login` (recommended), or set $env:GITHUB_TOKEN to a GitHub token, then re-run.' - } - } & cargo {{_anvil_stable_toolchain_args}} aprz deps --error-if-high-risk --console appraisal if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } From 45a9bb1c724a959c72612421183836288d180064 Mon Sep 17 00:00:00 2001 From: "Martin Kolinek (from Dev Box)" Date: Tue, 15 Sep 2026 17:13:19 +0200 Subject: [PATCH 03/13] test(cargo-anvil): refresh APRZ recipe snapshots Record the generated trees after native APRZ credential discovery moved into cargo-aprz. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a9fc919b-99e3-4546-847a-e30dd5cb18a4 --- .../snapshots/snapshots__ado_backend.snap | 23 ++++--------------- .../snapshots/snapshots__github_backend.snap | 23 ++++--------------- .../snapshots/snapshots__local_only.snap | 23 ++++--------------- 3 files changed, 12 insertions(+), 57 deletions(-) diff --git a/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap b/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap index db2a3abec..64eca5453 100644 --- a/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap +++ b/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap @@ -1828,12 +1828,10 @@ unknown-git = "deny" # capped at 60 requests an hour, and on a full workspace it exhausts that # and then waits for the quota to reset rather than failing; an # authenticated token raises the cap to 5000/hour. CI injects GITHUB_TOKEN -# (github.token). For local runs, if GITHUB_TOKEN is unset we borrow the -# gh CLI's stored token (non-interactive: `gh auth token` prints the -# active account's token for github.com and never opens a browser/auth -# prompt). If neither is available we warn with instructions and proceed -# unauthenticated. In a container the driver resolves the token the same -# way and forwards it by name, because the image has no gh CLI of its own. +# (github.token). For native local runs, cargo-aprz performs host-aware +# discovery from GITHUB_TOKEN and the gh CLI. In a container the driver +# resolves and forwards GITHUB_TOKEN by name because the image has no gh CLI +# of its own. # # Unscoped (consults external risk DB). @@ -1841,19 +1839,6 @@ unknown-git = "deny" [script("pwsh", "-NoProfile")] anvil-aprz: anvil-aprz-validate-prereqs $ErrorActionPreference = 'Stop' - if (-not $env:GITHUB_TOKEN) { - $tok = $null - if (Get-Command gh -ErrorAction SilentlyContinue) { - try { $tok = (gh auth token --hostname github.com 2>$null) } catch { $tok = $null } - } - if ($tok) { - $env:GITHUB_TOKEN = $tok.Trim() - } else { - Write-Warning 'anvil-aprz: GITHUB_TOKEN is not set and no token could be obtained from the gh CLI.' - Write-Warning 'cargo-aprz will use the unauthenticated GitHub API, which allows 60 requests an hour. On a full workspace it exhausts that and then blocks, for up to an hour, waiting for the quota to reset.' - Write-Warning 'To fix: run `gh auth login` (recommended), or set $env:GITHUB_TOKEN to a GitHub token, then re-run.' - } - } & cargo {{_anvil_stable_toolchain_args}} aprz deps --error-if-high-risk --console appraisal if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap b/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap index 41071d4bc..4be059d5a 100644 --- a/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap +++ b/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap @@ -1970,12 +1970,10 @@ unknown-git = "deny" # capped at 60 requests an hour, and on a full workspace it exhausts that # and then waits for the quota to reset rather than failing; an # authenticated token raises the cap to 5000/hour. CI injects GITHUB_TOKEN -# (github.token). For local runs, if GITHUB_TOKEN is unset we borrow the -# gh CLI's stored token (non-interactive: `gh auth token` prints the -# active account's token for github.com and never opens a browser/auth -# prompt). If neither is available we warn with instructions and proceed -# unauthenticated. In a container the driver resolves the token the same -# way and forwards it by name, because the image has no gh CLI of its own. +# (github.token). For native local runs, cargo-aprz performs host-aware +# discovery from GITHUB_TOKEN and the gh CLI. In a container the driver +# resolves and forwards GITHUB_TOKEN by name because the image has no gh CLI +# of its own. # # Unscoped (consults external risk DB). @@ -1983,19 +1981,6 @@ unknown-git = "deny" [script("pwsh", "-NoProfile")] anvil-aprz: anvil-aprz-validate-prereqs $ErrorActionPreference = 'Stop' - if (-not $env:GITHUB_TOKEN) { - $tok = $null - if (Get-Command gh -ErrorAction SilentlyContinue) { - try { $tok = (gh auth token --hostname github.com 2>$null) } catch { $tok = $null } - } - if ($tok) { - $env:GITHUB_TOKEN = $tok.Trim() - } else { - Write-Warning 'anvil-aprz: GITHUB_TOKEN is not set and no token could be obtained from the gh CLI.' - Write-Warning 'cargo-aprz will use the unauthenticated GitHub API, which allows 60 requests an hour. On a full workspace it exhausts that and then blocks, for up to an hour, waiting for the quota to reset.' - Write-Warning 'To fix: run `gh auth login` (recommended), or set $env:GITHUB_TOKEN to a GitHub token, then re-run.' - } - } & cargo {{_anvil_stable_toolchain_args}} aprz deps --error-if-high-risk --console appraisal if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap b/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap index e19faf5b9..6bbc2ac05 100644 --- a/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap +++ b/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap @@ -654,12 +654,10 @@ unknown-git = "deny" # capped at 60 requests an hour, and on a full workspace it exhausts that # and then waits for the quota to reset rather than failing; an # authenticated token raises the cap to 5000/hour. CI injects GITHUB_TOKEN -# (github.token). For local runs, if GITHUB_TOKEN is unset we borrow the -# gh CLI's stored token (non-interactive: `gh auth token` prints the -# active account's token for github.com and never opens a browser/auth -# prompt). If neither is available we warn with instructions and proceed -# unauthenticated. In a container the driver resolves the token the same -# way and forwards it by name, because the image has no gh CLI of its own. +# (github.token). For native local runs, cargo-aprz performs host-aware +# discovery from GITHUB_TOKEN and the gh CLI. In a container the driver +# resolves and forwards GITHUB_TOKEN by name because the image has no gh CLI +# of its own. # # Unscoped (consults external risk DB). @@ -667,19 +665,6 @@ unknown-git = "deny" [script("pwsh", "-NoProfile")] anvil-aprz: anvil-aprz-validate-prereqs $ErrorActionPreference = 'Stop' - if (-not $env:GITHUB_TOKEN) { - $tok = $null - if (Get-Command gh -ErrorAction SilentlyContinue) { - try { $tok = (gh auth token --hostname github.com 2>$null) } catch { $tok = $null } - } - if ($tok) { - $env:GITHUB_TOKEN = $tok.Trim() - } else { - Write-Warning 'anvil-aprz: GITHUB_TOKEN is not set and no token could be obtained from the gh CLI.' - Write-Warning 'cargo-aprz will use the unauthenticated GitHub API, which allows 60 requests an hour. On a full workspace it exhausts that and then blocks, for up to an hour, waiting for the quota to reset.' - Write-Warning 'To fix: run `gh auth login` (recommended), or set $env:GITHUB_TOKEN to a GitHub token, then re-run.' - } - } & cargo {{_anvil_stable_toolchain_args}} aprz deps --error-if-high-risk --console appraisal if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } From a8337b70e86fa50bc190abbb164bdaa0586507e3 Mon Sep 17 00:00:00 2001 From: "Martin Kolinek (from Dev Box)" Date: Tue, 15 Sep 2026 20:58:02 +0200 Subject: [PATCH 04/13] fix(cargo-anvil): preserve container APRZ credentials Keep GITHUB_TOKEN visible in the executable APRZ plan without selecting or mutating native credentials, so the container driver continues forwarding its host-derived token. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a9fc919b-99e3-4546-847a-e30dd5cb18a4 --- .anvil.lock | 4 ++-- crates/cargo-anvil/docs/design/containers.md | 3 +++ crates/cargo-anvil/src/anvil/artifacts/justfile.rs | 1 + .../templates/justfiles/anvil/checks/aprz.just | 3 +++ crates/cargo-anvil/tests/recipe_contracts.rs | 13 +++++++++++++ .../tests/snapshots/snapshots__ado_backend.snap | 3 +++ .../tests/snapshots/snapshots__github_backend.snap | 3 +++ .../tests/snapshots/snapshots__local_only.snap | 3 +++ justfiles/anvil/checks/aprz.just | 3 +++ 9 files changed, 34 insertions(+), 2 deletions(-) diff --git a/.anvil.lock b/.anvil.lock index 42553bda6..1e4c5f647 100644 --- a/.anvil.lock +++ b/.anvil.lock @@ -1,7 +1,7 @@ version = 1 tool = "anvil" tool_version = "0.9.0" -catalog_checksum = "sha256:28e0d00fd960d23f25da98b05e271ec031959c9736c2ec7d1c11d658a69d1723" +catalog_checksum = "sha256:89cb865a1757e5b6d87e4a3a0b8685bf5f214651e3bde2301fbed18a87abab9d" [[file]] path = ".anvil/container/Dockerfile.dockerignore" @@ -57,7 +57,7 @@ checksum = "sha256:d4d3bd645a5586e9a1cc3a5fc27e38c93e59b1e29f83ede0ccd610273eec0 [[file]] path = "justfiles/anvil/checks/aprz.just" -checksum = "sha256:5811524f5b9433a570de936a5a3d4593d17e2e4b93f164039a9f9160620de267" +checksum = "sha256:bf0f5587f6a9675acde3434ae3f2dba36744d761ba66c83bb3fb19d5ec78d558" [[file]] path = "justfiles/anvil/checks/audit.just" diff --git a/crates/cargo-anvil/docs/design/containers.md b/crates/cargo-anvil/docs/design/containers.md index c7ac785e7..82fde3f68 100644 --- a/crates/cargo-anvil/docs/design/containers.md +++ b/crates/cargo-anvil/docs/design/containers.md @@ -402,6 +402,9 @@ has no gh CLI of its own. Native `anvil-aprz` leaves discovery to cargo-aprz, wh effective GitHub endpoint and therefore respects GitHub Enterprise overrides. `anvil-aprz` runs in the `scheduled-advisories` group and queries the GitHub advisory API, which allows 60 requests an hour unauthenticated and then sleeps until the quota resets, so a tier needs the token to terminate rather than merely to run quickly. +The native recipe retains a no-op executable read of `GITHUB_TOKEN`: it does not select or change the credential, but +keeps the variable visible in `just --dry-run anvil-aprz` so the container driver knows that its gh-less image needs +the host-derived token forwarded. The two sources are not treated alike. An **exported** `GITHUB_TOKEN` is forwarded whatever the target is — that is exact parity, since a native run exposes it to every process the shell spawns too. A token **derived** from the gh CLI diff --git a/crates/cargo-anvil/src/anvil/artifacts/justfile.rs b/crates/cargo-anvil/src/anvil/artifacts/justfile.rs index 445e01ff7..84d25d7ba 100644 --- a/crates/cargo-anvil/src/anvil/artifacts/justfile.rs +++ b/crates/cargo-anvil/src/anvil/artifacts/justfile.rs @@ -85,6 +85,7 @@ fn aprz_leaves_native_github_credential_discovery_to_cargo_aprz() { .expect("aprz.just is registered in CHECK_FILES below"); assert!(!aprz.contains("Get-Command gh")); assert!(!aprz.contains("$env:GITHUB_TOKEN =")); + assert!(aprz.contains("$null = $env:GITHUB_TOKEN")); assert!(aprz.contains("cargo {{_anvil_stable_toolchain_args}} aprz deps")); } diff --git a/crates/cargo-anvil/templates/justfiles/anvil/checks/aprz.just b/crates/cargo-anvil/templates/justfiles/anvil/checks/aprz.just index 170e5b325..958f5a81c 100644 --- a/crates/cargo-anvil/templates/justfiles/anvil/checks/aprz.just +++ b/crates/cargo-anvil/templates/justfiles/anvil/checks/aprz.just @@ -21,6 +21,9 @@ [script("pwsh", "-NoProfile")] anvil-aprz: anvil-aprz-validate-prereqs $ErrorActionPreference = 'Stop' + # Load-bearing no-op: the container driver scans the executable dry-run + # plan for this variable before deriving a token for its gh-less image. + $null = $env:GITHUB_TOKEN & cargo {{_anvil_stable_toolchain_args}} aprz deps --error-if-high-risk --console appraisal if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/crates/cargo-anvil/tests/recipe_contracts.rs b/crates/cargo-anvil/tests/recipe_contracts.rs index 4c1ca8f5d..906b2b6b6 100644 --- a/crates/cargo-anvil/tests/recipe_contracts.rs +++ b/crates/cargo-anvil/tests/recipe_contracts.rs @@ -2426,6 +2426,19 @@ fn aprz_leaves_native_credential_discovery_to_cargo_aprz() { ); let log = tmp.path().join("cargo.log"); + let plan = run_just(tmp.path(), &["--dry-run", "anvil-aprz"], &[]); + assert!( + plan.status.success(), + "the container driver must be able to plan anvil-aprz\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&plan.stdout), + String::from_utf8_lossy(&plan.stderr) + ); + let planned = format!("{}{}", String::from_utf8_lossy(&plan.stdout), String::from_utf8_lossy(&plan.stderr)); + assert!( + planned.contains("$null = $env:GITHUB_TOKEN"), + "the executable plan must signal that the container needs token forwarding:\n{planned}" + ); + let output = run_just( tmp.path(), &["anvil-aprz"], diff --git a/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap b/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap index 64eca5453..235d0f3cb 100644 --- a/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap +++ b/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap @@ -1839,6 +1839,9 @@ unknown-git = "deny" [script("pwsh", "-NoProfile")] anvil-aprz: anvil-aprz-validate-prereqs $ErrorActionPreference = 'Stop' + # Load-bearing no-op: the container driver scans the executable dry-run + # plan for this variable before deriving a token for its gh-less image. + $null = $env:GITHUB_TOKEN & cargo {{_anvil_stable_toolchain_args}} aprz deps --error-if-high-risk --console appraisal if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap b/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap index 4be059d5a..23d1e7176 100644 --- a/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap +++ b/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap @@ -1981,6 +1981,9 @@ unknown-git = "deny" [script("pwsh", "-NoProfile")] anvil-aprz: anvil-aprz-validate-prereqs $ErrorActionPreference = 'Stop' + # Load-bearing no-op: the container driver scans the executable dry-run + # plan for this variable before deriving a token for its gh-less image. + $null = $env:GITHUB_TOKEN & cargo {{_anvil_stable_toolchain_args}} aprz deps --error-if-high-risk --console appraisal if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap b/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap index 6bbc2ac05..34fa594ec 100644 --- a/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap +++ b/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap @@ -665,6 +665,9 @@ unknown-git = "deny" [script("pwsh", "-NoProfile")] anvil-aprz: anvil-aprz-validate-prereqs $ErrorActionPreference = 'Stop' + # Load-bearing no-op: the container driver scans the executable dry-run + # plan for this variable before deriving a token for its gh-less image. + $null = $env:GITHUB_TOKEN & cargo {{_anvil_stable_toolchain_args}} aprz deps --error-if-high-risk --console appraisal if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/justfiles/anvil/checks/aprz.just b/justfiles/anvil/checks/aprz.just index 170e5b325..958f5a81c 100644 --- a/justfiles/anvil/checks/aprz.just +++ b/justfiles/anvil/checks/aprz.just @@ -21,6 +21,9 @@ [script("pwsh", "-NoProfile")] anvil-aprz: anvil-aprz-validate-prereqs $ErrorActionPreference = 'Stop' + # Load-bearing no-op: the container driver scans the executable dry-run + # plan for this variable before deriving a token for its gh-less image. + $null = $env:GITHUB_TOKEN & cargo {{_anvil_stable_toolchain_args}} aprz deps --error-if-high-risk --console appraisal if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } From 1854fc5b26360d93bf246e99888671b8c4277d62 Mon Sep 17 00:00:00 2001 From: "Martin Kolinek (from Dev Box)" Date: Thu, 17 Sep 2026 10:49:13 +0200 Subject: [PATCH 05/13] feat(cargo-aprz): require opt-in for gh tokens Add --github-token-from-gh and prevent both native and containerized commands from invoking the host GitHub CLI unless the command explicitly requests that credential source. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a9fc919b-99f7-4134-aad1-2116321b4e0c --- .anvil.lock | 6 +- crates/cargo-anvil/docs/design/containers.md | 37 ++++----- .../src/anvil/artifacts/container.rs | 49 ++++++------ .../src/anvil/artifacts/justfile.rs | 9 ++- .../justfiles/anvil/checks/aprz.just | 11 +-- .../templates/justfiles/anvil/container.just | 69 +++++++--------- crates/cargo-anvil/tests/recipe_contracts.rs | 31 +++---- .../snapshots/snapshots__ado_backend.snap | 80 +++++++------------ .../snapshots/snapshots__github_backend.snap | 80 +++++++------------ .../snapshots/snapshots__local_only.snap | 80 +++++++------------ crates/cargo-aprz-lib/src/commands/common.rs | 36 ++++++++- .../src/commands/github_credentials.rs | 51 +++++++++++- crates/cargo-aprz/README.md | 8 +- crates/cargo-aprz/docs/DESIGN.md | 23 ++++-- crates/cargo-aprz/src/main.rs | 8 +- justfiles/anvil/checks/aprz.just | 11 +-- justfiles/anvil/container.just | 69 +++++++--------- scripts/test-anvil-container.ps1 | 75 ++++++++--------- 18 files changed, 360 insertions(+), 373 deletions(-) diff --git a/.anvil.lock b/.anvil.lock index bda001fdf..a29155b7d 100644 --- a/.anvil.lock +++ b/.anvil.lock @@ -1,7 +1,7 @@ version = 1 tool = "anvil" tool_version = "0.9.0" -catalog_checksum = "sha256:cdb5e871f4cc3f355df7f7e8d6a79de3260a727903f3662dd5e2b52edd1d072b" +catalog_checksum = "sha256:227eaca2b2aa0bf8e938290a2597502c67eee196c9c9cd28f0252215c604bc3c" [[file]] path = ".anvil/container/Dockerfile.dockerignore" @@ -57,7 +57,7 @@ checksum = "sha256:d4d3bd645a5586e9a1cc3a5fc27e38c93e59b1e29f83ede0ccd610273eec0 [[file]] path = "justfiles/anvil/checks/aprz.just" -checksum = "sha256:bf0f5587f6a9675acde3434ae3f2dba36744d761ba66c83bb3fb19d5ec78d558" +checksum = "sha256:cac04328957d1fd5d18b958f78b8774bf322851e8d0a4dcb53a01a81836d5bd6" [[file]] path = "justfiles/anvil/checks/audit.just" @@ -181,7 +181,7 @@ checksum = "sha256:6efd7378a2cd0f5d86519bd32fd86f2055a60191187dd77a8842b374b8eb7 [[file]] path = "justfiles/anvil/container.just" -checksum = "sha256:ae4987c98728866a2cb29f2dcd56ed511f454443187b423a04c1c4d36e8714d5" +checksum = "sha256:4f000165d96007473ce8dd6de86b0fab97bfb10e338c70bc8447e24541823349" [[file]] path = "justfiles/anvil/dev/build.just" diff --git a/crates/cargo-anvil/docs/design/containers.md b/crates/cargo-anvil/docs/design/containers.md index b95d5cd18..6cd75b73f 100644 --- a/crates/cargo-anvil/docs/design/containers.md +++ b/crates/cargo-anvil/docs/design/containers.md @@ -104,7 +104,7 @@ try { just anvil-container just anvil-fmt } finally { Remove-Item Env:ANVIL_CONT | `ANVIL_CONTAINER_NO_RESOLVE=1` | Skip the resolve hook (§7.3), so a query never pulls. | | `ANVIL_CONTAINER_NO_CACHE=1` | Rebuild with `--no-cache` even when the tag resolves. Skips the resolve hook too (§7.3). | | `ANVIL_IN_CONTAINER=1` | Set inside the image. Makes a nested invocation execute natively (§5.4). | -| `GITHUB_TOKEN` | Forwarded into the run. Taken from the host environment, or derived from the gh CLI for a target that reads it (§5.3). | +| `GITHUB_TOKEN` | Forwarded into the run when exported. When the command explicitly opts into GitHub CLI discovery, it may instead be derived from the host gh CLI (§5.3). | `NO_REBUILD` is evaluated independently of `NO_CACHE`, so the two compose: `anvil-container-status` sets `NO_REBUILD` and `NO_RESOLVE` together and answers from local state alone. When `NO_REBUILD` stops a build the reference is still @@ -396,29 +396,26 @@ otherwise the engine leaves it as `/`, and anything falling back to `$HOME` writ ### 5.3 Environment -The run passes `ANVIL_IN_CONTAINER=1` (§5.4) and forwards `GITHUB_TOKEN` by name, resolved from the environment first -and then the gh CLI's stored `github.com` token. This wrapper lookup is only for container execution, because the image -has no gh CLI of its own. Native `anvil-aprz` leaves discovery to cargo-aprz, which selects the gh login from its -effective GitHub endpoint and therefore respects GitHub Enterprise overrides. `anvil-aprz` runs in the -`scheduled-advisories` group and queries the GitHub advisory API, which allows 60 requests an hour unauthenticated and -then sleeps until the quota resets, so a tier needs the token to terminate rather than merely to run quickly. -The native recipe retains a no-op executable read of `GITHUB_TOKEN`: it does not select or change the credential, but -keeps the variable visible in `just --dry-run anvil-aprz` so the container driver knows that its gh-less image needs -the host-derived token forwarded. - -The two sources are not treated alike. An **exported** `GITHUB_TOKEN` is forwarded whatever the target is — that is -exact parity, since a native run exposes it to every process the shell spawns too. A token **derived** from the gh CLI -is a credential the developer never put in this environment, and PID 1's environment is inherited by every build script -and proc macro in the container, where natively `anvil-aprz` would mint it inside its own process. So it is derived -only when the target's plan (`just --dry-run `) reads `GITHUB_TOKEN`, or when there is no target at all: an -interactive session can run anything, and refusing there would reintroduce the stall the token exists to prevent. The -predicate is the variable rather than the name of a check, so a catalog that adds another GitHub-authenticated check is -covered without touching the driver. +The run passes `ANVIL_IN_CONTAINER=1` (§5.4). An **exported** `GITHUB_TOKEN` is forwarded by name whatever the target +is — exact parity with a native run, where every process the shell spawns can already read it. + +GitHub CLI discovery is different: it manufactures a credential the developer did not export, and PID 1's environment +is inherited by every build script and proc macro in the container. The driver therefore invokes +`gh auth token --hostname github.com` only when the containerized command explicitly opts in with +`--github-token-from-gh`. A direct command opts in through an exact argv occurrence. A `just` command opts in when the +switch occurs as an exact argument in its expanded `just --dry-run ` plan. An explicit `--github-token` in the +same command or plan suppresses the host `gh` lookup, as does an exported `GITHUB_TOKEN`. The no-command interactive +form never derives a token. + +This wrapper lookup exists only because the image has no gh CLI of its own. Native cargo-aprz performs the same +discovery only when its switch is present and selects the gh login from its effective GitHub endpoint, including +GitHub Enterprise overrides. The generated `anvil-aprz` recipe does not pass the switch, so it uses an exported +`GITHUB_TOKEN` when available and otherwise runs anonymously without invoking the host gh CLI. A plan covers the bodies `just` runs itself, not the body of a recipe that one of them launches as a child process. The unscoped tier wrapper (§`helpers.just`) launches its tier that way, so planning `anvil-scheduled` shows the wrapper alone. The driver therefore follows each nested target a plan names, until nothing new appears; without that, a wrapped -tier reads as needing nothing and `anvil-aprz` runs unauthenticated inside an image that has no `gh` of its own. +tier would hide an explicit `--github-token-from-gh` in one of its checks. It also forwards the recipe contract's own inputs when they are set — `PR_TITLE`, `BASE_REF`, `GITHUB_BASE_REF`, `SYSTEM_PULLREQUEST_TARGETBRANCH`, `ANVIL_IMPACT` and `ANVIL_MIRI_JOBS` — because a check that reads one diff --git a/crates/cargo-anvil/src/anvil/artifacts/container.rs b/crates/cargo-anvil/src/anvil/artifacts/container.rs index c873e46d6..db4a710c9 100644 --- a/crates/cargo-anvil/src/anvil/artifacts/container.rs +++ b/crates/cargo-anvil/src/anvil/artifacts/container.rs @@ -783,13 +783,7 @@ mod tests { } #[test] - fn a_host_token_is_resolved_as_the_recipe_does_and_forwarded_by_name() { - // anvil-aprz is in scheduled-advisories, and unauthenticated it does not merely - // warn: `cargo aprz deps` sleeps until the hourly quota resets, so a - // containerized tier blocks for up to an hour. The driver therefore - // resolves a token the same way the recipe does natively -- the - // environment first, then the gh CLI -- so both paths authenticate for - // the same developers. + fn a_host_token_is_forwarded_by_name_without_exposing_its_value() { assert!(RECIPE.contains("gh auth token --hostname github.com")); assert!(RECIPE.contains("$forwardedEnv += 'GITHUB_TOKEN'")); assert!(RECIPE.contains("$runArgs += @('-e', 'GITHUB_TOKEN')")); @@ -801,7 +795,7 @@ mod tests { assert!(RECIPE.contains("$hookEnv += 'GITHUB_TOKEN'")); // An exported token is left alone rather than re-derived; scoping of // the derived one is asserted in its own test below. - assert!(RECIPE.contains("if (-not $env:GITHUB_TOKEN -and (Get-Command gh")); + assert!(RECIPE.contains("if (-not $env:GITHUB_TOKEN)")); // Forwarding by name only works if the engine can see the name, so a // WSL engine needs it bridged -- otherwise `-e NAME` forwards nothing. assert!(RECIPE.contains("$engineExe -eq 'wsl.exe' -and $forwardedEnv.Count -gt 0")); @@ -891,37 +885,44 @@ mod tests { } #[test] - fn a_derived_token_is_scoped_to_a_command_that_reads_it() { + fn a_derived_token_requires_explicit_command_intent() { // Forwarding an exported GITHUB_TOKEN is exact parity: natively it is // visible to every process the shell spawns too. Minting one from `gh` // is not -- PID 1's environment reaches every build script and proc - // macro, where natively the recipe mints it in its own process -- so it - // happens only for a command whose plan reads the variable. + // macro -- so it happens only after the exact opt-in switch appears in + // direct argv or an expanded Just plan. // Each search covers the whole recipe, so the comparison below is the // thing under test. Bounding a search by an earlier match makes the // ordering true by construction and the assertion vacuous. let derive = RECIPE.find("gh auth token --hostname").expect("the gh fallback must exist"); - let guard = RECIPE.find("if ($needsToken)").expect("the derive must be guarded"); + let guard = RECIPE + .find("if ($githubTokenFromGh -and -not $hasExplicitGitHubToken") + .expect("the derive must require consent and no explicit token"); let plan = RECIPE - .find("$plan -match 'GITHUB_TOKEN'") - .expect("the plan must decide whether a token is needed"); + .find("--github-token-from-gh($|") + .expect("the plan must recognize the exact opt-in switch"); + let direct = RECIPE + .find("$argv -contains '--github-token-from-gh'") + .expect("direct commands must recognize an exact argv occurrence"); let dry_run = RECIPE.find("--dry-run @target").expect("the plan must come from just"); assert!( - dry_run < plan && plan < guard && guard < derive, - "compute the plan, match it, guard on it, then derive" + dry_run < plan && plan < guard && direct < guard && guard < derive, + "compute explicit intent before resolving or invoking gh" ); // Through the launching binary, like every other nested call: a bare // `just` here fails silently when the caller invoked it by absolute // path, and an empty plan reads as "no token needed". assert!(!RECIPE.contains("(just --dry-run")); assert!(RECIPE.contains(r"}}' --dry-run @target")); - // The predicate is the variable, not the name of a check, so a catalog - // that adds another GitHub-authenticated check is covered for free. + // The switch, not a check name or ambient-variable read, is the + // contract. Interactive execution has no argv and therefore no opt-in. assert!(!RECIPE.contains("$plan -match 'aprz'")); - // An interactive session has no command to plan, and can run anything. - assert!(RECIPE.contains("$needsToken = $argv.Count -eq 0")); - // Only `just` can be planned, so nothing else earns a minted credential. - assert!(RECIPE.contains("if (-not $needsToken -and $argv[0] -eq 'just')")); + assert!(!RECIPE.contains("$plan -match 'GITHUB_TOKEN'")); + assert!(RECIPE.contains("if ($argv.Count -gt 0 -and $argv[0] -eq 'just')")); + // Explicit credentials remain authoritative even when opt-in is also + // present, for both direct and planned commands. + assert!(RECIPE.contains("$_ -eq '--github-token' -or $_.StartsWith('--github-token=')")); + assert!(RECIPE.contains("--github-token(?:=|$|")); } #[test] @@ -947,8 +948,8 @@ mod tests { // `just --dry-run` prints the bodies just runs itself. The unscoped tier // wrapper runs its tier as a child process instead, so a plan of // `anvil-scheduled` is the wrapper alone and reveals none of the checks - // under it -- including anvil-aprz, whose GITHUB_TOKEN is what stops it - // sleeping on the advisory API's unauthenticated rate limit. + // under it. Following the launched targets lets a recipe under that + // wrapper explicitly opt into --github-token-from-gh. assert!( RECIPE.contains(r#"[regex]::Matches($step, "'(_anvil-[^'\s]+)'")"#), "the plan must follow each nested target the wrapper names" diff --git a/crates/cargo-anvil/src/anvil/artifacts/justfile.rs b/crates/cargo-anvil/src/anvil/artifacts/justfile.rs index 84d25d7ba..f355ab5e2 100644 --- a/crates/cargo-anvil/src/anvil/artifacts/justfile.rs +++ b/crates/cargo-anvil/src/anvil/artifacts/justfile.rs @@ -78,15 +78,18 @@ macro_rules! split_recipe_files { const DEV_FILES: &[(&str, &str)] = split_recipe_files!("dev", ["build"]); #[test] -fn aprz_leaves_native_github_credential_discovery_to_cargo_aprz() { +fn aprz_does_not_opt_into_github_cli_credential_discovery() { let aprz = CHECK_FILES .iter() .find_map(|(path, body)| path.ends_with("/aprz.just").then_some(*body)) .expect("aprz.just is registered in CHECK_FILES below"); assert!(!aprz.contains("Get-Command gh")); assert!(!aprz.contains("$env:GITHUB_TOKEN =")); - assert!(aprz.contains("$null = $env:GITHUB_TOKEN")); - assert!(aprz.contains("cargo {{_anvil_stable_toolchain_args}} aprz deps")); + let invocation = aprz + .lines() + .find(|line| line.contains("cargo {{_anvil_stable_toolchain_args}} aprz deps")) + .expect("anvil-aprz invokes cargo-aprz"); + assert!(!invocation.contains("--github-token-from-gh")); } /// One `justfiles/anvil/checks/.just` file per catalog check diff --git a/crates/cargo-anvil/templates/justfiles/anvil/checks/aprz.just b/crates/cargo-anvil/templates/justfiles/anvil/checks/aprz.just index 958f5a81c..1862d8381 100644 --- a/crates/cargo-anvil/templates/justfiles/anvil/checks/aprz.just +++ b/crates/cargo-anvil/templates/justfiles/anvil/checks/aprz.just @@ -10,10 +10,10 @@ # capped at 60 requests an hour, and on a full workspace it exhausts that # and then waits for the quota to reset rather than failing; an # authenticated token raises the cap to 5000/hour. CI injects GITHUB_TOKEN -# (github.token). For native local runs, cargo-aprz performs host-aware -# discovery from GITHUB_TOKEN and the gh CLI. In a container the driver -# resolves and forwards GITHUB_TOKEN by name because the image has no gh CLI -# of its own. +# (github.token). Local runs use an exported GITHUB_TOKEN when available and +# otherwise remain anonymous. Developers can opt into host-aware GitHub CLI +# discovery by invoking cargo-aprz themselves with --github-token-from-gh; +# this generated check deliberately does not access their gh credentials. # # Unscoped (consults external risk DB). @@ -21,9 +21,6 @@ [script("pwsh", "-NoProfile")] anvil-aprz: anvil-aprz-validate-prereqs $ErrorActionPreference = 'Stop' - # Load-bearing no-op: the container driver scans the executable dry-run - # plan for this variable before deriving a token for its gh-less image. - $null = $env:GITHUB_TOKEN & cargo {{_anvil_stable_toolchain_args}} aprz deps --error-if-high-risk --console appraisal if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/crates/cargo-anvil/templates/justfiles/anvil/container.just b/crates/cargo-anvil/templates/justfiles/anvil/container.just index 871c5b735..4a8c1292a 100644 --- a/crates/cargo-anvil/templates/justfiles/anvil/container.just +++ b/crates/cargo-anvil/templates/justfiles/anvil/container.just @@ -927,52 +927,29 @@ anvil-container *command: $forwardedEnv = @() $hookEnv = @() - # anvil-aprz queries the GitHub advisory API, which allows 60 requests an - # hour unauthenticated -- less than a full tier needs. Unauthenticated is - # not a degraded-but-working mode: `cargo aprz deps` sleeps until the quota - # resets rather than failing, so a containerized tier blocks for up to an - # hour with no way to opt out. Authentication is what makes the check - # terminate, not what makes it fast. - # - # Resolve the token exactly as the recipe does natively -- GITHUB_TOKEN - # first, then the gh CLI's stored token -- so a containerized run - # authenticates for the same developers a native run does. - # # An already-exported GITHUB_TOKEN is forwarded whatever the command is: # that is exact parity, since a native run exposes it to every process the - # shell spawns too. Deriving one from `gh` is different -- it manufactures a - # credential the developer did not put in this environment, and PID 1's - # environment is inherited by every build script and proc macro in the - # container, where natively the recipe would mint it in its own process. So - # it is derived only when the command is known to read the variable, or when - # there is no command at all: an interactive session can run anything, and - # refusing there would reintroduce the silent hour-long stall on a tier the - # developer runs from inside the shell. - # `gh auth token` is non-interactive and never opens a prompt. + # shell spawns too. Deriving one from `gh` is different: it manufactures a + # credential the developer did not export, and PID 1 exposes it to every + # build script and proc macro in the container. It therefore requires the + # exact --github-token-from-gh switch in a direct command's argv or in an + # expanded `just --dry-run` plan. Interactive execution never opts in. # - # The predicate is the variable itself rather than the name of a check, so - # the driver stays generic: a catalog that adds another GitHub-authenticated - # check is covered without touching this recipe. - # - # Set here and passed by NAME, so the value never reaches the host's - # process command line, and unset again with the hook's variables below. - if (-not $env:GITHUB_TOKEN -and (Get-Command gh -ErrorAction SilentlyContinue)) { - $needsToken = $argv.Count -eq 0 - # Only a `just` command can be planned, and planning is the only way to - # know whether what runs reads the variable. Anything else keeps the - # environment it was given: a manufactured credential reaches every - # process in the container, so an unknown command does not earn one. - if (-not $needsToken -and $argv[0] -eq 'just') { + # Explicit --github-token and GITHUB_TOKEN remain authoritative even when + # the opt-in switch is also present, so neither path invokes gh. + if (-not $env:GITHUB_TOKEN) { + $githubTokenFromGh = $false + $hasExplicitGitHubToken = $false + if ($argv.Count -gt 0 -and $argv[0] -eq 'just') { # A dry run has no side effects, and a target that cannot be planned - # (a typo, a recipe needing arguments) yields nothing, so the run - # fails on its own terms rather than on a missing token. + # yields no consent, so the run fails on its own terms. # # A plan covers the bodies just runs itself, not the body of a # recipe that one of them launches as a child process. The unscoped # tier wrapper launches its tier that way, so planning # `anvil-scheduled` shows the wrapper and none of the checks # underneath it. Follow each nested target a plan names, or a - # wrapped tier reads as needing nothing and runs unauthenticated. + # wrapped tier can hide an explicit opt-in in one of its checks. $plan = '' $targets = [System.Collections.Generic.List[object]]::new() $targets.Add([string[]]@($argv | Select-Object -Skip 1)) @@ -985,10 +962,7 @@ anvil-container *command: # The same executable that launched this tree, for the reason # every other nested call uses it: a caller invoking `just` by # absolute path with its directory off PATH would otherwise fail - # here. That failure is silent, because an empty plan reads as - # "does not need a token" -- so anvil-aprz would run - # unauthenticated in an image with no gh of its own and block on - # the rate limit for up to an hour. + # here. An empty plan means no GitHub CLI consent. $step = '' try { $step = (& '{{ replace(just_executable(), "'", "''") }}' --dry-run @target 2>&1 | @@ -1002,9 +976,20 @@ anvil-container *command: $targets.Add([string[]]@($nested.Groups[1].Value)) } } - $needsToken = $plan -match 'GITHUB_TOKEN' + $githubTokenFromGh = [regex]::IsMatch( + $plan, '(?m)(^|[\s''"])--github-token-from-gh($|[\s''"])' + ) + $hasExplicitGitHubToken = [regex]::IsMatch( + $plan, '(?m)(^|[\s''"])--github-token(?:=|$|[\s''"])' + ) + } elseif ($argv.Count -gt 0) { + $githubTokenFromGh = $argv -contains '--github-token-from-gh' + $hasExplicitGitHubToken = @( + $argv | Where-Object { $_ -eq '--github-token' -or $_.StartsWith('--github-token=') } + ).Count -gt 0 } - if ($needsToken) { + + if ($githubTokenFromGh -and -not $hasExplicitGitHubToken -and (Get-Command gh -ErrorAction SilentlyContinue)) { $ghToken = $null try { $ghToken = (gh auth token --hostname github.com 2>$null) } catch { $ghToken = $null } if ($ghToken -and $ghToken.Trim()) { diff --git a/crates/cargo-anvil/tests/recipe_contracts.rs b/crates/cargo-anvil/tests/recipe_contracts.rs index b882b30d9..70b050ef6 100644 --- a/crates/cargo-anvil/tests/recipe_contracts.rs +++ b/crates/cargo-anvil/tests/recipe_contracts.rs @@ -2465,10 +2465,10 @@ fn windows_arm64_fallback_accepts_empty_nextest_sets_in_both_configurations() { // --- credential-specific behaviour ----------------------------------------- -/// Native `anvil-aprz` must not preempt cargo-aprz's host-aware credential -/// discovery with a token hard-coded for github.com. +/// Generated `anvil-aprz` must neither query gh itself nor opt cargo-aprz into +/// GitHub CLI credential discovery. #[test] -fn aprz_leaves_native_credential_discovery_to_cargo_aprz() { +fn aprz_does_not_opt_into_github_cli_credential_discovery() { if !tools_available() { return; } @@ -2490,8 +2490,8 @@ fn aprz_leaves_native_credential_discovery_to_cargo_aprz() { ); let planned = format!("{}{}", String::from_utf8_lossy(&plan.stdout), String::from_utf8_lossy(&plan.stderr)); assert!( - planned.contains("$null = $env:GITHUB_TOKEN"), - "the executable plan must signal that the container needs token forwarding:\n{planned}" + !planned.contains("--github-token-from-gh"), + "the generated recipe must not opt into GitHub CLI discovery:\n{planned}" ); let output = run_just( @@ -2506,7 +2506,7 @@ fn aprz_leaves_native_credential_discovery_to_cargo_aprz() { assert!( output.status.success(), - "credential discovery must be left to cargo-aprz\nstdout:\n{}\nstderr:\n{}", + "anvil-aprz must run without GitHub CLI discovery\nstdout:\n{}\nstderr:\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); @@ -2764,14 +2764,15 @@ fn unscoped_wrapper_exports_impact_off_before_dependencies_run() { /// launches its tier that way, so a plan of the public tier name reveals the /// wrapper alone. /// -/// The container driver decides whether to mint a GitHub token by matching the -/// plan for `GITHUB_TOKEN`, so this is why it has to follow each nested target -/// rather than reading one plan. If this test ever fails because a plan now -/// reaches through the child process, that expansion can be deleted. +/// The container driver decides whether to query the GitHub CLI by finding an +/// exact `--github-token-from-gh` in the expanded plan, so it has to follow +/// each nested target rather than reading one plan. If this test ever fails +/// because a plan now reaches through the child process, that expansion can be +/// deleted. #[test] -fn a_wrapped_tier_hides_its_checks_from_a_plan() { +fn a_wrapped_tier_hides_its_github_cli_opt_in_from_a_plan() { const PROBE: &str = "[private]\n[script(\"pwsh\", \"-NoProfile\")]\n_anvil-probe:\n \ - if (-not $env:GITHUB_TOKEN) { exit 1 }\n\n\ + & cargo aprz deps --github-token-from-gh\n\n\ probe: (_anvil-unscoped \"probe\")\n"; if !tools_available() { @@ -2787,7 +2788,7 @@ fn a_wrapped_tier_hides_its_checks_from_a_plan() { String::from_utf8_lossy(&wrapped.stderr) ); assert!( - !wrapped_plan.contains("GITHUB_TOKEN"), + !wrapped_plan.contains("--github-token-from-gh"), "a wrapped tier's plan must not reach the recipe it launches, or the driver's expansion is dead code\n{wrapped_plan}" ); assert!( @@ -2802,8 +2803,8 @@ fn a_wrapped_tier_hides_its_checks_from_a_plan() { String::from_utf8_lossy(&direct.stderr) ); assert!( - direct_plan.contains("GITHUB_TOKEN"), - "planning the launched recipe directly must reveal the variable, or this test proves nothing\n{direct_plan}" + direct_plan.contains("--github-token-from-gh"), + "planning the launched recipe directly must reveal the opt-in switch, or this test proves nothing\n{direct_plan}" ); } diff --git a/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap b/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap index 90264cfcc..e77d0d760 100644 --- a/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap +++ b/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap @@ -1857,10 +1857,10 @@ unknown-git = "deny" # capped at 60 requests an hour, and on a full workspace it exhausts that # and then waits for the quota to reset rather than failing; an # authenticated token raises the cap to 5000/hour. CI injects GITHUB_TOKEN -# (github.token). For native local runs, cargo-aprz performs host-aware -# discovery from GITHUB_TOKEN and the gh CLI. In a container the driver -# resolves and forwards GITHUB_TOKEN by name because the image has no gh CLI -# of its own. +# (github.token). Local runs use an exported GITHUB_TOKEN when available and +# otherwise remain anonymous. Developers can opt into host-aware GitHub CLI +# discovery by invoking cargo-aprz themselves with --github-token-from-gh; +# this generated check deliberately does not access their gh credentials. # # Unscoped (consults external risk DB). @@ -1868,9 +1868,6 @@ unknown-git = "deny" [script("pwsh", "-NoProfile")] anvil-aprz: anvil-aprz-validate-prereqs $ErrorActionPreference = 'Stop' - # Load-bearing no-op: the container driver scans the executable dry-run - # plan for this variable before deriving a token for its gh-less image. - $null = $env:GITHUB_TOKEN & cargo {{_anvil_stable_toolchain_args}} aprz deps --error-if-high-risk --console appraisal if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } @@ -5229,52 +5226,29 @@ anvil-container *command: $forwardedEnv = @() $hookEnv = @() - # anvil-aprz queries the GitHub advisory API, which allows 60 requests an - # hour unauthenticated -- less than a full tier needs. Unauthenticated is - # not a degraded-but-working mode: `cargo aprz deps` sleeps until the quota - # resets rather than failing, so a containerized tier blocks for up to an - # hour with no way to opt out. Authentication is what makes the check - # terminate, not what makes it fast. - # - # Resolve the token exactly as the recipe does natively -- GITHUB_TOKEN - # first, then the gh CLI's stored token -- so a containerized run - # authenticates for the same developers a native run does. - # # An already-exported GITHUB_TOKEN is forwarded whatever the command is: # that is exact parity, since a native run exposes it to every process the - # shell spawns too. Deriving one from `gh` is different -- it manufactures a - # credential the developer did not put in this environment, and PID 1's - # environment is inherited by every build script and proc macro in the - # container, where natively the recipe would mint it in its own process. So - # it is derived only when the command is known to read the variable, or when - # there is no command at all: an interactive session can run anything, and - # refusing there would reintroduce the silent hour-long stall on a tier the - # developer runs from inside the shell. - # `gh auth token` is non-interactive and never opens a prompt. - # - # The predicate is the variable itself rather than the name of a check, so - # the driver stays generic: a catalog that adds another GitHub-authenticated - # check is covered without touching this recipe. + # shell spawns too. Deriving one from `gh` is different: it manufactures a + # credential the developer did not export, and PID 1 exposes it to every + # build script and proc macro in the container. It therefore requires the + # exact --github-token-from-gh switch in a direct command's argv or in an + # expanded `just --dry-run` plan. Interactive execution never opts in. # - # Set here and passed by NAME, so the value never reaches the host's - # process command line, and unset again with the hook's variables below. - if (-not $env:GITHUB_TOKEN -and (Get-Command gh -ErrorAction SilentlyContinue)) { - $needsToken = $argv.Count -eq 0 - # Only a `just` command can be planned, and planning is the only way to - # know whether what runs reads the variable. Anything else keeps the - # environment it was given: a manufactured credential reaches every - # process in the container, so an unknown command does not earn one. - if (-not $needsToken -and $argv[0] -eq 'just') { + # Explicit --github-token and GITHUB_TOKEN remain authoritative even when + # the opt-in switch is also present, so neither path invokes gh. + if (-not $env:GITHUB_TOKEN) { + $githubTokenFromGh = $false + $hasExplicitGitHubToken = $false + if ($argv.Count -gt 0 -and $argv[0] -eq 'just') { # A dry run has no side effects, and a target that cannot be planned - # (a typo, a recipe needing arguments) yields nothing, so the run - # fails on its own terms rather than on a missing token. + # yields no consent, so the run fails on its own terms. # # A plan covers the bodies just runs itself, not the body of a # recipe that one of them launches as a child process. The unscoped # tier wrapper launches its tier that way, so planning # `anvil-scheduled` shows the wrapper and none of the checks # underneath it. Follow each nested target a plan names, or a - # wrapped tier reads as needing nothing and runs unauthenticated. + # wrapped tier can hide an explicit opt-in in one of its checks. $plan = '' $targets = [System.Collections.Generic.List[object]]::new() $targets.Add([string[]]@($argv | Select-Object -Skip 1)) @@ -5287,10 +5261,7 @@ anvil-container *command: # The same executable that launched this tree, for the reason # every other nested call uses it: a caller invoking `just` by # absolute path with its directory off PATH would otherwise fail - # here. That failure is silent, because an empty plan reads as - # "does not need a token" -- so anvil-aprz would run - # unauthenticated in an image with no gh of its own and block on - # the rate limit for up to an hour. + # here. An empty plan means no GitHub CLI consent. $step = '' try { $step = (& '{{ replace(just_executable(), "'", "''") }}' --dry-run @target 2>&1 | @@ -5304,9 +5275,20 @@ anvil-container *command: $targets.Add([string[]]@($nested.Groups[1].Value)) } } - $needsToken = $plan -match 'GITHUB_TOKEN' + $githubTokenFromGh = [regex]::IsMatch( + $plan, '(?m)(^|[\s''"])--github-token-from-gh($|[\s''"])' + ) + $hasExplicitGitHubToken = [regex]::IsMatch( + $plan, '(?m)(^|[\s''"])--github-token(?:=|$|[\s''"])' + ) + } elseif ($argv.Count -gt 0) { + $githubTokenFromGh = $argv -contains '--github-token-from-gh' + $hasExplicitGitHubToken = @( + $argv | Where-Object { $_ -eq '--github-token' -or $_.StartsWith('--github-token=') } + ).Count -gt 0 } - if ($needsToken) { + + if ($githubTokenFromGh -and -not $hasExplicitGitHubToken -and (Get-Command gh -ErrorAction SilentlyContinue)) { $ghToken = $null try { $ghToken = (gh auth token --hostname github.com 2>$null) } catch { $ghToken = $null } if ($ghToken -and $ghToken.Trim()) { diff --git a/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap b/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap index 76de80e86..fa9adf669 100644 --- a/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap +++ b/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap @@ -1970,10 +1970,10 @@ unknown-git = "deny" # capped at 60 requests an hour, and on a full workspace it exhausts that # and then waits for the quota to reset rather than failing; an # authenticated token raises the cap to 5000/hour. CI injects GITHUB_TOKEN -# (github.token). For native local runs, cargo-aprz performs host-aware -# discovery from GITHUB_TOKEN and the gh CLI. In a container the driver -# resolves and forwards GITHUB_TOKEN by name because the image has no gh CLI -# of its own. +# (github.token). Local runs use an exported GITHUB_TOKEN when available and +# otherwise remain anonymous. Developers can opt into host-aware GitHub CLI +# discovery by invoking cargo-aprz themselves with --github-token-from-gh; +# this generated check deliberately does not access their gh credentials. # # Unscoped (consults external risk DB). @@ -1981,9 +1981,6 @@ unknown-git = "deny" [script("pwsh", "-NoProfile")] anvil-aprz: anvil-aprz-validate-prereqs $ErrorActionPreference = 'Stop' - # Load-bearing no-op: the container driver scans the executable dry-run - # plan for this variable before deriving a token for its gh-less image. - $null = $env:GITHUB_TOKEN & cargo {{_anvil_stable_toolchain_args}} aprz deps --error-if-high-risk --console appraisal if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } @@ -5342,52 +5339,29 @@ anvil-container *command: $forwardedEnv = @() $hookEnv = @() - # anvil-aprz queries the GitHub advisory API, which allows 60 requests an - # hour unauthenticated -- less than a full tier needs. Unauthenticated is - # not a degraded-but-working mode: `cargo aprz deps` sleeps until the quota - # resets rather than failing, so a containerized tier blocks for up to an - # hour with no way to opt out. Authentication is what makes the check - # terminate, not what makes it fast. - # - # Resolve the token exactly as the recipe does natively -- GITHUB_TOKEN - # first, then the gh CLI's stored token -- so a containerized run - # authenticates for the same developers a native run does. - # # An already-exported GITHUB_TOKEN is forwarded whatever the command is: # that is exact parity, since a native run exposes it to every process the - # shell spawns too. Deriving one from `gh` is different -- it manufactures a - # credential the developer did not put in this environment, and PID 1's - # environment is inherited by every build script and proc macro in the - # container, where natively the recipe would mint it in its own process. So - # it is derived only when the command is known to read the variable, or when - # there is no command at all: an interactive session can run anything, and - # refusing there would reintroduce the silent hour-long stall on a tier the - # developer runs from inside the shell. - # `gh auth token` is non-interactive and never opens a prompt. - # - # The predicate is the variable itself rather than the name of a check, so - # the driver stays generic: a catalog that adds another GitHub-authenticated - # check is covered without touching this recipe. + # shell spawns too. Deriving one from `gh` is different: it manufactures a + # credential the developer did not export, and PID 1 exposes it to every + # build script and proc macro in the container. It therefore requires the + # exact --github-token-from-gh switch in a direct command's argv or in an + # expanded `just --dry-run` plan. Interactive execution never opts in. # - # Set here and passed by NAME, so the value never reaches the host's - # process command line, and unset again with the hook's variables below. - if (-not $env:GITHUB_TOKEN -and (Get-Command gh -ErrorAction SilentlyContinue)) { - $needsToken = $argv.Count -eq 0 - # Only a `just` command can be planned, and planning is the only way to - # know whether what runs reads the variable. Anything else keeps the - # environment it was given: a manufactured credential reaches every - # process in the container, so an unknown command does not earn one. - if (-not $needsToken -and $argv[0] -eq 'just') { + # Explicit --github-token and GITHUB_TOKEN remain authoritative even when + # the opt-in switch is also present, so neither path invokes gh. + if (-not $env:GITHUB_TOKEN) { + $githubTokenFromGh = $false + $hasExplicitGitHubToken = $false + if ($argv.Count -gt 0 -and $argv[0] -eq 'just') { # A dry run has no side effects, and a target that cannot be planned - # (a typo, a recipe needing arguments) yields nothing, so the run - # fails on its own terms rather than on a missing token. + # yields no consent, so the run fails on its own terms. # # A plan covers the bodies just runs itself, not the body of a # recipe that one of them launches as a child process. The unscoped # tier wrapper launches its tier that way, so planning # `anvil-scheduled` shows the wrapper and none of the checks # underneath it. Follow each nested target a plan names, or a - # wrapped tier reads as needing nothing and runs unauthenticated. + # wrapped tier can hide an explicit opt-in in one of its checks. $plan = '' $targets = [System.Collections.Generic.List[object]]::new() $targets.Add([string[]]@($argv | Select-Object -Skip 1)) @@ -5400,10 +5374,7 @@ anvil-container *command: # The same executable that launched this tree, for the reason # every other nested call uses it: a caller invoking `just` by # absolute path with its directory off PATH would otherwise fail - # here. That failure is silent, because an empty plan reads as - # "does not need a token" -- so anvil-aprz would run - # unauthenticated in an image with no gh of its own and block on - # the rate limit for up to an hour. + # here. An empty plan means no GitHub CLI consent. $step = '' try { $step = (& '{{ replace(just_executable(), "'", "''") }}' --dry-run @target 2>&1 | @@ -5417,9 +5388,20 @@ anvil-container *command: $targets.Add([string[]]@($nested.Groups[1].Value)) } } - $needsToken = $plan -match 'GITHUB_TOKEN' + $githubTokenFromGh = [regex]::IsMatch( + $plan, '(?m)(^|[\s''"])--github-token-from-gh($|[\s''"])' + ) + $hasExplicitGitHubToken = [regex]::IsMatch( + $plan, '(?m)(^|[\s''"])--github-token(?:=|$|[\s''"])' + ) + } elseif ($argv.Count -gt 0) { + $githubTokenFromGh = $argv -contains '--github-token-from-gh' + $hasExplicitGitHubToken = @( + $argv | Where-Object { $_ -eq '--github-token' -or $_.StartsWith('--github-token=') } + ).Count -gt 0 } - if ($needsToken) { + + if ($githubTokenFromGh -and -not $hasExplicitGitHubToken -and (Get-Command gh -ErrorAction SilentlyContinue)) { $ghToken = $null try { $ghToken = (gh auth token --hostname github.com 2>$null) } catch { $ghToken = $null } if ($ghToken -and $ghToken.Trim()) { diff --git a/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap b/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap index 0dae03d31..ffe20fbd6 100644 --- a/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap +++ b/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap @@ -654,10 +654,10 @@ unknown-git = "deny" # capped at 60 requests an hour, and on a full workspace it exhausts that # and then waits for the quota to reset rather than failing; an # authenticated token raises the cap to 5000/hour. CI injects GITHUB_TOKEN -# (github.token). For native local runs, cargo-aprz performs host-aware -# discovery from GITHUB_TOKEN and the gh CLI. In a container the driver -# resolves and forwards GITHUB_TOKEN by name because the image has no gh CLI -# of its own. +# (github.token). Local runs use an exported GITHUB_TOKEN when available and +# otherwise remain anonymous. Developers can opt into host-aware GitHub CLI +# discovery by invoking cargo-aprz themselves with --github-token-from-gh; +# this generated check deliberately does not access their gh credentials. # # Unscoped (consults external risk DB). @@ -665,9 +665,6 @@ unknown-git = "deny" [script("pwsh", "-NoProfile")] anvil-aprz: anvil-aprz-validate-prereqs $ErrorActionPreference = 'Stop' - # Load-bearing no-op: the container driver scans the executable dry-run - # plan for this variable before deriving a token for its gh-less image. - $null = $env:GITHUB_TOKEN & cargo {{_anvil_stable_toolchain_args}} aprz deps --error-if-high-risk --console appraisal if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } @@ -4026,52 +4023,29 @@ anvil-container *command: $forwardedEnv = @() $hookEnv = @() - # anvil-aprz queries the GitHub advisory API, which allows 60 requests an - # hour unauthenticated -- less than a full tier needs. Unauthenticated is - # not a degraded-but-working mode: `cargo aprz deps` sleeps until the quota - # resets rather than failing, so a containerized tier blocks for up to an - # hour with no way to opt out. Authentication is what makes the check - # terminate, not what makes it fast. - # - # Resolve the token exactly as the recipe does natively -- GITHUB_TOKEN - # first, then the gh CLI's stored token -- so a containerized run - # authenticates for the same developers a native run does. - # # An already-exported GITHUB_TOKEN is forwarded whatever the command is: # that is exact parity, since a native run exposes it to every process the - # shell spawns too. Deriving one from `gh` is different -- it manufactures a - # credential the developer did not put in this environment, and PID 1's - # environment is inherited by every build script and proc macro in the - # container, where natively the recipe would mint it in its own process. So - # it is derived only when the command is known to read the variable, or when - # there is no command at all: an interactive session can run anything, and - # refusing there would reintroduce the silent hour-long stall on a tier the - # developer runs from inside the shell. - # `gh auth token` is non-interactive and never opens a prompt. - # - # The predicate is the variable itself rather than the name of a check, so - # the driver stays generic: a catalog that adds another GitHub-authenticated - # check is covered without touching this recipe. + # shell spawns too. Deriving one from `gh` is different: it manufactures a + # credential the developer did not export, and PID 1 exposes it to every + # build script and proc macro in the container. It therefore requires the + # exact --github-token-from-gh switch in a direct command's argv or in an + # expanded `just --dry-run` plan. Interactive execution never opts in. # - # Set here and passed by NAME, so the value never reaches the host's - # process command line, and unset again with the hook's variables below. - if (-not $env:GITHUB_TOKEN -and (Get-Command gh -ErrorAction SilentlyContinue)) { - $needsToken = $argv.Count -eq 0 - # Only a `just` command can be planned, and planning is the only way to - # know whether what runs reads the variable. Anything else keeps the - # environment it was given: a manufactured credential reaches every - # process in the container, so an unknown command does not earn one. - if (-not $needsToken -and $argv[0] -eq 'just') { + # Explicit --github-token and GITHUB_TOKEN remain authoritative even when + # the opt-in switch is also present, so neither path invokes gh. + if (-not $env:GITHUB_TOKEN) { + $githubTokenFromGh = $false + $hasExplicitGitHubToken = $false + if ($argv.Count -gt 0 -and $argv[0] -eq 'just') { # A dry run has no side effects, and a target that cannot be planned - # (a typo, a recipe needing arguments) yields nothing, so the run - # fails on its own terms rather than on a missing token. + # yields no consent, so the run fails on its own terms. # # A plan covers the bodies just runs itself, not the body of a # recipe that one of them launches as a child process. The unscoped # tier wrapper launches its tier that way, so planning # `anvil-scheduled` shows the wrapper and none of the checks # underneath it. Follow each nested target a plan names, or a - # wrapped tier reads as needing nothing and runs unauthenticated. + # wrapped tier can hide an explicit opt-in in one of its checks. $plan = '' $targets = [System.Collections.Generic.List[object]]::new() $targets.Add([string[]]@($argv | Select-Object -Skip 1)) @@ -4084,10 +4058,7 @@ anvil-container *command: # The same executable that launched this tree, for the reason # every other nested call uses it: a caller invoking `just` by # absolute path with its directory off PATH would otherwise fail - # here. That failure is silent, because an empty plan reads as - # "does not need a token" -- so anvil-aprz would run - # unauthenticated in an image with no gh of its own and block on - # the rate limit for up to an hour. + # here. An empty plan means no GitHub CLI consent. $step = '' try { $step = (& '{{ replace(just_executable(), "'", "''") }}' --dry-run @target 2>&1 | @@ -4101,9 +4072,20 @@ anvil-container *command: $targets.Add([string[]]@($nested.Groups[1].Value)) } } - $needsToken = $plan -match 'GITHUB_TOKEN' + $githubTokenFromGh = [regex]::IsMatch( + $plan, '(?m)(^|[\s''"])--github-token-from-gh($|[\s''"])' + ) + $hasExplicitGitHubToken = [regex]::IsMatch( + $plan, '(?m)(^|[\s''"])--github-token(?:=|$|[\s''"])' + ) + } elseif ($argv.Count -gt 0) { + $githubTokenFromGh = $argv -contains '--github-token-from-gh' + $hasExplicitGitHubToken = @( + $argv | Where-Object { $_ -eq '--github-token' -or $_.StartsWith('--github-token=') } + ).Count -gt 0 } - if ($needsToken) { + + if ($githubTokenFromGh -and -not $hasExplicitGitHubToken -and (Get-Command gh -ErrorAction SilentlyContinue)) { $ghToken = $null try { $ghToken = (gh auth token --hostname github.com 2>$null) } catch { $ghToken = $null } if ($ghToken -and $ghToken.Trim()) { diff --git a/crates/cargo-aprz-lib/src/commands/common.rs b/crates/cargo-aprz-lib/src/commands/common.rs index ca51d26a5..aa0b9a6dd 100644 --- a/crates/cargo-aprz-lib/src/commands/common.rs +++ b/crates/cargo-aprz-lib/src/commands/common.rs @@ -76,14 +76,21 @@ pub enum ConsoleSection { /// Common arguments shared between crates and deps commands #[derive(Args, Debug)] +#[expect(clippy::struct_excessive_bools, reason = "each bool is an independent clap CLI flag")] pub struct CommonArgs { /// GitHub token. /// - /// Defaults to `GITHUB_TOKEN`, then the authenticated `gh` token for the - /// configured GitHub host. If none is available, GitHub access is anonymous. + /// Defaults to `GITHUB_TOKEN`. If none is available, GitHub access is anonymous. #[arg(long, value_name = "TOKEN")] pub github_token: Option, + /// Discover a GitHub token from the authenticated `gh` CLI. + /// + /// Used only after `--github-token` and `GITHUB_TOKEN`, for the configured + /// GitHub host. + #[arg(long)] + pub github_token_from_gh: bool, + /// Codeberg personal access token #[arg(long, value_name = "TOKEN", env = "CODEBERG_TOKEN")] pub codeberg_token: Option, @@ -265,7 +272,7 @@ impl<'a, H: super::Host> Common<'a, H> { let progress_reporter = ProgressReporter::new(delay, use_colors_for_progress); let endpoints = args.endpoints(); - let github_token = discover(args.github_token.as_ref(), &endpoints).await; + let github_token = discover(args.github_token.as_ref(), args.github_token_from_gh, &endpoints).await; let collector = Collector::new( github_token.as_ref().map(GitHubToken::expose_secret), @@ -685,7 +692,7 @@ fn should_include_rejection_details(console_mode: Option<&ConsoleOutputMode>) -> #[cfg(test)] #[cfg(not(miri))] mod tests { - use clap::Parser; + use clap::{CommandFactory, Parser}; use semver::{Version, VersionReq}; use super::*; @@ -732,6 +739,27 @@ mod tests { assert_eq!(endpoints.advisory_url(), defaults.advisory_url()); } + #[test] + fn github_cli_token_discovery_is_visible_and_opt_in() { + let crates_default = crate::commands::CratesArgs::parse_from(["crates"]); + assert!(!crates_default.common.github_token_from_gh); + let deps_default = crate::commands::DepsArgs::parse_from(["deps"]); + assert!(!deps_default.common.github_token_from_gh); + + let crates = crate::commands::CratesArgs::parse_from(["crates", "--github-token-from-gh"]); + assert!(crates.common.github_token_from_gh); + let deps = crate::commands::DepsArgs::parse_from(["deps", "--github-token-from-gh"]); + assert!(deps.common.github_token_from_gh); + + for mut command in [crate::commands::CratesArgs::command(), crate::commands::DepsArgs::command()] { + let help = command.render_long_help().to_string(); + assert!( + help.contains("--github-token-from-gh"), + "the opt-in switch must be visible in help:\n{help}" + ); + } + } + #[test] fn test_endpoints_apply_every_override() { let parsed = ArgsHarness::parse_from([ diff --git a/crates/cargo-aprz-lib/src/commands/github_credentials.rs b/crates/cargo-aprz-lib/src/commands/github_credentials.rs index 01668a39a..487ed277d 100644 --- a/crates/cargo-aprz-lib/src/commands/github_credentials.rs +++ b/crates/cargo-aprz-lib/src/commands/github_credentials.rs @@ -76,12 +76,20 @@ struct GhCommandRequest { } /// Resolve the GitHub credential used by the hosting provider. -pub(super) async fn discover(explicit: Option<&GitHubToken>, endpoints: &Endpoints) -> Option { - discover_with(explicit, endpoints, || std::env::var_os(GITHUB_TOKEN_ENV), query_gh).await +pub(super) async fn discover(explicit: Option<&GitHubToken>, github_token_from_gh: bool, endpoints: &Endpoints) -> Option { + discover_with( + explicit, + github_token_from_gh, + endpoints, + || std::env::var_os(GITHUB_TOKEN_ENV), + query_gh, + ) + .await } async fn discover_with( explicit: Option<&GitHubToken>, + github_token_from_gh: bool, endpoints: &Endpoints, read_environment: impl FnOnce() -> Option, query_gh: impl FnOnce(String) -> OutputFuture, @@ -113,6 +121,11 @@ where ); } + if !github_token_from_gh { + log::trace!(target: LOG_TARGET, "GitHub CLI credential discovery was not requested; using anonymous access"); + return None; + } + let Some(hostname) = github_hostname(endpoints) else { log::trace!( target: LOG_TARGET, @@ -357,6 +370,7 @@ mod tests { let selected = discover_with( Some(&explicit), + true, &Endpoints::default(), || { environment_read.set(true); @@ -381,6 +395,7 @@ mod tests { let selected = discover_with( None, + true, &Endpoints::default(), || Some(OsString::from("environment-secret")), |_| { @@ -396,12 +411,35 @@ mod tests { } #[tokio::test] - async fn blank_environment_tokens_continue_to_gh() { + async fn default_off_never_queries_gh_for_absent_or_blank_environment_tokens() { + for environment in [None, Some(""), Some(" \r\n\t ")] { + let gh_called = Cell::new(false); + + let selected = discover_with( + None, + false, + &Endpoints::default(), + || environment.map(OsString::from), + |_| { + gh_called.set(true); + std::future::ready(Ok(successful(b"gh-secret"))) + }, + ) + .await; + + assert!(selected.is_none()); + assert!(!gh_called.get(), "gh must remain disabled for environment value {environment:?}"); + } + } + + #[tokio::test] + async fn blank_environment_tokens_continue_to_gh_when_enabled() { for environment in ["", " \r\n\t "] { let gh_called = Cell::new(false); let selected = discover_with( None, + true, &Endpoints::default(), || Some(OsString::from(environment)), |_| { @@ -424,6 +462,7 @@ mod tests { let selected = discover_with( None, + true, &endpoints, || None, |hostname| { @@ -444,6 +483,7 @@ mod tests { let selected = discover_with( None, + true, &Endpoints::default(), || None, |hostname| { @@ -461,6 +501,7 @@ mod tests { async fn command_not_found_continues_anonymously() { let selected = discover_with( None, + true, &Endpoints::default(), || None, |_| std::future::ready(Err(io::Error::new(io::ErrorKind::NotFound, "test gh is absent"))), @@ -474,6 +515,7 @@ mod tests { async fn timed_out_command_continues_anonymously() { let selected = discover_with( None, + true, &Endpoints::default(), || None, |_| std::future::ready(Err(io::Error::new(io::ErrorKind::TimedOut, "test gh lookup expired"))), @@ -488,6 +530,7 @@ mod tests { let secret = "failed-command-secret"; let selected = discover_with( None, + true, &Endpoints::default(), || None, |_| { @@ -506,6 +549,7 @@ mod tests { async fn blank_command_output_continues_anonymously() { let selected = discover_with( None, + true, &Endpoints::default(), || None, |_| std::future::ready(Ok(successful(b" \r\n\t "))), @@ -519,6 +563,7 @@ mod tests { async fn non_utf8_command_output_continues_anonymously() { let selected = discover_with( None, + true, &Endpoints::default(), || None, |_| std::future::ready(Ok(successful(&[0xff, 0xfe]))), diff --git a/crates/cargo-aprz/README.md b/crates/cargo-aprz/README.md index 31e8d20ff..b7569a108 100644 --- a/crates/cargo-aprz/README.md +++ b/crates/cargo-aprz/README.md @@ -178,14 +178,16 @@ GitHub credentials are discovered in this order: 1. `--github-token` 1. `GITHUB_TOKEN` -1. the token reported by `gh auth token --hostname ` +1. when `--github-token-from-gh` is present, the token reported by + `gh auth token --hostname ` 1. anonymous access The host passed to `gh` comes from the effective GitHub service URL, including `--github-url` or `APRZ_GITHUB_URL` overrides for GitHub Enterprise. Missing or blank credentials and a `gh` lookup that exceeds its finite deadline are -ignored and retain the existing anonymous rate-limit behavior. Codeberg -credentials continue to use `--codeberg-token` or `CODEBERG_TOKEN`. +ignored and retain the existing anonymous rate-limit behavior. Without +`--github-token-from-gh`, `cargo-aprz` never searches for or invokes `gh`. +Codeberg credentials continue to use `--codeberg-token` or `CODEBERG_TOKEN`. ### Reports diff --git a/crates/cargo-aprz/docs/DESIGN.md b/crates/cargo-aprz/docs/DESIGN.md index 4aa2c1b93..ba441fc8d 100644 --- a/crates/cargo-aprz/docs/DESIGN.md +++ b/crates/cargo-aprz/docs/DESIGN.md @@ -66,20 +66,27 @@ exhaust anonymous API limits quickly. Credential resolution follows this order: 1. the explicit `--github-token` command-line option; 2. the `GITHUB_TOKEN` environment variable; -3. the output of `gh auth token --hostname ` when the `gh` executable is - available and has an authenticated account for the configured GitHub host; +3. when `--github-token-from-gh` is present, the output of + `gh auth token --hostname ` when the `gh` executable is available and + has an authenticated account for the configured GitHub host; 4. anonymous access. `` comes from the effective GitHub service address, so a `--github-url`/`APRZ_GITHUB_URL` override can use the matching GitHub Enterprise login rather than accidentally querying `github.com`. -The `gh` fallback is convenience, not a prerequisite. A missing executable, -missing login, nonzero `gh auth token` result, blank output, or non-UTF-8 output -continues anonymously and retains the provider's existing rate-limit behavior. -An explicit option is authoritative, as is a nonblank environment token; -cargo-aprz never invokes `gh` when either applies. Environment values are -trimmed; an empty or whitespace-only `GITHUB_TOKEN` is treated as absent so +GitHub CLI discovery is explicit opt-in rather than a default fallback. Without +`--github-token-from-gh`, exhausting the explicit and environment sources +continues anonymously without resolving the effective hostname, searching +`PATH`, scheduling a blocking lookup, or starting a `gh` process. + +When enabled, the `gh` fallback is convenience, not a prerequisite. A missing +executable, missing login, nonzero `gh auth token` result, blank output, or +non-UTF-8 output continues anonymously and retains the provider's existing +rate-limit behavior. An explicit option is authoritative, as is a nonblank +environment token; cargo-aprz never invokes `gh` when either applies, even when +the switch is present. Environment values are trimmed; an empty or +whitespace-only `GITHUB_TOKEN` is treated as absent so explicitly authorized, host-aware `gh` discovery can continue. The command is spawned directly without a shell. Its stdout is trimmed and used diff --git a/crates/cargo-aprz/src/main.rs b/crates/cargo-aprz/src/main.rs index 5ef5f9996..0f78535bd 100644 --- a/crates/cargo-aprz/src/main.rs +++ b/crates/cargo-aprz/src/main.rs @@ -159,14 +159,16 @@ //! //! 1. `--github-token` //! 2. `GITHUB_TOKEN` -//! 3. the token reported by `gh auth token --hostname ` +//! 3. when `--github-token-from-gh` is present, the token reported by +//! `gh auth token --hostname ` //! 4. anonymous access //! //! The host passed to `gh` comes from the effective GitHub service URL, including //! `--github-url` or `APRZ_GITHUB_URL` overrides for GitHub Enterprise. Missing //! or blank credentials and a `gh` lookup that exceeds its finite deadline are -//! ignored and retain the existing anonymous rate-limit behavior. Codeberg -//! credentials continue to use `--codeberg-token` or `CODEBERG_TOKEN`. +//! ignored and retain the existing anonymous rate-limit behavior. Without +//! `--github-token-from-gh`, `cargo-aprz` never searches for or invokes `gh`. +//! Codeberg credentials continue to use `--codeberg-token` or `CODEBERG_TOKEN`. //! //! ## Reports //! diff --git a/justfiles/anvil/checks/aprz.just b/justfiles/anvil/checks/aprz.just index 958f5a81c..1862d8381 100644 --- a/justfiles/anvil/checks/aprz.just +++ b/justfiles/anvil/checks/aprz.just @@ -10,10 +10,10 @@ # capped at 60 requests an hour, and on a full workspace it exhausts that # and then waits for the quota to reset rather than failing; an # authenticated token raises the cap to 5000/hour. CI injects GITHUB_TOKEN -# (github.token). For native local runs, cargo-aprz performs host-aware -# discovery from GITHUB_TOKEN and the gh CLI. In a container the driver -# resolves and forwards GITHUB_TOKEN by name because the image has no gh CLI -# of its own. +# (github.token). Local runs use an exported GITHUB_TOKEN when available and +# otherwise remain anonymous. Developers can opt into host-aware GitHub CLI +# discovery by invoking cargo-aprz themselves with --github-token-from-gh; +# this generated check deliberately does not access their gh credentials. # # Unscoped (consults external risk DB). @@ -21,9 +21,6 @@ [script("pwsh", "-NoProfile")] anvil-aprz: anvil-aprz-validate-prereqs $ErrorActionPreference = 'Stop' - # Load-bearing no-op: the container driver scans the executable dry-run - # plan for this variable before deriving a token for its gh-less image. - $null = $env:GITHUB_TOKEN & cargo {{_anvil_stable_toolchain_args}} aprz deps --error-if-high-risk --console appraisal if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/justfiles/anvil/container.just b/justfiles/anvil/container.just index 871c5b735..4a8c1292a 100644 --- a/justfiles/anvil/container.just +++ b/justfiles/anvil/container.just @@ -927,52 +927,29 @@ anvil-container *command: $forwardedEnv = @() $hookEnv = @() - # anvil-aprz queries the GitHub advisory API, which allows 60 requests an - # hour unauthenticated -- less than a full tier needs. Unauthenticated is - # not a degraded-but-working mode: `cargo aprz deps` sleeps until the quota - # resets rather than failing, so a containerized tier blocks for up to an - # hour with no way to opt out. Authentication is what makes the check - # terminate, not what makes it fast. - # - # Resolve the token exactly as the recipe does natively -- GITHUB_TOKEN - # first, then the gh CLI's stored token -- so a containerized run - # authenticates for the same developers a native run does. - # # An already-exported GITHUB_TOKEN is forwarded whatever the command is: # that is exact parity, since a native run exposes it to every process the - # shell spawns too. Deriving one from `gh` is different -- it manufactures a - # credential the developer did not put in this environment, and PID 1's - # environment is inherited by every build script and proc macro in the - # container, where natively the recipe would mint it in its own process. So - # it is derived only when the command is known to read the variable, or when - # there is no command at all: an interactive session can run anything, and - # refusing there would reintroduce the silent hour-long stall on a tier the - # developer runs from inside the shell. - # `gh auth token` is non-interactive and never opens a prompt. + # shell spawns too. Deriving one from `gh` is different: it manufactures a + # credential the developer did not export, and PID 1 exposes it to every + # build script and proc macro in the container. It therefore requires the + # exact --github-token-from-gh switch in a direct command's argv or in an + # expanded `just --dry-run` plan. Interactive execution never opts in. # - # The predicate is the variable itself rather than the name of a check, so - # the driver stays generic: a catalog that adds another GitHub-authenticated - # check is covered without touching this recipe. - # - # Set here and passed by NAME, so the value never reaches the host's - # process command line, and unset again with the hook's variables below. - if (-not $env:GITHUB_TOKEN -and (Get-Command gh -ErrorAction SilentlyContinue)) { - $needsToken = $argv.Count -eq 0 - # Only a `just` command can be planned, and planning is the only way to - # know whether what runs reads the variable. Anything else keeps the - # environment it was given: a manufactured credential reaches every - # process in the container, so an unknown command does not earn one. - if (-not $needsToken -and $argv[0] -eq 'just') { + # Explicit --github-token and GITHUB_TOKEN remain authoritative even when + # the opt-in switch is also present, so neither path invokes gh. + if (-not $env:GITHUB_TOKEN) { + $githubTokenFromGh = $false + $hasExplicitGitHubToken = $false + if ($argv.Count -gt 0 -and $argv[0] -eq 'just') { # A dry run has no side effects, and a target that cannot be planned - # (a typo, a recipe needing arguments) yields nothing, so the run - # fails on its own terms rather than on a missing token. + # yields no consent, so the run fails on its own terms. # # A plan covers the bodies just runs itself, not the body of a # recipe that one of them launches as a child process. The unscoped # tier wrapper launches its tier that way, so planning # `anvil-scheduled` shows the wrapper and none of the checks # underneath it. Follow each nested target a plan names, or a - # wrapped tier reads as needing nothing and runs unauthenticated. + # wrapped tier can hide an explicit opt-in in one of its checks. $plan = '' $targets = [System.Collections.Generic.List[object]]::new() $targets.Add([string[]]@($argv | Select-Object -Skip 1)) @@ -985,10 +962,7 @@ anvil-container *command: # The same executable that launched this tree, for the reason # every other nested call uses it: a caller invoking `just` by # absolute path with its directory off PATH would otherwise fail - # here. That failure is silent, because an empty plan reads as - # "does not need a token" -- so anvil-aprz would run - # unauthenticated in an image with no gh of its own and block on - # the rate limit for up to an hour. + # here. An empty plan means no GitHub CLI consent. $step = '' try { $step = (& '{{ replace(just_executable(), "'", "''") }}' --dry-run @target 2>&1 | @@ -1002,9 +976,20 @@ anvil-container *command: $targets.Add([string[]]@($nested.Groups[1].Value)) } } - $needsToken = $plan -match 'GITHUB_TOKEN' + $githubTokenFromGh = [regex]::IsMatch( + $plan, '(?m)(^|[\s''"])--github-token-from-gh($|[\s''"])' + ) + $hasExplicitGitHubToken = [regex]::IsMatch( + $plan, '(?m)(^|[\s''"])--github-token(?:=|$|[\s''"])' + ) + } elseif ($argv.Count -gt 0) { + $githubTokenFromGh = $argv -contains '--github-token-from-gh' + $hasExplicitGitHubToken = @( + $argv | Where-Object { $_ -eq '--github-token' -or $_.StartsWith('--github-token=') } + ).Count -gt 0 } - if ($needsToken) { + + if ($githubTokenFromGh -and -not $hasExplicitGitHubToken -and (Get-Command gh -ErrorAction SilentlyContinue)) { $ghToken = $null try { $ghToken = (gh auth token --hostname github.com 2>$null) } catch { $ghToken = $null } if ($ghToken -and $ghToken.Trim()) { diff --git a/scripts/test-anvil-container.ps1 b/scripts/test-anvil-container.ps1 index 94d6a39c7..7204d8156 100644 --- a/scripts/test-anvil-container.ps1 +++ b/scripts/test-anvil-container.ps1 @@ -22,8 +22,8 @@ 2. The first run builds an image and runs the recipe inside it. 3. A second run reuses the image (the tag resolves, nothing is built), no cache volume masks the tools the image installed, and a host - GITHUB_TOKEN is forwarded — from the environment, or from the gh CLI - when the environment has none. + GITHUB_TOKEN is forwarded from the environment. GitHub CLI discovery + remains off unless the command explicitly opts in. 3b. A recipe run from a linked worktree can still reach git history. 4. Changing a hashed input (the pinned toolchain) selects a new tag. 5. Reverting that input returns to the original tag. @@ -327,25 +327,25 @@ set unstable e2e-show-env: @echo "E2E:$ANVIL_E2E_RUNTIME" -# Proves the driver forwards a host token, and invents one when it should not. +# Proves the driver forwards a host token, and invents one only with consent. # `:-` because just runs recipe lines under `sh -u`, where a bare $NAME that # was correctly *not* forwarded would abort instead of printing empty. e2e-show-token: @echo "E2E-TOKEN:[${GITHUB_TOKEN:-}]" -# The negative case for the same rule. A derived token is minted only when the -# target's plan reads GITHUB_TOKEN, so this recipe must observe the environment -# *without naming the variable* -- naming it is what would opt it in. Dumping -# every name lets the assertion look for the value without the plan mentioning -# it. -e2e-dump-env: - @env | sed 's/=.*//' | sort | tr '\n' ' ' +# An exact switch in the expanded plan explicitly authorizes host gh discovery. +e2e-show-token-from-gh: + @sh -c 'echo "E2E-TOKEN:[${GITHUB_TOKEN:-}]"' --github-token-from-gh # Proves git resolves inside the container, which a linked worktree breaks # unless the driver mounts the common git directory. e2e-show-git: @echo "E2E-GIT:[$(git rev-parse --abbrev-ref HEAD)]" '@ +Write-Fixture (Join-Path $repo 'e2e-show-token.sh') @' +#!/bin/sh +echo "E2E-TOKEN:[${GITHUB_TOKEN:-}]" +'@ Invoke-Native -Command 'git' -Arguments @('init', '-q') -WorkingDirectory $repo | Out-Null # Pin the newline policy: the fixture writes LF, and a developer with @@ -439,10 +439,6 @@ Assert-Equal 'a tool installed by the image survives the cache mounts' 0 $probe. Assert-That 'the tool resolves inside the image, not a volume' ` ($probe.StdOut -match '/usr/local/cargo/bin/cargo-binstall') "$($probe.StdOut)$($probe.StdErr)" -# anvil-aprz runs in scheduled-advisories and blocks on the rate limit without a token, so a -# host token has to reach the container. The driver resolves it the way the -# recipe does natively: the environment first, then the gh CLI. -# # Failure details are redacted: on a developer machine the value below is a real # credential, and a test that prints it to the terminal on failure is a leak. function Hide-Token([string]$Text) { $Text -replace 'E2E-TOKEN:\[[^\]]+\]', 'E2E-TOKEN:[]' } @@ -452,47 +448,42 @@ $withToken = Invoke-Just -Repo $repo -Arguments @('anvil-container', 'just', 'e2 Assert-That 'a host GITHUB_TOKEN reaches a recipe in the container' ` ($withToken.StdOut -match 'E2E-TOKEN:\[e2e-forwarded-token\]') (Hide-Token "$($withToken.StdOut)$($withToken.StdErr)") -# No environment token and no gh CLI: nothing is forwarded. gh is hidden by -# dropping its directory from PATH, which is what the driver actually probes -- -# `GH_CONFIG_DIR` does not work here, because modern gh keeps credentials in the -# OS keyring rather than in its config directory. -$pathWithoutGh = $env:PATH -$ghCommand = Get-Command gh -ErrorAction SilentlyContinue -if ($ghCommand) { - $ghDir = (Split-Path $ghCommand.Source).TrimEnd('\', '/') - $separator = if ($IsWindows) { ';' } else { ':' } - $pathWithoutGh = (($env:PATH -split $separator) | - Where-Object { $_ -and $_.TrimEnd('\', '/') -ne $ghDir }) -join $separator -} +# No environment token and no opt-in: nothing is forwarded even when the host +# has an authenticated gh CLI. $withoutToken = Invoke-Just -Repo $repo -Arguments @('anvil-container', 'just', 'e2e-show-token') ` - -Environment @{ GITHUB_TOKEN = ''; GH_TOKEN = ''; PATH = $pathWithoutGh } -Assert-That 'no token is invented when the host has none' ` + -Environment @{ GITHUB_TOKEN = '' } +Assert-That 'GitHub CLI discovery is off by default' ` ($withoutToken.StdOut -match 'E2E-TOKEN:\[\]') (Hide-Token "$($withoutToken.StdOut)$($withoutToken.StdErr)") -# The gh fallback itself, which is what keeps a containerized tier from blocking -# for a developer who signed in with `gh auth login` and never exported a token. -# Skipped rather than failed when the host is not signed in, since that is a -# property of the machine running the suite. +# The explicit gh opt-in. Skipped rather than failed when the host is not signed +# in, since that is a property of the machine running the suite. $hostGhToken = $null if (Get-Command gh -ErrorAction SilentlyContinue) { try { $hostGhToken = (gh auth token --hostname github.com 2>$null) } catch { $hostGhToken = $null } } if ($hostGhToken -and $hostGhToken.Trim()) { - $viaGh = Invoke-Just -Repo $repo -Arguments @('anvil-container', 'just', 'e2e-show-token') ` + $viaGh = Invoke-Just -Repo $repo -Arguments @('anvil-container', 'just', 'e2e-show-token-from-gh') ` -Environment @{ GITHUB_TOKEN = '' } - Assert-That 'the gh CLI token is used when the environment has none' ` + Assert-That 'the gh CLI token is used only after explicit opt-in' ` ($viaGh.StdOut -match ('E2E-TOKEN:\[' + [regex]::Escape($hostGhToken.Trim()) + '\]')) ` (Hide-Token "$($viaGh.StdOut)$($viaGh.StdErr)") - # The other half of the rule. Minting a credential the developer never put - # in this environment hands it to every build script and proc macro in the - # container, where natively the recipe would mint it in its own process -- - # so a target that never reads the variable must not receive it. - $noNeed = Invoke-Just -Repo $repo -Arguments @('anvil-container', 'just', 'e2e-dump-env') ` + $directViaGh = Invoke-Just -Repo $repo ` + -Arguments @('anvil-container', 'sh', 'e2e-show-token.sh', '--github-token-from-gh') ` + -Environment @{ GITHUB_TOKEN = '' } + Assert-That 'a direct command opts in through an exact argv switch' ` + ($directViaGh.StdOut -match ('E2E-TOKEN:\[' + [regex]::Escape($hostGhToken.Trim()) + '\]')) ` + (Hide-Token "$($directViaGh.StdOut)$($directViaGh.StdErr)") + + $explicit = Invoke-Just -Repo $repo ` + -Arguments @( + 'anvil-container', 'sh', 'e2e-show-token.sh', + '--github-token', 'explicit-secret', '--github-token-from-gh' + ) ` -Environment @{ GITHUB_TOKEN = '' } - Assert-That 'no token is derived for a target that does not read it' ` - ($noNeed.StdOut -notmatch 'GITHUB_TOKEN') ` - (Hide-Token "$($noNeed.StdOut)$($noNeed.StdErr)") + Assert-That 'an explicit command token suppresses host gh discovery' ` + ($explicit.StdOut -match 'E2E-TOKEN:\[\]') ` + (Hide-Token "$($explicit.StdOut)$($explicit.StdErr)") } else { Write-Step 'skipping the gh-fallback check: this host has no gh credential' } From cbd68bfaee1e4654241c3c888eb4586e95bc66ad Mon Sep 17 00:00:00 2001 From: "Martin Kolinek (from Dev Box)" Date: Thu, 17 Sep 2026 13:24:29 +0200 Subject: [PATCH 06/13] fix(cargo-anvil): preserve GitHub credential host Resolve opted-in container GitHub CLI credentials for the effective cargo-aprz endpoint, forward APRZ_GITHUB_URL, and reject blank inherited token overrides. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a9fc919b-99f7-4134-aad1-2116321b4e0c --- .anvil.lock | 4 +- crates/cargo-anvil/docs/design/containers.md | 12 +- .../templates/justfiles/anvil/container.just | 76 +++++++-- crates/cargo-anvil/tests/recipe_contracts.rs | 158 ++++++++++++++++++ .../src/commands/github_credentials.rs | 2 + crates/cargo-aprz/docs/DESIGN.md | 4 +- justfiles/anvil/container.just | 76 +++++++-- 7 files changed, 306 insertions(+), 26 deletions(-) diff --git a/.anvil.lock b/.anvil.lock index b1ac92c65..86d3d2d30 100644 --- a/.anvil.lock +++ b/.anvil.lock @@ -1,7 +1,7 @@ version = 1 tool = "anvil" tool_version = "0.10.0" -catalog_checksum = "sha256:227eaca2b2aa0bf8e938290a2597502c67eee196c9c9cd28f0252215c604bc3c" +catalog_checksum = "sha256:aafc7caeb15077ff1187572b67b543fcb38db9f901ac6cb8651370bc269056ce" [[file]] path = ".anvil/container/Dockerfile.dockerignore" @@ -181,7 +181,7 @@ checksum = "sha256:6efd7378a2cd0f5d86519bd32fd86f2055a60191187dd77a8842b374b8eb7 [[file]] path = "justfiles/anvil/container.just" -checksum = "sha256:4f000165d96007473ce8dd6de86b0fab97bfb10e338c70bc8447e24541823349" +checksum = "sha256:cda190b4fd5c8cd241c2db027818b3a63ca1958266951bdee73da1ad7aa430db" [[file]] path = "justfiles/anvil/dev/build.just" diff --git a/crates/cargo-anvil/docs/design/containers.md b/crates/cargo-anvil/docs/design/containers.md index 6cd75b73f..baac4a48e 100644 --- a/crates/cargo-anvil/docs/design/containers.md +++ b/crates/cargo-anvil/docs/design/containers.md @@ -401,11 +401,17 @@ is — exact parity with a native run, where every process the shell spawns can GitHub CLI discovery is different: it manufactures a credential the developer did not export, and PID 1's environment is inherited by every build script and proc macro in the container. The driver therefore invokes -`gh auth token --hostname github.com` only when the containerized command explicitly opts in with +`gh auth token --hostname ` only when the containerized command explicitly opts in with `--github-token-from-gh`. A direct command opts in through an exact argv occurrence. A `just` command opts in when the switch occurs as an exact argument in its expanded `just --dry-run ` plan. An explicit `--github-token` in the -same command or plan suppresses the host `gh` lookup, as does an exported `GITHUB_TOKEN`. The no-command interactive -form never derives a token. +same command or plan suppresses the host `gh` lookup, as does a nonblank exported `GITHUB_TOKEN`. The no-command +interactive form never derives a token. + +`` follows cargo-aprz's effective endpoint: `--github-url` in the direct argv or expanded plan wins over +`APRZ_GITHUB_URL`, and no override means `github.com`. The environment override is forwarded into the container so +the host lookup and the process using its token keep the same endpoint. An invalid override suppresses host discovery +rather than querying an unrelated login; the inner command retains responsibility for reporting its invalid service +address. This wrapper lookup exists only because the image has no gh CLI of its own. Native cargo-aprz performs the same discovery only when its switch is present and selects the gh login from its effective GitHub endpoint, including diff --git a/crates/cargo-anvil/templates/justfiles/anvil/container.just b/crates/cargo-anvil/templates/justfiles/anvil/container.just index 4a8c1292a..ab2025cc4 100644 --- a/crates/cargo-anvil/templates/justfiles/anvil/container.just +++ b/crates/cargo-anvil/templates/justfiles/anvil/container.just @@ -935,11 +935,13 @@ anvil-container *command: # exact --github-token-from-gh switch in a direct command's argv or in an # expanded `just --dry-run` plan. Interactive execution never opts in. # - # Explicit --github-token and GITHUB_TOKEN remain authoritative even when - # the opt-in switch is also present, so neither path invokes gh. - if (-not $env:GITHUB_TOKEN) { + # Explicit --github-token and a nonblank GITHUB_TOKEN remain authoritative + # even when the opt-in switch is also present, so neither path invokes gh. + $hasEnvironmentGitHubToken = -not [string]::IsNullOrWhiteSpace($env:GITHUB_TOKEN) + if (-not $hasEnvironmentGitHubToken) { $githubTokenFromGh = $false $hasExplicitGitHubToken = $false + $githubUrl = $null if ($argv.Count -gt 0 -and $argv[0] -eq 'just') { # A dry run has no side effects, and a target that cannot be planned # yields no consent, so the run fails on its own terms. @@ -982,26 +984,80 @@ anvil-container *command: $hasExplicitGitHubToken = [regex]::IsMatch( $plan, '(?m)(^|[\s''"])--github-token(?:=|$|[\s''"])' ) + if ($githubTokenFromGh) { + $githubUrlMatch = [regex]::Match( + $plan, + '(?m)(?:^|[\s''"])--github-url(?:=|\s+)(?:''([^'']*)''|"([^"]*)"|([^\s''"]+))' + ) + if ($githubUrlMatch.Success) { + foreach ($group in 1..3) { + if ($githubUrlMatch.Groups[$group].Success) { + $githubUrl = $githubUrlMatch.Groups[$group].Value + break + } + } + } + } } elseif ($argv.Count -gt 0) { $githubTokenFromGh = $argv -contains '--github-token-from-gh' $hasExplicitGitHubToken = @( $argv | Where-Object { $_ -eq '--github-token' -or $_.StartsWith('--github-token=') } ).Count -gt 0 + if ($githubTokenFromGh) { + for ($i = 0; $i -lt $argv.Count; $i++) { + if ($argv[$i] -eq '--github-url' -and $i + 1 -lt $argv.Count) { + $githubUrl = $argv[$i + 1] + break + } + if ($argv[$i].StartsWith('--github-url=')) { + $githubUrl = $argv[$i].Substring('--github-url='.Length) + break + } + } + } } - if ($githubTokenFromGh -and -not $hasExplicitGitHubToken -and (Get-Command gh -ErrorAction SilentlyContinue)) { - $ghToken = $null - try { $ghToken = (gh auth token --hostname github.com 2>$null) } catch { $ghToken = $null } - if ($ghToken -and $ghToken.Trim()) { - Set-Item -LiteralPath 'Env:GITHUB_TOKEN' -Value $ghToken.Trim() - $hookEnv += 'GITHUB_TOKEN' + if ($githubTokenFromGh -and -not $hasExplicitGitHubToken) { + if ([string]::IsNullOrWhiteSpace($githubUrl) -and + -not [string]::IsNullOrWhiteSpace($env:APRZ_GITHUB_URL)) { + $githubUrl = $env:APRZ_GITHUB_URL + } + + $githubHostname = 'github.com' + if (-not [string]::IsNullOrWhiteSpace($githubUrl)) { + $githubUri = $null + try { $githubUri = [Uri]::new($githubUrl, [UriKind]::Absolute) } catch {} + if ($null -eq $githubUri -or [string]::IsNullOrWhiteSpace($githubUri.Host)) { + $githubHostname = $null + } elseif ($githubUri.Host -eq 'api.github.com') { + $githubHostname = 'github.com' + } else { + $githubHostname = $githubUri.Host + } + } + + if ($githubHostname -and (Get-Command gh -ErrorAction SilentlyContinue)) { + # gh treats even a whitespace-only inherited GITHUB_TOKEN as + # authoritative. Remove the value cargo-aprz already rejected + # so the authenticated account lookup can actually proceed. + Remove-Item -LiteralPath 'Env:GITHUB_TOKEN' -ErrorAction SilentlyContinue + $ghToken = $null + try { $ghToken = (gh auth token --hostname $githubHostname 2>$null) } catch { $ghToken = $null } + if ($ghToken -and $ghToken.Trim()) { + Set-Item -LiteralPath 'Env:GITHUB_TOKEN' -Value $ghToken.Trim() + $hookEnv += 'GITHUB_TOKEN' + } } } } - if ($env:GITHUB_TOKEN) { + if (-not [string]::IsNullOrWhiteSpace($env:GITHUB_TOKEN)) { $forwardedEnv += 'GITHUB_TOKEN' $runArgs += @('-e', 'GITHUB_TOKEN') } + if (-not [string]::IsNullOrWhiteSpace($env:APRZ_GITHUB_URL)) { + $forwardedEnv += 'APRZ_GITHUB_URL' + $runArgs += @('-e', 'APRZ_GITHUB_URL') + } # The recipe contract's own inputs. These are read by generated checks -- # `anvil-pr-title` reads PR_TITLE, `_anvil-base-ref` reads BASE_REF and its diff --git a/crates/cargo-anvil/tests/recipe_contracts.rs b/crates/cargo-anvil/tests/recipe_contracts.rs index 70b050ef6..2ac83dfde 100644 --- a/crates/cargo-anvil/tests/recipe_contracts.rs +++ b/crates/cargo-anvil/tests/recipe_contracts.rs @@ -571,6 +571,59 @@ fn run_just_with_real_cargo(root: &Path, arguments: &[&str]) -> Output { command.output().expect("just is required to verify generated recipe behavior") } +fn run_container_github_credential_probe(root: &Path, arguments: &[&str], environment: &[(&str, &OsStr)]) -> (Output, String) { + let start = CONTAINER + .find(" # An already-exported GITHUB_TOKEN") + .expect("container GitHub credential block"); + let end = CONTAINER[start..] + .find(" # The recipe contract's own inputs.") + .map(|offset| start + offset) + .expect("end of container GitHub credential block"); + let block = CONTAINER[start..end].replace("'{{ replace(just_executable(), \"'\", \"''\") }}'", "'just'"); + let argv = arguments + .iter() + .map(|argument| format!("'{}'", argument.replace('\'', "''"))) + .collect::>() + .join(", "); + let script = format!( + "$argv = @({argv})\n\ + $runArgs = @()\n\ + $forwardedEnv = @()\n\ + $hookEnv = @()\n\ + {block}\n\ + Write-Output \"TOKEN=$($env:GITHUB_TOKEN)\"\n\ + Write-Output \"RUN_ARGS=$($runArgs -join '|')\"\n" + ); + let log = root.join("gh.log"); + if log.exists() { + fs::remove_file(&log).unwrap(); + } + + let mut command = Command::new("pwsh"); + command + .args(["-NoProfile", "-Command", &script]) + .current_dir(root) + .env("PATH", path_with_fake_bin(root)) + .env("FAKE_GH_LOG", &log) + .env_remove("GITHUB_TOKEN") + .env_remove("APRZ_GITHUB_URL"); + for &(key, value) in environment { + command.env(key, value); + } + let output = command.output().expect("pwsh is required to verify generated recipe behavior"); + let calls = fs::read_to_string(log).unwrap_or_default(); + (output, calls) +} + +fn assert_probe_success(output: &Output, context: &str) { + assert!( + output.status.success(), + "{context}\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + fn assert_failed(output: &Output, context: &str) { assert!( !output.status.success(), @@ -2524,6 +2577,111 @@ fn aprz_does_not_opt_into_github_cli_credential_discovery() { assert!(calls.contains("aprz deps"), "cargo aprz must still be invoked:\n{calls}"); } +#[test] +fn container_github_cli_discovery_is_opt_in_host_aware_and_preserves_precedence() { + const PROBE: &str = "[script(\"pwsh\", \"-NoProfile\")]\n\ + enterprise-probe:\n \ + & cargo aprz deps --github-url https://github.plan.test/api/v3 --github-token-from-gh\n"; + + if !tools_available() { + return; + } + let tmp = fixture(&[("probe.just", PROBE)], &[]); + let root = tmp.path(); + write( + &root.join("fake-bin/gh.ps1"), + "if (Test-Path -LiteralPath 'Env:GITHUB_TOKEN') {\n \ + Add-Content -LiteralPath $env:FAKE_GH_LOG -Value \"inherited-token=[$env:GITHUB_TOKEN]\"\n\ + }\n\ + Add-Content -LiteralPath $env:FAKE_GH_LOG -Value ($args -join '|')\n\ + Write-Output 'discovered-token'\n", + ); + + let (planned, calls) = run_container_github_credential_probe(root, &["just", "enterprise-probe"], &[]); + assert_probe_success(&planned, "expanded-plan credential discovery failed"); + assert_eq!(calls.trim(), "auth|token|--hostname|github.plan.test"); + assert!( + String::from_utf8_lossy(&planned.stdout).contains("TOKEN=discovered-token"), + "the discovered token must be forwarded\n{}", + String::from_utf8_lossy(&planned.stdout) + ); + + let (direct, calls) = run_container_github_credential_probe( + root, + &[ + "cargo", + "aprz", + "deps", + "--github-url=https://github.argv.test/api/v3", + "--github-token-from-gh", + ], + &[("APRZ_GITHUB_URL", OsStr::new("https://github.environment.test/api/v3"))], + ); + assert_probe_success(&direct, "direct-argv credential discovery failed"); + assert_eq!( + calls.trim(), + "auth|token|--hostname|github.argv.test", + "the command-line service URL must win over the environment override" + ); + let direct_stdout = String::from_utf8_lossy(&direct.stdout); + assert!( + direct_stdout.contains("RUN_ARGS=-e|GITHUB_TOKEN|-e|APRZ_GITHUB_URL"), + "the token and endpoint override must both reach the container\n{direct_stdout}" + ); + + let (environment_url, calls) = run_container_github_credential_probe( + root, + &["cargo", "aprz", "deps", "--github-token-from-gh"], + &[("APRZ_GITHUB_URL", OsStr::new("https://github.environment.test/api/v3"))], + ); + assert_probe_success(&environment_url, "environment-host credential discovery failed"); + assert_eq!(calls.trim(), "auth|token|--hostname|github.environment.test"); + + let (explicit, calls) = run_container_github_credential_probe( + root, + &[ + "cargo", + "aprz", + "deps", + "--github-token", + "explicit-token", + "--github-token-from-gh", + ], + &[], + ); + assert_probe_success(&explicit, "explicit-token precedence probe failed"); + assert!(calls.is_empty(), "an explicit command token must suppress host gh discovery"); + + let (environment_token, calls) = run_container_github_credential_probe( + root, + &["cargo", "aprz", "deps", "--github-token-from-gh"], + &[("GITHUB_TOKEN", OsStr::new("environment-token"))], + ); + assert_probe_success(&environment_token, "environment-token precedence probe failed"); + assert!(calls.is_empty(), "a nonblank environment token must suppress host gh discovery"); + assert!( + String::from_utf8_lossy(&environment_token.stdout).contains("RUN_ARGS=-e|GITHUB_TOKEN"), + "the authoritative environment token must still be forwarded\n{}", + String::from_utf8_lossy(&environment_token.stdout) + ); + + let (whitespace_token, calls) = run_container_github_credential_probe( + root, + &["cargo", "aprz", "deps", "--github-token-from-gh"], + &[("GITHUB_TOKEN", OsStr::new(" \t "))], + ); + assert_probe_success(&whitespace_token, "blank environment-token probe failed"); + assert_eq!( + calls.trim(), + "auth|token|--hostname|github.com", + "a whitespace-only environment token is absent, so opted-in discovery must continue" + ); + + let (not_opted_in, calls) = run_container_github_credential_probe(root, &["echo", "--github-token-from-gh-extra"], &[]); + assert_probe_success(¬_opted_in, "non-opt-in probe failed"); + assert!(calls.is_empty(), "only an exact opt-in argument may invoke host gh"); +} + /// `anvil-mutants-diff` diffs the base against the WORKING TREE, not against /// HEAD. cargo-mutants validates every diff line against the file on disk and /// aborts when they disagree, so a commit-to-commit diff fails as soon as diff --git a/crates/cargo-aprz-lib/src/commands/github_credentials.rs b/crates/cargo-aprz-lib/src/commands/github_credentials.rs index 487ed277d..a7afad4fb 100644 --- a/crates/cargo-aprz-lib/src/commands/github_credentials.rs +++ b/crates/cargo-aprz-lib/src/commands/github_credentials.rs @@ -233,6 +233,8 @@ async fn run_gh_command(request: GhCommandRequest) -> io::Result$null) } catch { $ghToken = $null } + if ($ghToken -and $ghToken.Trim()) { + Set-Item -LiteralPath 'Env:GITHUB_TOKEN' -Value $ghToken.Trim() + $hookEnv += 'GITHUB_TOKEN' + } } } } - if ($env:GITHUB_TOKEN) { + if (-not [string]::IsNullOrWhiteSpace($env:GITHUB_TOKEN)) { $forwardedEnv += 'GITHUB_TOKEN' $runArgs += @('-e', 'GITHUB_TOKEN') } + if (-not [string]::IsNullOrWhiteSpace($env:APRZ_GITHUB_URL)) { + $forwardedEnv += 'APRZ_GITHUB_URL' + $runArgs += @('-e', 'APRZ_GITHUB_URL') + } # The recipe contract's own inputs. These are read by generated checks -- # `anvil-pr-title` reads PR_TITLE, `_anvil-base-ref` reads BASE_REF and its From 6e01ce38435041c887ff754a44dfd2b079dfc47d Mon Sep 17 00:00:00 2001 From: "Martin Kolinek (from Dev Box)" Date: Thu, 17 Sep 2026 19:07:08 +0200 Subject: [PATCH 07/13] fix(cargo-anvil): bound opted-in gh discovery Resolve host GitHub CLI credentials through direct executable images with bounded process-tree termination, regenerate Anvil outputs, and cover native Enterprise credential propagation end to end. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a9fc919b-99f7-4134-aad1-2116321b4e0c --- .anvil.lock | 4 +- crates/cargo-anvil/docs/design/containers.md | 15 +- .../src/anvil/artifacts/container.rs | 39 ++- .../templates/justfiles/anvil/container.just | 132 +++++++++- crates/cargo-anvil/tests/recipe_contracts.rs | 246 ++++++++++++++++-- .../snapshots/snapshots__ado_backend.snap | 190 +++++++++++++- .../snapshots/snapshots__github_backend.snap | 190 +++++++++++++- .../snapshots/snapshots__local_only.snap | 190 +++++++++++++- crates/cargo-aprz-lib/Cargo.toml | 4 + .../github_credentials_process_integration.rs | 201 ++++++++++++++ crates/cargo-aprz/docs/DESIGN.md | 11 + justfiles/anvil/container.just | 132 +++++++++- 12 files changed, 1274 insertions(+), 80 deletions(-) create mode 100644 crates/cargo-aprz-lib/tests/github_credentials_process_integration.rs diff --git a/.anvil.lock b/.anvil.lock index 86d3d2d30..b73b3e3d6 100644 --- a/.anvil.lock +++ b/.anvil.lock @@ -1,7 +1,7 @@ version = 1 tool = "anvil" tool_version = "0.10.0" -catalog_checksum = "sha256:aafc7caeb15077ff1187572b67b543fcb38db9f901ac6cb8651370bc269056ce" +catalog_checksum = "sha256:9b06c34f5cafedb7c0e78da98d23437b646d4aae34805cc9a9140fac7585ae0b" [[file]] path = ".anvil/container/Dockerfile.dockerignore" @@ -181,7 +181,7 @@ checksum = "sha256:6efd7378a2cd0f5d86519bd32fd86f2055a60191187dd77a8842b374b8eb7 [[file]] path = "justfiles/anvil/container.just" -checksum = "sha256:cda190b4fd5c8cd241c2db027818b3a63ca1958266951bdee73da1ad7aa430db" +checksum = "sha256:f6cce7dac0c264807471672546104b53f2e59bae378860b68d755d9e132060e1" [[file]] path = "justfiles/anvil/dev/build.just" diff --git a/crates/cargo-anvil/docs/design/containers.md b/crates/cargo-anvil/docs/design/containers.md index baac4a48e..49592df0d 100644 --- a/crates/cargo-anvil/docs/design/containers.md +++ b/crates/cargo-anvil/docs/design/containers.md @@ -413,10 +413,17 @@ the host lookup and the process using its token keep the same endpoint. An inval rather than querying an unrelated login; the inner command retains responsibility for reporting its invalid service address. -This wrapper lookup exists only because the image has no gh CLI of its own. Native cargo-aprz performs the same -discovery only when its switch is present and selects the gh login from its effective GitHub endpoint, including -GitHub Enterprise overrides. The generated `anvil-aprz` recipe does not pass the switch, so it uses an exported -`GITHUB_TOKEN` when available and otherwise runs anonymously without invoking the host gh CLI. +This wrapper lookup exists only because the image has no gh CLI of its own. It applies the same process boundary as +native cargo-aprz: explicit nonempty `PATH` entries only, resolved to an absolute executable; `.COM` and `.EXE` only +in Windows `PATHEXT` order, excluding functions, aliases, batch files and PowerShell shims; and a regular executable +file on Unix. The executable is launched directly with an argument vector and no shell, with stdin closed, stderr +captured and discarded, stdout captured as strict UTF-8, and the rejected blank `GITHUB_TOKEN` removed from its +environment. A missing executable, nonzero result, blank or invalid output, or ten-second deadline continues +anonymously. Deadline expiry terminates the complete process tree. + +Native cargo-aprz performs discovery only when its switch is present and selects the gh login from its effective +GitHub endpoint, including GitHub Enterprise overrides. The generated `anvil-aprz` recipe does not pass the switch, +so it uses an exported `GITHUB_TOKEN` when available and otherwise runs anonymously without invoking the host gh CLI. A plan covers the bodies `just` runs itself, not the body of a recipe that one of them launches as a child process. The unscoped tier wrapper (§`helpers.just`) launches its tier that way, so planning `anvil-scheduled` shows the wrapper diff --git a/crates/cargo-anvil/src/anvil/artifacts/container.rs b/crates/cargo-anvil/src/anvil/artifacts/container.rs index db4a710c9..c607e20bc 100644 --- a/crates/cargo-anvil/src/anvil/artifacts/container.rs +++ b/crates/cargo-anvil/src/anvil/artifacts/container.rs @@ -784,7 +784,8 @@ mod tests { #[test] fn a_host_token_is_forwarded_by_name_without_exposing_its_value() { - assert!(RECIPE.contains("gh auth token --hostname github.com")); + assert!(RECIPE.contains("$ghToken = Invoke-AnvilGhToken $ghExecutable $githubHostname")); + assert!(RECIPE.contains("@('auth', 'token', '--hostname', $Hostname)")); assert!(RECIPE.contains("$forwardedEnv += 'GITHUB_TOKEN'")); assert!(RECIPE.contains("$runArgs += @('-e', 'GITHUB_TOKEN')")); // By name, never by value: `-e NAME=VALUE` would put the credential on @@ -795,12 +796,42 @@ mod tests { assert!(RECIPE.contains("$hookEnv += 'GITHUB_TOKEN'")); // An exported token is left alone rather than re-derived; scoping of // the derived one is asserted in its own test below. - assert!(RECIPE.contains("if (-not $env:GITHUB_TOKEN)")); + assert!(RECIPE.contains("if (-not $hasEnvironmentGitHubToken)")); // Forwarding by name only works if the engine can see the name, so a // WSL engine needs it bridged -- otherwise `-e NAME` forwards nothing. assert!(RECIPE.contains("$engineExe -eq 'wsl.exe' -and $forwardedEnv.Count -gt 0")); } + #[test] + fn host_github_cli_discovery_is_direct_bounded_and_non_interactive() { + assert!(RECIPE.contains("$ghExecutable = Resolve-AnvilGhExecutable")); + assert!(RECIPE.contains("$start.FileName = $Executable")); + assert!(RECIPE.contains("$start.ArgumentList.Add($argument)")); + assert!(RECIPE.contains("$start.UseShellExecute = $false")); + assert!(RECIPE.contains("$start.RedirectStandardInput = $true")); + assert!(RECIPE.contains("$start.RedirectStandardOutput = $true")); + assert!(RECIPE.contains("$start.RedirectStandardError = $true")); + assert!(RECIPE.contains("$process.WaitForExit($TimeoutMilliseconds)")); + assert!(RECIPE.contains("[int]$TimeoutMilliseconds = 10000")); + assert!(RECIPE.contains("$process.Kill($true)")); + assert!(RECIPE.contains("$start.Environment.Remove('GITHUB_TOKEN')")); + assert!(!RECIPE.contains("Get-Command gh")); + assert!(!RECIPE.contains("& gh ")); + assert!(!RECIPE.contains("(gh ")); + } + + #[test] + fn host_github_cli_resolution_uses_only_direct_path_executables() { + assert!(RECIPE.contains("$path = [Environment]::GetEnvironmentVariable('PATH')")); + assert!(RECIPE.contains("if ([string]::IsNullOrEmpty($entry)) { continue }")); + assert!(RECIPE.contains("[IO.Path]::GetFullPath($candidate)")); + assert!(RECIPE.contains("$extension -ieq '.COM' -or $extension -ieq '.EXE'")); + assert!(!RECIPE.contains("$extension -ieq '.BAT'")); + assert!(!RECIPE.contains("$extension -ieq '.CMD'")); + assert!(RECIPE.contains("& /usr/bin/test -f $candidate")); + assert!(RECIPE.contains("& /usr/bin/test -x $candidate")); + } + #[test] fn the_whole_recipe_tree_defines_the_image() { // `just anvil-setup` reaches the install recipes through the tier, @@ -894,7 +925,9 @@ mod tests { // Each search covers the whole recipe, so the comparison below is the // thing under test. Bounding a search by an earlier match makes the // ordering true by construction and the assertion vacuous. - let derive = RECIPE.find("gh auth token --hostname").expect("the gh fallback must exist"); + let derive = RECIPE + .find("$ghToken = Invoke-AnvilGhToken $ghExecutable $githubHostname") + .expect("the host-aware gh fallback must exist"); let guard = RECIPE .find("if ($githubTokenFromGh -and -not $hasExplicitGitHubToken") .expect("the derive must require consent and no explicit token"); diff --git a/crates/cargo-anvil/templates/justfiles/anvil/container.just b/crates/cargo-anvil/templates/justfiles/anvil/container.just index ab2025cc4..8dc603334 100644 --- a/crates/cargo-anvil/templates/justfiles/anvil/container.just +++ b/crates/cargo-anvil/templates/justfiles/anvil/container.just @@ -937,6 +937,120 @@ anvil-container *command: # # Explicit --github-token and a nonblank GITHUB_TOKEN remain authoritative # even when the opt-in switch is also present, so neither path invokes gh. + function Resolve-AnvilGhExecutable { + $path = [Environment]::GetEnvironmentVariable('PATH') + if ([string]::IsNullOrEmpty($path)) { return $null } + + if ($IsWindows) { + $pathExt = [Environment]::GetEnvironmentVariable('PATHEXT') + if ([string]::IsNullOrEmpty($pathExt)) { + $pathExt = '.COM;.EXE;.BAT;.CMD' + } + $names = @( + foreach ($extension in ($pathExt -split ';')) { + if ([string]::IsNullOrEmpty($extension)) { continue } + if (-not $extension.StartsWith('.')) { $extension = ".$extension" } + if ($extension -ieq '.COM' -or $extension -ieq '.EXE') { + "gh$extension" + } + } + ) + } else { + $names = @('gh') + } + + foreach ($entry in ($path -split [IO.Path]::PathSeparator)) { + # Empty PATH entries implicitly mean the current directory to + # command lookup. Require an explicit entry such as `.` instead. + if ([string]::IsNullOrEmpty($entry)) { continue } + try { + $directory = if ([IO.Path]::IsPathRooted($entry)) { + [IO.Path]::GetFullPath($entry) + } else { + [IO.Path]::GetFullPath((Join-Path (Get-Location).Path $entry)) + } + } catch { + continue + } + + foreach ($name in $names) { + $candidate = Join-Path $directory $name + if (-not (Test-Path -LiteralPath $candidate -PathType Leaf)) { continue } + if (-not $IsWindows) { + # FileInfo.UnixMode is unavailable on the PowerShell 7.0 + # floor, so ask the host directly whether this regular file + # is executable. + & /usr/bin/test -f $candidate + if ($LASTEXITCODE -ne 0) { continue } + & /usr/bin/test -x $candidate + if ($LASTEXITCODE -ne 0) { continue } + } + return [IO.Path]::GetFullPath($candidate) + } + } + $null + } + + function Invoke-AnvilGhToken( + [Parameter(Mandatory)][string]$Executable, + [Parameter(Mandatory)][string]$Hostname, + [int]$TimeoutMilliseconds = 10000 + ) { + $start = [Diagnostics.ProcessStartInfo]::new() + $start.FileName = $Executable + $start.UseShellExecute = $false + $start.CreateNoWindow = $true + $start.RedirectStandardInput = $true + $start.RedirectStandardOutput = $true + $start.RedirectStandardError = $true + $start.StandardOutputEncoding = [Text.UTF8Encoding]::new($false, $true) + foreach ($argument in @('auth', 'token', '--hostname', $Hostname)) { + $null = $start.ArgumentList.Add($argument) + } + # gh treats even a whitespace-only inherited GITHUB_TOKEN as + # authoritative. Remove the value cargo-aprz already rejected so the + # authenticated account lookup can actually proceed. + $null = $start.Environment.Remove('GITHUB_TOKEN') + + $process = [Diagnostics.Process]::new() + $process.StartInfo = $start + $started = $false + try { + $started = $process.Start() + if (-not $started) { return $null } + $process.StandardInput.Close() + $stdout = $process.StandardOutput.ReadToEndAsync() + $stderr = $process.StandardError.ReadToEndAsync() + if (-not $process.WaitForExit($TimeoutMilliseconds)) { + $process.Kill($true) + $process.WaitForExit() + try { $null = $stdout.GetAwaiter().GetResult() } catch {} + try { $null = $stderr.GetAwaiter().GetResult() } catch {} + return $null + } + try { + $token = $stdout.GetAwaiter().GetResult() + $null = $stderr.GetAwaiter().GetResult() + } catch { + return $null + } + if ($process.ExitCode -ne 0 -or [string]::IsNullOrWhiteSpace($token)) { + return $null + } + $token.Trim() + } catch { + $null + } finally { + if ($started -and -not $process.HasExited) { + try { + $process.Kill($true) + $process.WaitForExit() + } catch {} + } + $process.Dispose() + } + } + $hasEnvironmentGitHubToken = -not [string]::IsNullOrWhiteSpace($env:GITHUB_TOKEN) if (-not $hasEnvironmentGitHubToken) { $githubTokenFromGh = $false @@ -1036,15 +1150,15 @@ anvil-container *command: } } - if ($githubHostname -and (Get-Command gh -ErrorAction SilentlyContinue)) { - # gh treats even a whitespace-only inherited GITHUB_TOKEN as - # authoritative. Remove the value cargo-aprz already rejected - # so the authenticated account lookup can actually proceed. - Remove-Item -LiteralPath 'Env:GITHUB_TOKEN' -ErrorAction SilentlyContinue - $ghToken = $null - try { $ghToken = (gh auth token --hostname $githubHostname 2>$null) } catch { $ghToken = $null } - if ($ghToken -and $ghToken.Trim()) { - Set-Item -LiteralPath 'Env:GITHUB_TOKEN' -Value $ghToken.Trim() + if ($githubHostname) { + $ghExecutable = Resolve-AnvilGhExecutable + if ($ghExecutable) { + $ghToken = Invoke-AnvilGhToken $ghExecutable $githubHostname + } else { + $ghToken = $null + } + if ($ghToken) { + Set-Item -LiteralPath 'Env:GITHUB_TOKEN' -Value $ghToken $hookEnv += 'GITHUB_TOKEN' } } diff --git a/crates/cargo-anvil/tests/recipe_contracts.rs b/crates/cargo-anvil/tests/recipe_contracts.rs index 2ac83dfde..224a9dc6d 100644 --- a/crates/cargo-anvil/tests/recipe_contracts.rs +++ b/crates/cargo-anvil/tests/recipe_contracts.rs @@ -12,7 +12,7 @@ use std::collections::HashSet; use std::ffi::{OsStr, OsString}; use std::fmt::Write as _; use std::fs; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::process::{Command, Output, Stdio}; use std::time::{Duration, Instant}; @@ -572,6 +572,15 @@ fn run_just_with_real_cargo(root: &Path, arguments: &[&str]) -> Output { } fn run_container_github_credential_probe(root: &Path, arguments: &[&str], environment: &[(&str, &OsStr)]) -> (Output, String) { + run_container_github_credential_probe_with_timeout(root, arguments, environment, None) +} + +fn run_container_github_credential_probe_with_timeout( + root: &Path, + arguments: &[&str], + environment: &[(&str, &OsStr)], + timeout_milliseconds: Option, +) -> (Output, String) { let start = CONTAINER .find(" # An already-exported GITHUB_TOKEN") .expect("container GitHub credential block"); @@ -579,14 +588,21 @@ fn run_container_github_credential_probe(root: &Path, arguments: &[&str], enviro .find(" # The recipe contract's own inputs.") .map(|offset| start + offset) .expect("end of container GitHub credential block"); - let block = CONTAINER[start..end].replace("'{{ replace(just_executable(), \"'\", \"''\") }}'", "'just'"); + let mut block = CONTAINER[start..end].replace("'{{ replace(just_executable(), \"'\", \"''\") }}'", "'just'"); + if let Some(timeout_milliseconds) = timeout_milliseconds { + block = block.replace( + "[int]$TimeoutMilliseconds = 10000", + &format!("[int]$TimeoutMilliseconds = {timeout_milliseconds}"), + ); + } let argv = arguments .iter() .map(|argument| format!("'{}'", argument.replace('\'', "''"))) .collect::>() .join(", "); let script = format!( - "$argv = @({argv})\n\ + "function gh {{ throw 'PowerShell command shim invoked' }}\n\ + $argv = @({argv})\n\ $runArgs = @()\n\ $forwardedEnv = @()\n\ $hookEnv = @()\n\ @@ -615,6 +631,84 @@ fn run_container_github_credential_probe(root: &Path, arguments: &[&str], enviro (output, calls) } +fn install_fake_gh(root: &Path) -> PathBuf { + const SOURCE: &str = r#" +use std::env; +use std::fs; +use std::io::{self, Write as _}; +use std::process::{self, Command}; +use std::thread; +use std::time::Duration; + +fn main() { + let mode = env::var("FAKE_GH_MODE").unwrap_or_else(|_| "token".to_owned()); + if mode == "descendant" { + thread::sleep(Duration::from_secs(8)); + fs::write(env::var_os("FAKE_GH_SENTINEL").expect("sentinel path"), b"survived") + .expect("write descendant sentinel"); + return; + } + + let args = env::args().skip(1).collect::>(); + let executable = env::current_exe() + .ok() + .and_then(|path| path.file_name().map(|name| name.to_string_lossy().into_owned())) + .unwrap_or_default(); + let log = format!( + "executable={executable}\ninherited-token={}\nargs={}", + env::var_os("GITHUB_TOKEN").is_some(), + args.join("|") + ); + fs::write(env::var_os("FAKE_GH_LOG").expect("log path"), log).expect("write invocation log"); + + match mode.as_str() { + "token" => println!("{}", env::var("FAKE_GH_TOKEN").unwrap_or_else(|_| "discovered-token".to_owned())), + "nonzero" => { + println!("nonzero-output-secret"); + eprintln!("nonzero-stderr-secret"); + process::exit(17); + } + "blank" => println!(" \t "), + "invalid" => io::stdout().write_all(&[0xff, 0xfe]).expect("write invalid UTF-8"), + "timeout" => { + Command::new(env::current_exe().expect("current executable")) + .env("FAKE_GH_MODE", "descendant") + .spawn() + .expect("spawn descendant"); + thread::sleep(Duration::from_secs(30)); + } + other => panic!("unknown fake gh mode: {other}"), + } +} +"#; + + let source = root.join("fake-gh.rs"); + write(&source, SOURCE); + let executable = root.join("fake-bin").join(if cfg!(windows) { "gh.exe" } else { "gh" }); + let rustc = std::env::var_os("RUSTC").unwrap_or_else(|| OsString::from("rustc")); + let output = Command::new(rustc) + .args(["--edition=2024", "-o"]) + .arg(&executable) + .arg(&source) + .output() + .expect("rustc is available while running Rust integration tests"); + assert!( + output.status.success(), + "compiling fake gh failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + executable +} + +fn assert_gh_call(calls: &str, hostname: &str) { + assert!(calls.contains("inherited-token=false"), "gh inherited the rejected token:\n{calls}"); + assert!( + calls.contains(&format!("args=auth|token|--hostname|{hostname}")), + "unexpected gh arguments:\n{calls}" + ); +} + fn assert_probe_success(output: &Output, context: &str) { assert!( output.status.success(), @@ -2588,18 +2682,11 @@ fn container_github_cli_discovery_is_opt_in_host_aware_and_preserves_precedence( } let tmp = fixture(&[("probe.just", PROBE)], &[]); let root = tmp.path(); - write( - &root.join("fake-bin/gh.ps1"), - "if (Test-Path -LiteralPath 'Env:GITHUB_TOKEN') {\n \ - Add-Content -LiteralPath $env:FAKE_GH_LOG -Value \"inherited-token=[$env:GITHUB_TOKEN]\"\n\ - }\n\ - Add-Content -LiteralPath $env:FAKE_GH_LOG -Value ($args -join '|')\n\ - Write-Output 'discovered-token'\n", - ); + install_fake_gh(root); let (planned, calls) = run_container_github_credential_probe(root, &["just", "enterprise-probe"], &[]); assert_probe_success(&planned, "expanded-plan credential discovery failed"); - assert_eq!(calls.trim(), "auth|token|--hostname|github.plan.test"); + assert_gh_call(&calls, "github.plan.test"); assert!( String::from_utf8_lossy(&planned.stdout).contains("TOKEN=discovered-token"), "the discovered token must be forwarded\n{}", @@ -2618,11 +2705,7 @@ fn container_github_cli_discovery_is_opt_in_host_aware_and_preserves_precedence( &[("APRZ_GITHUB_URL", OsStr::new("https://github.environment.test/api/v3"))], ); assert_probe_success(&direct, "direct-argv credential discovery failed"); - assert_eq!( - calls.trim(), - "auth|token|--hostname|github.argv.test", - "the command-line service URL must win over the environment override" - ); + assert_gh_call(&calls, "github.argv.test"); let direct_stdout = String::from_utf8_lossy(&direct.stdout); assert!( direct_stdout.contains("RUN_ARGS=-e|GITHUB_TOKEN|-e|APRZ_GITHUB_URL"), @@ -2635,7 +2718,7 @@ fn container_github_cli_discovery_is_opt_in_host_aware_and_preserves_precedence( &[("APRZ_GITHUB_URL", OsStr::new("https://github.environment.test/api/v3"))], ); assert_probe_success(&environment_url, "environment-host credential discovery failed"); - assert_eq!(calls.trim(), "auth|token|--hostname|github.environment.test"); + assert_gh_call(&calls, "github.environment.test"); let (explicit, calls) = run_container_github_credential_probe( root, @@ -2671,17 +2754,134 @@ fn container_github_cli_discovery_is_opt_in_host_aware_and_preserves_precedence( &[("GITHUB_TOKEN", OsStr::new(" \t "))], ); assert_probe_success(&whitespace_token, "blank environment-token probe failed"); - assert_eq!( - calls.trim(), - "auth|token|--hostname|github.com", - "a whitespace-only environment token is absent, so opted-in discovery must continue" - ); + assert_gh_call(&calls, "github.com"); let (not_opted_in, calls) = run_container_github_credential_probe(root, &["echo", "--github-token-from-gh-extra"], &[]); assert_probe_success(¬_opted_in, "non-opt-in probe failed"); assert!(calls.is_empty(), "only an exact opt-in argument may invoke host gh"); } +#[test] +fn container_github_cli_output_failures_continue_anonymously_without_leaking_output() { + if !tools_available() { + return; + } + let tmp = fixture(&[("container.just", CONTAINER)], &[]); + let root = tmp.path(); + install_fake_gh(root); + + for mode in ["nonzero", "blank", "invalid"] { + let (output, calls) = run_container_github_credential_probe( + root, + &["cargo", "aprz", "deps", "--github-token-from-gh"], + &[("FAKE_GH_MODE", OsStr::new(mode))], + ); + assert_probe_success(&output, &format!("{mode} gh output must continue anonymously")); + assert_gh_call(&calls, "github.com"); + let diagnostics = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + diagnostics.contains("TOKEN="), + "the probe must report its final state:\n{diagnostics}" + ); + assert!( + !diagnostics.contains("TOKEN=discovered-token"), + "{mode} output became a credential:\n{diagnostics}" + ); + assert!(!diagnostics.contains("nonzero-output-secret"), "gh stdout leaked:\n{diagnostics}"); + assert!(!diagnostics.contains("nonzero-stderr-secret"), "gh stderr leaked:\n{diagnostics}"); + } +} + +#[test] +fn container_github_cli_timeout_terminates_the_process_tree() { + if !tools_available() { + return; + } + let tmp = fixture(&[("container.just", CONTAINER)], &[]); + let root = tmp.path(); + install_fake_gh(root); + let sentinel = root.join("descendant-survived"); + let started = Instant::now(); + + let (output, calls) = run_container_github_credential_probe_with_timeout( + root, + &["cargo", "aprz", "deps", "--github-token-from-gh"], + &[("FAKE_GH_MODE", OsStr::new("timeout")), ("FAKE_GH_SENTINEL", sentinel.as_os_str())], + Some(5000), + ); + + assert_probe_success(&output, "a timed-out gh lookup must continue anonymously"); + assert!( + started.elapsed() < Duration::from_secs(15), + "the fake gh process was awaited instead of terminated" + ); + assert_gh_call(&calls, "github.com"); + std::thread::sleep(Duration::from_millis(8500)); + assert!(!sentinel.exists(), "the timed-out gh descendant survived process-tree termination"); +} + +#[test] +fn container_github_cli_resolution_ignores_command_shims_and_implicit_cwd() { + if !tools_available() { + return; + } + let tmp = fixture(&[("container.just", CONTAINER)], &[]); + let root = tmp.path(); + let executable = install_fake_gh(root); + let cwd_executable = root.join(if cfg!(windows) { "gh.exe" } else { "gh" }); + fs::copy(&executable, &cwd_executable).expect("copy fake gh into the current directory"); + let empty_path = if cfg!(windows) { OsStr::new(";") } else { OsStr::new(":") }; + + let (implicit_cwd, calls) = + run_container_github_credential_probe(root, &["cargo", "aprz", "deps", "--github-token-from-gh"], &[("PATH", empty_path)]); + assert_probe_success(&implicit_cwd, "implicit-current-directory lookup must continue anonymously"); + assert!( + calls.is_empty(), + "empty PATH entries must not select a planted current-directory executable" + ); + + if cfg!(windows) { + let com = root.join("fake-bin/gh.com"); + fs::copy(&executable, &com).expect("copy fake gh as a COM image"); + let (ordered, calls) = run_container_github_credential_probe( + root, + &["cargo", "aprz", "deps", "--github-token-from-gh"], + &[ + ("PATH", root.join("fake-bin").as_os_str()), + ("PATHEXT", OsStr::new(".COM;.CMD;.EXE;.PS1")), + ], + ); + assert_probe_success(&ordered, "PATHEXT-ordered lookup failed"); + assert!( + calls.to_ascii_lowercase().contains("executable=gh.com"), + "COM must win in PATHEXT order:\n{calls}" + ); + + fs::remove_file(executable).expect("remove direct EXE image"); + fs::remove_file(com).expect("remove direct COM image"); + write(&root.join("fake-bin/gh.cmd"), "@echo off\r\nexit /b 99\r\n"); + write(&root.join("fake-bin/gh.ps1"), "throw 'shim invoked'\n"); + } else { + fs::remove_file(executable).expect("remove executable image"); + write(&root.join("fake-bin/gh"), "#!/bin/sh\nexit 99\n"); + } + + let (shims, calls) = run_container_github_credential_probe( + root, + &["cargo", "aprz", "deps", "--github-token-from-gh"], + &[("PATH", root.join("fake-bin").as_os_str()), ("PATHEXT", OsStr::new(".CMD;.PS1"))], + ); + assert_probe_success(&shims, "rejected command shims must continue anonymously"); + assert!( + calls.is_empty(), + "a function, batch/PowerShell shim, or non-executable file was invoked" + ); +} + /// `anvil-mutants-diff` diffs the base against the WORKING TREE, not against /// HEAD. cargo-mutants validates every diff line against the file on disk and /// aborts when they disagree, so a commit-to-commit diff fails as soon as diff --git a/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap b/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap index e77d0d760..7cbabccbd 100644 --- a/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap +++ b/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap @@ -5234,11 +5234,127 @@ anvil-container *command: # exact --github-token-from-gh switch in a direct command's argv or in an # expanded `just --dry-run` plan. Interactive execution never opts in. # - # Explicit --github-token and GITHUB_TOKEN remain authoritative even when - # the opt-in switch is also present, so neither path invokes gh. - if (-not $env:GITHUB_TOKEN) { + # Explicit --github-token and a nonblank GITHUB_TOKEN remain authoritative + # even when the opt-in switch is also present, so neither path invokes gh. + function Resolve-AnvilGhExecutable { + $path = [Environment]::GetEnvironmentVariable('PATH') + if ([string]::IsNullOrEmpty($path)) { return $null } + + if ($IsWindows) { + $pathExt = [Environment]::GetEnvironmentVariable('PATHEXT') + if ([string]::IsNullOrEmpty($pathExt)) { + $pathExt = '.COM;.EXE;.BAT;.CMD' + } + $names = @( + foreach ($extension in ($pathExt -split ';')) { + if ([string]::IsNullOrEmpty($extension)) { continue } + if (-not $extension.StartsWith('.')) { $extension = ".$extension" } + if ($extension -ieq '.COM' -or $extension -ieq '.EXE') { + "gh$extension" + } + } + ) + } else { + $names = @('gh') + } + + foreach ($entry in ($path -split [IO.Path]::PathSeparator)) { + # Empty PATH entries implicitly mean the current directory to + # command lookup. Require an explicit entry such as `.` instead. + if ([string]::IsNullOrEmpty($entry)) { continue } + try { + $directory = if ([IO.Path]::IsPathRooted($entry)) { + [IO.Path]::GetFullPath($entry) + } else { + [IO.Path]::GetFullPath((Join-Path (Get-Location).Path $entry)) + } + } catch { + continue + } + + foreach ($name in $names) { + $candidate = Join-Path $directory $name + if (-not (Test-Path -LiteralPath $candidate -PathType Leaf)) { continue } + if (-not $IsWindows) { + # FileInfo.UnixMode is unavailable on the PowerShell 7.0 + # floor, so ask the host directly whether this regular file + # is executable. + & /usr/bin/test -f $candidate + if ($LASTEXITCODE -ne 0) { continue } + & /usr/bin/test -x $candidate + if ($LASTEXITCODE -ne 0) { continue } + } + return [IO.Path]::GetFullPath($candidate) + } + } + $null + } + + function Invoke-AnvilGhToken( + [Parameter(Mandatory)][string]$Executable, + [Parameter(Mandatory)][string]$Hostname, + [int]$TimeoutMilliseconds = 10000 + ) { + $start = [Diagnostics.ProcessStartInfo]::new() + $start.FileName = $Executable + $start.UseShellExecute = $false + $start.CreateNoWindow = $true + $start.RedirectStandardInput = $true + $start.RedirectStandardOutput = $true + $start.RedirectStandardError = $true + $start.StandardOutputEncoding = [Text.UTF8Encoding]::new($false, $true) + foreach ($argument in @('auth', 'token', '--hostname', $Hostname)) { + $null = $start.ArgumentList.Add($argument) + } + # gh treats even a whitespace-only inherited GITHUB_TOKEN as + # authoritative. Remove the value cargo-aprz already rejected so the + # authenticated account lookup can actually proceed. + $null = $start.Environment.Remove('GITHUB_TOKEN') + + $process = [Diagnostics.Process]::new() + $process.StartInfo = $start + $started = $false + try { + $started = $process.Start() + if (-not $started) { return $null } + $process.StandardInput.Close() + $stdout = $process.StandardOutput.ReadToEndAsync() + $stderr = $process.StandardError.ReadToEndAsync() + if (-not $process.WaitForExit($TimeoutMilliseconds)) { + $process.Kill($true) + $process.WaitForExit() + try { $null = $stdout.GetAwaiter().GetResult() } catch {} + try { $null = $stderr.GetAwaiter().GetResult() } catch {} + return $null + } + try { + $token = $stdout.GetAwaiter().GetResult() + $null = $stderr.GetAwaiter().GetResult() + } catch { + return $null + } + if ($process.ExitCode -ne 0 -or [string]::IsNullOrWhiteSpace($token)) { + return $null + } + $token.Trim() + } catch { + $null + } finally { + if ($started -and -not $process.HasExited) { + try { + $process.Kill($true) + $process.WaitForExit() + } catch {} + } + $process.Dispose() + } + } + + $hasEnvironmentGitHubToken = -not [string]::IsNullOrWhiteSpace($env:GITHUB_TOKEN) + if (-not $hasEnvironmentGitHubToken) { $githubTokenFromGh = $false $hasExplicitGitHubToken = $false + $githubUrl = $null if ($argv.Count -gt 0 -and $argv[0] -eq 'just') { # A dry run has no side effects, and a target that cannot be planned # yields no consent, so the run fails on its own terms. @@ -5281,26 +5397,80 @@ anvil-container *command: $hasExplicitGitHubToken = [regex]::IsMatch( $plan, '(?m)(^|[\s''"])--github-token(?:=|$|[\s''"])' ) + if ($githubTokenFromGh) { + $githubUrlMatch = [regex]::Match( + $plan, + '(?m)(?:^|[\s''"])--github-url(?:=|\s+)(?:''([^'']*)''|"([^"]*)"|([^\s''"]+))' + ) + if ($githubUrlMatch.Success) { + foreach ($group in 1..3) { + if ($githubUrlMatch.Groups[$group].Success) { + $githubUrl = $githubUrlMatch.Groups[$group].Value + break + } + } + } + } } elseif ($argv.Count -gt 0) { $githubTokenFromGh = $argv -contains '--github-token-from-gh' $hasExplicitGitHubToken = @( $argv | Where-Object { $_ -eq '--github-token' -or $_.StartsWith('--github-token=') } ).Count -gt 0 + if ($githubTokenFromGh) { + for ($i = 0; $i -lt $argv.Count; $i++) { + if ($argv[$i] -eq '--github-url' -and $i + 1 -lt $argv.Count) { + $githubUrl = $argv[$i + 1] + break + } + if ($argv[$i].StartsWith('--github-url=')) { + $githubUrl = $argv[$i].Substring('--github-url='.Length) + break + } + } + } } - if ($githubTokenFromGh -and -not $hasExplicitGitHubToken -and (Get-Command gh -ErrorAction SilentlyContinue)) { - $ghToken = $null - try { $ghToken = (gh auth token --hostname github.com 2>$null) } catch { $ghToken = $null } - if ($ghToken -and $ghToken.Trim()) { - Set-Item -LiteralPath 'Env:GITHUB_TOKEN' -Value $ghToken.Trim() - $hookEnv += 'GITHUB_TOKEN' + if ($githubTokenFromGh -and -not $hasExplicitGitHubToken) { + if ([string]::IsNullOrWhiteSpace($githubUrl) -and + -not [string]::IsNullOrWhiteSpace($env:APRZ_GITHUB_URL)) { + $githubUrl = $env:APRZ_GITHUB_URL + } + + $githubHostname = 'github.com' + if (-not [string]::IsNullOrWhiteSpace($githubUrl)) { + $githubUri = $null + try { $githubUri = [Uri]::new($githubUrl, [UriKind]::Absolute) } catch {} + if ($null -eq $githubUri -or [string]::IsNullOrWhiteSpace($githubUri.Host)) { + $githubHostname = $null + } elseif ($githubUri.Host -eq 'api.github.com') { + $githubHostname = 'github.com' + } else { + $githubHostname = $githubUri.Host + } + } + + if ($githubHostname) { + $ghExecutable = Resolve-AnvilGhExecutable + if ($ghExecutable) { + $ghToken = Invoke-AnvilGhToken $ghExecutable $githubHostname + } else { + $ghToken = $null + } + if ($ghToken) { + Set-Item -LiteralPath 'Env:GITHUB_TOKEN' -Value $ghToken + $hookEnv += 'GITHUB_TOKEN' + } } } } - if ($env:GITHUB_TOKEN) { + if (-not [string]::IsNullOrWhiteSpace($env:GITHUB_TOKEN)) { $forwardedEnv += 'GITHUB_TOKEN' $runArgs += @('-e', 'GITHUB_TOKEN') } + if (-not [string]::IsNullOrWhiteSpace($env:APRZ_GITHUB_URL)) { + $forwardedEnv += 'APRZ_GITHUB_URL' + $runArgs += @('-e', 'APRZ_GITHUB_URL') + } # The recipe contract's own inputs. These are read by generated checks -- # `anvil-pr-title` reads PR_TITLE, `_anvil-base-ref` reads BASE_REF and its diff --git a/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap b/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap index fa9adf669..56d3b3ab9 100644 --- a/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap +++ b/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap @@ -5347,11 +5347,127 @@ anvil-container *command: # exact --github-token-from-gh switch in a direct command's argv or in an # expanded `just --dry-run` plan. Interactive execution never opts in. # - # Explicit --github-token and GITHUB_TOKEN remain authoritative even when - # the opt-in switch is also present, so neither path invokes gh. - if (-not $env:GITHUB_TOKEN) { + # Explicit --github-token and a nonblank GITHUB_TOKEN remain authoritative + # even when the opt-in switch is also present, so neither path invokes gh. + function Resolve-AnvilGhExecutable { + $path = [Environment]::GetEnvironmentVariable('PATH') + if ([string]::IsNullOrEmpty($path)) { return $null } + + if ($IsWindows) { + $pathExt = [Environment]::GetEnvironmentVariable('PATHEXT') + if ([string]::IsNullOrEmpty($pathExt)) { + $pathExt = '.COM;.EXE;.BAT;.CMD' + } + $names = @( + foreach ($extension in ($pathExt -split ';')) { + if ([string]::IsNullOrEmpty($extension)) { continue } + if (-not $extension.StartsWith('.')) { $extension = ".$extension" } + if ($extension -ieq '.COM' -or $extension -ieq '.EXE') { + "gh$extension" + } + } + ) + } else { + $names = @('gh') + } + + foreach ($entry in ($path -split [IO.Path]::PathSeparator)) { + # Empty PATH entries implicitly mean the current directory to + # command lookup. Require an explicit entry such as `.` instead. + if ([string]::IsNullOrEmpty($entry)) { continue } + try { + $directory = if ([IO.Path]::IsPathRooted($entry)) { + [IO.Path]::GetFullPath($entry) + } else { + [IO.Path]::GetFullPath((Join-Path (Get-Location).Path $entry)) + } + } catch { + continue + } + + foreach ($name in $names) { + $candidate = Join-Path $directory $name + if (-not (Test-Path -LiteralPath $candidate -PathType Leaf)) { continue } + if (-not $IsWindows) { + # FileInfo.UnixMode is unavailable on the PowerShell 7.0 + # floor, so ask the host directly whether this regular file + # is executable. + & /usr/bin/test -f $candidate + if ($LASTEXITCODE -ne 0) { continue } + & /usr/bin/test -x $candidate + if ($LASTEXITCODE -ne 0) { continue } + } + return [IO.Path]::GetFullPath($candidate) + } + } + $null + } + + function Invoke-AnvilGhToken( + [Parameter(Mandatory)][string]$Executable, + [Parameter(Mandatory)][string]$Hostname, + [int]$TimeoutMilliseconds = 10000 + ) { + $start = [Diagnostics.ProcessStartInfo]::new() + $start.FileName = $Executable + $start.UseShellExecute = $false + $start.CreateNoWindow = $true + $start.RedirectStandardInput = $true + $start.RedirectStandardOutput = $true + $start.RedirectStandardError = $true + $start.StandardOutputEncoding = [Text.UTF8Encoding]::new($false, $true) + foreach ($argument in @('auth', 'token', '--hostname', $Hostname)) { + $null = $start.ArgumentList.Add($argument) + } + # gh treats even a whitespace-only inherited GITHUB_TOKEN as + # authoritative. Remove the value cargo-aprz already rejected so the + # authenticated account lookup can actually proceed. + $null = $start.Environment.Remove('GITHUB_TOKEN') + + $process = [Diagnostics.Process]::new() + $process.StartInfo = $start + $started = $false + try { + $started = $process.Start() + if (-not $started) { return $null } + $process.StandardInput.Close() + $stdout = $process.StandardOutput.ReadToEndAsync() + $stderr = $process.StandardError.ReadToEndAsync() + if (-not $process.WaitForExit($TimeoutMilliseconds)) { + $process.Kill($true) + $process.WaitForExit() + try { $null = $stdout.GetAwaiter().GetResult() } catch {} + try { $null = $stderr.GetAwaiter().GetResult() } catch {} + return $null + } + try { + $token = $stdout.GetAwaiter().GetResult() + $null = $stderr.GetAwaiter().GetResult() + } catch { + return $null + } + if ($process.ExitCode -ne 0 -or [string]::IsNullOrWhiteSpace($token)) { + return $null + } + $token.Trim() + } catch { + $null + } finally { + if ($started -and -not $process.HasExited) { + try { + $process.Kill($true) + $process.WaitForExit() + } catch {} + } + $process.Dispose() + } + } + + $hasEnvironmentGitHubToken = -not [string]::IsNullOrWhiteSpace($env:GITHUB_TOKEN) + if (-not $hasEnvironmentGitHubToken) { $githubTokenFromGh = $false $hasExplicitGitHubToken = $false + $githubUrl = $null if ($argv.Count -gt 0 -and $argv[0] -eq 'just') { # A dry run has no side effects, and a target that cannot be planned # yields no consent, so the run fails on its own terms. @@ -5394,26 +5510,80 @@ anvil-container *command: $hasExplicitGitHubToken = [regex]::IsMatch( $plan, '(?m)(^|[\s''"])--github-token(?:=|$|[\s''"])' ) + if ($githubTokenFromGh) { + $githubUrlMatch = [regex]::Match( + $plan, + '(?m)(?:^|[\s''"])--github-url(?:=|\s+)(?:''([^'']*)''|"([^"]*)"|([^\s''"]+))' + ) + if ($githubUrlMatch.Success) { + foreach ($group in 1..3) { + if ($githubUrlMatch.Groups[$group].Success) { + $githubUrl = $githubUrlMatch.Groups[$group].Value + break + } + } + } + } } elseif ($argv.Count -gt 0) { $githubTokenFromGh = $argv -contains '--github-token-from-gh' $hasExplicitGitHubToken = @( $argv | Where-Object { $_ -eq '--github-token' -or $_.StartsWith('--github-token=') } ).Count -gt 0 + if ($githubTokenFromGh) { + for ($i = 0; $i -lt $argv.Count; $i++) { + if ($argv[$i] -eq '--github-url' -and $i + 1 -lt $argv.Count) { + $githubUrl = $argv[$i + 1] + break + } + if ($argv[$i].StartsWith('--github-url=')) { + $githubUrl = $argv[$i].Substring('--github-url='.Length) + break + } + } + } } - if ($githubTokenFromGh -and -not $hasExplicitGitHubToken -and (Get-Command gh -ErrorAction SilentlyContinue)) { - $ghToken = $null - try { $ghToken = (gh auth token --hostname github.com 2>$null) } catch { $ghToken = $null } - if ($ghToken -and $ghToken.Trim()) { - Set-Item -LiteralPath 'Env:GITHUB_TOKEN' -Value $ghToken.Trim() - $hookEnv += 'GITHUB_TOKEN' + if ($githubTokenFromGh -and -not $hasExplicitGitHubToken) { + if ([string]::IsNullOrWhiteSpace($githubUrl) -and + -not [string]::IsNullOrWhiteSpace($env:APRZ_GITHUB_URL)) { + $githubUrl = $env:APRZ_GITHUB_URL + } + + $githubHostname = 'github.com' + if (-not [string]::IsNullOrWhiteSpace($githubUrl)) { + $githubUri = $null + try { $githubUri = [Uri]::new($githubUrl, [UriKind]::Absolute) } catch {} + if ($null -eq $githubUri -or [string]::IsNullOrWhiteSpace($githubUri.Host)) { + $githubHostname = $null + } elseif ($githubUri.Host -eq 'api.github.com') { + $githubHostname = 'github.com' + } else { + $githubHostname = $githubUri.Host + } + } + + if ($githubHostname) { + $ghExecutable = Resolve-AnvilGhExecutable + if ($ghExecutable) { + $ghToken = Invoke-AnvilGhToken $ghExecutable $githubHostname + } else { + $ghToken = $null + } + if ($ghToken) { + Set-Item -LiteralPath 'Env:GITHUB_TOKEN' -Value $ghToken + $hookEnv += 'GITHUB_TOKEN' + } } } } - if ($env:GITHUB_TOKEN) { + if (-not [string]::IsNullOrWhiteSpace($env:GITHUB_TOKEN)) { $forwardedEnv += 'GITHUB_TOKEN' $runArgs += @('-e', 'GITHUB_TOKEN') } + if (-not [string]::IsNullOrWhiteSpace($env:APRZ_GITHUB_URL)) { + $forwardedEnv += 'APRZ_GITHUB_URL' + $runArgs += @('-e', 'APRZ_GITHUB_URL') + } # The recipe contract's own inputs. These are read by generated checks -- # `anvil-pr-title` reads PR_TITLE, `_anvil-base-ref` reads BASE_REF and its diff --git a/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap b/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap index ffe20fbd6..a6260e937 100644 --- a/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap +++ b/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap @@ -4031,11 +4031,127 @@ anvil-container *command: # exact --github-token-from-gh switch in a direct command's argv or in an # expanded `just --dry-run` plan. Interactive execution never opts in. # - # Explicit --github-token and GITHUB_TOKEN remain authoritative even when - # the opt-in switch is also present, so neither path invokes gh. - if (-not $env:GITHUB_TOKEN) { + # Explicit --github-token and a nonblank GITHUB_TOKEN remain authoritative + # even when the opt-in switch is also present, so neither path invokes gh. + function Resolve-AnvilGhExecutable { + $path = [Environment]::GetEnvironmentVariable('PATH') + if ([string]::IsNullOrEmpty($path)) { return $null } + + if ($IsWindows) { + $pathExt = [Environment]::GetEnvironmentVariable('PATHEXT') + if ([string]::IsNullOrEmpty($pathExt)) { + $pathExt = '.COM;.EXE;.BAT;.CMD' + } + $names = @( + foreach ($extension in ($pathExt -split ';')) { + if ([string]::IsNullOrEmpty($extension)) { continue } + if (-not $extension.StartsWith('.')) { $extension = ".$extension" } + if ($extension -ieq '.COM' -or $extension -ieq '.EXE') { + "gh$extension" + } + } + ) + } else { + $names = @('gh') + } + + foreach ($entry in ($path -split [IO.Path]::PathSeparator)) { + # Empty PATH entries implicitly mean the current directory to + # command lookup. Require an explicit entry such as `.` instead. + if ([string]::IsNullOrEmpty($entry)) { continue } + try { + $directory = if ([IO.Path]::IsPathRooted($entry)) { + [IO.Path]::GetFullPath($entry) + } else { + [IO.Path]::GetFullPath((Join-Path (Get-Location).Path $entry)) + } + } catch { + continue + } + + foreach ($name in $names) { + $candidate = Join-Path $directory $name + if (-not (Test-Path -LiteralPath $candidate -PathType Leaf)) { continue } + if (-not $IsWindows) { + # FileInfo.UnixMode is unavailable on the PowerShell 7.0 + # floor, so ask the host directly whether this regular file + # is executable. + & /usr/bin/test -f $candidate + if ($LASTEXITCODE -ne 0) { continue } + & /usr/bin/test -x $candidate + if ($LASTEXITCODE -ne 0) { continue } + } + return [IO.Path]::GetFullPath($candidate) + } + } + $null + } + + function Invoke-AnvilGhToken( + [Parameter(Mandatory)][string]$Executable, + [Parameter(Mandatory)][string]$Hostname, + [int]$TimeoutMilliseconds = 10000 + ) { + $start = [Diagnostics.ProcessStartInfo]::new() + $start.FileName = $Executable + $start.UseShellExecute = $false + $start.CreateNoWindow = $true + $start.RedirectStandardInput = $true + $start.RedirectStandardOutput = $true + $start.RedirectStandardError = $true + $start.StandardOutputEncoding = [Text.UTF8Encoding]::new($false, $true) + foreach ($argument in @('auth', 'token', '--hostname', $Hostname)) { + $null = $start.ArgumentList.Add($argument) + } + # gh treats even a whitespace-only inherited GITHUB_TOKEN as + # authoritative. Remove the value cargo-aprz already rejected so the + # authenticated account lookup can actually proceed. + $null = $start.Environment.Remove('GITHUB_TOKEN') + + $process = [Diagnostics.Process]::new() + $process.StartInfo = $start + $started = $false + try { + $started = $process.Start() + if (-not $started) { return $null } + $process.StandardInput.Close() + $stdout = $process.StandardOutput.ReadToEndAsync() + $stderr = $process.StandardError.ReadToEndAsync() + if (-not $process.WaitForExit($TimeoutMilliseconds)) { + $process.Kill($true) + $process.WaitForExit() + try { $null = $stdout.GetAwaiter().GetResult() } catch {} + try { $null = $stderr.GetAwaiter().GetResult() } catch {} + return $null + } + try { + $token = $stdout.GetAwaiter().GetResult() + $null = $stderr.GetAwaiter().GetResult() + } catch { + return $null + } + if ($process.ExitCode -ne 0 -or [string]::IsNullOrWhiteSpace($token)) { + return $null + } + $token.Trim() + } catch { + $null + } finally { + if ($started -and -not $process.HasExited) { + try { + $process.Kill($true) + $process.WaitForExit() + } catch {} + } + $process.Dispose() + } + } + + $hasEnvironmentGitHubToken = -not [string]::IsNullOrWhiteSpace($env:GITHUB_TOKEN) + if (-not $hasEnvironmentGitHubToken) { $githubTokenFromGh = $false $hasExplicitGitHubToken = $false + $githubUrl = $null if ($argv.Count -gt 0 -and $argv[0] -eq 'just') { # A dry run has no side effects, and a target that cannot be planned # yields no consent, so the run fails on its own terms. @@ -4078,26 +4194,80 @@ anvil-container *command: $hasExplicitGitHubToken = [regex]::IsMatch( $plan, '(?m)(^|[\s''"])--github-token(?:=|$|[\s''"])' ) + if ($githubTokenFromGh) { + $githubUrlMatch = [regex]::Match( + $plan, + '(?m)(?:^|[\s''"])--github-url(?:=|\s+)(?:''([^'']*)''|"([^"]*)"|([^\s''"]+))' + ) + if ($githubUrlMatch.Success) { + foreach ($group in 1..3) { + if ($githubUrlMatch.Groups[$group].Success) { + $githubUrl = $githubUrlMatch.Groups[$group].Value + break + } + } + } + } } elseif ($argv.Count -gt 0) { $githubTokenFromGh = $argv -contains '--github-token-from-gh' $hasExplicitGitHubToken = @( $argv | Where-Object { $_ -eq '--github-token' -or $_.StartsWith('--github-token=') } ).Count -gt 0 + if ($githubTokenFromGh) { + for ($i = 0; $i -lt $argv.Count; $i++) { + if ($argv[$i] -eq '--github-url' -and $i + 1 -lt $argv.Count) { + $githubUrl = $argv[$i + 1] + break + } + if ($argv[$i].StartsWith('--github-url=')) { + $githubUrl = $argv[$i].Substring('--github-url='.Length) + break + } + } + } } - if ($githubTokenFromGh -and -not $hasExplicitGitHubToken -and (Get-Command gh -ErrorAction SilentlyContinue)) { - $ghToken = $null - try { $ghToken = (gh auth token --hostname github.com 2>$null) } catch { $ghToken = $null } - if ($ghToken -and $ghToken.Trim()) { - Set-Item -LiteralPath 'Env:GITHUB_TOKEN' -Value $ghToken.Trim() - $hookEnv += 'GITHUB_TOKEN' + if ($githubTokenFromGh -and -not $hasExplicitGitHubToken) { + if ([string]::IsNullOrWhiteSpace($githubUrl) -and + -not [string]::IsNullOrWhiteSpace($env:APRZ_GITHUB_URL)) { + $githubUrl = $env:APRZ_GITHUB_URL + } + + $githubHostname = 'github.com' + if (-not [string]::IsNullOrWhiteSpace($githubUrl)) { + $githubUri = $null + try { $githubUri = [Uri]::new($githubUrl, [UriKind]::Absolute) } catch {} + if ($null -eq $githubUri -or [string]::IsNullOrWhiteSpace($githubUri.Host)) { + $githubHostname = $null + } elseif ($githubUri.Host -eq 'api.github.com') { + $githubHostname = 'github.com' + } else { + $githubHostname = $githubUri.Host + } + } + + if ($githubHostname) { + $ghExecutable = Resolve-AnvilGhExecutable + if ($ghExecutable) { + $ghToken = Invoke-AnvilGhToken $ghExecutable $githubHostname + } else { + $ghToken = $null + } + if ($ghToken) { + Set-Item -LiteralPath 'Env:GITHUB_TOKEN' -Value $ghToken + $hookEnv += 'GITHUB_TOKEN' + } } } } - if ($env:GITHUB_TOKEN) { + if (-not [string]::IsNullOrWhiteSpace($env:GITHUB_TOKEN)) { $forwardedEnv += 'GITHUB_TOKEN' $runArgs += @('-e', 'GITHUB_TOKEN') } + if (-not [string]::IsNullOrWhiteSpace($env:APRZ_GITHUB_URL)) { + $forwardedEnv += 'APRZ_GITHUB_URL' + $runArgs += @('-e', 'APRZ_GITHUB_URL') + } # The recipe contract's own inputs. These are read by generated checks -- # `anvil-pr-title` reads PR_TITLE, `_anvil-base-ref` reads BASE_REF and its diff --git a/crates/cargo-aprz-lib/Cargo.toml b/crates/cargo-aprz-lib/Cargo.toml index a89921fd1..a28e77712 100644 --- a/crates/cargo-aprz-lib/Cargo.toml +++ b/crates/cargo-aprz-lib/Cargo.toml @@ -172,6 +172,10 @@ required-features = ["internals"] name = "docs_provider_integration" required-features = ["internals"] +[[test]] +name = "github_credentials_process_integration" +required-features = ["internals"] + [[test]] name = "hosting_provider_integration" required-features = ["internals"] diff --git a/crates/cargo-aprz-lib/tests/github_credentials_process_integration.rs b/crates/cargo-aprz-lib/tests/github_credentials_process_integration.rs new file mode 100644 index 000000000..247b8e5d9 --- /dev/null +++ b/crates/cargo-aprz-lib/tests/github_credentials_process_integration.rs @@ -0,0 +1,201 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! End-to-end coverage for GitHub CLI credential discovery. +//! +//! This binary deliberately contains one test because it temporarily changes +//! process-global environment variables used by executable discovery. + +#![cfg(not(miri))] + +mod support; + +use std::ffi::OsString; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use chrono::Utc; +use serde_json::json; +use support::dump::Dump; +use support::{TestHost, dump_server, dump_url, failing_server, seed_advisory_db}; +use url::Url; +use wiremock::matchers::{header, method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +const DISCOVERED_TOKEN: &str = "integration-discovered-secret"; + +struct EnvironmentGuard { + previous: Vec<(OsString, Option)>, +} + +impl EnvironmentGuard { + fn set(values: impl IntoIterator) -> Self { + let mut previous = Vec::new(); + for (name, value) in values { + previous.push((OsString::from(name), std::env::var_os(name))); + // SAFETY: this integration-test binary contains one current-thread + // test, and every modified value is restored by Drop. + unsafe { + std::env::set_var(name, value); + } + } + Self { previous } + } +} + +impl Drop for EnvironmentGuard { + fn drop(&mut self) { + for (name, value) in self.previous.drain(..).rev() { + if let Some(value) = value { + // SAFETY: the single current-thread test is still the only code + // mutating this process environment. + unsafe { + std::env::set_var(name, value); + } + } else { + // SAFETY: the single current-thread test is still the only code + // mutating this process environment. + unsafe { + std::env::remove_var(name); + } + } + } + } +} + +fn compile_fake_gh(bin: &Path) -> PathBuf { + const SOURCE: &str = r#" +use std::env; +use std::fs; + +fn main() { + let args = env::args().skip(1).collect::>(); + let log = format!( + "inherited-token={}\nargs={}", + env::var_os("GITHUB_TOKEN").is_some(), + args.join("|") + ); + fs::write(env::var_os("FAKE_GH_LOG").expect("log path"), log) + .expect("write fake gh log"); + println!("integration-discovered-secret"); +} +"#; + + std::fs::create_dir_all(bin).expect("creating fake executable directory"); + let source = bin.join("fake-gh.rs"); + std::fs::write(&source, SOURCE).expect("writing fake gh source"); + let executable = bin.join(if cfg!(windows) { "gh.exe" } else { "gh" }); + let rustc = std::env::var_os("RUSTC").unwrap_or_else(|| OsString::from("rustc")); + let output = Command::new(rustc) + .args(["--edition=2024", "-o"]) + .arg(&executable) + .arg(source) + .output() + .expect("rustc is available while running Rust integration tests"); + assert!( + output.status.success(), + "compiling fake gh failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + executable +} + +#[tokio::test(flavor = "current_thread")] +async fn run_discovers_an_enterprise_token_through_the_production_process_path() { + let dump = dump_server(Dump::sample(Utc::now()).to_tar_gz(), Some(1)).await; + let github = MockServer::start().await; + let services = failing_server(404).await; + let temp = tempfile::tempdir().expect("creating test directory"); + let cache = temp.path().join("cache"); + seed_advisory_db(&cache); + + for request_path in [ + "/repos/fake-org/schemeless-repo-crate", + "/repos/fake-org/schemeless-repo-crate/issues", + ] { + let body = if request_path.ends_with("/issues") { + json!([]) + } else { + json!({ + "stargazers_count": 1, + "forks_count": 2, + "subscribers_count": 3, + }) + }; + Mock::given(method("GET")) + .and(path(request_path)) + .and(header("authorization", format!("token {DISCOVERED_TOKEN}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(body)) + .expect(1) + .mount(&github) + .await; + } + + let bin = temp.path().join("bin"); + compile_fake_gh(&bin); + let gh_log = temp.path().join("gh.log"); + let mut environment = vec![ + ("PATH", bin.into_os_string()), + ("GITHUB_TOKEN", OsString::from(" \t ")), + ("FAKE_GH_LOG", gh_log.clone().into_os_string()), + ]; + if cfg!(windows) { + environment.push(("PATHEXT", OsString::from(".COM;.EXE;.BAT;.CMD"))); + } + let _environment = EnvironmentGuard::set(environment); + + let github_url = github.uri(); + let github_hostname = Url::parse(&github_url) + .expect("wiremock URI is valid") + .host_str() + .expect("wiremock URI has a host") + .to_owned(); + let dump_url = dump_url(&dump); + let service_url = services.uri(); + let cache = cache.to_string_lossy().into_owned(); + let args = [ + "cargo", + "aprz", + "crates", + "schemeless-repo-crate@0.1.0", + "--console", + "--color", + "never", + "--cache-dir", + &cache, + "--dump-url", + &dump_url, + "--docs-url", + &service_url, + "--coverage-url", + &service_url, + "--github-url", + &github_url, + "--github-token-from-gh", + "--codeberg-url", + &service_url, + "--advisory-url", + &service_url, + ]; + let mut host = TestHost::new(); + + cargo_aprz_lib::run(&mut host, args).await; + + assert!(host.exit_code.is_none(), "the command should succeed: {}", host.error_str()); + let gh_call = std::fs::read_to_string(gh_log).expect("the production path invokes fake gh"); + assert!( + gh_call.contains("inherited-token=false"), + "gh inherited the rejected blank token:\n{gh_call}" + ); + assert!( + gh_call.contains(&format!("args=auth|token|--hostname|{github_hostname}")), + "gh did not receive the Enterprise hostname:\n{gh_call}" + ); + let diagnostics = format!("{}{}", host.output_str(), host.error_str()); + assert!(!diagnostics.contains(DISCOVERED_TOKEN), "diagnostics exposed the discovered token"); + assert!( + !gh_call.contains(DISCOVERED_TOKEN), + "the token appeared in gh arguments or test diagnostics" + ); +} diff --git a/crates/cargo-aprz/docs/DESIGN.md b/crates/cargo-aprz/docs/DESIGN.md index c76b9dca2..21900ca35 100644 --- a/crates/cargo-aprz/docs/DESIGN.md +++ b/crates/cargo-aprz/docs/DESIGN.md @@ -105,6 +105,17 @@ and the child process is awaited asynchronously with a ten-second deadline. Expiry terminates the child and continues anonymously, so credential discovery does not block an async runtime worker indefinitely. +Containerized opt-in applies the same process contract on the host because the +generated image does not contain `gh`: absolute resolution from explicit, +nonempty `PATH` entries; direct executable images only on Windows and regular +executable files on Unix; direct argument-vector launch with stdin and stderr +suppressed and strict UTF-8 stdout captured; the rejected blank environment +token removed; and a ten-second deadline that terminates the complete process +tree. Missing, unsuccessful, timed-out, blank, and invalid-output lookups all +continue anonymously. The container driver derives the same effective hostname +and forwards `APRZ_GITHUB_URL` with a discovered token so the inner provider +cannot target a different endpoint. + ## Cache storage Provider data is stored beneath a platform-specific cache root, partitioned by diff --git a/justfiles/anvil/container.just b/justfiles/anvil/container.just index ab2025cc4..8dc603334 100644 --- a/justfiles/anvil/container.just +++ b/justfiles/anvil/container.just @@ -937,6 +937,120 @@ anvil-container *command: # # Explicit --github-token and a nonblank GITHUB_TOKEN remain authoritative # even when the opt-in switch is also present, so neither path invokes gh. + function Resolve-AnvilGhExecutable { + $path = [Environment]::GetEnvironmentVariable('PATH') + if ([string]::IsNullOrEmpty($path)) { return $null } + + if ($IsWindows) { + $pathExt = [Environment]::GetEnvironmentVariable('PATHEXT') + if ([string]::IsNullOrEmpty($pathExt)) { + $pathExt = '.COM;.EXE;.BAT;.CMD' + } + $names = @( + foreach ($extension in ($pathExt -split ';')) { + if ([string]::IsNullOrEmpty($extension)) { continue } + if (-not $extension.StartsWith('.')) { $extension = ".$extension" } + if ($extension -ieq '.COM' -or $extension -ieq '.EXE') { + "gh$extension" + } + } + ) + } else { + $names = @('gh') + } + + foreach ($entry in ($path -split [IO.Path]::PathSeparator)) { + # Empty PATH entries implicitly mean the current directory to + # command lookup. Require an explicit entry such as `.` instead. + if ([string]::IsNullOrEmpty($entry)) { continue } + try { + $directory = if ([IO.Path]::IsPathRooted($entry)) { + [IO.Path]::GetFullPath($entry) + } else { + [IO.Path]::GetFullPath((Join-Path (Get-Location).Path $entry)) + } + } catch { + continue + } + + foreach ($name in $names) { + $candidate = Join-Path $directory $name + if (-not (Test-Path -LiteralPath $candidate -PathType Leaf)) { continue } + if (-not $IsWindows) { + # FileInfo.UnixMode is unavailable on the PowerShell 7.0 + # floor, so ask the host directly whether this regular file + # is executable. + & /usr/bin/test -f $candidate + if ($LASTEXITCODE -ne 0) { continue } + & /usr/bin/test -x $candidate + if ($LASTEXITCODE -ne 0) { continue } + } + return [IO.Path]::GetFullPath($candidate) + } + } + $null + } + + function Invoke-AnvilGhToken( + [Parameter(Mandatory)][string]$Executable, + [Parameter(Mandatory)][string]$Hostname, + [int]$TimeoutMilliseconds = 10000 + ) { + $start = [Diagnostics.ProcessStartInfo]::new() + $start.FileName = $Executable + $start.UseShellExecute = $false + $start.CreateNoWindow = $true + $start.RedirectStandardInput = $true + $start.RedirectStandardOutput = $true + $start.RedirectStandardError = $true + $start.StandardOutputEncoding = [Text.UTF8Encoding]::new($false, $true) + foreach ($argument in @('auth', 'token', '--hostname', $Hostname)) { + $null = $start.ArgumentList.Add($argument) + } + # gh treats even a whitespace-only inherited GITHUB_TOKEN as + # authoritative. Remove the value cargo-aprz already rejected so the + # authenticated account lookup can actually proceed. + $null = $start.Environment.Remove('GITHUB_TOKEN') + + $process = [Diagnostics.Process]::new() + $process.StartInfo = $start + $started = $false + try { + $started = $process.Start() + if (-not $started) { return $null } + $process.StandardInput.Close() + $stdout = $process.StandardOutput.ReadToEndAsync() + $stderr = $process.StandardError.ReadToEndAsync() + if (-not $process.WaitForExit($TimeoutMilliseconds)) { + $process.Kill($true) + $process.WaitForExit() + try { $null = $stdout.GetAwaiter().GetResult() } catch {} + try { $null = $stderr.GetAwaiter().GetResult() } catch {} + return $null + } + try { + $token = $stdout.GetAwaiter().GetResult() + $null = $stderr.GetAwaiter().GetResult() + } catch { + return $null + } + if ($process.ExitCode -ne 0 -or [string]::IsNullOrWhiteSpace($token)) { + return $null + } + $token.Trim() + } catch { + $null + } finally { + if ($started -and -not $process.HasExited) { + try { + $process.Kill($true) + $process.WaitForExit() + } catch {} + } + $process.Dispose() + } + } + $hasEnvironmentGitHubToken = -not [string]::IsNullOrWhiteSpace($env:GITHUB_TOKEN) if (-not $hasEnvironmentGitHubToken) { $githubTokenFromGh = $false @@ -1036,15 +1150,15 @@ anvil-container *command: } } - if ($githubHostname -and (Get-Command gh -ErrorAction SilentlyContinue)) { - # gh treats even a whitespace-only inherited GITHUB_TOKEN as - # authoritative. Remove the value cargo-aprz already rejected - # so the authenticated account lookup can actually proceed. - Remove-Item -LiteralPath 'Env:GITHUB_TOKEN' -ErrorAction SilentlyContinue - $ghToken = $null - try { $ghToken = (gh auth token --hostname $githubHostname 2>$null) } catch { $ghToken = $null } - if ($ghToken -and $ghToken.Trim()) { - Set-Item -LiteralPath 'Env:GITHUB_TOKEN' -Value $ghToken.Trim() + if ($githubHostname) { + $ghExecutable = Resolve-AnvilGhExecutable + if ($ghExecutable) { + $ghToken = Invoke-AnvilGhToken $ghExecutable $githubHostname + } else { + $ghToken = $null + } + if ($ghToken) { + Set-Item -LiteralPath 'Env:GITHUB_TOKEN' -Value $ghToken $hookEnv += 'GITHUB_TOKEN' } } From d9728f28233447d7739cf348668927aeca9703d1 Mon Sep 17 00:00:00 2001 From: "Martin Kolinek (from Dev Box)" Date: Fri, 18 Sep 2026 11:33:42 +0200 Subject: [PATCH 08/13] fix(cargo-anvil): isolate credential discovery inputs Bind Just-plan opt-in and endpoint parsing to one command, use process startup for Unix executable validation, and isolate environment-dependent credential integration in a child process. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a9fc919b-99f7-4134-aad1-2116321b4e0c --- .anvil.lock | 4 +- crates/cargo-anvil/docs/design/containers.md | 17 ++- .../src/anvil/artifacts/container.rs | 5 +- .../templates/justfiles/anvil/container.just | 38 ++--- crates/cargo-anvil/tests/recipe_contracts.rs | 11 +- .../snapshots/snapshots__ado_backend.snap | 38 ++--- .../snapshots/snapshots__github_backend.snap | 38 ++--- .../snapshots/snapshots__local_only.snap | 38 ++--- .../github_credentials_process_integration.rs | 132 +++++++++--------- justfiles/anvil/container.just | 38 ++--- 10 files changed, 193 insertions(+), 166 deletions(-) diff --git a/.anvil.lock b/.anvil.lock index b73b3e3d6..142dac363 100644 --- a/.anvil.lock +++ b/.anvil.lock @@ -1,7 +1,7 @@ version = 1 tool = "anvil" tool_version = "0.10.0" -catalog_checksum = "sha256:9b06c34f5cafedb7c0e78da98d23437b646d4aae34805cc9a9140fac7585ae0b" +catalog_checksum = "sha256:64c0c2c26274ec375486bd0955b5d3c6c77834a28f9a586963b8e09c9f55a5e5" [[file]] path = ".anvil/container/Dockerfile.dockerignore" @@ -181,7 +181,7 @@ checksum = "sha256:6efd7378a2cd0f5d86519bd32fd86f2055a60191187dd77a8842b374b8eb7 [[file]] path = "justfiles/anvil/container.just" -checksum = "sha256:f6cce7dac0c264807471672546104b53f2e59bae378860b68d755d9e132060e1" +checksum = "sha256:cc7825beba4147d882d2db4471c351796ea18f1bee1ca40c8b3b32c69296fbed" [[file]] path = "justfiles/anvil/dev/build.just" diff --git a/crates/cargo-anvil/docs/design/containers.md b/crates/cargo-anvil/docs/design/containers.md index 49592df0d..75bfbe2a9 100644 --- a/crates/cargo-anvil/docs/design/containers.md +++ b/crates/cargo-anvil/docs/design/containers.md @@ -402,10 +402,12 @@ is — exact parity with a native run, where every process the shell spawns can GitHub CLI discovery is different: it manufactures a credential the developer did not export, and PID 1's environment is inherited by every build script and proc macro in the container. The driver therefore invokes `gh auth token --hostname ` only when the containerized command explicitly opts in with -`--github-token-from-gh`. A direct command opts in through an exact argv occurrence. A `just` command opts in when the -switch occurs as an exact argument in its expanded `just --dry-run ` plan. An explicit `--github-token` in the -same command or plan suppresses the host `gh` lookup, as does a nonblank exported `GITHUB_TOKEN`. The no-command -interactive form never derives a token. +`--github-token-from-gh`. A direct command opts in through an exact argv occurrence. A `just` command opts in only when +its expanded `just --dry-run ` plan contains one unique command with the exact switch; consent, an explicit +token, and `--github-url` are parsed from that same command. Multiple distinct opted-in commands are ambiguous because +the container can forward only one token, so they run anonymously. An explicit `--github-token` in the opted-in command +suppresses the host `gh` lookup, as does a nonblank exported `GITHUB_TOKEN`. The no-command interactive form never +derives a token. `` follows cargo-aprz's effective endpoint: `--github-url` in the direct argv or expanded plan wins over `APRZ_GITHUB_URL`, and no override means `github.com`. The environment override is forwarded into the container so @@ -414,9 +416,10 @@ rather than querying an unrelated login; the inner command retains responsibilit address. This wrapper lookup exists only because the image has no gh CLI of its own. It applies the same process boundary as -native cargo-aprz: explicit nonempty `PATH` entries only, resolved to an absolute executable; `.COM` and `.EXE` only -in Windows `PATHEXT` order, excluding functions, aliases, batch files and PowerShell shims; and a regular executable -file on Unix. The executable is launched directly with an argument vector and no shell, with stdin closed, stderr +native cargo-aprz: explicit nonempty `PATH` entries only, resolved to an absolute regular file; `.COM` and `.EXE` only +in Windows `PATHEXT` order, excluding functions, aliases, batch files and PowerShell shims. On Unix, direct process +startup is the executable-permission check, so no external filesystem-test utility is required. The executable is +launched directly with an argument vector and no shell, with stdin closed, stderr captured and discarded, stdout captured as strict UTF-8, and the rejected blank `GITHUB_TOKEN` removed from its environment. A missing executable, nonzero result, blank or invalid output, or ten-second deadline continues anonymously. Deadline expiry terminates the complete process tree. diff --git a/crates/cargo-anvil/src/anvil/artifacts/container.rs b/crates/cargo-anvil/src/anvil/artifacts/container.rs index c607e20bc..a433a49f4 100644 --- a/crates/cargo-anvil/src/anvil/artifacts/container.rs +++ b/crates/cargo-anvil/src/anvil/artifacts/container.rs @@ -828,8 +828,9 @@ mod tests { assert!(RECIPE.contains("$extension -ieq '.COM' -or $extension -ieq '.EXE'")); assert!(!RECIPE.contains("$extension -ieq '.BAT'")); assert!(!RECIPE.contains("$extension -ieq '.CMD'")); - assert!(RECIPE.contains("& /usr/bin/test -f $candidate")); - assert!(RECIPE.contains("& /usr/bin/test -x $candidate")); + assert!(RECIPE.contains("Test-Path -LiteralPath $candidate -PathType Leaf")); + assert!(RECIPE.contains("$started = $process.Start()")); + assert!(!RECIPE.contains("/usr/bin/test")); } #[test] diff --git a/crates/cargo-anvil/templates/justfiles/anvil/container.just b/crates/cargo-anvil/templates/justfiles/anvil/container.just index 8dc603334..c002a55b8 100644 --- a/crates/cargo-anvil/templates/justfiles/anvil/container.just +++ b/crates/cargo-anvil/templates/justfiles/anvil/container.just @@ -976,15 +976,9 @@ anvil-container *command: foreach ($name in $names) { $candidate = Join-Path $directory $name if (-not (Test-Path -LiteralPath $candidate -PathType Leaf)) { continue } - if (-not $IsWindows) { - # FileInfo.UnixMode is unavailable on the PowerShell 7.0 - # floor, so ask the host directly whether this regular file - # is executable. - & /usr/bin/test -f $candidate - if ($LASTEXITCODE -ne 0) { continue } - & /usr/bin/test -x $candidate - if ($LASTEXITCODE -ne 0) { continue } - } + # Process.Start below is the portable executable check. A Unix + # file without execute permission fails there and falls through + # anonymously without requiring an external `test` utility. return [IO.Path]::GetFullPath($candidate) } } @@ -1092,16 +1086,26 @@ anvil-container *command: $targets.Add([string[]]@($nested.Groups[1].Value)) } } - $githubTokenFromGh = [regex]::IsMatch( - $plan, '(?m)(^|[\s''"])--github-token-from-gh($|[\s''"])' - ) - $hasExplicitGitHubToken = [regex]::IsMatch( - $plan, '(?m)(^|[\s''"])--github-token(?:=|$|[\s''"])' + # Bind consent, explicit-token precedence, and endpoint selection + # to the same planned command. Distinct opted-in commands are + # ambiguous because the container can forward only one token, so + # fail closed and let each inner command run anonymously. + $optedInCommands = @( + $plan -split '\r?\n' | + Where-Object { + [regex]::IsMatch($_, '(^|[\s''"])--github-token-from-gh($|[\s''"])') + } | + Select-Object -Unique ) - if ($githubTokenFromGh) { + if ($optedInCommands.Count -eq 1) { + $optedInCommand = $optedInCommands[0] + $githubTokenFromGh = $true + $hasExplicitGitHubToken = [regex]::IsMatch( + $optedInCommand, '(^|[\s''"])--github-token(?:=|$|[\s''"])' + ) $githubUrlMatch = [regex]::Match( - $plan, - '(?m)(?:^|[\s''"])--github-url(?:=|\s+)(?:''([^'']*)''|"([^"]*)"|([^\s''"]+))' + $optedInCommand, + '(?:^|[\s''"])--github-url(?:=|\s+)(?:''([^'']*)''|"([^"]*)"|([^\s''"]+))' ) if ($githubUrlMatch.Success) { foreach ($group in 1..3) { diff --git a/crates/cargo-anvil/tests/recipe_contracts.rs b/crates/cargo-anvil/tests/recipe_contracts.rs index 224a9dc6d..6463d9bde 100644 --- a/crates/cargo-anvil/tests/recipe_contracts.rs +++ b/crates/cargo-anvil/tests/recipe_contracts.rs @@ -531,6 +531,14 @@ fn path_with_fake_bin(root: &Path) -> OsString { std::env::join_paths(paths).unwrap() } +fn test_pwsh() -> PathBuf { + let executable = if cfg!(windows) { "pwsh.exe" } else { "pwsh" }; + std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()) + .map(|directory| directory.join(executable)) + .find(|candidate| candidate.is_file()) + .expect("pwsh was checked by tools_available") +} + fn just_command(root: &Path, arguments: &[&str], environment: &[(&str, &OsStr)]) -> Command { let mut command = Command::new("just"); command @@ -615,7 +623,7 @@ fn run_container_github_credential_probe_with_timeout( fs::remove_file(&log).unwrap(); } - let mut command = Command::new("pwsh"); + let mut command = Command::new(test_pwsh()); command .args(["-NoProfile", "-Command", &script]) .current_dir(root) @@ -2675,6 +2683,7 @@ fn aprz_does_not_opt_into_github_cli_credential_discovery() { fn container_github_cli_discovery_is_opt_in_host_aware_and_preserves_precedence() { const PROBE: &str = "[script(\"pwsh\", \"-NoProfile\")]\n\ enterprise-probe:\n \ + & cargo aprz deps --github-url https://unrelated.plan.test/api/v3\n \ & cargo aprz deps --github-url https://github.plan.test/api/v3 --github-token-from-gh\n"; if !tools_available() { diff --git a/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap b/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap index 7cbabccbd..5ff48f778 100644 --- a/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap +++ b/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap @@ -5275,15 +5275,9 @@ anvil-container *command: foreach ($name in $names) { $candidate = Join-Path $directory $name if (-not (Test-Path -LiteralPath $candidate -PathType Leaf)) { continue } - if (-not $IsWindows) { - # FileInfo.UnixMode is unavailable on the PowerShell 7.0 - # floor, so ask the host directly whether this regular file - # is executable. - & /usr/bin/test -f $candidate - if ($LASTEXITCODE -ne 0) { continue } - & /usr/bin/test -x $candidate - if ($LASTEXITCODE -ne 0) { continue } - } + # Process.Start below is the portable executable check. A Unix + # file without execute permission fails there and falls through + # anonymously without requiring an external `test` utility. return [IO.Path]::GetFullPath($candidate) } } @@ -5391,16 +5385,26 @@ anvil-container *command: $targets.Add([string[]]@($nested.Groups[1].Value)) } } - $githubTokenFromGh = [regex]::IsMatch( - $plan, '(?m)(^|[\s''"])--github-token-from-gh($|[\s''"])' - ) - $hasExplicitGitHubToken = [regex]::IsMatch( - $plan, '(?m)(^|[\s''"])--github-token(?:=|$|[\s''"])' + # Bind consent, explicit-token precedence, and endpoint selection + # to the same planned command. Distinct opted-in commands are + # ambiguous because the container can forward only one token, so + # fail closed and let each inner command run anonymously. + $optedInCommands = @( + $plan -split '\r?\n' | + Where-Object { + [regex]::IsMatch($_, '(^|[\s''"])--github-token-from-gh($|[\s''"])') + } | + Select-Object -Unique ) - if ($githubTokenFromGh) { + if ($optedInCommands.Count -eq 1) { + $optedInCommand = $optedInCommands[0] + $githubTokenFromGh = $true + $hasExplicitGitHubToken = [regex]::IsMatch( + $optedInCommand, '(^|[\s''"])--github-token(?:=|$|[\s''"])' + ) $githubUrlMatch = [regex]::Match( - $plan, - '(?m)(?:^|[\s''"])--github-url(?:=|\s+)(?:''([^'']*)''|"([^"]*)"|([^\s''"]+))' + $optedInCommand, + '(?:^|[\s''"])--github-url(?:=|\s+)(?:''([^'']*)''|"([^"]*)"|([^\s''"]+))' ) if ($githubUrlMatch.Success) { foreach ($group in 1..3) { diff --git a/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap b/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap index 56d3b3ab9..d2a6d9d29 100644 --- a/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap +++ b/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap @@ -5388,15 +5388,9 @@ anvil-container *command: foreach ($name in $names) { $candidate = Join-Path $directory $name if (-not (Test-Path -LiteralPath $candidate -PathType Leaf)) { continue } - if (-not $IsWindows) { - # FileInfo.UnixMode is unavailable on the PowerShell 7.0 - # floor, so ask the host directly whether this regular file - # is executable. - & /usr/bin/test -f $candidate - if ($LASTEXITCODE -ne 0) { continue } - & /usr/bin/test -x $candidate - if ($LASTEXITCODE -ne 0) { continue } - } + # Process.Start below is the portable executable check. A Unix + # file without execute permission fails there and falls through + # anonymously without requiring an external `test` utility. return [IO.Path]::GetFullPath($candidate) } } @@ -5504,16 +5498,26 @@ anvil-container *command: $targets.Add([string[]]@($nested.Groups[1].Value)) } } - $githubTokenFromGh = [regex]::IsMatch( - $plan, '(?m)(^|[\s''"])--github-token-from-gh($|[\s''"])' - ) - $hasExplicitGitHubToken = [regex]::IsMatch( - $plan, '(?m)(^|[\s''"])--github-token(?:=|$|[\s''"])' + # Bind consent, explicit-token precedence, and endpoint selection + # to the same planned command. Distinct opted-in commands are + # ambiguous because the container can forward only one token, so + # fail closed and let each inner command run anonymously. + $optedInCommands = @( + $plan -split '\r?\n' | + Where-Object { + [regex]::IsMatch($_, '(^|[\s''"])--github-token-from-gh($|[\s''"])') + } | + Select-Object -Unique ) - if ($githubTokenFromGh) { + if ($optedInCommands.Count -eq 1) { + $optedInCommand = $optedInCommands[0] + $githubTokenFromGh = $true + $hasExplicitGitHubToken = [regex]::IsMatch( + $optedInCommand, '(^|[\s''"])--github-token(?:=|$|[\s''"])' + ) $githubUrlMatch = [regex]::Match( - $plan, - '(?m)(?:^|[\s''"])--github-url(?:=|\s+)(?:''([^'']*)''|"([^"]*)"|([^\s''"]+))' + $optedInCommand, + '(?:^|[\s''"])--github-url(?:=|\s+)(?:''([^'']*)''|"([^"]*)"|([^\s''"]+))' ) if ($githubUrlMatch.Success) { foreach ($group in 1..3) { diff --git a/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap b/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap index a6260e937..4c6e14481 100644 --- a/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap +++ b/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap @@ -4072,15 +4072,9 @@ anvil-container *command: foreach ($name in $names) { $candidate = Join-Path $directory $name if (-not (Test-Path -LiteralPath $candidate -PathType Leaf)) { continue } - if (-not $IsWindows) { - # FileInfo.UnixMode is unavailable on the PowerShell 7.0 - # floor, so ask the host directly whether this regular file - # is executable. - & /usr/bin/test -f $candidate - if ($LASTEXITCODE -ne 0) { continue } - & /usr/bin/test -x $candidate - if ($LASTEXITCODE -ne 0) { continue } - } + # Process.Start below is the portable executable check. A Unix + # file without execute permission fails there and falls through + # anonymously without requiring an external `test` utility. return [IO.Path]::GetFullPath($candidate) } } @@ -4188,16 +4182,26 @@ anvil-container *command: $targets.Add([string[]]@($nested.Groups[1].Value)) } } - $githubTokenFromGh = [regex]::IsMatch( - $plan, '(?m)(^|[\s''"])--github-token-from-gh($|[\s''"])' - ) - $hasExplicitGitHubToken = [regex]::IsMatch( - $plan, '(?m)(^|[\s''"])--github-token(?:=|$|[\s''"])' + # Bind consent, explicit-token precedence, and endpoint selection + # to the same planned command. Distinct opted-in commands are + # ambiguous because the container can forward only one token, so + # fail closed and let each inner command run anonymously. + $optedInCommands = @( + $plan -split '\r?\n' | + Where-Object { + [regex]::IsMatch($_, '(^|[\s''"])--github-token-from-gh($|[\s''"])') + } | + Select-Object -Unique ) - if ($githubTokenFromGh) { + if ($optedInCommands.Count -eq 1) { + $optedInCommand = $optedInCommands[0] + $githubTokenFromGh = $true + $hasExplicitGitHubToken = [regex]::IsMatch( + $optedInCommand, '(^|[\s''"])--github-token(?:=|$|[\s''"])' + ) $githubUrlMatch = [regex]::Match( - $plan, - '(?m)(?:^|[\s''"])--github-url(?:=|\s+)(?:''([^'']*)''|"([^"]*)"|([^\s''"]+))' + $optedInCommand, + '(?:^|[\s''"])--github-url(?:=|\s+)(?:''([^'']*)''|"([^"]*)"|([^\s''"]+))' ) if ($githubUrlMatch.Success) { foreach ($group in 1..3) { diff --git a/crates/cargo-aprz-lib/tests/github_credentials_process_integration.rs b/crates/cargo-aprz-lib/tests/github_credentials_process_integration.rs index 247b8e5d9..8a8f9b5cd 100644 --- a/crates/cargo-aprz-lib/tests/github_credentials_process_integration.rs +++ b/crates/cargo-aprz-lib/tests/github_credentials_process_integration.rs @@ -24,45 +24,6 @@ use wiremock::{Mock, MockServer, ResponseTemplate}; const DISCOVERED_TOKEN: &str = "integration-discovered-secret"; -struct EnvironmentGuard { - previous: Vec<(OsString, Option)>, -} - -impl EnvironmentGuard { - fn set(values: impl IntoIterator) -> Self { - let mut previous = Vec::new(); - for (name, value) in values { - previous.push((OsString::from(name), std::env::var_os(name))); - // SAFETY: this integration-test binary contains one current-thread - // test, and every modified value is restored by Drop. - unsafe { - std::env::set_var(name, value); - } - } - Self { previous } - } -} - -impl Drop for EnvironmentGuard { - fn drop(&mut self) { - for (name, value) in self.previous.drain(..).rev() { - if let Some(value) = value { - // SAFETY: the single current-thread test is still the only code - // mutating this process environment. - unsafe { - std::env::set_var(name, value); - } - } else { - // SAFETY: the single current-thread test is still the only code - // mutating this process environment. - unsafe { - std::env::remove_var(name); - } - } - } - } -} - fn compile_fake_gh(bin: &Path) -> PathBuf { const SOURCE: &str = r#" use std::env; @@ -101,8 +62,61 @@ fn main() { executable } -#[tokio::test(flavor = "current_thread")] -async fn run_discovers_an_enterprise_token_through_the_production_process_path() { +#[test] +fn run_discovers_an_enterprise_token_through_the_production_process_path() { + let temp = tempfile::tempdir().expect("creating test directory"); + let bin = temp.path().join("bin"); + compile_fake_gh(&bin); + let gh_log = temp.path().join("gh.log"); + let mut command = Command::new(std::env::current_exe().expect("the integration test knows its executable")); + command + .args([ + "--ignored", + "--exact", + "helper_run_discovers_an_enterprise_token_through_the_production_process_path", + "--nocapture", + ]) + .env("PATH", bin) + .env("GITHUB_TOKEN", " \t ") + .env("FAKE_GH_LOG", &gh_log); + if cfg!(windows) { + command.env("PATHEXT", ".COM;.EXE;.BAT;.CMD"); + } + + let output = command.output().expect("start isolated credential scenario"); + assert!( + output.status.success(), + "isolated credential scenario failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + String::from_utf8_lossy(&output.stdout).contains("production credential path passed"), + "isolated scenario did not report completion" + ); + let gh_call = std::fs::read_to_string(gh_log).expect("the production path invokes fake gh"); + assert!( + gh_call.contains("inherited-token=false"), + "gh inherited the rejected blank token:\n{gh_call}" + ); + assert!( + !gh_call.contains(DISCOVERED_TOKEN), + "the token appeared in gh arguments or test diagnostics" + ); +} + +#[test] +#[ignore = "subprocess fixture for run_discovers_an_enterprise_token_through_the_production_process_path"] +fn helper_run_discovers_an_enterprise_token_through_the_production_process_path() { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("create isolated runtime") + .block_on(run_credential_scenario()); + println!("production credential path passed"); +} + +async fn run_credential_scenario() { let dump = dump_server(Dump::sample(Utc::now()).to_tar_gz(), Some(1)).await; let github = MockServer::start().await; let services = failing_server(404).await; @@ -132,25 +146,7 @@ async fn run_discovers_an_enterprise_token_through_the_production_process_path() .await; } - let bin = temp.path().join("bin"); - compile_fake_gh(&bin); - let gh_log = temp.path().join("gh.log"); - let mut environment = vec![ - ("PATH", bin.into_os_string()), - ("GITHUB_TOKEN", OsString::from(" \t ")), - ("FAKE_GH_LOG", gh_log.clone().into_os_string()), - ]; - if cfg!(windows) { - environment.push(("PATHEXT", OsString::from(".COM;.EXE;.BAT;.CMD"))); - } - let _environment = EnvironmentGuard::set(environment); - let github_url = github.uri(); - let github_hostname = Url::parse(&github_url) - .expect("wiremock URI is valid") - .host_str() - .expect("wiremock URI has a host") - .to_owned(); let dump_url = dump_url(&dump); let service_url = services.uri(); let cache = cache.to_string_lossy().into_owned(); @@ -183,19 +179,17 @@ async fn run_discovers_an_enterprise_token_through_the_production_process_path() cargo_aprz_lib::run(&mut host, args).await; assert!(host.exit_code.is_none(), "the command should succeed: {}", host.error_str()); - let gh_call = std::fs::read_to_string(gh_log).expect("the production path invokes fake gh"); - assert!( - gh_call.contains("inherited-token=false"), - "gh inherited the rejected blank token:\n{gh_call}" - ); + let expected_hostname = Url::parse(&github_url) + .expect("wiremock URI is valid") + .host_str() + .expect("wiremock URI has a host") + .to_owned(); + let gh_call = std::fs::read_to_string(std::env::var_os("FAKE_GH_LOG").expect("fake gh log configured")) + .expect("the production path invokes fake gh"); assert!( - gh_call.contains(&format!("args=auth|token|--hostname|{github_hostname}")), + gh_call.contains(&format!("args=auth|token|--hostname|{expected_hostname}")), "gh did not receive the Enterprise hostname:\n{gh_call}" ); let diagnostics = format!("{}{}", host.output_str(), host.error_str()); assert!(!diagnostics.contains(DISCOVERED_TOKEN), "diagnostics exposed the discovered token"); - assert!( - !gh_call.contains(DISCOVERED_TOKEN), - "the token appeared in gh arguments or test diagnostics" - ); } diff --git a/justfiles/anvil/container.just b/justfiles/anvil/container.just index 8dc603334..c002a55b8 100644 --- a/justfiles/anvil/container.just +++ b/justfiles/anvil/container.just @@ -976,15 +976,9 @@ anvil-container *command: foreach ($name in $names) { $candidate = Join-Path $directory $name if (-not (Test-Path -LiteralPath $candidate -PathType Leaf)) { continue } - if (-not $IsWindows) { - # FileInfo.UnixMode is unavailable on the PowerShell 7.0 - # floor, so ask the host directly whether this regular file - # is executable. - & /usr/bin/test -f $candidate - if ($LASTEXITCODE -ne 0) { continue } - & /usr/bin/test -x $candidate - if ($LASTEXITCODE -ne 0) { continue } - } + # Process.Start below is the portable executable check. A Unix + # file without execute permission fails there and falls through + # anonymously without requiring an external `test` utility. return [IO.Path]::GetFullPath($candidate) } } @@ -1092,16 +1086,26 @@ anvil-container *command: $targets.Add([string[]]@($nested.Groups[1].Value)) } } - $githubTokenFromGh = [regex]::IsMatch( - $plan, '(?m)(^|[\s''"])--github-token-from-gh($|[\s''"])' - ) - $hasExplicitGitHubToken = [regex]::IsMatch( - $plan, '(?m)(^|[\s''"])--github-token(?:=|$|[\s''"])' + # Bind consent, explicit-token precedence, and endpoint selection + # to the same planned command. Distinct opted-in commands are + # ambiguous because the container can forward only one token, so + # fail closed and let each inner command run anonymously. + $optedInCommands = @( + $plan -split '\r?\n' | + Where-Object { + [regex]::IsMatch($_, '(^|[\s''"])--github-token-from-gh($|[\s''"])') + } | + Select-Object -Unique ) - if ($githubTokenFromGh) { + if ($optedInCommands.Count -eq 1) { + $optedInCommand = $optedInCommands[0] + $githubTokenFromGh = $true + $hasExplicitGitHubToken = [regex]::IsMatch( + $optedInCommand, '(^|[\s''"])--github-token(?:=|$|[\s''"])' + ) $githubUrlMatch = [regex]::Match( - $plan, - '(?m)(?:^|[\s''"])--github-url(?:=|\s+)(?:''([^'']*)''|"([^"]*)"|([^\s''"]+))' + $optedInCommand, + '(?:^|[\s''"])--github-url(?:=|\s+)(?:''([^'']*)''|"([^"]*)"|([^\s''"]+))' ) if ($githubUrlMatch.Success) { foreach ($group in 1..3) { From 38796f1c521d5e52d0dd960cfe0b80bb02ef3e24 Mon Sep 17 00:00:00 2001 From: "Martin Kolinek (from Dev Box)" Date: Fri, 18 Sep 2026 11:59:40 +0200 Subject: [PATCH 09/13] refactor(cargo-aprz): defer Anvil adoption Remove cargo-anvil recipes, container integration, generated artifacts, and related tests from this prerequisite PR so the opt-in credential capability can be released before Anvil consumes it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a9fc919b-99f7-4134-aad1-2116321b4e0c --- .anvil.lock | 6 +- crates/cargo-anvil/docs/design/containers.md | 48 +- .../src/anvil/artifacts/container.rs | 87 ++-- .../src/anvil/artifacts/justfile.rs | 14 +- .../justfiles/anvil/checks/aprz.just | 23 +- .../templates/justfiles/anvil/container.just | 255 ++-------- crates/cargo-anvil/tests/recipe_contracts.rs | 442 ++---------------- .../snapshots/snapshots__ado_backend.snap | 280 +++-------- .../snapshots/snapshots__github_backend.snap | 280 +++-------- .../snapshots/snapshots__local_only.snap | 280 +++-------- justfiles/anvil/checks/aprz.just | 23 +- justfiles/anvil/container.just | 255 ++-------- scripts/test-anvil-container.ps1 | 75 +-- 13 files changed, 473 insertions(+), 1595 deletions(-) diff --git a/.anvil.lock b/.anvil.lock index 142dac363..51e7b3724 100644 --- a/.anvil.lock +++ b/.anvil.lock @@ -1,7 +1,7 @@ version = 1 tool = "anvil" tool_version = "0.10.0" -catalog_checksum = "sha256:64c0c2c26274ec375486bd0955b5d3c6c77834a28f9a586963b8e09c9f55a5e5" +catalog_checksum = "sha256:d98d8d30baa01fe5f59b243aec4147ff5de6921107bdc60879eef31e11286855" [[file]] path = ".anvil/container/Dockerfile.dockerignore" @@ -57,7 +57,7 @@ checksum = "sha256:d4d3bd645a5586e9a1cc3a5fc27e38c93e59b1e29f83ede0ccd610273eec0 [[file]] path = "justfiles/anvil/checks/aprz.just" -checksum = "sha256:cac04328957d1fd5d18b958f78b8774bf322851e8d0a4dcb53a01a81836d5bd6" +checksum = "sha256:e10edbbe60358ec930241cd5d4e681a84f30f9d5278e652696e2f149e112d066" [[file]] path = "justfiles/anvil/checks/audit.just" @@ -181,7 +181,7 @@ checksum = "sha256:6efd7378a2cd0f5d86519bd32fd86f2055a60191187dd77a8842b374b8eb7 [[file]] path = "justfiles/anvil/container.just" -checksum = "sha256:cc7825beba4147d882d2db4471c351796ea18f1bee1ca40c8b3b32c69296fbed" +checksum = "sha256:ae4987c98728866a2cb29f2dcd56ed511f454443187b423a04c1c4d36e8714d5" [[file]] path = "justfiles/anvil/dev/build.just" diff --git a/crates/cargo-anvil/docs/design/containers.md b/crates/cargo-anvil/docs/design/containers.md index 75bfbe2a9..2721e43a5 100644 --- a/crates/cargo-anvil/docs/design/containers.md +++ b/crates/cargo-anvil/docs/design/containers.md @@ -104,7 +104,7 @@ try { just anvil-container just anvil-fmt } finally { Remove-Item Env:ANVIL_CONT | `ANVIL_CONTAINER_NO_RESOLVE=1` | Skip the resolve hook (§7.3), so a query never pulls. | | `ANVIL_CONTAINER_NO_CACHE=1` | Rebuild with `--no-cache` even when the tag resolves. Skips the resolve hook too (§7.3). | | `ANVIL_IN_CONTAINER=1` | Set inside the image. Makes a nested invocation execute natively (§5.4). | -| `GITHUB_TOKEN` | Forwarded into the run when exported. When the command explicitly opts into GitHub CLI discovery, it may instead be derived from the host gh CLI (§5.3). | +| `GITHUB_TOKEN` | Forwarded into the run. Taken from the host environment, or derived from the gh CLI for a target that reads it (§5.3). | `NO_REBUILD` is evaluated independently of `NO_CACHE`, so the two compose: `anvil-container-status` sets `NO_REBUILD` and `NO_RESOLVE` together and answers from local state alone. When `NO_REBUILD` stops a build the reference is still @@ -396,42 +396,24 @@ otherwise the engine leaves it as `/`, and anything falling back to `$HOME` writ ### 5.3 Environment -The run passes `ANVIL_IN_CONTAINER=1` (§5.4). An **exported** `GITHUB_TOKEN` is forwarded by name whatever the target -is — exact parity with a native run, where every process the shell spawns can already read it. - -GitHub CLI discovery is different: it manufactures a credential the developer did not export, and PID 1's environment -is inherited by every build script and proc macro in the container. The driver therefore invokes -`gh auth token --hostname ` only when the containerized command explicitly opts in with -`--github-token-from-gh`. A direct command opts in through an exact argv occurrence. A `just` command opts in only when -its expanded `just --dry-run ` plan contains one unique command with the exact switch; consent, an explicit -token, and `--github-url` are parsed from that same command. Multiple distinct opted-in commands are ambiguous because -the container can forward only one token, so they run anonymously. An explicit `--github-token` in the opted-in command -suppresses the host `gh` lookup, as does a nonblank exported `GITHUB_TOKEN`. The no-command interactive form never -derives a token. - -`` follows cargo-aprz's effective endpoint: `--github-url` in the direct argv or expanded plan wins over -`APRZ_GITHUB_URL`, and no override means `github.com`. The environment override is forwarded into the container so -the host lookup and the process using its token keep the same endpoint. An invalid override suppresses host discovery -rather than querying an unrelated login; the inner command retains responsibility for reporting its invalid service -address. - -This wrapper lookup exists only because the image has no gh CLI of its own. It applies the same process boundary as -native cargo-aprz: explicit nonempty `PATH` entries only, resolved to an absolute regular file; `.COM` and `.EXE` only -in Windows `PATHEXT` order, excluding functions, aliases, batch files and PowerShell shims. On Unix, direct process -startup is the executable-permission check, so no external filesystem-test utility is required. The executable is -launched directly with an argument vector and no shell, with stdin closed, stderr -captured and discarded, stdout captured as strict UTF-8, and the rejected blank `GITHUB_TOKEN` removed from its -environment. A missing executable, nonzero result, blank or invalid output, or ten-second deadline continues -anonymously. Deadline expiry terminates the complete process tree. - -Native cargo-aprz performs discovery only when its switch is present and selects the gh login from its effective -GitHub endpoint, including GitHub Enterprise overrides. The generated `anvil-aprz` recipe does not pass the switch, -so it uses an exported `GITHUB_TOKEN` when available and otherwise runs anonymously without invoking the host gh CLI. +The run passes `ANVIL_IN_CONTAINER=1` (§5.4) and forwards `GITHUB_TOKEN` by name, resolved the way the recipe resolves +it natively: the environment first, then the gh CLI's stored token. `anvil-aprz` runs in the `scheduled-advisories` group and +queries the GitHub advisory API, which allows 60 requests an hour unauthenticated and then sleeps until the quota +resets, so a tier needs the token to terminate rather than merely to run quickly. + +The two sources are not treated alike. An **exported** `GITHUB_TOKEN` is forwarded whatever the target is — that is +exact parity, since a native run exposes it to every process the shell spawns too. A token **derived** from the gh CLI +is a credential the developer never put in this environment, and PID 1's environment is inherited by every build script +and proc macro in the container, where natively `anvil-aprz` would mint it inside its own process. So it is derived +only when the target's plan (`just --dry-run `) reads `GITHUB_TOKEN`, or when there is no target at all: an +interactive session can run anything, and refusing there would reintroduce the stall the token exists to prevent. The +predicate is the variable rather than the name of a check, so a catalog that adds another GitHub-authenticated check is +covered without touching the driver. A plan covers the bodies `just` runs itself, not the body of a recipe that one of them launches as a child process. The unscoped tier wrapper (§`helpers.just`) launches its tier that way, so planning `anvil-scheduled` shows the wrapper alone. The driver therefore follows each nested target a plan names, until nothing new appears; without that, a wrapped -tier would hide an explicit `--github-token-from-gh` in one of its checks. +tier reads as needing nothing and `anvil-aprz` runs unauthenticated inside an image that has no `gh` of its own. It also forwards the recipe contract's own inputs when they are set — `PR_TITLE`, `BASE_REF`, `GITHUB_BASE_REF`, `SYSTEM_PULLREQUEST_TARGETBRANCH`, `ANVIL_IMPACT` and `ANVIL_MIRI_JOBS` — because a check that reads one diff --git a/crates/cargo-anvil/src/anvil/artifacts/container.rs b/crates/cargo-anvil/src/anvil/artifacts/container.rs index a433a49f4..c873e46d6 100644 --- a/crates/cargo-anvil/src/anvil/artifacts/container.rs +++ b/crates/cargo-anvil/src/anvil/artifacts/container.rs @@ -783,9 +783,14 @@ mod tests { } #[test] - fn a_host_token_is_forwarded_by_name_without_exposing_its_value() { - assert!(RECIPE.contains("$ghToken = Invoke-AnvilGhToken $ghExecutable $githubHostname")); - assert!(RECIPE.contains("@('auth', 'token', '--hostname', $Hostname)")); + fn a_host_token_is_resolved_as_the_recipe_does_and_forwarded_by_name() { + // anvil-aprz is in scheduled-advisories, and unauthenticated it does not merely + // warn: `cargo aprz deps` sleeps until the hourly quota resets, so a + // containerized tier blocks for up to an hour. The driver therefore + // resolves a token the same way the recipe does natively -- the + // environment first, then the gh CLI -- so both paths authenticate for + // the same developers. + assert!(RECIPE.contains("gh auth token --hostname github.com")); assert!(RECIPE.contains("$forwardedEnv += 'GITHUB_TOKEN'")); assert!(RECIPE.contains("$runArgs += @('-e', 'GITHUB_TOKEN')")); // By name, never by value: `-e NAME=VALUE` would put the credential on @@ -796,43 +801,12 @@ mod tests { assert!(RECIPE.contains("$hookEnv += 'GITHUB_TOKEN'")); // An exported token is left alone rather than re-derived; scoping of // the derived one is asserted in its own test below. - assert!(RECIPE.contains("if (-not $hasEnvironmentGitHubToken)")); + assert!(RECIPE.contains("if (-not $env:GITHUB_TOKEN -and (Get-Command gh")); // Forwarding by name only works if the engine can see the name, so a // WSL engine needs it bridged -- otherwise `-e NAME` forwards nothing. assert!(RECIPE.contains("$engineExe -eq 'wsl.exe' -and $forwardedEnv.Count -gt 0")); } - #[test] - fn host_github_cli_discovery_is_direct_bounded_and_non_interactive() { - assert!(RECIPE.contains("$ghExecutable = Resolve-AnvilGhExecutable")); - assert!(RECIPE.contains("$start.FileName = $Executable")); - assert!(RECIPE.contains("$start.ArgumentList.Add($argument)")); - assert!(RECIPE.contains("$start.UseShellExecute = $false")); - assert!(RECIPE.contains("$start.RedirectStandardInput = $true")); - assert!(RECIPE.contains("$start.RedirectStandardOutput = $true")); - assert!(RECIPE.contains("$start.RedirectStandardError = $true")); - assert!(RECIPE.contains("$process.WaitForExit($TimeoutMilliseconds)")); - assert!(RECIPE.contains("[int]$TimeoutMilliseconds = 10000")); - assert!(RECIPE.contains("$process.Kill($true)")); - assert!(RECIPE.contains("$start.Environment.Remove('GITHUB_TOKEN')")); - assert!(!RECIPE.contains("Get-Command gh")); - assert!(!RECIPE.contains("& gh ")); - assert!(!RECIPE.contains("(gh ")); - } - - #[test] - fn host_github_cli_resolution_uses_only_direct_path_executables() { - assert!(RECIPE.contains("$path = [Environment]::GetEnvironmentVariable('PATH')")); - assert!(RECIPE.contains("if ([string]::IsNullOrEmpty($entry)) { continue }")); - assert!(RECIPE.contains("[IO.Path]::GetFullPath($candidate)")); - assert!(RECIPE.contains("$extension -ieq '.COM' -or $extension -ieq '.EXE'")); - assert!(!RECIPE.contains("$extension -ieq '.BAT'")); - assert!(!RECIPE.contains("$extension -ieq '.CMD'")); - assert!(RECIPE.contains("Test-Path -LiteralPath $candidate -PathType Leaf")); - assert!(RECIPE.contains("$started = $process.Start()")); - assert!(!RECIPE.contains("/usr/bin/test")); - } - #[test] fn the_whole_recipe_tree_defines_the_image() { // `just anvil-setup` reaches the install recipes through the tier, @@ -917,46 +891,37 @@ mod tests { } #[test] - fn a_derived_token_requires_explicit_command_intent() { + fn a_derived_token_is_scoped_to_a_command_that_reads_it() { // Forwarding an exported GITHUB_TOKEN is exact parity: natively it is // visible to every process the shell spawns too. Minting one from `gh` // is not -- PID 1's environment reaches every build script and proc - // macro -- so it happens only after the exact opt-in switch appears in - // direct argv or an expanded Just plan. + // macro, where natively the recipe mints it in its own process -- so it + // happens only for a command whose plan reads the variable. // Each search covers the whole recipe, so the comparison below is the // thing under test. Bounding a search by an earlier match makes the // ordering true by construction and the assertion vacuous. - let derive = RECIPE - .find("$ghToken = Invoke-AnvilGhToken $ghExecutable $githubHostname") - .expect("the host-aware gh fallback must exist"); - let guard = RECIPE - .find("if ($githubTokenFromGh -and -not $hasExplicitGitHubToken") - .expect("the derive must require consent and no explicit token"); + let derive = RECIPE.find("gh auth token --hostname").expect("the gh fallback must exist"); + let guard = RECIPE.find("if ($needsToken)").expect("the derive must be guarded"); let plan = RECIPE - .find("--github-token-from-gh($|") - .expect("the plan must recognize the exact opt-in switch"); - let direct = RECIPE - .find("$argv -contains '--github-token-from-gh'") - .expect("direct commands must recognize an exact argv occurrence"); + .find("$plan -match 'GITHUB_TOKEN'") + .expect("the plan must decide whether a token is needed"); let dry_run = RECIPE.find("--dry-run @target").expect("the plan must come from just"); assert!( - dry_run < plan && plan < guard && direct < guard && guard < derive, - "compute explicit intent before resolving or invoking gh" + dry_run < plan && plan < guard && guard < derive, + "compute the plan, match it, guard on it, then derive" ); // Through the launching binary, like every other nested call: a bare // `just` here fails silently when the caller invoked it by absolute // path, and an empty plan reads as "no token needed". assert!(!RECIPE.contains("(just --dry-run")); assert!(RECIPE.contains(r"}}' --dry-run @target")); - // The switch, not a check name or ambient-variable read, is the - // contract. Interactive execution has no argv and therefore no opt-in. + // The predicate is the variable, not the name of a check, so a catalog + // that adds another GitHub-authenticated check is covered for free. assert!(!RECIPE.contains("$plan -match 'aprz'")); - assert!(!RECIPE.contains("$plan -match 'GITHUB_TOKEN'")); - assert!(RECIPE.contains("if ($argv.Count -gt 0 -and $argv[0] -eq 'just')")); - // Explicit credentials remain authoritative even when opt-in is also - // present, for both direct and planned commands. - assert!(RECIPE.contains("$_ -eq '--github-token' -or $_.StartsWith('--github-token=')")); - assert!(RECIPE.contains("--github-token(?:=|$|")); + // An interactive session has no command to plan, and can run anything. + assert!(RECIPE.contains("$needsToken = $argv.Count -eq 0")); + // Only `just` can be planned, so nothing else earns a minted credential. + assert!(RECIPE.contains("if (-not $needsToken -and $argv[0] -eq 'just')")); } #[test] @@ -982,8 +947,8 @@ mod tests { // `just --dry-run` prints the bodies just runs itself. The unscoped tier // wrapper runs its tier as a child process instead, so a plan of // `anvil-scheduled` is the wrapper alone and reveals none of the checks - // under it. Following the launched targets lets a recipe under that - // wrapper explicitly opt into --github-token-from-gh. + // under it -- including anvil-aprz, whose GITHUB_TOKEN is what stops it + // sleeping on the advisory API's unauthenticated rate limit. assert!( RECIPE.contains(r#"[regex]::Matches($step, "'(_anvil-[^'\s]+)'")"#), "the plan must follow each nested target the wrapper names" diff --git a/crates/cargo-anvil/src/anvil/artifacts/justfile.rs b/crates/cargo-anvil/src/anvil/artifacts/justfile.rs index f355ab5e2..dc4777691 100644 --- a/crates/cargo-anvil/src/anvil/artifacts/justfile.rs +++ b/crates/cargo-anvil/src/anvil/artifacts/justfile.rs @@ -78,18 +78,16 @@ macro_rules! split_recipe_files { const DEV_FILES: &[(&str, &str)] = split_recipe_files!("dev", ["build"]); #[test] -fn aprz_does_not_opt_into_github_cli_credential_discovery() { +fn aprz_forwards_a_github_token_into_the_container() { let aprz = CHECK_FILES .iter() .find_map(|(path, body)| path.ends_with("/aprz.just").then_some(*body)) .expect("aprz.just is registered in CHECK_FILES below"); - assert!(!aprz.contains("Get-Command gh")); - assert!(!aprz.contains("$env:GITHUB_TOKEN =")); - let invocation = aprz - .lines() - .find(|line| line.contains("cargo {{_anvil_stable_toolchain_args}} aprz deps")) - .expect("anvil-aprz invokes cargo-aprz"); - assert!(!invocation.contains("--github-token-from-gh")); + // The container driver forwards GITHUB_TOKEN by name, so the check reads + // the variable and says how to obtain one rather than reaching for a + // mounted secret path. + assert!(aprz.contains("GITHUB_TOKEN")); + assert!(aprz.contains("gh auth")); } /// One `justfiles/anvil/checks/.just` file per catalog check diff --git a/crates/cargo-anvil/templates/justfiles/anvil/checks/aprz.just b/crates/cargo-anvil/templates/justfiles/anvil/checks/aprz.just index 1862d8381..e068388cd 100644 --- a/crates/cargo-anvil/templates/justfiles/anvil/checks/aprz.just +++ b/crates/cargo-anvil/templates/justfiles/anvil/checks/aprz.just @@ -10,10 +10,12 @@ # capped at 60 requests an hour, and on a full workspace it exhausts that # and then waits for the quota to reset rather than failing; an # authenticated token raises the cap to 5000/hour. CI injects GITHUB_TOKEN -# (github.token). Local runs use an exported GITHUB_TOKEN when available and -# otherwise remain anonymous. Developers can opt into host-aware GitHub CLI -# discovery by invoking cargo-aprz themselves with --github-token-from-gh; -# this generated check deliberately does not access their gh credentials. +# (github.token). For local runs, if GITHUB_TOKEN is unset we borrow the +# gh CLI's stored token (non-interactive: `gh auth token` prints the +# active account's token for github.com and never opens a browser/auth +# prompt). If neither is available we warn with instructions and proceed +# unauthenticated. In a container the driver resolves the token the same +# way and forwards it by name, because the image has no gh CLI of its own. # # Unscoped (consults external risk DB). @@ -21,6 +23,19 @@ [script("pwsh", "-NoProfile")] anvil-aprz: anvil-aprz-validate-prereqs $ErrorActionPreference = 'Stop' + if (-not $env:GITHUB_TOKEN) { + $tok = $null + if (Get-Command gh -ErrorAction SilentlyContinue) { + try { $tok = (gh auth token --hostname github.com 2>$null) } catch { $tok = $null } + } + if ($tok) { + $env:GITHUB_TOKEN = $tok.Trim() + } else { + Write-Warning 'anvil-aprz: GITHUB_TOKEN is not set and no token could be obtained from the gh CLI.' + Write-Warning 'cargo-aprz will use the unauthenticated GitHub API, which allows 60 requests an hour. On a full workspace it exhausts that and then blocks, for up to an hour, waiting for the quota to reset.' + Write-Warning 'To fix: run `gh auth login` (recommended), or set $env:GITHUB_TOKEN to a GitHub token, then re-run.' + } + } & cargo {{_anvil_stable_toolchain_args}} aprz deps --error-if-high-risk --console appraisal if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/crates/cargo-anvil/templates/justfiles/anvil/container.just b/crates/cargo-anvil/templates/justfiles/anvil/container.just index c002a55b8..871c5b735 100644 --- a/crates/cargo-anvil/templates/justfiles/anvil/container.just +++ b/crates/cargo-anvil/templates/justfiles/anvil/container.just @@ -927,139 +927,52 @@ anvil-container *command: $forwardedEnv = @() $hookEnv = @() + # anvil-aprz queries the GitHub advisory API, which allows 60 requests an + # hour unauthenticated -- less than a full tier needs. Unauthenticated is + # not a degraded-but-working mode: `cargo aprz deps` sleeps until the quota + # resets rather than failing, so a containerized tier blocks for up to an + # hour with no way to opt out. Authentication is what makes the check + # terminate, not what makes it fast. + # + # Resolve the token exactly as the recipe does natively -- GITHUB_TOKEN + # first, then the gh CLI's stored token -- so a containerized run + # authenticates for the same developers a native run does. + # # An already-exported GITHUB_TOKEN is forwarded whatever the command is: # that is exact parity, since a native run exposes it to every process the - # shell spawns too. Deriving one from `gh` is different: it manufactures a - # credential the developer did not export, and PID 1 exposes it to every - # build script and proc macro in the container. It therefore requires the - # exact --github-token-from-gh switch in a direct command's argv or in an - # expanded `just --dry-run` plan. Interactive execution never opts in. + # shell spawns too. Deriving one from `gh` is different -- it manufactures a + # credential the developer did not put in this environment, and PID 1's + # environment is inherited by every build script and proc macro in the + # container, where natively the recipe would mint it in its own process. So + # it is derived only when the command is known to read the variable, or when + # there is no command at all: an interactive session can run anything, and + # refusing there would reintroduce the silent hour-long stall on a tier the + # developer runs from inside the shell. + # `gh auth token` is non-interactive and never opens a prompt. # - # Explicit --github-token and a nonblank GITHUB_TOKEN remain authoritative - # even when the opt-in switch is also present, so neither path invokes gh. - function Resolve-AnvilGhExecutable { - $path = [Environment]::GetEnvironmentVariable('PATH') - if ([string]::IsNullOrEmpty($path)) { return $null } - - if ($IsWindows) { - $pathExt = [Environment]::GetEnvironmentVariable('PATHEXT') - if ([string]::IsNullOrEmpty($pathExt)) { - $pathExt = '.COM;.EXE;.BAT;.CMD' - } - $names = @( - foreach ($extension in ($pathExt -split ';')) { - if ([string]::IsNullOrEmpty($extension)) { continue } - if (-not $extension.StartsWith('.')) { $extension = ".$extension" } - if ($extension -ieq '.COM' -or $extension -ieq '.EXE') { - "gh$extension" - } - } - ) - } else { - $names = @('gh') - } - - foreach ($entry in ($path -split [IO.Path]::PathSeparator)) { - # Empty PATH entries implicitly mean the current directory to - # command lookup. Require an explicit entry such as `.` instead. - if ([string]::IsNullOrEmpty($entry)) { continue } - try { - $directory = if ([IO.Path]::IsPathRooted($entry)) { - [IO.Path]::GetFullPath($entry) - } else { - [IO.Path]::GetFullPath((Join-Path (Get-Location).Path $entry)) - } - } catch { - continue - } - - foreach ($name in $names) { - $candidate = Join-Path $directory $name - if (-not (Test-Path -LiteralPath $candidate -PathType Leaf)) { continue } - # Process.Start below is the portable executable check. A Unix - # file without execute permission fails there and falls through - # anonymously without requiring an external `test` utility. - return [IO.Path]::GetFullPath($candidate) - } - } - $null - } - - function Invoke-AnvilGhToken( - [Parameter(Mandatory)][string]$Executable, - [Parameter(Mandatory)][string]$Hostname, - [int]$TimeoutMilliseconds = 10000 - ) { - $start = [Diagnostics.ProcessStartInfo]::new() - $start.FileName = $Executable - $start.UseShellExecute = $false - $start.CreateNoWindow = $true - $start.RedirectStandardInput = $true - $start.RedirectStandardOutput = $true - $start.RedirectStandardError = $true - $start.StandardOutputEncoding = [Text.UTF8Encoding]::new($false, $true) - foreach ($argument in @('auth', 'token', '--hostname', $Hostname)) { - $null = $start.ArgumentList.Add($argument) - } - # gh treats even a whitespace-only inherited GITHUB_TOKEN as - # authoritative. Remove the value cargo-aprz already rejected so the - # authenticated account lookup can actually proceed. - $null = $start.Environment.Remove('GITHUB_TOKEN') - - $process = [Diagnostics.Process]::new() - $process.StartInfo = $start - $started = $false - try { - $started = $process.Start() - if (-not $started) { return $null } - $process.StandardInput.Close() - $stdout = $process.StandardOutput.ReadToEndAsync() - $stderr = $process.StandardError.ReadToEndAsync() - if (-not $process.WaitForExit($TimeoutMilliseconds)) { - $process.Kill($true) - $process.WaitForExit() - try { $null = $stdout.GetAwaiter().GetResult() } catch {} - try { $null = $stderr.GetAwaiter().GetResult() } catch {} - return $null - } - try { - $token = $stdout.GetAwaiter().GetResult() - $null = $stderr.GetAwaiter().GetResult() - } catch { - return $null - } - if ($process.ExitCode -ne 0 -or [string]::IsNullOrWhiteSpace($token)) { - return $null - } - $token.Trim() - } catch { - $null - } finally { - if ($started -and -not $process.HasExited) { - try { - $process.Kill($true) - $process.WaitForExit() - } catch {} - } - $process.Dispose() - } - } - - $hasEnvironmentGitHubToken = -not [string]::IsNullOrWhiteSpace($env:GITHUB_TOKEN) - if (-not $hasEnvironmentGitHubToken) { - $githubTokenFromGh = $false - $hasExplicitGitHubToken = $false - $githubUrl = $null - if ($argv.Count -gt 0 -and $argv[0] -eq 'just') { + # The predicate is the variable itself rather than the name of a check, so + # the driver stays generic: a catalog that adds another GitHub-authenticated + # check is covered without touching this recipe. + # + # Set here and passed by NAME, so the value never reaches the host's + # process command line, and unset again with the hook's variables below. + if (-not $env:GITHUB_TOKEN -and (Get-Command gh -ErrorAction SilentlyContinue)) { + $needsToken = $argv.Count -eq 0 + # Only a `just` command can be planned, and planning is the only way to + # know whether what runs reads the variable. Anything else keeps the + # environment it was given: a manufactured credential reaches every + # process in the container, so an unknown command does not earn one. + if (-not $needsToken -and $argv[0] -eq 'just') { # A dry run has no side effects, and a target that cannot be planned - # yields no consent, so the run fails on its own terms. + # (a typo, a recipe needing arguments) yields nothing, so the run + # fails on its own terms rather than on a missing token. # # A plan covers the bodies just runs itself, not the body of a # recipe that one of them launches as a child process. The unscoped # tier wrapper launches its tier that way, so planning # `anvil-scheduled` shows the wrapper and none of the checks # underneath it. Follow each nested target a plan names, or a - # wrapped tier can hide an explicit opt-in in one of its checks. + # wrapped tier reads as needing nothing and runs unauthenticated. $plan = '' $targets = [System.Collections.Generic.List[object]]::new() $targets.Add([string[]]@($argv | Select-Object -Skip 1)) @@ -1072,7 +985,10 @@ anvil-container *command: # The same executable that launched this tree, for the reason # every other nested call uses it: a caller invoking `just` by # absolute path with its directory off PATH would otherwise fail - # here. An empty plan means no GitHub CLI consent. + # here. That failure is silent, because an empty plan reads as + # "does not need a token" -- so anvil-aprz would run + # unauthenticated in an image with no gh of its own and block on + # the rate limit for up to an hour. $step = '' try { $step = (& '{{ replace(just_executable(), "'", "''") }}' --dry-run @target 2>&1 | @@ -1086,96 +1002,21 @@ anvil-container *command: $targets.Add([string[]]@($nested.Groups[1].Value)) } } - # Bind consent, explicit-token precedence, and endpoint selection - # to the same planned command. Distinct opted-in commands are - # ambiguous because the container can forward only one token, so - # fail closed and let each inner command run anonymously. - $optedInCommands = @( - $plan -split '\r?\n' | - Where-Object { - [regex]::IsMatch($_, '(^|[\s''"])--github-token-from-gh($|[\s''"])') - } | - Select-Object -Unique - ) - if ($optedInCommands.Count -eq 1) { - $optedInCommand = $optedInCommands[0] - $githubTokenFromGh = $true - $hasExplicitGitHubToken = [regex]::IsMatch( - $optedInCommand, '(^|[\s''"])--github-token(?:=|$|[\s''"])' - ) - $githubUrlMatch = [regex]::Match( - $optedInCommand, - '(?:^|[\s''"])--github-url(?:=|\s+)(?:''([^'']*)''|"([^"]*)"|([^\s''"]+))' - ) - if ($githubUrlMatch.Success) { - foreach ($group in 1..3) { - if ($githubUrlMatch.Groups[$group].Success) { - $githubUrl = $githubUrlMatch.Groups[$group].Value - break - } - } - } - } - } elseif ($argv.Count -gt 0) { - $githubTokenFromGh = $argv -contains '--github-token-from-gh' - $hasExplicitGitHubToken = @( - $argv | Where-Object { $_ -eq '--github-token' -or $_.StartsWith('--github-token=') } - ).Count -gt 0 - if ($githubTokenFromGh) { - for ($i = 0; $i -lt $argv.Count; $i++) { - if ($argv[$i] -eq '--github-url' -and $i + 1 -lt $argv.Count) { - $githubUrl = $argv[$i + 1] - break - } - if ($argv[$i].StartsWith('--github-url=')) { - $githubUrl = $argv[$i].Substring('--github-url='.Length) - break - } - } - } + $needsToken = $plan -match 'GITHUB_TOKEN' } - - if ($githubTokenFromGh -and -not $hasExplicitGitHubToken) { - if ([string]::IsNullOrWhiteSpace($githubUrl) -and - -not [string]::IsNullOrWhiteSpace($env:APRZ_GITHUB_URL)) { - $githubUrl = $env:APRZ_GITHUB_URL - } - - $githubHostname = 'github.com' - if (-not [string]::IsNullOrWhiteSpace($githubUrl)) { - $githubUri = $null - try { $githubUri = [Uri]::new($githubUrl, [UriKind]::Absolute) } catch {} - if ($null -eq $githubUri -or [string]::IsNullOrWhiteSpace($githubUri.Host)) { - $githubHostname = $null - } elseif ($githubUri.Host -eq 'api.github.com') { - $githubHostname = 'github.com' - } else { - $githubHostname = $githubUri.Host - } - } - - if ($githubHostname) { - $ghExecutable = Resolve-AnvilGhExecutable - if ($ghExecutable) { - $ghToken = Invoke-AnvilGhToken $ghExecutable $githubHostname - } else { - $ghToken = $null - } - if ($ghToken) { - Set-Item -LiteralPath 'Env:GITHUB_TOKEN' -Value $ghToken - $hookEnv += 'GITHUB_TOKEN' - } + if ($needsToken) { + $ghToken = $null + try { $ghToken = (gh auth token --hostname github.com 2>$null) } catch { $ghToken = $null } + if ($ghToken -and $ghToken.Trim()) { + Set-Item -LiteralPath 'Env:GITHUB_TOKEN' -Value $ghToken.Trim() + $hookEnv += 'GITHUB_TOKEN' } } } - if (-not [string]::IsNullOrWhiteSpace($env:GITHUB_TOKEN)) { + if ($env:GITHUB_TOKEN) { $forwardedEnv += 'GITHUB_TOKEN' $runArgs += @('-e', 'GITHUB_TOKEN') } - if (-not [string]::IsNullOrWhiteSpace($env:APRZ_GITHUB_URL)) { - $forwardedEnv += 'APRZ_GITHUB_URL' - $runArgs += @('-e', 'APRZ_GITHUB_URL') - } # The recipe contract's own inputs. These are read by generated checks -- # `anvil-pr-title` reads PR_TITLE, `_anvil-base-ref` reads BASE_REF and its diff --git a/crates/cargo-anvil/tests/recipe_contracts.rs b/crates/cargo-anvil/tests/recipe_contracts.rs index 6463d9bde..fb83ffb6f 100644 --- a/crates/cargo-anvil/tests/recipe_contracts.rs +++ b/crates/cargo-anvil/tests/recipe_contracts.rs @@ -12,7 +12,7 @@ use std::collections::HashSet; use std::ffi::{OsStr, OsString}; use std::fmt::Write as _; use std::fs; -use std::path::{Path, PathBuf}; +use std::path::Path; use std::process::{Command, Output, Stdio}; use std::time::{Duration, Instant}; @@ -531,14 +531,6 @@ fn path_with_fake_bin(root: &Path) -> OsString { std::env::join_paths(paths).unwrap() } -fn test_pwsh() -> PathBuf { - let executable = if cfg!(windows) { "pwsh.exe" } else { "pwsh" }; - std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()) - .map(|directory| directory.join(executable)) - .find(|candidate| candidate.is_file()) - .expect("pwsh was checked by tools_available") -} - fn just_command(root: &Path, arguments: &[&str], environment: &[(&str, &OsStr)]) -> Command { let mut command = Command::new("just"); command @@ -579,153 +571,6 @@ fn run_just_with_real_cargo(root: &Path, arguments: &[&str]) -> Output { command.output().expect("just is required to verify generated recipe behavior") } -fn run_container_github_credential_probe(root: &Path, arguments: &[&str], environment: &[(&str, &OsStr)]) -> (Output, String) { - run_container_github_credential_probe_with_timeout(root, arguments, environment, None) -} - -fn run_container_github_credential_probe_with_timeout( - root: &Path, - arguments: &[&str], - environment: &[(&str, &OsStr)], - timeout_milliseconds: Option, -) -> (Output, String) { - let start = CONTAINER - .find(" # An already-exported GITHUB_TOKEN") - .expect("container GitHub credential block"); - let end = CONTAINER[start..] - .find(" # The recipe contract's own inputs.") - .map(|offset| start + offset) - .expect("end of container GitHub credential block"); - let mut block = CONTAINER[start..end].replace("'{{ replace(just_executable(), \"'\", \"''\") }}'", "'just'"); - if let Some(timeout_milliseconds) = timeout_milliseconds { - block = block.replace( - "[int]$TimeoutMilliseconds = 10000", - &format!("[int]$TimeoutMilliseconds = {timeout_milliseconds}"), - ); - } - let argv = arguments - .iter() - .map(|argument| format!("'{}'", argument.replace('\'', "''"))) - .collect::>() - .join(", "); - let script = format!( - "function gh {{ throw 'PowerShell command shim invoked' }}\n\ - $argv = @({argv})\n\ - $runArgs = @()\n\ - $forwardedEnv = @()\n\ - $hookEnv = @()\n\ - {block}\n\ - Write-Output \"TOKEN=$($env:GITHUB_TOKEN)\"\n\ - Write-Output \"RUN_ARGS=$($runArgs -join '|')\"\n" - ); - let log = root.join("gh.log"); - if log.exists() { - fs::remove_file(&log).unwrap(); - } - - let mut command = Command::new(test_pwsh()); - command - .args(["-NoProfile", "-Command", &script]) - .current_dir(root) - .env("PATH", path_with_fake_bin(root)) - .env("FAKE_GH_LOG", &log) - .env_remove("GITHUB_TOKEN") - .env_remove("APRZ_GITHUB_URL"); - for &(key, value) in environment { - command.env(key, value); - } - let output = command.output().expect("pwsh is required to verify generated recipe behavior"); - let calls = fs::read_to_string(log).unwrap_or_default(); - (output, calls) -} - -fn install_fake_gh(root: &Path) -> PathBuf { - const SOURCE: &str = r#" -use std::env; -use std::fs; -use std::io::{self, Write as _}; -use std::process::{self, Command}; -use std::thread; -use std::time::Duration; - -fn main() { - let mode = env::var("FAKE_GH_MODE").unwrap_or_else(|_| "token".to_owned()); - if mode == "descendant" { - thread::sleep(Duration::from_secs(8)); - fs::write(env::var_os("FAKE_GH_SENTINEL").expect("sentinel path"), b"survived") - .expect("write descendant sentinel"); - return; - } - - let args = env::args().skip(1).collect::>(); - let executable = env::current_exe() - .ok() - .and_then(|path| path.file_name().map(|name| name.to_string_lossy().into_owned())) - .unwrap_or_default(); - let log = format!( - "executable={executable}\ninherited-token={}\nargs={}", - env::var_os("GITHUB_TOKEN").is_some(), - args.join("|") - ); - fs::write(env::var_os("FAKE_GH_LOG").expect("log path"), log).expect("write invocation log"); - - match mode.as_str() { - "token" => println!("{}", env::var("FAKE_GH_TOKEN").unwrap_or_else(|_| "discovered-token".to_owned())), - "nonzero" => { - println!("nonzero-output-secret"); - eprintln!("nonzero-stderr-secret"); - process::exit(17); - } - "blank" => println!(" \t "), - "invalid" => io::stdout().write_all(&[0xff, 0xfe]).expect("write invalid UTF-8"), - "timeout" => { - Command::new(env::current_exe().expect("current executable")) - .env("FAKE_GH_MODE", "descendant") - .spawn() - .expect("spawn descendant"); - thread::sleep(Duration::from_secs(30)); - } - other => panic!("unknown fake gh mode: {other}"), - } -} -"#; - - let source = root.join("fake-gh.rs"); - write(&source, SOURCE); - let executable = root.join("fake-bin").join(if cfg!(windows) { "gh.exe" } else { "gh" }); - let rustc = std::env::var_os("RUSTC").unwrap_or_else(|| OsString::from("rustc")); - let output = Command::new(rustc) - .args(["--edition=2024", "-o"]) - .arg(&executable) - .arg(&source) - .output() - .expect("rustc is available while running Rust integration tests"); - assert!( - output.status.success(), - "compiling fake gh failed\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - executable -} - -fn assert_gh_call(calls: &str, hostname: &str) { - assert!(calls.contains("inherited-token=false"), "gh inherited the rejected token:\n{calls}"); - assert!( - calls.contains(&format!("args=auth|token|--hostname|{hostname}")), - "unexpected gh arguments:\n{calls}" - ); -} - -fn assert_probe_success(output: &Output, context: &str) { - assert!( - output.status.success(), - "{context}\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); -} - fn assert_failed(output: &Output, context: &str) { assert!( !output.status.success(), @@ -2618,12 +2463,14 @@ fn windows_arm64_fallback_accepts_empty_nextest_sets_in_both_configurations() { assert!(!calls.contains("llvm-cov"), "coverage commands must not run:\n{calls}"); } -// --- credential-specific behaviour ----------------------------------------- +// --- container-specific behaviour ------------------------------------------ -/// Generated `anvil-aprz` must neither query gh itself nor opt cargo-aprz into -/// GitHub CLI credential discovery. +/// `anvil-aprz` warns and proceeds when it cannot obtain a token, rather than +/// throwing. That change exists so a containerized tier is not aborted by a +/// missing credential, and nothing else covers it: the dogfood run normally has +/// a host token, and the tokenless container E2E case runs a custom echo recipe. #[test] -fn aprz_does_not_opt_into_github_cli_credential_discovery() { +fn aprz_without_a_token_warns_and_still_runs() { if !tools_available() { return; } @@ -2634,263 +2481,61 @@ fn aprz_does_not_opt_into_github_cli_credential_discovery() { "anvil-tool-cargo-aprz-install installer=\"install\"", ], ); + // A gh that yields no token: the recipe must fall through to the warnings + // rather than treating a failed lookup as fatal. + // + // Three stubs because command lookup differs by platform and the fallback + // is the developer's real, signed-in `gh`: on Windows only `.cmd` is in + // PATHEXT, so a `.ps1` stub is skipped; on Unix a bare `gh` must exist and + // be executable. Getting this wrong does not fail the test -- it makes it + // pass while exercising the authenticated path, which is the opposite of + // what the name claims. + write(&tmp.path().join("fake-bin/gh.cmd"), "@exit /b 1\r\n"); + write(&tmp.path().join("fake-bin/gh.ps1"), "exit 1\n"); + let unix_stub = tmp.path().join("fake-bin/gh"); + write(&unix_stub, "#!/bin/sh\nexit 1\n"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + std::fs::set_permissions(&unix_stub, std::fs::Permissions::from_mode(0o755)).unwrap(); + } let log = tmp.path().join("cargo.log"); - let plan = run_just(tmp.path(), &["--dry-run", "anvil-aprz"], &[]); - assert!( - plan.status.success(), - "the container driver must be able to plan anvil-aprz\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&plan.stdout), - String::from_utf8_lossy(&plan.stderr) - ); - let planned = format!("{}{}", String::from_utf8_lossy(&plan.stdout), String::from_utf8_lossy(&plan.stderr)); - assert!( - !planned.contains("--github-token-from-gh"), - "the generated recipe must not opt into GitHub CLI discovery:\n{planned}" - ); - let output = run_just( tmp.path(), &["anvil-aprz"], &[ ("FAKE_CARGO_LOG", log.as_os_str()), ("GITHUB_TOKEN", OsStr::new("")), - ("APRZ_GITHUB_URL", OsStr::new("https://github.example.test/api/v3")), + ("ANVIL_IN_CONTAINER", OsStr::new("1")), ], ); assert!( output.status.success(), - "anvil-aprz must run without GitHub CLI discovery\nstdout:\n{}\nstderr:\n{}", + "a missing token must not fail the check\nstdout:\n{}\nstderr:\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); + // PowerShell's warning stream surfaces on stdout once `just` has run the + // script, so assert on what the developer actually sees rather than on a + // particular stream. let seen = format!( "{}{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); assert!( - !seen.contains("gh auth token"), - "the wrapper must not perform its own GitHub CLI lookup:\n{seen}" + seen.contains("GITHUB_TOKEN is not set"), + "the warning must name the variable:\n{seen}" ); + assert!(seen.contains("gh auth login"), "the warning must say how to fix it:\n{seen}"); + // The point of warning rather than throwing: the check still runs. let calls = std::fs::read_to_string(&log).unwrap_or_default(); assert!(calls.contains("aprz deps"), "cargo aprz must still be invoked:\n{calls}"); } -#[test] -fn container_github_cli_discovery_is_opt_in_host_aware_and_preserves_precedence() { - const PROBE: &str = "[script(\"pwsh\", \"-NoProfile\")]\n\ - enterprise-probe:\n \ - & cargo aprz deps --github-url https://unrelated.plan.test/api/v3\n \ - & cargo aprz deps --github-url https://github.plan.test/api/v3 --github-token-from-gh\n"; - - if !tools_available() { - return; - } - let tmp = fixture(&[("probe.just", PROBE)], &[]); - let root = tmp.path(); - install_fake_gh(root); - - let (planned, calls) = run_container_github_credential_probe(root, &["just", "enterprise-probe"], &[]); - assert_probe_success(&planned, "expanded-plan credential discovery failed"); - assert_gh_call(&calls, "github.plan.test"); - assert!( - String::from_utf8_lossy(&planned.stdout).contains("TOKEN=discovered-token"), - "the discovered token must be forwarded\n{}", - String::from_utf8_lossy(&planned.stdout) - ); - - let (direct, calls) = run_container_github_credential_probe( - root, - &[ - "cargo", - "aprz", - "deps", - "--github-url=https://github.argv.test/api/v3", - "--github-token-from-gh", - ], - &[("APRZ_GITHUB_URL", OsStr::new("https://github.environment.test/api/v3"))], - ); - assert_probe_success(&direct, "direct-argv credential discovery failed"); - assert_gh_call(&calls, "github.argv.test"); - let direct_stdout = String::from_utf8_lossy(&direct.stdout); - assert!( - direct_stdout.contains("RUN_ARGS=-e|GITHUB_TOKEN|-e|APRZ_GITHUB_URL"), - "the token and endpoint override must both reach the container\n{direct_stdout}" - ); - - let (environment_url, calls) = run_container_github_credential_probe( - root, - &["cargo", "aprz", "deps", "--github-token-from-gh"], - &[("APRZ_GITHUB_URL", OsStr::new("https://github.environment.test/api/v3"))], - ); - assert_probe_success(&environment_url, "environment-host credential discovery failed"); - assert_gh_call(&calls, "github.environment.test"); - - let (explicit, calls) = run_container_github_credential_probe( - root, - &[ - "cargo", - "aprz", - "deps", - "--github-token", - "explicit-token", - "--github-token-from-gh", - ], - &[], - ); - assert_probe_success(&explicit, "explicit-token precedence probe failed"); - assert!(calls.is_empty(), "an explicit command token must suppress host gh discovery"); - - let (environment_token, calls) = run_container_github_credential_probe( - root, - &["cargo", "aprz", "deps", "--github-token-from-gh"], - &[("GITHUB_TOKEN", OsStr::new("environment-token"))], - ); - assert_probe_success(&environment_token, "environment-token precedence probe failed"); - assert!(calls.is_empty(), "a nonblank environment token must suppress host gh discovery"); - assert!( - String::from_utf8_lossy(&environment_token.stdout).contains("RUN_ARGS=-e|GITHUB_TOKEN"), - "the authoritative environment token must still be forwarded\n{}", - String::from_utf8_lossy(&environment_token.stdout) - ); - - let (whitespace_token, calls) = run_container_github_credential_probe( - root, - &["cargo", "aprz", "deps", "--github-token-from-gh"], - &[("GITHUB_TOKEN", OsStr::new(" \t "))], - ); - assert_probe_success(&whitespace_token, "blank environment-token probe failed"); - assert_gh_call(&calls, "github.com"); - - let (not_opted_in, calls) = run_container_github_credential_probe(root, &["echo", "--github-token-from-gh-extra"], &[]); - assert_probe_success(¬_opted_in, "non-opt-in probe failed"); - assert!(calls.is_empty(), "only an exact opt-in argument may invoke host gh"); -} - -#[test] -fn container_github_cli_output_failures_continue_anonymously_without_leaking_output() { - if !tools_available() { - return; - } - let tmp = fixture(&[("container.just", CONTAINER)], &[]); - let root = tmp.path(); - install_fake_gh(root); - - for mode in ["nonzero", "blank", "invalid"] { - let (output, calls) = run_container_github_credential_probe( - root, - &["cargo", "aprz", "deps", "--github-token-from-gh"], - &[("FAKE_GH_MODE", OsStr::new(mode))], - ); - assert_probe_success(&output, &format!("{mode} gh output must continue anonymously")); - assert_gh_call(&calls, "github.com"); - let diagnostics = format!( - "{}{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - assert!( - diagnostics.contains("TOKEN="), - "the probe must report its final state:\n{diagnostics}" - ); - assert!( - !diagnostics.contains("TOKEN=discovered-token"), - "{mode} output became a credential:\n{diagnostics}" - ); - assert!(!diagnostics.contains("nonzero-output-secret"), "gh stdout leaked:\n{diagnostics}"); - assert!(!diagnostics.contains("nonzero-stderr-secret"), "gh stderr leaked:\n{diagnostics}"); - } -} - -#[test] -fn container_github_cli_timeout_terminates_the_process_tree() { - if !tools_available() { - return; - } - let tmp = fixture(&[("container.just", CONTAINER)], &[]); - let root = tmp.path(); - install_fake_gh(root); - let sentinel = root.join("descendant-survived"); - let started = Instant::now(); - - let (output, calls) = run_container_github_credential_probe_with_timeout( - root, - &["cargo", "aprz", "deps", "--github-token-from-gh"], - &[("FAKE_GH_MODE", OsStr::new("timeout")), ("FAKE_GH_SENTINEL", sentinel.as_os_str())], - Some(5000), - ); - - assert_probe_success(&output, "a timed-out gh lookup must continue anonymously"); - assert!( - started.elapsed() < Duration::from_secs(15), - "the fake gh process was awaited instead of terminated" - ); - assert_gh_call(&calls, "github.com"); - std::thread::sleep(Duration::from_millis(8500)); - assert!(!sentinel.exists(), "the timed-out gh descendant survived process-tree termination"); -} - -#[test] -fn container_github_cli_resolution_ignores_command_shims_and_implicit_cwd() { - if !tools_available() { - return; - } - let tmp = fixture(&[("container.just", CONTAINER)], &[]); - let root = tmp.path(); - let executable = install_fake_gh(root); - let cwd_executable = root.join(if cfg!(windows) { "gh.exe" } else { "gh" }); - fs::copy(&executable, &cwd_executable).expect("copy fake gh into the current directory"); - let empty_path = if cfg!(windows) { OsStr::new(";") } else { OsStr::new(":") }; - - let (implicit_cwd, calls) = - run_container_github_credential_probe(root, &["cargo", "aprz", "deps", "--github-token-from-gh"], &[("PATH", empty_path)]); - assert_probe_success(&implicit_cwd, "implicit-current-directory lookup must continue anonymously"); - assert!( - calls.is_empty(), - "empty PATH entries must not select a planted current-directory executable" - ); - - if cfg!(windows) { - let com = root.join("fake-bin/gh.com"); - fs::copy(&executable, &com).expect("copy fake gh as a COM image"); - let (ordered, calls) = run_container_github_credential_probe( - root, - &["cargo", "aprz", "deps", "--github-token-from-gh"], - &[ - ("PATH", root.join("fake-bin").as_os_str()), - ("PATHEXT", OsStr::new(".COM;.CMD;.EXE;.PS1")), - ], - ); - assert_probe_success(&ordered, "PATHEXT-ordered lookup failed"); - assert!( - calls.to_ascii_lowercase().contains("executable=gh.com"), - "COM must win in PATHEXT order:\n{calls}" - ); - - fs::remove_file(executable).expect("remove direct EXE image"); - fs::remove_file(com).expect("remove direct COM image"); - write(&root.join("fake-bin/gh.cmd"), "@echo off\r\nexit /b 99\r\n"); - write(&root.join("fake-bin/gh.ps1"), "throw 'shim invoked'\n"); - } else { - fs::remove_file(executable).expect("remove executable image"); - write(&root.join("fake-bin/gh"), "#!/bin/sh\nexit 99\n"); - } - - let (shims, calls) = run_container_github_credential_probe( - root, - &["cargo", "aprz", "deps", "--github-token-from-gh"], - &[("PATH", root.join("fake-bin").as_os_str()), ("PATHEXT", OsStr::new(".CMD;.PS1"))], - ); - assert_probe_success(&shims, "rejected command shims must continue anonymously"); - assert!( - calls.is_empty(), - "a function, batch/PowerShell shim, or non-executable file was invoked" - ); -} - /// `anvil-mutants-diff` diffs the base against the WORKING TREE, not against /// HEAD. cargo-mutants validates every diff line against the file on disk and /// aborts when they disagree, so a commit-to-commit diff fails as soon as @@ -3131,15 +2776,14 @@ fn unscoped_wrapper_exports_impact_off_before_dependencies_run() { /// launches its tier that way, so a plan of the public tier name reveals the /// wrapper alone. /// -/// The container driver decides whether to query the GitHub CLI by finding an -/// exact `--github-token-from-gh` in the expanded plan, so it has to follow -/// each nested target rather than reading one plan. If this test ever fails -/// because a plan now reaches through the child process, that expansion can be -/// deleted. +/// The container driver decides whether to mint a GitHub token by matching the +/// plan for `GITHUB_TOKEN`, so this is why it has to follow each nested target +/// rather than reading one plan. If this test ever fails because a plan now +/// reaches through the child process, that expansion can be deleted. #[test] -fn a_wrapped_tier_hides_its_github_cli_opt_in_from_a_plan() { +fn a_wrapped_tier_hides_its_checks_from_a_plan() { const PROBE: &str = "[private]\n[script(\"pwsh\", \"-NoProfile\")]\n_anvil-probe:\n \ - & cargo aprz deps --github-token-from-gh\n\n\ + if (-not $env:GITHUB_TOKEN) { exit 1 }\n\n\ probe: (_anvil-unscoped \"probe\")\n"; if !tools_available() { @@ -3155,7 +2799,7 @@ fn a_wrapped_tier_hides_its_github_cli_opt_in_from_a_plan() { String::from_utf8_lossy(&wrapped.stderr) ); assert!( - !wrapped_plan.contains("--github-token-from-gh"), + !wrapped_plan.contains("GITHUB_TOKEN"), "a wrapped tier's plan must not reach the recipe it launches, or the driver's expansion is dead code\n{wrapped_plan}" ); assert!( @@ -3170,8 +2814,8 @@ fn a_wrapped_tier_hides_its_github_cli_opt_in_from_a_plan() { String::from_utf8_lossy(&direct.stderr) ); assert!( - direct_plan.contains("--github-token-from-gh"), - "planning the launched recipe directly must reveal the opt-in switch, or this test proves nothing\n{direct_plan}" + direct_plan.contains("GITHUB_TOKEN"), + "planning the launched recipe directly must reveal the variable, or this test proves nothing\n{direct_plan}" ); } diff --git a/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap b/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap index 5ff48f778..efe8997e1 100644 --- a/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap +++ b/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap @@ -1857,10 +1857,12 @@ unknown-git = "deny" # capped at 60 requests an hour, and on a full workspace it exhausts that # and then waits for the quota to reset rather than failing; an # authenticated token raises the cap to 5000/hour. CI injects GITHUB_TOKEN -# (github.token). Local runs use an exported GITHUB_TOKEN when available and -# otherwise remain anonymous. Developers can opt into host-aware GitHub CLI -# discovery by invoking cargo-aprz themselves with --github-token-from-gh; -# this generated check deliberately does not access their gh credentials. +# (github.token). For local runs, if GITHUB_TOKEN is unset we borrow the +# gh CLI's stored token (non-interactive: `gh auth token` prints the +# active account's token for github.com and never opens a browser/auth +# prompt). If neither is available we warn with instructions and proceed +# unauthenticated. In a container the driver resolves the token the same +# way and forwards it by name, because the image has no gh CLI of its own. # # Unscoped (consults external risk DB). @@ -1868,6 +1870,19 @@ unknown-git = "deny" [script("pwsh", "-NoProfile")] anvil-aprz: anvil-aprz-validate-prereqs $ErrorActionPreference = 'Stop' + if (-not $env:GITHUB_TOKEN) { + $tok = $null + if (Get-Command gh -ErrorAction SilentlyContinue) { + try { $tok = (gh auth token --hostname github.com 2>$null) } catch { $tok = $null } + } + if ($tok) { + $env:GITHUB_TOKEN = $tok.Trim() + } else { + Write-Warning 'anvil-aprz: GITHUB_TOKEN is not set and no token could be obtained from the gh CLI.' + Write-Warning 'cargo-aprz will use the unauthenticated GitHub API, which allows 60 requests an hour. On a full workspace it exhausts that and then blocks, for up to an hour, waiting for the quota to reset.' + Write-Warning 'To fix: run `gh auth login` (recommended), or set $env:GITHUB_TOKEN to a GitHub token, then re-run.' + } + } & cargo {{_anvil_stable_toolchain_args}} aprz deps --error-if-high-risk --console appraisal if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } @@ -5226,139 +5241,52 @@ anvil-container *command: $forwardedEnv = @() $hookEnv = @() + # anvil-aprz queries the GitHub advisory API, which allows 60 requests an + # hour unauthenticated -- less than a full tier needs. Unauthenticated is + # not a degraded-but-working mode: `cargo aprz deps` sleeps until the quota + # resets rather than failing, so a containerized tier blocks for up to an + # hour with no way to opt out. Authentication is what makes the check + # terminate, not what makes it fast. + # + # Resolve the token exactly as the recipe does natively -- GITHUB_TOKEN + # first, then the gh CLI's stored token -- so a containerized run + # authenticates for the same developers a native run does. + # # An already-exported GITHUB_TOKEN is forwarded whatever the command is: # that is exact parity, since a native run exposes it to every process the - # shell spawns too. Deriving one from `gh` is different: it manufactures a - # credential the developer did not export, and PID 1 exposes it to every - # build script and proc macro in the container. It therefore requires the - # exact --github-token-from-gh switch in a direct command's argv or in an - # expanded `just --dry-run` plan. Interactive execution never opts in. + # shell spawns too. Deriving one from `gh` is different -- it manufactures a + # credential the developer did not put in this environment, and PID 1's + # environment is inherited by every build script and proc macro in the + # container, where natively the recipe would mint it in its own process. So + # it is derived only when the command is known to read the variable, or when + # there is no command at all: an interactive session can run anything, and + # refusing there would reintroduce the silent hour-long stall on a tier the + # developer runs from inside the shell. + # `gh auth token` is non-interactive and never opens a prompt. # - # Explicit --github-token and a nonblank GITHUB_TOKEN remain authoritative - # even when the opt-in switch is also present, so neither path invokes gh. - function Resolve-AnvilGhExecutable { - $path = [Environment]::GetEnvironmentVariable('PATH') - if ([string]::IsNullOrEmpty($path)) { return $null } - - if ($IsWindows) { - $pathExt = [Environment]::GetEnvironmentVariable('PATHEXT') - if ([string]::IsNullOrEmpty($pathExt)) { - $pathExt = '.COM;.EXE;.BAT;.CMD' - } - $names = @( - foreach ($extension in ($pathExt -split ';')) { - if ([string]::IsNullOrEmpty($extension)) { continue } - if (-not $extension.StartsWith('.')) { $extension = ".$extension" } - if ($extension -ieq '.COM' -or $extension -ieq '.EXE') { - "gh$extension" - } - } - ) - } else { - $names = @('gh') - } - - foreach ($entry in ($path -split [IO.Path]::PathSeparator)) { - # Empty PATH entries implicitly mean the current directory to - # command lookup. Require an explicit entry such as `.` instead. - if ([string]::IsNullOrEmpty($entry)) { continue } - try { - $directory = if ([IO.Path]::IsPathRooted($entry)) { - [IO.Path]::GetFullPath($entry) - } else { - [IO.Path]::GetFullPath((Join-Path (Get-Location).Path $entry)) - } - } catch { - continue - } - - foreach ($name in $names) { - $candidate = Join-Path $directory $name - if (-not (Test-Path -LiteralPath $candidate -PathType Leaf)) { continue } - # Process.Start below is the portable executable check. A Unix - # file without execute permission fails there and falls through - # anonymously without requiring an external `test` utility. - return [IO.Path]::GetFullPath($candidate) - } - } - $null - } - - function Invoke-AnvilGhToken( - [Parameter(Mandatory)][string]$Executable, - [Parameter(Mandatory)][string]$Hostname, - [int]$TimeoutMilliseconds = 10000 - ) { - $start = [Diagnostics.ProcessStartInfo]::new() - $start.FileName = $Executable - $start.UseShellExecute = $false - $start.CreateNoWindow = $true - $start.RedirectStandardInput = $true - $start.RedirectStandardOutput = $true - $start.RedirectStandardError = $true - $start.StandardOutputEncoding = [Text.UTF8Encoding]::new($false, $true) - foreach ($argument in @('auth', 'token', '--hostname', $Hostname)) { - $null = $start.ArgumentList.Add($argument) - } - # gh treats even a whitespace-only inherited GITHUB_TOKEN as - # authoritative. Remove the value cargo-aprz already rejected so the - # authenticated account lookup can actually proceed. - $null = $start.Environment.Remove('GITHUB_TOKEN') - - $process = [Diagnostics.Process]::new() - $process.StartInfo = $start - $started = $false - try { - $started = $process.Start() - if (-not $started) { return $null } - $process.StandardInput.Close() - $stdout = $process.StandardOutput.ReadToEndAsync() - $stderr = $process.StandardError.ReadToEndAsync() - if (-not $process.WaitForExit($TimeoutMilliseconds)) { - $process.Kill($true) - $process.WaitForExit() - try { $null = $stdout.GetAwaiter().GetResult() } catch {} - try { $null = $stderr.GetAwaiter().GetResult() } catch {} - return $null - } - try { - $token = $stdout.GetAwaiter().GetResult() - $null = $stderr.GetAwaiter().GetResult() - } catch { - return $null - } - if ($process.ExitCode -ne 0 -or [string]::IsNullOrWhiteSpace($token)) { - return $null - } - $token.Trim() - } catch { - $null - } finally { - if ($started -and -not $process.HasExited) { - try { - $process.Kill($true) - $process.WaitForExit() - } catch {} - } - $process.Dispose() - } - } - - $hasEnvironmentGitHubToken = -not [string]::IsNullOrWhiteSpace($env:GITHUB_TOKEN) - if (-not $hasEnvironmentGitHubToken) { - $githubTokenFromGh = $false - $hasExplicitGitHubToken = $false - $githubUrl = $null - if ($argv.Count -gt 0 -and $argv[0] -eq 'just') { + # The predicate is the variable itself rather than the name of a check, so + # the driver stays generic: a catalog that adds another GitHub-authenticated + # check is covered without touching this recipe. + # + # Set here and passed by NAME, so the value never reaches the host's + # process command line, and unset again with the hook's variables below. + if (-not $env:GITHUB_TOKEN -and (Get-Command gh -ErrorAction SilentlyContinue)) { + $needsToken = $argv.Count -eq 0 + # Only a `just` command can be planned, and planning is the only way to + # know whether what runs reads the variable. Anything else keeps the + # environment it was given: a manufactured credential reaches every + # process in the container, so an unknown command does not earn one. + if (-not $needsToken -and $argv[0] -eq 'just') { # A dry run has no side effects, and a target that cannot be planned - # yields no consent, so the run fails on its own terms. + # (a typo, a recipe needing arguments) yields nothing, so the run + # fails on its own terms rather than on a missing token. # # A plan covers the bodies just runs itself, not the body of a # recipe that one of them launches as a child process. The unscoped # tier wrapper launches its tier that way, so planning # `anvil-scheduled` shows the wrapper and none of the checks # underneath it. Follow each nested target a plan names, or a - # wrapped tier can hide an explicit opt-in in one of its checks. + # wrapped tier reads as needing nothing and runs unauthenticated. $plan = '' $targets = [System.Collections.Generic.List[object]]::new() $targets.Add([string[]]@($argv | Select-Object -Skip 1)) @@ -5371,7 +5299,10 @@ anvil-container *command: # The same executable that launched this tree, for the reason # every other nested call uses it: a caller invoking `just` by # absolute path with its directory off PATH would otherwise fail - # here. An empty plan means no GitHub CLI consent. + # here. That failure is silent, because an empty plan reads as + # "does not need a token" -- so anvil-aprz would run + # unauthenticated in an image with no gh of its own and block on + # the rate limit for up to an hour. $step = '' try { $step = (& '{{ replace(just_executable(), "'", "''") }}' --dry-run @target 2>&1 | @@ -5385,96 +5316,21 @@ anvil-container *command: $targets.Add([string[]]@($nested.Groups[1].Value)) } } - # Bind consent, explicit-token precedence, and endpoint selection - # to the same planned command. Distinct opted-in commands are - # ambiguous because the container can forward only one token, so - # fail closed and let each inner command run anonymously. - $optedInCommands = @( - $plan -split '\r?\n' | - Where-Object { - [regex]::IsMatch($_, '(^|[\s''"])--github-token-from-gh($|[\s''"])') - } | - Select-Object -Unique - ) - if ($optedInCommands.Count -eq 1) { - $optedInCommand = $optedInCommands[0] - $githubTokenFromGh = $true - $hasExplicitGitHubToken = [regex]::IsMatch( - $optedInCommand, '(^|[\s''"])--github-token(?:=|$|[\s''"])' - ) - $githubUrlMatch = [regex]::Match( - $optedInCommand, - '(?:^|[\s''"])--github-url(?:=|\s+)(?:''([^'']*)''|"([^"]*)"|([^\s''"]+))' - ) - if ($githubUrlMatch.Success) { - foreach ($group in 1..3) { - if ($githubUrlMatch.Groups[$group].Success) { - $githubUrl = $githubUrlMatch.Groups[$group].Value - break - } - } - } - } - } elseif ($argv.Count -gt 0) { - $githubTokenFromGh = $argv -contains '--github-token-from-gh' - $hasExplicitGitHubToken = @( - $argv | Where-Object { $_ -eq '--github-token' -or $_.StartsWith('--github-token=') } - ).Count -gt 0 - if ($githubTokenFromGh) { - for ($i = 0; $i -lt $argv.Count; $i++) { - if ($argv[$i] -eq '--github-url' -and $i + 1 -lt $argv.Count) { - $githubUrl = $argv[$i + 1] - break - } - if ($argv[$i].StartsWith('--github-url=')) { - $githubUrl = $argv[$i].Substring('--github-url='.Length) - break - } - } - } - } - - if ($githubTokenFromGh -and -not $hasExplicitGitHubToken) { - if ([string]::IsNullOrWhiteSpace($githubUrl) -and - -not [string]::IsNullOrWhiteSpace($env:APRZ_GITHUB_URL)) { - $githubUrl = $env:APRZ_GITHUB_URL - } - - $githubHostname = 'github.com' - if (-not [string]::IsNullOrWhiteSpace($githubUrl)) { - $githubUri = $null - try { $githubUri = [Uri]::new($githubUrl, [UriKind]::Absolute) } catch {} - if ($null -eq $githubUri -or [string]::IsNullOrWhiteSpace($githubUri.Host)) { - $githubHostname = $null - } elseif ($githubUri.Host -eq 'api.github.com') { - $githubHostname = 'github.com' - } else { - $githubHostname = $githubUri.Host - } - } - - if ($githubHostname) { - $ghExecutable = Resolve-AnvilGhExecutable - if ($ghExecutable) { - $ghToken = Invoke-AnvilGhToken $ghExecutable $githubHostname - } else { - $ghToken = $null - } - if ($ghToken) { - Set-Item -LiteralPath 'Env:GITHUB_TOKEN' -Value $ghToken - $hookEnv += 'GITHUB_TOKEN' - } + $needsToken = $plan -match 'GITHUB_TOKEN' + } + if ($needsToken) { + $ghToken = $null + try { $ghToken = (gh auth token --hostname github.com 2>$null) } catch { $ghToken = $null } + if ($ghToken -and $ghToken.Trim()) { + Set-Item -LiteralPath 'Env:GITHUB_TOKEN' -Value $ghToken.Trim() + $hookEnv += 'GITHUB_TOKEN' } } } - if (-not [string]::IsNullOrWhiteSpace($env:GITHUB_TOKEN)) { + if ($env:GITHUB_TOKEN) { $forwardedEnv += 'GITHUB_TOKEN' $runArgs += @('-e', 'GITHUB_TOKEN') } - if (-not [string]::IsNullOrWhiteSpace($env:APRZ_GITHUB_URL)) { - $forwardedEnv += 'APRZ_GITHUB_URL' - $runArgs += @('-e', 'APRZ_GITHUB_URL') - } # The recipe contract's own inputs. These are read by generated checks -- # `anvil-pr-title` reads PR_TITLE, `_anvil-base-ref` reads BASE_REF and its diff --git a/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap b/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap index d2a6d9d29..3ead3b839 100644 --- a/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap +++ b/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap @@ -1970,10 +1970,12 @@ unknown-git = "deny" # capped at 60 requests an hour, and on a full workspace it exhausts that # and then waits for the quota to reset rather than failing; an # authenticated token raises the cap to 5000/hour. CI injects GITHUB_TOKEN -# (github.token). Local runs use an exported GITHUB_TOKEN when available and -# otherwise remain anonymous. Developers can opt into host-aware GitHub CLI -# discovery by invoking cargo-aprz themselves with --github-token-from-gh; -# this generated check deliberately does not access their gh credentials. +# (github.token). For local runs, if GITHUB_TOKEN is unset we borrow the +# gh CLI's stored token (non-interactive: `gh auth token` prints the +# active account's token for github.com and never opens a browser/auth +# prompt). If neither is available we warn with instructions and proceed +# unauthenticated. In a container the driver resolves the token the same +# way and forwards it by name, because the image has no gh CLI of its own. # # Unscoped (consults external risk DB). @@ -1981,6 +1983,19 @@ unknown-git = "deny" [script("pwsh", "-NoProfile")] anvil-aprz: anvil-aprz-validate-prereqs $ErrorActionPreference = 'Stop' + if (-not $env:GITHUB_TOKEN) { + $tok = $null + if (Get-Command gh -ErrorAction SilentlyContinue) { + try { $tok = (gh auth token --hostname github.com 2>$null) } catch { $tok = $null } + } + if ($tok) { + $env:GITHUB_TOKEN = $tok.Trim() + } else { + Write-Warning 'anvil-aprz: GITHUB_TOKEN is not set and no token could be obtained from the gh CLI.' + Write-Warning 'cargo-aprz will use the unauthenticated GitHub API, which allows 60 requests an hour. On a full workspace it exhausts that and then blocks, for up to an hour, waiting for the quota to reset.' + Write-Warning 'To fix: run `gh auth login` (recommended), or set $env:GITHUB_TOKEN to a GitHub token, then re-run.' + } + } & cargo {{_anvil_stable_toolchain_args}} aprz deps --error-if-high-risk --console appraisal if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } @@ -5339,139 +5354,52 @@ anvil-container *command: $forwardedEnv = @() $hookEnv = @() + # anvil-aprz queries the GitHub advisory API, which allows 60 requests an + # hour unauthenticated -- less than a full tier needs. Unauthenticated is + # not a degraded-but-working mode: `cargo aprz deps` sleeps until the quota + # resets rather than failing, so a containerized tier blocks for up to an + # hour with no way to opt out. Authentication is what makes the check + # terminate, not what makes it fast. + # + # Resolve the token exactly as the recipe does natively -- GITHUB_TOKEN + # first, then the gh CLI's stored token -- so a containerized run + # authenticates for the same developers a native run does. + # # An already-exported GITHUB_TOKEN is forwarded whatever the command is: # that is exact parity, since a native run exposes it to every process the - # shell spawns too. Deriving one from `gh` is different: it manufactures a - # credential the developer did not export, and PID 1 exposes it to every - # build script and proc macro in the container. It therefore requires the - # exact --github-token-from-gh switch in a direct command's argv or in an - # expanded `just --dry-run` plan. Interactive execution never opts in. + # shell spawns too. Deriving one from `gh` is different -- it manufactures a + # credential the developer did not put in this environment, and PID 1's + # environment is inherited by every build script and proc macro in the + # container, where natively the recipe would mint it in its own process. So + # it is derived only when the command is known to read the variable, or when + # there is no command at all: an interactive session can run anything, and + # refusing there would reintroduce the silent hour-long stall on a tier the + # developer runs from inside the shell. + # `gh auth token` is non-interactive and never opens a prompt. # - # Explicit --github-token and a nonblank GITHUB_TOKEN remain authoritative - # even when the opt-in switch is also present, so neither path invokes gh. - function Resolve-AnvilGhExecutable { - $path = [Environment]::GetEnvironmentVariable('PATH') - if ([string]::IsNullOrEmpty($path)) { return $null } - - if ($IsWindows) { - $pathExt = [Environment]::GetEnvironmentVariable('PATHEXT') - if ([string]::IsNullOrEmpty($pathExt)) { - $pathExt = '.COM;.EXE;.BAT;.CMD' - } - $names = @( - foreach ($extension in ($pathExt -split ';')) { - if ([string]::IsNullOrEmpty($extension)) { continue } - if (-not $extension.StartsWith('.')) { $extension = ".$extension" } - if ($extension -ieq '.COM' -or $extension -ieq '.EXE') { - "gh$extension" - } - } - ) - } else { - $names = @('gh') - } - - foreach ($entry in ($path -split [IO.Path]::PathSeparator)) { - # Empty PATH entries implicitly mean the current directory to - # command lookup. Require an explicit entry such as `.` instead. - if ([string]::IsNullOrEmpty($entry)) { continue } - try { - $directory = if ([IO.Path]::IsPathRooted($entry)) { - [IO.Path]::GetFullPath($entry) - } else { - [IO.Path]::GetFullPath((Join-Path (Get-Location).Path $entry)) - } - } catch { - continue - } - - foreach ($name in $names) { - $candidate = Join-Path $directory $name - if (-not (Test-Path -LiteralPath $candidate -PathType Leaf)) { continue } - # Process.Start below is the portable executable check. A Unix - # file without execute permission fails there and falls through - # anonymously without requiring an external `test` utility. - return [IO.Path]::GetFullPath($candidate) - } - } - $null - } - - function Invoke-AnvilGhToken( - [Parameter(Mandatory)][string]$Executable, - [Parameter(Mandatory)][string]$Hostname, - [int]$TimeoutMilliseconds = 10000 - ) { - $start = [Diagnostics.ProcessStartInfo]::new() - $start.FileName = $Executable - $start.UseShellExecute = $false - $start.CreateNoWindow = $true - $start.RedirectStandardInput = $true - $start.RedirectStandardOutput = $true - $start.RedirectStandardError = $true - $start.StandardOutputEncoding = [Text.UTF8Encoding]::new($false, $true) - foreach ($argument in @('auth', 'token', '--hostname', $Hostname)) { - $null = $start.ArgumentList.Add($argument) - } - # gh treats even a whitespace-only inherited GITHUB_TOKEN as - # authoritative. Remove the value cargo-aprz already rejected so the - # authenticated account lookup can actually proceed. - $null = $start.Environment.Remove('GITHUB_TOKEN') - - $process = [Diagnostics.Process]::new() - $process.StartInfo = $start - $started = $false - try { - $started = $process.Start() - if (-not $started) { return $null } - $process.StandardInput.Close() - $stdout = $process.StandardOutput.ReadToEndAsync() - $stderr = $process.StandardError.ReadToEndAsync() - if (-not $process.WaitForExit($TimeoutMilliseconds)) { - $process.Kill($true) - $process.WaitForExit() - try { $null = $stdout.GetAwaiter().GetResult() } catch {} - try { $null = $stderr.GetAwaiter().GetResult() } catch {} - return $null - } - try { - $token = $stdout.GetAwaiter().GetResult() - $null = $stderr.GetAwaiter().GetResult() - } catch { - return $null - } - if ($process.ExitCode -ne 0 -or [string]::IsNullOrWhiteSpace($token)) { - return $null - } - $token.Trim() - } catch { - $null - } finally { - if ($started -and -not $process.HasExited) { - try { - $process.Kill($true) - $process.WaitForExit() - } catch {} - } - $process.Dispose() - } - } - - $hasEnvironmentGitHubToken = -not [string]::IsNullOrWhiteSpace($env:GITHUB_TOKEN) - if (-not $hasEnvironmentGitHubToken) { - $githubTokenFromGh = $false - $hasExplicitGitHubToken = $false - $githubUrl = $null - if ($argv.Count -gt 0 -and $argv[0] -eq 'just') { + # The predicate is the variable itself rather than the name of a check, so + # the driver stays generic: a catalog that adds another GitHub-authenticated + # check is covered without touching this recipe. + # + # Set here and passed by NAME, so the value never reaches the host's + # process command line, and unset again with the hook's variables below. + if (-not $env:GITHUB_TOKEN -and (Get-Command gh -ErrorAction SilentlyContinue)) { + $needsToken = $argv.Count -eq 0 + # Only a `just` command can be planned, and planning is the only way to + # know whether what runs reads the variable. Anything else keeps the + # environment it was given: a manufactured credential reaches every + # process in the container, so an unknown command does not earn one. + if (-not $needsToken -and $argv[0] -eq 'just') { # A dry run has no side effects, and a target that cannot be planned - # yields no consent, so the run fails on its own terms. + # (a typo, a recipe needing arguments) yields nothing, so the run + # fails on its own terms rather than on a missing token. # # A plan covers the bodies just runs itself, not the body of a # recipe that one of them launches as a child process. The unscoped # tier wrapper launches its tier that way, so planning # `anvil-scheduled` shows the wrapper and none of the checks # underneath it. Follow each nested target a plan names, or a - # wrapped tier can hide an explicit opt-in in one of its checks. + # wrapped tier reads as needing nothing and runs unauthenticated. $plan = '' $targets = [System.Collections.Generic.List[object]]::new() $targets.Add([string[]]@($argv | Select-Object -Skip 1)) @@ -5484,7 +5412,10 @@ anvil-container *command: # The same executable that launched this tree, for the reason # every other nested call uses it: a caller invoking `just` by # absolute path with its directory off PATH would otherwise fail - # here. An empty plan means no GitHub CLI consent. + # here. That failure is silent, because an empty plan reads as + # "does not need a token" -- so anvil-aprz would run + # unauthenticated in an image with no gh of its own and block on + # the rate limit for up to an hour. $step = '' try { $step = (& '{{ replace(just_executable(), "'", "''") }}' --dry-run @target 2>&1 | @@ -5498,96 +5429,21 @@ anvil-container *command: $targets.Add([string[]]@($nested.Groups[1].Value)) } } - # Bind consent, explicit-token precedence, and endpoint selection - # to the same planned command. Distinct opted-in commands are - # ambiguous because the container can forward only one token, so - # fail closed and let each inner command run anonymously. - $optedInCommands = @( - $plan -split '\r?\n' | - Where-Object { - [regex]::IsMatch($_, '(^|[\s''"])--github-token-from-gh($|[\s''"])') - } | - Select-Object -Unique - ) - if ($optedInCommands.Count -eq 1) { - $optedInCommand = $optedInCommands[0] - $githubTokenFromGh = $true - $hasExplicitGitHubToken = [regex]::IsMatch( - $optedInCommand, '(^|[\s''"])--github-token(?:=|$|[\s''"])' - ) - $githubUrlMatch = [regex]::Match( - $optedInCommand, - '(?:^|[\s''"])--github-url(?:=|\s+)(?:''([^'']*)''|"([^"]*)"|([^\s''"]+))' - ) - if ($githubUrlMatch.Success) { - foreach ($group in 1..3) { - if ($githubUrlMatch.Groups[$group].Success) { - $githubUrl = $githubUrlMatch.Groups[$group].Value - break - } - } - } - } - } elseif ($argv.Count -gt 0) { - $githubTokenFromGh = $argv -contains '--github-token-from-gh' - $hasExplicitGitHubToken = @( - $argv | Where-Object { $_ -eq '--github-token' -or $_.StartsWith('--github-token=') } - ).Count -gt 0 - if ($githubTokenFromGh) { - for ($i = 0; $i -lt $argv.Count; $i++) { - if ($argv[$i] -eq '--github-url' -and $i + 1 -lt $argv.Count) { - $githubUrl = $argv[$i + 1] - break - } - if ($argv[$i].StartsWith('--github-url=')) { - $githubUrl = $argv[$i].Substring('--github-url='.Length) - break - } - } - } - } - - if ($githubTokenFromGh -and -not $hasExplicitGitHubToken) { - if ([string]::IsNullOrWhiteSpace($githubUrl) -and - -not [string]::IsNullOrWhiteSpace($env:APRZ_GITHUB_URL)) { - $githubUrl = $env:APRZ_GITHUB_URL - } - - $githubHostname = 'github.com' - if (-not [string]::IsNullOrWhiteSpace($githubUrl)) { - $githubUri = $null - try { $githubUri = [Uri]::new($githubUrl, [UriKind]::Absolute) } catch {} - if ($null -eq $githubUri -or [string]::IsNullOrWhiteSpace($githubUri.Host)) { - $githubHostname = $null - } elseif ($githubUri.Host -eq 'api.github.com') { - $githubHostname = 'github.com' - } else { - $githubHostname = $githubUri.Host - } - } - - if ($githubHostname) { - $ghExecutable = Resolve-AnvilGhExecutable - if ($ghExecutable) { - $ghToken = Invoke-AnvilGhToken $ghExecutable $githubHostname - } else { - $ghToken = $null - } - if ($ghToken) { - Set-Item -LiteralPath 'Env:GITHUB_TOKEN' -Value $ghToken - $hookEnv += 'GITHUB_TOKEN' - } + $needsToken = $plan -match 'GITHUB_TOKEN' + } + if ($needsToken) { + $ghToken = $null + try { $ghToken = (gh auth token --hostname github.com 2>$null) } catch { $ghToken = $null } + if ($ghToken -and $ghToken.Trim()) { + Set-Item -LiteralPath 'Env:GITHUB_TOKEN' -Value $ghToken.Trim() + $hookEnv += 'GITHUB_TOKEN' } } } - if (-not [string]::IsNullOrWhiteSpace($env:GITHUB_TOKEN)) { + if ($env:GITHUB_TOKEN) { $forwardedEnv += 'GITHUB_TOKEN' $runArgs += @('-e', 'GITHUB_TOKEN') } - if (-not [string]::IsNullOrWhiteSpace($env:APRZ_GITHUB_URL)) { - $forwardedEnv += 'APRZ_GITHUB_URL' - $runArgs += @('-e', 'APRZ_GITHUB_URL') - } # The recipe contract's own inputs. These are read by generated checks -- # `anvil-pr-title` reads PR_TITLE, `_anvil-base-ref` reads BASE_REF and its diff --git a/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap b/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap index 4c6e14481..49d2402ce 100644 --- a/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap +++ b/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap @@ -654,10 +654,12 @@ unknown-git = "deny" # capped at 60 requests an hour, and on a full workspace it exhausts that # and then waits for the quota to reset rather than failing; an # authenticated token raises the cap to 5000/hour. CI injects GITHUB_TOKEN -# (github.token). Local runs use an exported GITHUB_TOKEN when available and -# otherwise remain anonymous. Developers can opt into host-aware GitHub CLI -# discovery by invoking cargo-aprz themselves with --github-token-from-gh; -# this generated check deliberately does not access their gh credentials. +# (github.token). For local runs, if GITHUB_TOKEN is unset we borrow the +# gh CLI's stored token (non-interactive: `gh auth token` prints the +# active account's token for github.com and never opens a browser/auth +# prompt). If neither is available we warn with instructions and proceed +# unauthenticated. In a container the driver resolves the token the same +# way and forwards it by name, because the image has no gh CLI of its own. # # Unscoped (consults external risk DB). @@ -665,6 +667,19 @@ unknown-git = "deny" [script("pwsh", "-NoProfile")] anvil-aprz: anvil-aprz-validate-prereqs $ErrorActionPreference = 'Stop' + if (-not $env:GITHUB_TOKEN) { + $tok = $null + if (Get-Command gh -ErrorAction SilentlyContinue) { + try { $tok = (gh auth token --hostname github.com 2>$null) } catch { $tok = $null } + } + if ($tok) { + $env:GITHUB_TOKEN = $tok.Trim() + } else { + Write-Warning 'anvil-aprz: GITHUB_TOKEN is not set and no token could be obtained from the gh CLI.' + Write-Warning 'cargo-aprz will use the unauthenticated GitHub API, which allows 60 requests an hour. On a full workspace it exhausts that and then blocks, for up to an hour, waiting for the quota to reset.' + Write-Warning 'To fix: run `gh auth login` (recommended), or set $env:GITHUB_TOKEN to a GitHub token, then re-run.' + } + } & cargo {{_anvil_stable_toolchain_args}} aprz deps --error-if-high-risk --console appraisal if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } @@ -4023,139 +4038,52 @@ anvil-container *command: $forwardedEnv = @() $hookEnv = @() + # anvil-aprz queries the GitHub advisory API, which allows 60 requests an + # hour unauthenticated -- less than a full tier needs. Unauthenticated is + # not a degraded-but-working mode: `cargo aprz deps` sleeps until the quota + # resets rather than failing, so a containerized tier blocks for up to an + # hour with no way to opt out. Authentication is what makes the check + # terminate, not what makes it fast. + # + # Resolve the token exactly as the recipe does natively -- GITHUB_TOKEN + # first, then the gh CLI's stored token -- so a containerized run + # authenticates for the same developers a native run does. + # # An already-exported GITHUB_TOKEN is forwarded whatever the command is: # that is exact parity, since a native run exposes it to every process the - # shell spawns too. Deriving one from `gh` is different: it manufactures a - # credential the developer did not export, and PID 1 exposes it to every - # build script and proc macro in the container. It therefore requires the - # exact --github-token-from-gh switch in a direct command's argv or in an - # expanded `just --dry-run` plan. Interactive execution never opts in. + # shell spawns too. Deriving one from `gh` is different -- it manufactures a + # credential the developer did not put in this environment, and PID 1's + # environment is inherited by every build script and proc macro in the + # container, where natively the recipe would mint it in its own process. So + # it is derived only when the command is known to read the variable, or when + # there is no command at all: an interactive session can run anything, and + # refusing there would reintroduce the silent hour-long stall on a tier the + # developer runs from inside the shell. + # `gh auth token` is non-interactive and never opens a prompt. # - # Explicit --github-token and a nonblank GITHUB_TOKEN remain authoritative - # even when the opt-in switch is also present, so neither path invokes gh. - function Resolve-AnvilGhExecutable { - $path = [Environment]::GetEnvironmentVariable('PATH') - if ([string]::IsNullOrEmpty($path)) { return $null } - - if ($IsWindows) { - $pathExt = [Environment]::GetEnvironmentVariable('PATHEXT') - if ([string]::IsNullOrEmpty($pathExt)) { - $pathExt = '.COM;.EXE;.BAT;.CMD' - } - $names = @( - foreach ($extension in ($pathExt -split ';')) { - if ([string]::IsNullOrEmpty($extension)) { continue } - if (-not $extension.StartsWith('.')) { $extension = ".$extension" } - if ($extension -ieq '.COM' -or $extension -ieq '.EXE') { - "gh$extension" - } - } - ) - } else { - $names = @('gh') - } - - foreach ($entry in ($path -split [IO.Path]::PathSeparator)) { - # Empty PATH entries implicitly mean the current directory to - # command lookup. Require an explicit entry such as `.` instead. - if ([string]::IsNullOrEmpty($entry)) { continue } - try { - $directory = if ([IO.Path]::IsPathRooted($entry)) { - [IO.Path]::GetFullPath($entry) - } else { - [IO.Path]::GetFullPath((Join-Path (Get-Location).Path $entry)) - } - } catch { - continue - } - - foreach ($name in $names) { - $candidate = Join-Path $directory $name - if (-not (Test-Path -LiteralPath $candidate -PathType Leaf)) { continue } - # Process.Start below is the portable executable check. A Unix - # file without execute permission fails there and falls through - # anonymously without requiring an external `test` utility. - return [IO.Path]::GetFullPath($candidate) - } - } - $null - } - - function Invoke-AnvilGhToken( - [Parameter(Mandatory)][string]$Executable, - [Parameter(Mandatory)][string]$Hostname, - [int]$TimeoutMilliseconds = 10000 - ) { - $start = [Diagnostics.ProcessStartInfo]::new() - $start.FileName = $Executable - $start.UseShellExecute = $false - $start.CreateNoWindow = $true - $start.RedirectStandardInput = $true - $start.RedirectStandardOutput = $true - $start.RedirectStandardError = $true - $start.StandardOutputEncoding = [Text.UTF8Encoding]::new($false, $true) - foreach ($argument in @('auth', 'token', '--hostname', $Hostname)) { - $null = $start.ArgumentList.Add($argument) - } - # gh treats even a whitespace-only inherited GITHUB_TOKEN as - # authoritative. Remove the value cargo-aprz already rejected so the - # authenticated account lookup can actually proceed. - $null = $start.Environment.Remove('GITHUB_TOKEN') - - $process = [Diagnostics.Process]::new() - $process.StartInfo = $start - $started = $false - try { - $started = $process.Start() - if (-not $started) { return $null } - $process.StandardInput.Close() - $stdout = $process.StandardOutput.ReadToEndAsync() - $stderr = $process.StandardError.ReadToEndAsync() - if (-not $process.WaitForExit($TimeoutMilliseconds)) { - $process.Kill($true) - $process.WaitForExit() - try { $null = $stdout.GetAwaiter().GetResult() } catch {} - try { $null = $stderr.GetAwaiter().GetResult() } catch {} - return $null - } - try { - $token = $stdout.GetAwaiter().GetResult() - $null = $stderr.GetAwaiter().GetResult() - } catch { - return $null - } - if ($process.ExitCode -ne 0 -or [string]::IsNullOrWhiteSpace($token)) { - return $null - } - $token.Trim() - } catch { - $null - } finally { - if ($started -and -not $process.HasExited) { - try { - $process.Kill($true) - $process.WaitForExit() - } catch {} - } - $process.Dispose() - } - } - - $hasEnvironmentGitHubToken = -not [string]::IsNullOrWhiteSpace($env:GITHUB_TOKEN) - if (-not $hasEnvironmentGitHubToken) { - $githubTokenFromGh = $false - $hasExplicitGitHubToken = $false - $githubUrl = $null - if ($argv.Count -gt 0 -and $argv[0] -eq 'just') { + # The predicate is the variable itself rather than the name of a check, so + # the driver stays generic: a catalog that adds another GitHub-authenticated + # check is covered without touching this recipe. + # + # Set here and passed by NAME, so the value never reaches the host's + # process command line, and unset again with the hook's variables below. + if (-not $env:GITHUB_TOKEN -and (Get-Command gh -ErrorAction SilentlyContinue)) { + $needsToken = $argv.Count -eq 0 + # Only a `just` command can be planned, and planning is the only way to + # know whether what runs reads the variable. Anything else keeps the + # environment it was given: a manufactured credential reaches every + # process in the container, so an unknown command does not earn one. + if (-not $needsToken -and $argv[0] -eq 'just') { # A dry run has no side effects, and a target that cannot be planned - # yields no consent, so the run fails on its own terms. + # (a typo, a recipe needing arguments) yields nothing, so the run + # fails on its own terms rather than on a missing token. # # A plan covers the bodies just runs itself, not the body of a # recipe that one of them launches as a child process. The unscoped # tier wrapper launches its tier that way, so planning # `anvil-scheduled` shows the wrapper and none of the checks # underneath it. Follow each nested target a plan names, or a - # wrapped tier can hide an explicit opt-in in one of its checks. + # wrapped tier reads as needing nothing and runs unauthenticated. $plan = '' $targets = [System.Collections.Generic.List[object]]::new() $targets.Add([string[]]@($argv | Select-Object -Skip 1)) @@ -4168,7 +4096,10 @@ anvil-container *command: # The same executable that launched this tree, for the reason # every other nested call uses it: a caller invoking `just` by # absolute path with its directory off PATH would otherwise fail - # here. An empty plan means no GitHub CLI consent. + # here. That failure is silent, because an empty plan reads as + # "does not need a token" -- so anvil-aprz would run + # unauthenticated in an image with no gh of its own and block on + # the rate limit for up to an hour. $step = '' try { $step = (& '{{ replace(just_executable(), "'", "''") }}' --dry-run @target 2>&1 | @@ -4182,96 +4113,21 @@ anvil-container *command: $targets.Add([string[]]@($nested.Groups[1].Value)) } } - # Bind consent, explicit-token precedence, and endpoint selection - # to the same planned command. Distinct opted-in commands are - # ambiguous because the container can forward only one token, so - # fail closed and let each inner command run anonymously. - $optedInCommands = @( - $plan -split '\r?\n' | - Where-Object { - [regex]::IsMatch($_, '(^|[\s''"])--github-token-from-gh($|[\s''"])') - } | - Select-Object -Unique - ) - if ($optedInCommands.Count -eq 1) { - $optedInCommand = $optedInCommands[0] - $githubTokenFromGh = $true - $hasExplicitGitHubToken = [regex]::IsMatch( - $optedInCommand, '(^|[\s''"])--github-token(?:=|$|[\s''"])' - ) - $githubUrlMatch = [regex]::Match( - $optedInCommand, - '(?:^|[\s''"])--github-url(?:=|\s+)(?:''([^'']*)''|"([^"]*)"|([^\s''"]+))' - ) - if ($githubUrlMatch.Success) { - foreach ($group in 1..3) { - if ($githubUrlMatch.Groups[$group].Success) { - $githubUrl = $githubUrlMatch.Groups[$group].Value - break - } - } - } - } - } elseif ($argv.Count -gt 0) { - $githubTokenFromGh = $argv -contains '--github-token-from-gh' - $hasExplicitGitHubToken = @( - $argv | Where-Object { $_ -eq '--github-token' -or $_.StartsWith('--github-token=') } - ).Count -gt 0 - if ($githubTokenFromGh) { - for ($i = 0; $i -lt $argv.Count; $i++) { - if ($argv[$i] -eq '--github-url' -and $i + 1 -lt $argv.Count) { - $githubUrl = $argv[$i + 1] - break - } - if ($argv[$i].StartsWith('--github-url=')) { - $githubUrl = $argv[$i].Substring('--github-url='.Length) - break - } - } - } - } - - if ($githubTokenFromGh -and -not $hasExplicitGitHubToken) { - if ([string]::IsNullOrWhiteSpace($githubUrl) -and - -not [string]::IsNullOrWhiteSpace($env:APRZ_GITHUB_URL)) { - $githubUrl = $env:APRZ_GITHUB_URL - } - - $githubHostname = 'github.com' - if (-not [string]::IsNullOrWhiteSpace($githubUrl)) { - $githubUri = $null - try { $githubUri = [Uri]::new($githubUrl, [UriKind]::Absolute) } catch {} - if ($null -eq $githubUri -or [string]::IsNullOrWhiteSpace($githubUri.Host)) { - $githubHostname = $null - } elseif ($githubUri.Host -eq 'api.github.com') { - $githubHostname = 'github.com' - } else { - $githubHostname = $githubUri.Host - } - } - - if ($githubHostname) { - $ghExecutable = Resolve-AnvilGhExecutable - if ($ghExecutable) { - $ghToken = Invoke-AnvilGhToken $ghExecutable $githubHostname - } else { - $ghToken = $null - } - if ($ghToken) { - Set-Item -LiteralPath 'Env:GITHUB_TOKEN' -Value $ghToken - $hookEnv += 'GITHUB_TOKEN' - } + $needsToken = $plan -match 'GITHUB_TOKEN' + } + if ($needsToken) { + $ghToken = $null + try { $ghToken = (gh auth token --hostname github.com 2>$null) } catch { $ghToken = $null } + if ($ghToken -and $ghToken.Trim()) { + Set-Item -LiteralPath 'Env:GITHUB_TOKEN' -Value $ghToken.Trim() + $hookEnv += 'GITHUB_TOKEN' } } } - if (-not [string]::IsNullOrWhiteSpace($env:GITHUB_TOKEN)) { + if ($env:GITHUB_TOKEN) { $forwardedEnv += 'GITHUB_TOKEN' $runArgs += @('-e', 'GITHUB_TOKEN') } - if (-not [string]::IsNullOrWhiteSpace($env:APRZ_GITHUB_URL)) { - $forwardedEnv += 'APRZ_GITHUB_URL' - $runArgs += @('-e', 'APRZ_GITHUB_URL') - } # The recipe contract's own inputs. These are read by generated checks -- # `anvil-pr-title` reads PR_TITLE, `_anvil-base-ref` reads BASE_REF and its diff --git a/justfiles/anvil/checks/aprz.just b/justfiles/anvil/checks/aprz.just index 1862d8381..e068388cd 100644 --- a/justfiles/anvil/checks/aprz.just +++ b/justfiles/anvil/checks/aprz.just @@ -10,10 +10,12 @@ # capped at 60 requests an hour, and on a full workspace it exhausts that # and then waits for the quota to reset rather than failing; an # authenticated token raises the cap to 5000/hour. CI injects GITHUB_TOKEN -# (github.token). Local runs use an exported GITHUB_TOKEN when available and -# otherwise remain anonymous. Developers can opt into host-aware GitHub CLI -# discovery by invoking cargo-aprz themselves with --github-token-from-gh; -# this generated check deliberately does not access their gh credentials. +# (github.token). For local runs, if GITHUB_TOKEN is unset we borrow the +# gh CLI's stored token (non-interactive: `gh auth token` prints the +# active account's token for github.com and never opens a browser/auth +# prompt). If neither is available we warn with instructions and proceed +# unauthenticated. In a container the driver resolves the token the same +# way and forwards it by name, because the image has no gh CLI of its own. # # Unscoped (consults external risk DB). @@ -21,6 +23,19 @@ [script("pwsh", "-NoProfile")] anvil-aprz: anvil-aprz-validate-prereqs $ErrorActionPreference = 'Stop' + if (-not $env:GITHUB_TOKEN) { + $tok = $null + if (Get-Command gh -ErrorAction SilentlyContinue) { + try { $tok = (gh auth token --hostname github.com 2>$null) } catch { $tok = $null } + } + if ($tok) { + $env:GITHUB_TOKEN = $tok.Trim() + } else { + Write-Warning 'anvil-aprz: GITHUB_TOKEN is not set and no token could be obtained from the gh CLI.' + Write-Warning 'cargo-aprz will use the unauthenticated GitHub API, which allows 60 requests an hour. On a full workspace it exhausts that and then blocks, for up to an hour, waiting for the quota to reset.' + Write-Warning 'To fix: run `gh auth login` (recommended), or set $env:GITHUB_TOKEN to a GitHub token, then re-run.' + } + } & cargo {{_anvil_stable_toolchain_args}} aprz deps --error-if-high-risk --console appraisal if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/justfiles/anvil/container.just b/justfiles/anvil/container.just index c002a55b8..871c5b735 100644 --- a/justfiles/anvil/container.just +++ b/justfiles/anvil/container.just @@ -927,139 +927,52 @@ anvil-container *command: $forwardedEnv = @() $hookEnv = @() + # anvil-aprz queries the GitHub advisory API, which allows 60 requests an + # hour unauthenticated -- less than a full tier needs. Unauthenticated is + # not a degraded-but-working mode: `cargo aprz deps` sleeps until the quota + # resets rather than failing, so a containerized tier blocks for up to an + # hour with no way to opt out. Authentication is what makes the check + # terminate, not what makes it fast. + # + # Resolve the token exactly as the recipe does natively -- GITHUB_TOKEN + # first, then the gh CLI's stored token -- so a containerized run + # authenticates for the same developers a native run does. + # # An already-exported GITHUB_TOKEN is forwarded whatever the command is: # that is exact parity, since a native run exposes it to every process the - # shell spawns too. Deriving one from `gh` is different: it manufactures a - # credential the developer did not export, and PID 1 exposes it to every - # build script and proc macro in the container. It therefore requires the - # exact --github-token-from-gh switch in a direct command's argv or in an - # expanded `just --dry-run` plan. Interactive execution never opts in. + # shell spawns too. Deriving one from `gh` is different -- it manufactures a + # credential the developer did not put in this environment, and PID 1's + # environment is inherited by every build script and proc macro in the + # container, where natively the recipe would mint it in its own process. So + # it is derived only when the command is known to read the variable, or when + # there is no command at all: an interactive session can run anything, and + # refusing there would reintroduce the silent hour-long stall on a tier the + # developer runs from inside the shell. + # `gh auth token` is non-interactive and never opens a prompt. # - # Explicit --github-token and a nonblank GITHUB_TOKEN remain authoritative - # even when the opt-in switch is also present, so neither path invokes gh. - function Resolve-AnvilGhExecutable { - $path = [Environment]::GetEnvironmentVariable('PATH') - if ([string]::IsNullOrEmpty($path)) { return $null } - - if ($IsWindows) { - $pathExt = [Environment]::GetEnvironmentVariable('PATHEXT') - if ([string]::IsNullOrEmpty($pathExt)) { - $pathExt = '.COM;.EXE;.BAT;.CMD' - } - $names = @( - foreach ($extension in ($pathExt -split ';')) { - if ([string]::IsNullOrEmpty($extension)) { continue } - if (-not $extension.StartsWith('.')) { $extension = ".$extension" } - if ($extension -ieq '.COM' -or $extension -ieq '.EXE') { - "gh$extension" - } - } - ) - } else { - $names = @('gh') - } - - foreach ($entry in ($path -split [IO.Path]::PathSeparator)) { - # Empty PATH entries implicitly mean the current directory to - # command lookup. Require an explicit entry such as `.` instead. - if ([string]::IsNullOrEmpty($entry)) { continue } - try { - $directory = if ([IO.Path]::IsPathRooted($entry)) { - [IO.Path]::GetFullPath($entry) - } else { - [IO.Path]::GetFullPath((Join-Path (Get-Location).Path $entry)) - } - } catch { - continue - } - - foreach ($name in $names) { - $candidate = Join-Path $directory $name - if (-not (Test-Path -LiteralPath $candidate -PathType Leaf)) { continue } - # Process.Start below is the portable executable check. A Unix - # file without execute permission fails there and falls through - # anonymously without requiring an external `test` utility. - return [IO.Path]::GetFullPath($candidate) - } - } - $null - } - - function Invoke-AnvilGhToken( - [Parameter(Mandatory)][string]$Executable, - [Parameter(Mandatory)][string]$Hostname, - [int]$TimeoutMilliseconds = 10000 - ) { - $start = [Diagnostics.ProcessStartInfo]::new() - $start.FileName = $Executable - $start.UseShellExecute = $false - $start.CreateNoWindow = $true - $start.RedirectStandardInput = $true - $start.RedirectStandardOutput = $true - $start.RedirectStandardError = $true - $start.StandardOutputEncoding = [Text.UTF8Encoding]::new($false, $true) - foreach ($argument in @('auth', 'token', '--hostname', $Hostname)) { - $null = $start.ArgumentList.Add($argument) - } - # gh treats even a whitespace-only inherited GITHUB_TOKEN as - # authoritative. Remove the value cargo-aprz already rejected so the - # authenticated account lookup can actually proceed. - $null = $start.Environment.Remove('GITHUB_TOKEN') - - $process = [Diagnostics.Process]::new() - $process.StartInfo = $start - $started = $false - try { - $started = $process.Start() - if (-not $started) { return $null } - $process.StandardInput.Close() - $stdout = $process.StandardOutput.ReadToEndAsync() - $stderr = $process.StandardError.ReadToEndAsync() - if (-not $process.WaitForExit($TimeoutMilliseconds)) { - $process.Kill($true) - $process.WaitForExit() - try { $null = $stdout.GetAwaiter().GetResult() } catch {} - try { $null = $stderr.GetAwaiter().GetResult() } catch {} - return $null - } - try { - $token = $stdout.GetAwaiter().GetResult() - $null = $stderr.GetAwaiter().GetResult() - } catch { - return $null - } - if ($process.ExitCode -ne 0 -or [string]::IsNullOrWhiteSpace($token)) { - return $null - } - $token.Trim() - } catch { - $null - } finally { - if ($started -and -not $process.HasExited) { - try { - $process.Kill($true) - $process.WaitForExit() - } catch {} - } - $process.Dispose() - } - } - - $hasEnvironmentGitHubToken = -not [string]::IsNullOrWhiteSpace($env:GITHUB_TOKEN) - if (-not $hasEnvironmentGitHubToken) { - $githubTokenFromGh = $false - $hasExplicitGitHubToken = $false - $githubUrl = $null - if ($argv.Count -gt 0 -and $argv[0] -eq 'just') { + # The predicate is the variable itself rather than the name of a check, so + # the driver stays generic: a catalog that adds another GitHub-authenticated + # check is covered without touching this recipe. + # + # Set here and passed by NAME, so the value never reaches the host's + # process command line, and unset again with the hook's variables below. + if (-not $env:GITHUB_TOKEN -and (Get-Command gh -ErrorAction SilentlyContinue)) { + $needsToken = $argv.Count -eq 0 + # Only a `just` command can be planned, and planning is the only way to + # know whether what runs reads the variable. Anything else keeps the + # environment it was given: a manufactured credential reaches every + # process in the container, so an unknown command does not earn one. + if (-not $needsToken -and $argv[0] -eq 'just') { # A dry run has no side effects, and a target that cannot be planned - # yields no consent, so the run fails on its own terms. + # (a typo, a recipe needing arguments) yields nothing, so the run + # fails on its own terms rather than on a missing token. # # A plan covers the bodies just runs itself, not the body of a # recipe that one of them launches as a child process. The unscoped # tier wrapper launches its tier that way, so planning # `anvil-scheduled` shows the wrapper and none of the checks # underneath it. Follow each nested target a plan names, or a - # wrapped tier can hide an explicit opt-in in one of its checks. + # wrapped tier reads as needing nothing and runs unauthenticated. $plan = '' $targets = [System.Collections.Generic.List[object]]::new() $targets.Add([string[]]@($argv | Select-Object -Skip 1)) @@ -1072,7 +985,10 @@ anvil-container *command: # The same executable that launched this tree, for the reason # every other nested call uses it: a caller invoking `just` by # absolute path with its directory off PATH would otherwise fail - # here. An empty plan means no GitHub CLI consent. + # here. That failure is silent, because an empty plan reads as + # "does not need a token" -- so anvil-aprz would run + # unauthenticated in an image with no gh of its own and block on + # the rate limit for up to an hour. $step = '' try { $step = (& '{{ replace(just_executable(), "'", "''") }}' --dry-run @target 2>&1 | @@ -1086,96 +1002,21 @@ anvil-container *command: $targets.Add([string[]]@($nested.Groups[1].Value)) } } - # Bind consent, explicit-token precedence, and endpoint selection - # to the same planned command. Distinct opted-in commands are - # ambiguous because the container can forward only one token, so - # fail closed and let each inner command run anonymously. - $optedInCommands = @( - $plan -split '\r?\n' | - Where-Object { - [regex]::IsMatch($_, '(^|[\s''"])--github-token-from-gh($|[\s''"])') - } | - Select-Object -Unique - ) - if ($optedInCommands.Count -eq 1) { - $optedInCommand = $optedInCommands[0] - $githubTokenFromGh = $true - $hasExplicitGitHubToken = [regex]::IsMatch( - $optedInCommand, '(^|[\s''"])--github-token(?:=|$|[\s''"])' - ) - $githubUrlMatch = [regex]::Match( - $optedInCommand, - '(?:^|[\s''"])--github-url(?:=|\s+)(?:''([^'']*)''|"([^"]*)"|([^\s''"]+))' - ) - if ($githubUrlMatch.Success) { - foreach ($group in 1..3) { - if ($githubUrlMatch.Groups[$group].Success) { - $githubUrl = $githubUrlMatch.Groups[$group].Value - break - } - } - } - } - } elseif ($argv.Count -gt 0) { - $githubTokenFromGh = $argv -contains '--github-token-from-gh' - $hasExplicitGitHubToken = @( - $argv | Where-Object { $_ -eq '--github-token' -or $_.StartsWith('--github-token=') } - ).Count -gt 0 - if ($githubTokenFromGh) { - for ($i = 0; $i -lt $argv.Count; $i++) { - if ($argv[$i] -eq '--github-url' -and $i + 1 -lt $argv.Count) { - $githubUrl = $argv[$i + 1] - break - } - if ($argv[$i].StartsWith('--github-url=')) { - $githubUrl = $argv[$i].Substring('--github-url='.Length) - break - } - } - } + $needsToken = $plan -match 'GITHUB_TOKEN' } - - if ($githubTokenFromGh -and -not $hasExplicitGitHubToken) { - if ([string]::IsNullOrWhiteSpace($githubUrl) -and - -not [string]::IsNullOrWhiteSpace($env:APRZ_GITHUB_URL)) { - $githubUrl = $env:APRZ_GITHUB_URL - } - - $githubHostname = 'github.com' - if (-not [string]::IsNullOrWhiteSpace($githubUrl)) { - $githubUri = $null - try { $githubUri = [Uri]::new($githubUrl, [UriKind]::Absolute) } catch {} - if ($null -eq $githubUri -or [string]::IsNullOrWhiteSpace($githubUri.Host)) { - $githubHostname = $null - } elseif ($githubUri.Host -eq 'api.github.com') { - $githubHostname = 'github.com' - } else { - $githubHostname = $githubUri.Host - } - } - - if ($githubHostname) { - $ghExecutable = Resolve-AnvilGhExecutable - if ($ghExecutable) { - $ghToken = Invoke-AnvilGhToken $ghExecutable $githubHostname - } else { - $ghToken = $null - } - if ($ghToken) { - Set-Item -LiteralPath 'Env:GITHUB_TOKEN' -Value $ghToken - $hookEnv += 'GITHUB_TOKEN' - } + if ($needsToken) { + $ghToken = $null + try { $ghToken = (gh auth token --hostname github.com 2>$null) } catch { $ghToken = $null } + if ($ghToken -and $ghToken.Trim()) { + Set-Item -LiteralPath 'Env:GITHUB_TOKEN' -Value $ghToken.Trim() + $hookEnv += 'GITHUB_TOKEN' } } } - if (-not [string]::IsNullOrWhiteSpace($env:GITHUB_TOKEN)) { + if ($env:GITHUB_TOKEN) { $forwardedEnv += 'GITHUB_TOKEN' $runArgs += @('-e', 'GITHUB_TOKEN') } - if (-not [string]::IsNullOrWhiteSpace($env:APRZ_GITHUB_URL)) { - $forwardedEnv += 'APRZ_GITHUB_URL' - $runArgs += @('-e', 'APRZ_GITHUB_URL') - } # The recipe contract's own inputs. These are read by generated checks -- # `anvil-pr-title` reads PR_TITLE, `_anvil-base-ref` reads BASE_REF and its diff --git a/scripts/test-anvil-container.ps1 b/scripts/test-anvil-container.ps1 index 7204d8156..94d6a39c7 100644 --- a/scripts/test-anvil-container.ps1 +++ b/scripts/test-anvil-container.ps1 @@ -22,8 +22,8 @@ 2. The first run builds an image and runs the recipe inside it. 3. A second run reuses the image (the tag resolves, nothing is built), no cache volume masks the tools the image installed, and a host - GITHUB_TOKEN is forwarded from the environment. GitHub CLI discovery - remains off unless the command explicitly opts in. + GITHUB_TOKEN is forwarded — from the environment, or from the gh CLI + when the environment has none. 3b. A recipe run from a linked worktree can still reach git history. 4. Changing a hashed input (the pinned toolchain) selects a new tag. 5. Reverting that input returns to the original tag. @@ -327,25 +327,25 @@ set unstable e2e-show-env: @echo "E2E:$ANVIL_E2E_RUNTIME" -# Proves the driver forwards a host token, and invents one only with consent. +# Proves the driver forwards a host token, and invents one when it should not. # `:-` because just runs recipe lines under `sh -u`, where a bare $NAME that # was correctly *not* forwarded would abort instead of printing empty. e2e-show-token: @echo "E2E-TOKEN:[${GITHUB_TOKEN:-}]" -# An exact switch in the expanded plan explicitly authorizes host gh discovery. -e2e-show-token-from-gh: - @sh -c 'echo "E2E-TOKEN:[${GITHUB_TOKEN:-}]"' --github-token-from-gh +# The negative case for the same rule. A derived token is minted only when the +# target's plan reads GITHUB_TOKEN, so this recipe must observe the environment +# *without naming the variable* -- naming it is what would opt it in. Dumping +# every name lets the assertion look for the value without the plan mentioning +# it. +e2e-dump-env: + @env | sed 's/=.*//' | sort | tr '\n' ' ' # Proves git resolves inside the container, which a linked worktree breaks # unless the driver mounts the common git directory. e2e-show-git: @echo "E2E-GIT:[$(git rev-parse --abbrev-ref HEAD)]" '@ -Write-Fixture (Join-Path $repo 'e2e-show-token.sh') @' -#!/bin/sh -echo "E2E-TOKEN:[${GITHUB_TOKEN:-}]" -'@ Invoke-Native -Command 'git' -Arguments @('init', '-q') -WorkingDirectory $repo | Out-Null # Pin the newline policy: the fixture writes LF, and a developer with @@ -439,6 +439,10 @@ Assert-Equal 'a tool installed by the image survives the cache mounts' 0 $probe. Assert-That 'the tool resolves inside the image, not a volume' ` ($probe.StdOut -match '/usr/local/cargo/bin/cargo-binstall') "$($probe.StdOut)$($probe.StdErr)" +# anvil-aprz runs in scheduled-advisories and blocks on the rate limit without a token, so a +# host token has to reach the container. The driver resolves it the way the +# recipe does natively: the environment first, then the gh CLI. +# # Failure details are redacted: on a developer machine the value below is a real # credential, and a test that prints it to the terminal on failure is a leak. function Hide-Token([string]$Text) { $Text -replace 'E2E-TOKEN:\[[^\]]+\]', 'E2E-TOKEN:[]' } @@ -448,42 +452,47 @@ $withToken = Invoke-Just -Repo $repo -Arguments @('anvil-container', 'just', 'e2 Assert-That 'a host GITHUB_TOKEN reaches a recipe in the container' ` ($withToken.StdOut -match 'E2E-TOKEN:\[e2e-forwarded-token\]') (Hide-Token "$($withToken.StdOut)$($withToken.StdErr)") -# No environment token and no opt-in: nothing is forwarded even when the host -# has an authenticated gh CLI. +# No environment token and no gh CLI: nothing is forwarded. gh is hidden by +# dropping its directory from PATH, which is what the driver actually probes -- +# `GH_CONFIG_DIR` does not work here, because modern gh keeps credentials in the +# OS keyring rather than in its config directory. +$pathWithoutGh = $env:PATH +$ghCommand = Get-Command gh -ErrorAction SilentlyContinue +if ($ghCommand) { + $ghDir = (Split-Path $ghCommand.Source).TrimEnd('\', '/') + $separator = if ($IsWindows) { ';' } else { ':' } + $pathWithoutGh = (($env:PATH -split $separator) | + Where-Object { $_ -and $_.TrimEnd('\', '/') -ne $ghDir }) -join $separator +} $withoutToken = Invoke-Just -Repo $repo -Arguments @('anvil-container', 'just', 'e2e-show-token') ` - -Environment @{ GITHUB_TOKEN = '' } -Assert-That 'GitHub CLI discovery is off by default' ` + -Environment @{ GITHUB_TOKEN = ''; GH_TOKEN = ''; PATH = $pathWithoutGh } +Assert-That 'no token is invented when the host has none' ` ($withoutToken.StdOut -match 'E2E-TOKEN:\[\]') (Hide-Token "$($withoutToken.StdOut)$($withoutToken.StdErr)") -# The explicit gh opt-in. Skipped rather than failed when the host is not signed -# in, since that is a property of the machine running the suite. +# The gh fallback itself, which is what keeps a containerized tier from blocking +# for a developer who signed in with `gh auth login` and never exported a token. +# Skipped rather than failed when the host is not signed in, since that is a +# property of the machine running the suite. $hostGhToken = $null if (Get-Command gh -ErrorAction SilentlyContinue) { try { $hostGhToken = (gh auth token --hostname github.com 2>$null) } catch { $hostGhToken = $null } } if ($hostGhToken -and $hostGhToken.Trim()) { - $viaGh = Invoke-Just -Repo $repo -Arguments @('anvil-container', 'just', 'e2e-show-token-from-gh') ` + $viaGh = Invoke-Just -Repo $repo -Arguments @('anvil-container', 'just', 'e2e-show-token') ` -Environment @{ GITHUB_TOKEN = '' } - Assert-That 'the gh CLI token is used only after explicit opt-in' ` + Assert-That 'the gh CLI token is used when the environment has none' ` ($viaGh.StdOut -match ('E2E-TOKEN:\[' + [regex]::Escape($hostGhToken.Trim()) + '\]')) ` (Hide-Token "$($viaGh.StdOut)$($viaGh.StdErr)") - $directViaGh = Invoke-Just -Repo $repo ` - -Arguments @('anvil-container', 'sh', 'e2e-show-token.sh', '--github-token-from-gh') ` - -Environment @{ GITHUB_TOKEN = '' } - Assert-That 'a direct command opts in through an exact argv switch' ` - ($directViaGh.StdOut -match ('E2E-TOKEN:\[' + [regex]::Escape($hostGhToken.Trim()) + '\]')) ` - (Hide-Token "$($directViaGh.StdOut)$($directViaGh.StdErr)") - - $explicit = Invoke-Just -Repo $repo ` - -Arguments @( - 'anvil-container', 'sh', 'e2e-show-token.sh', - '--github-token', 'explicit-secret', '--github-token-from-gh' - ) ` + # The other half of the rule. Minting a credential the developer never put + # in this environment hands it to every build script and proc macro in the + # container, where natively the recipe would mint it in its own process -- + # so a target that never reads the variable must not receive it. + $noNeed = Invoke-Just -Repo $repo -Arguments @('anvil-container', 'just', 'e2e-dump-env') ` -Environment @{ GITHUB_TOKEN = '' } - Assert-That 'an explicit command token suppresses host gh discovery' ` - ($explicit.StdOut -match 'E2E-TOKEN:\[\]') ` - (Hide-Token "$($explicit.StdOut)$($explicit.StdErr)") + Assert-That 'no token is derived for a target that does not read it' ` + ($noNeed.StdOut -notmatch 'GITHUB_TOKEN') ` + (Hide-Token "$($noNeed.StdOut)$($noNeed.StdErr)") } else { Write-Step 'skipping the gh-fallback check: this host has no gh credential' } From e17598fbee18c8f45fae8c46de7cb59322792bc0 Mon Sep 17 00:00:00 2001 From: "Martin Kolinek (from Dev Box)" Date: Fri, 18 Sep 2026 12:08:27 +0200 Subject: [PATCH 10/13] fix(cargo-aprz): ignore unusable environment tokens Treat non-UTF-8 GITHUB_TOKEN values as absent so an explicitly requested GitHub CLI fallback can still resolve credentials. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a9fc919b-99f7-4134-aad1-2116321b4e0c --- .../src/commands/github_credentials.rs | 47 ++++++++++++++----- crates/cargo-aprz/README.md | 11 +++-- crates/cargo-aprz/docs/DESIGN.md | 8 ++-- crates/cargo-aprz/src/main.rs | 11 +++-- 4 files changed, 52 insertions(+), 25 deletions(-) diff --git a/crates/cargo-aprz-lib/src/commands/github_credentials.rs b/crates/cargo-aprz-lib/src/commands/github_credentials.rs index a7afad4fb..d1e387fff 100644 --- a/crates/cargo-aprz-lib/src/commands/github_credentials.rs +++ b/crates/cargo-aprz-lib/src/commands/github_credentials.rs @@ -103,22 +103,22 @@ where } if let Some(token) = read_environment() { - let Ok(token) = token.into_string() else { + if let Ok(token) = token.into_string() { + let token = token.trim(); + if !token.is_empty() { + log::trace!(target: LOG_TARGET, "GitHub credential source: {GITHUB_TOKEN_ENV}"); + return Some(GitHubToken(token.to_owned())); + } log::trace!( target: LOG_TARGET, - "GitHub credential source {GITHUB_TOKEN_ENV} is not valid UTF-8; using anonymous access" + "GitHub credential source {GITHUB_TOKEN_ENV} is blank; continuing credential discovery" + ); + } else { + log::trace!( + target: LOG_TARGET, + "GitHub credential source {GITHUB_TOKEN_ENV} is not valid UTF-8; continuing credential discovery" ); - return None; - }; - let token = token.trim(); - if !token.is_empty() { - log::trace!(target: LOG_TARGET, "GitHub credential source: {GITHUB_TOKEN_ENV}"); - return Some(GitHubToken(token.to_owned())); } - log::trace!( - target: LOG_TARGET, - "GitHub credential source {GITHUB_TOKEN_ENV} is blank; continuing credential discovery" - ); } if !github_token_from_gh { @@ -434,6 +434,29 @@ mod tests { } } + #[cfg(unix)] + #[tokio::test] + async fn non_utf8_environment_token_continues_to_gh_when_enabled() { + use std::os::unix::ffi::OsStringExt as _; + + let gh_called = Cell::new(false); + let selected = discover_with( + None, + true, + &Endpoints::default(), + || Some(OsString::from_vec(vec![0xff])), + |_| { + gh_called.set(true); + std::future::ready(Ok(successful(b"gh-secret"))) + }, + ) + .await + .expect("an unusable environment token falls through to gh"); + + assert_eq!(selected.expose_secret(), "gh-secret"); + assert!(gh_called.get()); + } + #[tokio::test] async fn blank_environment_tokens_continue_to_gh_when_enabled() { for environment in ["", " \r\n\t "] { diff --git a/crates/cargo-aprz/README.md b/crates/cargo-aprz/README.md index b7569a108..4cc96c47c 100644 --- a/crates/cargo-aprz/README.md +++ b/crates/cargo-aprz/README.md @@ -183,11 +183,12 @@ GitHub credentials are discovered in this order: 1. anonymous access The host passed to `gh` comes from the effective GitHub service URL, including -`--github-url` or `APRZ_GITHUB_URL` overrides for GitHub Enterprise. Missing -or blank credentials and a `gh` lookup that exceeds its finite deadline are -ignored and retain the existing anonymous rate-limit behavior. Without -`--github-token-from-gh`, `cargo-aprz` never searches for or invokes `gh`. -Codeberg credentials continue to use `--codeberg-token` or `CODEBERG_TOKEN`. +`--github-url` or `APRZ_GITHUB_URL` overrides for GitHub Enterprise. Missing, +blank, or non-UTF-8 credentials and a `gh` lookup that exceeds its finite +deadline are ignored and retain the existing anonymous rate-limit behavior. +Without `--github-token-from-gh`, `cargo-aprz` never searches for or invokes +`gh`. Codeberg credentials continue to use `--codeberg-token` or +`CODEBERG_TOKEN`. ### Reports diff --git a/crates/cargo-aprz/docs/DESIGN.md b/crates/cargo-aprz/docs/DESIGN.md index 21900ca35..79d09bdb3 100644 --- a/crates/cargo-aprz/docs/DESIGN.md +++ b/crates/cargo-aprz/docs/DESIGN.md @@ -87,9 +87,11 @@ rate-limit behavior. An explicit option is authoritative, as is a nonblank environment token; cargo-aprz never invokes `gh` when either applies, even when the switch is present. Environment values are trimmed; an empty or whitespace-only `GITHUB_TOKEN` is treated as absent so explicitly authorized, -host-aware `gh` discovery can continue. The rejected value is removed from the -`gh` child environment so the CLI can consult its authenticated account instead -of treating the blank environment override as authoritative. +host-aware `gh` discovery can continue. On Unix, an environment value can also +contain non-UTF-8 bytes; such a value is likewise unusable as an HTTP credential +and treated as absent. A rejected value is removed from the `gh` child +environment so the CLI can consult its authenticated account instead of +treating the environment override as authoritative. The command is spawned directly without a shell. Its stdout is trimmed and used only as the request credential; it is never logged, cached, included in an diff --git a/crates/cargo-aprz/src/main.rs b/crates/cargo-aprz/src/main.rs index 0f78535bd..c3d0a83c6 100644 --- a/crates/cargo-aprz/src/main.rs +++ b/crates/cargo-aprz/src/main.rs @@ -164,11 +164,12 @@ //! 4. anonymous access //! //! The host passed to `gh` comes from the effective GitHub service URL, including -//! `--github-url` or `APRZ_GITHUB_URL` overrides for GitHub Enterprise. Missing -//! or blank credentials and a `gh` lookup that exceeds its finite deadline are -//! ignored and retain the existing anonymous rate-limit behavior. Without -//! `--github-token-from-gh`, `cargo-aprz` never searches for or invokes `gh`. -//! Codeberg credentials continue to use `--codeberg-token` or `CODEBERG_TOKEN`. +//! `--github-url` or `APRZ_GITHUB_URL` overrides for GitHub Enterprise. Missing, +//! blank, or non-UTF-8 credentials and a `gh` lookup that exceeds its finite +//! deadline are ignored and retain the existing anonymous rate-limit behavior. +//! Without `--github-token-from-gh`, `cargo-aprz` never searches for or invokes +//! `gh`. Codeberg credentials continue to use `--codeberg-token` or +//! `CODEBERG_TOKEN`. //! //! ## Reports //! From 9fb0f0f8bc695f1dec312f6ac91b16b2c0cb718e Mon Sep 17 00:00:00 2001 From: "Martin Kolinek (from Dev Box)" Date: Fri, 18 Sep 2026 12:36:27 +0200 Subject: [PATCH 11/13] test(cargo-aprz): preserve toolchain PATH in credential probe Keep the controlled gh directory first while retaining the inherited PATH needed by cargo metadata and rustc on Linux CI. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a9fc919b-99f7-4134-aad1-2116321b4e0c --- .../tests/github_credentials_process_integration.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/cargo-aprz-lib/tests/github_credentials_process_integration.rs b/crates/cargo-aprz-lib/tests/github_credentials_process_integration.rs index 8a8f9b5cd..eacbeb3b8 100644 --- a/crates/cargo-aprz-lib/tests/github_credentials_process_integration.rs +++ b/crates/cargo-aprz-lib/tests/github_credentials_process_integration.rs @@ -68,6 +68,8 @@ fn run_discovers_an_enterprise_token_through_the_production_process_path() { let bin = temp.path().join("bin"); compile_fake_gh(&bin); let gh_log = temp.path().join("gh.log"); + let path = std::env::join_paths(std::iter::once(bin).chain(std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()))) + .expect("the fake gh directory and inherited PATH form a valid search path"); let mut command = Command::new(std::env::current_exe().expect("the integration test knows its executable")); command .args([ @@ -76,7 +78,7 @@ fn run_discovers_an_enterprise_token_through_the_production_process_path() { "helper_run_discovers_an_enterprise_token_through_the_production_process_path", "--nocapture", ]) - .env("PATH", bin) + .env("PATH", path) .env("GITHUB_TOKEN", " \t ") .env("FAKE_GH_LOG", &gh_log); if cfg!(windows) { From 4cec93238f9dc347dfc9234d3f5558e6e280072f Mon Sep 17 00:00:00 2001 From: "Martin Kolinek (from Dev Box)" Date: Fri, 18 Sep 2026 15:37:17 +0200 Subject: [PATCH 12/13] docs(cargo-aprz): defer container adoption contract Remove the cargo-anvil container behavior from this prerequisite release design; adoption will be documented with its post-release implementation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a9fc919b-99f7-4134-aad1-2116321b4e0c --- crates/cargo-aprz/docs/DESIGN.md | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/crates/cargo-aprz/docs/DESIGN.md b/crates/cargo-aprz/docs/DESIGN.md index 79d09bdb3..0034cc513 100644 --- a/crates/cargo-aprz/docs/DESIGN.md +++ b/crates/cargo-aprz/docs/DESIGN.md @@ -107,17 +107,6 @@ and the child process is awaited asynchronously with a ten-second deadline. Expiry terminates the child and continues anonymously, so credential discovery does not block an async runtime worker indefinitely. -Containerized opt-in applies the same process contract on the host because the -generated image does not contain `gh`: absolute resolution from explicit, -nonempty `PATH` entries; direct executable images only on Windows and regular -executable files on Unix; direct argument-vector launch with stdin and stderr -suppressed and strict UTF-8 stdout captured; the rejected blank environment -token removed; and a ten-second deadline that terminates the complete process -tree. Missing, unsuccessful, timed-out, blank, and invalid-output lookups all -continue anonymously. The container driver derives the same effective hostname -and forwards `APRZ_GITHUB_URL` with a discovered token so the inner provider -cannot target a different endpoint. - ## Cache storage Provider data is stored beneath a platform-specific cache root, partitioned by From a0cfb455f95986dbaf44c2db397d6fa7f6b13ea4 Mon Sep 17 00:00:00 2001 From: "Martin Kolinek (from Dev Box)" Date: Fri, 18 Sep 2026 16:45:39 +0200 Subject: [PATCH 13/13] fix(cargo-aprz): normalize explicit token input Trim explicit GitHub tokens and treat blank values as absent so later configured credential sources or anonymous access retain documented behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a9fc919b-99f7-4134-aad1-2116321b4e0c --- .../src/commands/github_credentials.rs | 29 +++++++++++++++++-- crates/cargo-aprz/docs/DESIGN.md | 18 ++++++------ 2 files changed, 36 insertions(+), 11 deletions(-) diff --git a/crates/cargo-aprz-lib/src/commands/github_credentials.rs b/crates/cargo-aprz-lib/src/commands/github_credentials.rs index d1e387fff..ee55830db 100644 --- a/crates/cargo-aprz-lib/src/commands/github_credentials.rs +++ b/crates/cargo-aprz-lib/src/commands/github_credentials.rs @@ -98,8 +98,12 @@ where OutputFuture: Future>, { if let Some(token) = explicit { - log::trace!(target: LOG_TARGET, "GitHub credential source: --github-token"); - return Some(token.clone()); + let token = token.expose_secret().trim(); + if !token.is_empty() { + log::trace!(target: LOG_TARGET, "GitHub credential source: --github-token"); + return Some(GitHubToken(token.to_owned())); + } + log::trace!(target: LOG_TARGET, "GitHub credential source --github-token is blank; continuing credential discovery"); } if let Some(token) = read_environment() { @@ -391,6 +395,27 @@ mod tests { assert!(!gh_called.get(), "an explicit token suppresses gh"); } + #[tokio::test] + async fn blank_explicit_token_continues_credential_discovery() { + let explicit = token(" \r\n\t "); + let gh_called = Cell::new(false); + let selected = discover_with( + Some(&explicit), + true, + &Endpoints::default(), + || Some(OsString::from("environment-secret")), + |_| { + gh_called.set(true); + std::future::ready(Ok(successful(b"gh-secret"))) + }, + ) + .await + .expect("the environment token is selected after a blank explicit token"); + + assert_eq!(selected.expose_secret(), "environment-secret"); + assert!(!gh_called.get(), "the environment token still suppresses gh"); + } + #[tokio::test] async fn environment_token_precedes_gh() { let gh_called = Cell::new(false); diff --git a/crates/cargo-aprz/docs/DESIGN.md b/crates/cargo-aprz/docs/DESIGN.md index 0034cc513..5758c57c2 100644 --- a/crates/cargo-aprz/docs/DESIGN.md +++ b/crates/cargo-aprz/docs/DESIGN.md @@ -83,15 +83,15 @@ continues anonymously without resolving the effective hostname, searching When enabled, the `gh` fallback is convenience, not a prerequisite. A missing executable, missing login, nonzero `gh auth token` result, blank output, or non-UTF-8 output continues anonymously and retains the provider's existing -rate-limit behavior. An explicit option is authoritative, as is a nonblank -environment token; cargo-aprz never invokes `gh` when either applies, even when -the switch is present. Environment values are trimmed; an empty or -whitespace-only `GITHUB_TOKEN` is treated as absent so explicitly authorized, -host-aware `gh` discovery can continue. On Unix, an environment value can also -contain non-UTF-8 bytes; such a value is likewise unusable as an HTTP credential -and treated as absent. A rejected value is removed from the `gh` child -environment so the CLI can consult its authenticated account instead of -treating the environment override as authoritative. +rate-limit behavior. A nonblank explicit option is authoritative, as is a +nonblank environment token; cargo-aprz never invokes `gh` when either applies, +even when the switch is present. Explicit and environment values are trimmed; +empty or whitespace-only values are treated as absent so later configured +sources can continue. On Unix, an environment value can also contain non-UTF-8 +bytes; such a value is likewise unusable as an HTTP credential and treated as +absent. A rejected environment value is removed from the `gh` child environment +so the CLI can consult its authenticated account instead of treating the +environment override as authoritative. The command is spawned directly without a shell. Its stdout is trimmed and used only as the request credential; it is never logged, cached, included in an