From 80d2912bc12266ea68cdc910e7a8e20b4ad8d864 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 21 Jul 2026 14:09:22 -0400 Subject: [PATCH 1/5] =?UTF-8?q?refactor(args):=20drop=20clap=20defaults=20?= =?UTF-8?q?from=20--api-url/--proxy-url=20=E2=80=94=20resolve=20in=20core?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit api_url/proxy_url become Option with no default_value so api_client_overrides() forwards None when neither flag nor env var is set. get_api_client_with_overrides already owns env → default resolution, and this makes room for the socket-cli config fallback layer between env and the built-in defaults. Parse-time contract tests now pin None; the documented URLs stay pinned in core's resolver tests. Co-Authored-By: Claude Fable 5 --- crates/socket-patch-cli/src/args.rs | 74 +++++++++---------- .../socket-patch-cli/tests/cli_global_args.rs | 44 ++++++----- .../socket-patch-cli/tests/cli_parse_apply.rs | 18 +++-- .../socket-patch-cli/tests/cli_parse_get.rs | 10 +-- .../tests/cli_parse_repair.rs | 8 +- .../tests/cli_parse_rollback.rs | 8 +- .../socket-patch-cli/tests/cli_parse_scan.rs | 4 +- .../tests/cli_parse_vendor.rs | 8 +- .../socket-patch-cli/tests/cli_parse_vex.rs | 8 +- .../tests/in_process_cargo_apply.rs | 6 +- .../tests/in_process_gem_apply.rs | 4 +- .../tests/in_process_gem_multi_platform.rs | 6 +- .../socket-patch-cli/tests/in_process_get.rs | 42 +++++------ .../tests/in_process_get_corrupt_manifest.rs | 4 +- .../tests/in_process_get_manifest_path.rs | 2 +- .../tests/in_process_get_uuid_fallback.rs | 4 +- .../tests/in_process_pypi_apply.rs | 8 +- .../tests/in_process_pypi_multi_release.rs | 6 +- .../tests/in_process_python_envs.rs | 2 +- .../tests/in_process_redirect.rs | 2 +- .../tests/in_process_redirect_pnpm.rs | 2 +- .../in_process_remote_ecosystems_apply.rs | 2 +- .../socket-patch-cli/tests/in_process_scan.rs | 36 ++++----- 23 files changed, 156 insertions(+), 152 deletions(-) diff --git a/crates/socket-patch-cli/src/args.rs b/crates/socket-patch-cli/src/args.rs index a14955fd..8148a2b9 100644 --- a/crates/socket-patch-cli/src/args.rs +++ b/crates/socket-patch-cli/src/args.rs @@ -19,7 +19,7 @@ use clap::Args; use socket_patch_core::api::client::ApiClientEnvOverrides; use socket_patch_core::constants::{ - DEFAULT_PATCH_API_PROXY_URL, DEFAULT_PATCH_MANIFEST_PATH, DEFAULT_SOCKET_API_URL, + DEFAULT_PATCH_MANIFEST_PATH, }; use socket_patch_core::crawlers::Ecosystem; use socket_patch_core::patch::vendor::VendorSource; @@ -97,13 +97,12 @@ pub struct GlobalArgs { )] pub manifest_path: String, - /// Socket API URL (authenticated endpoint). - #[arg( - long = "api-url", - env = "SOCKET_API_URL", - default_value = DEFAULT_SOCKET_API_URL, - )] - pub api_url: String, + /// Socket API URL (authenticated endpoint) [default: + /// https://api.socket.dev]. No clap default: `None` lets the core + /// resolver fall through env and the socket-cli config file before + /// applying `DEFAULT_SOCKET_API_URL`. + #[arg(long = "api-url", env = "SOCKET_API_URL")] + pub api_url: Option, /// Socket API token. Absence selects the public patch proxy. #[arg(long = "api-token", env = "SOCKET_API_TOKEN")] @@ -113,13 +112,11 @@ pub struct GlobalArgs { #[arg(long = "org", short = 'o', env = "SOCKET_ORG_SLUG")] pub org: Option, - /// Public proxy URL used when no API token is set. - #[arg( - long = "proxy-url", - env = "SOCKET_PROXY_URL", - default_value = DEFAULT_PATCH_API_PROXY_URL, - )] - pub proxy_url: String, + /// Public proxy URL used when no API token is set [default: + /// https://patches-api.socket.dev]. No clap default, matching + /// `api_url` — resolution happens in `get_api_client_with_overrides`. + #[arg(long = "proxy-url", env = "SOCKET_PROXY_URL")] + pub proxy_url: Option, /// Restrict to these ecosystems (comma-separated). Names that are not /// supported ecosystems are rejected. @@ -314,20 +311,18 @@ impl GlobalArgs { /// Build [`ApiClientEnvOverrides`] from the CLI flags. /// - /// `api_token` and `org` are forwarded as `Some(_)` only when set. - /// `api_url` and `proxy_url` are forwarded only when non-empty; - /// `GlobalArgs::default()` leaves both empty so integration tests - /// that mutate env vars *after* constructing args still get env-var - /// resolution from `get_api_client_with_overrides`. In production - /// clap always populates them with either the CLI value, the env - /// value, or the clap-declared default — all non-empty — so the - /// resolved value still flows through. + /// Every field is forwarded as `Some(_)` only when set and non-empty. + /// `None` (no flag, no env var — the fields carry no clap default) + /// defers resolution to `get_api_client_with_overrides`, which falls + /// through env vars and the socket-cli config file to the built-in + /// defaults. The empty filter keeps `--api-url ""` meaning "unset" + /// rather than forwarding a blank override. pub fn api_client_overrides(&self) -> ApiClientEnvOverrides { ApiClientEnvOverrides { - api_url: Some(self.api_url.clone()).filter(|s| !s.is_empty()), + api_url: self.api_url.clone().filter(|s| !s.is_empty()), api_token: self.api_token.clone().filter(|s| !s.is_empty()), org_slug: self.org.clone().filter(|s| !s.is_empty()), - proxy_url: Some(self.proxy_url.clone()).filter(|s| !s.is_empty()), + proxy_url: self.proxy_url.clone().filter(|s| !s.is_empty()), } } } @@ -442,21 +437,20 @@ impl Default for GlobalArgs { /// when neither CLI flag nor env var is set), so this `Default` is /// only reached from tests building `GlobalArgs` directly. /// - /// `api_url` and `proxy_url` are intentionally **empty** here (not - /// the production default URLs). That lets tests set - /// `SOCKET_API_URL` / `SOCKET_PROXY_URL` via `std::env::set_var` - /// *after* constructing the args struct and have those env vars - /// flow through to the API client — `api_client_overrides` skips - /// empty values so the underlying `get_api_client_with_overrides` - /// falls back to env-var resolution. + /// `api_url` and `proxy_url` are `None` here (not the production + /// default URLs). That lets tests set `SOCKET_API_URL` / + /// `SOCKET_PROXY_URL` via `std::env::set_var` *after* constructing + /// the args struct and have those env vars flow through to the API + /// client — `api_client_overrides` forwards `None` so the underlying + /// `get_api_client_with_overrides` falls back to env-var resolution. fn default() -> Self { Self { cwd: PathBuf::from("."), manifest_path: DEFAULT_PATCH_MANIFEST_PATH.to_string(), - api_url: String::new(), + api_url: None, api_token: None, org: None, - proxy_url: String::new(), + proxy_url: None, ecosystems: None, download_mode: "diff".to_string(), vendor_source: "auto".to_string(), @@ -836,10 +830,10 @@ mod tests { #[test] fn api_client_overrides_forwards_set_values() { let args = GlobalArgs { - api_url: "https://api.example.com".to_string(), + api_url: Some("https://api.example.com".to_string()), api_token: Some("tok123".to_string()), org: Some("acme".to_string()), - proxy_url: "https://proxy.example.com".to_string(), + proxy_url: Some("https://proxy.example.com".to_string()), ..GlobalArgs::default() }; let o = args.api_client_overrides(); @@ -849,8 +843,8 @@ mod tests { assert_eq!(o.proxy_url.as_deref(), Some("https://proxy.example.com")); } - /// `GlobalArgs::default()` leaves `api_url`/`proxy_url` empty and the - /// optional fields `None`, so every override must come back `None` — + /// `GlobalArgs::default()` leaves every field `None`, + /// so every override must come back `None` — /// this is what lets integration tests set `SOCKET_*` env vars *after* /// constructing args and still have env-var resolution win downstream. #[test] @@ -870,10 +864,10 @@ mod tests { #[test] fn api_client_overrides_filters_empty_strings() { let args = GlobalArgs { - api_url: String::new(), + api_url: Some(String::new()), api_token: Some(String::new()), org: Some(String::new()), - proxy_url: String::new(), + proxy_url: Some(String::new()), ..GlobalArgs::default() }; let o = args.api_client_overrides(); diff --git a/crates/socket-patch-cli/tests/cli_global_args.rs b/crates/socket-patch-cli/tests/cli_global_args.rs index 067f6a92..c8538aa5 100644 --- a/crates/socket-patch-cli/tests/cli_global_args.rs +++ b/crates/socket-patch-cli/tests/cli_global_args.rs @@ -56,7 +56,7 @@ fn global_flag_cases() -> Vec<(&'static str, Option<&'static str>, fn(&GlobalArg assert_eq!(c.manifest_path, "custom.json") }), ("--api-url", Some("https://example.com"), |c| { - assert_eq!(c.api_url, "https://example.com") + assert_eq!(c.api_url.as_deref(), Some("https://example.com")) }), ("--api-token", Some("tok123"), |c| { assert_eq!(c.api_token.as_deref(), Some("tok123")) @@ -65,7 +65,7 @@ fn global_flag_cases() -> Vec<(&'static str, Option<&'static str>, fn(&GlobalArg assert_eq!(c.org.as_deref(), Some("acme")) }), ("--proxy-url", Some("https://proxy.example.com"), |c| { - assert_eq!(c.proxy_url, "https://proxy.example.com") + assert_eq!(c.proxy_url.as_deref(), Some("https://proxy.example.com")) }), ("--ecosystems", Some("npm,pypi"), |c| { assert_eq!( @@ -455,10 +455,16 @@ fn env_vars_populate_global_args() { if let socket_patch_cli::Commands::List(args) = cli.command { assert_eq!(args.common.cwd, std::path::PathBuf::from("/env/cwd")); assert_eq!(args.common.manifest_path, "env-manifest.json"); - assert_eq!(args.common.api_url, "https://env-api.example.com"); + assert_eq!( + args.common.api_url.as_deref(), + Some("https://env-api.example.com") + ); assert_eq!(args.common.api_token.as_deref(), Some("env-token")); assert_eq!(args.common.org.as_deref(), Some("env-org")); - assert_eq!(args.common.proxy_url, "https://env-proxy.example.com"); + assert_eq!( + args.common.proxy_url.as_deref(), + Some("https://env-proxy.example.com") + ); assert_eq!( args.common.ecosystems.as_deref(), Some(&["npm".to_string(), "gem".to_string()][..]) @@ -833,7 +839,8 @@ fn cli_arg_overrides_env_var() { panic!("expected List"); }; assert_eq!( - args.common.api_url, "https://cli-api.example.com", + args.common.api_url.as_deref(), + Some("https://cli-api.example.com"), "CLI --api-url must override SOCKET_API_URL" ); @@ -843,7 +850,8 @@ fn cli_arg_overrides_env_var() { panic!("expected List"); }; assert_eq!( - args.common.api_url, "https://env-api.example.com", + args.common.api_url.as_deref(), + Some("https://env-api.example.com"), "with no CLI flag the env var must resolve through" ); @@ -862,10 +870,11 @@ fn cli_arg_overrides_env_var() { } /// Regression: with neither CLI flags nor env vars set, clap must populate the -/// documented production defaults (the `default_value = ".."` attributes). This -/// is the production path that `GlobalArgs::default()` deliberately does *not* -/// mirror for `api_url`/`proxy_url`, so it needs its own coverage — and -/// `api_client_overrides()` must therefore forward those concrete URLs. +/// documented production defaults (the `default_value = ".."` attributes). +/// `api_url`/`proxy_url` deliberately carry **no** clap default: they parse to +/// `None` so `get_api_client_with_overrides` can fall through env and the +/// socket-cli config file before applying the documented production URLs — +/// `api_client_overrides()` must therefore forward `None` for both. #[test] #[serial_test::serial] fn production_defaults_populate_when_unset() { @@ -878,8 +887,8 @@ fn production_defaults_populate_when_unset() { let c = &args.common; assert_eq!(c.cwd, std::path::PathBuf::from(".")); assert_eq!(c.manifest_path, ".socket/manifest.json"); - assert_eq!(c.api_url, "https://api.socket.dev"); - assert_eq!(c.proxy_url, "https://patches-api.socket.dev"); + assert_eq!(c.api_url, None, "no clap default — resolved in core"); + assert_eq!(c.proxy_url, None, "no clap default — resolved in core"); assert_eq!(c.download_mode, "diff"); assert_eq!(c.vendor_source, "auto"); assert!(c.vendor_url.is_none()); @@ -893,14 +902,11 @@ fn production_defaults_populate_when_unset() { assert!(c.lock_timeout.is_none()); assert!(c.global_prefix.is_none()); - // On the production path (unlike GlobalArgs::default()) the URLs are - // non-empty, so api_client_overrides must forward them. + // With no flag and no env var the overrides stay `None`, so the core + // resolver (env → socket-cli config → documented default) decides. let o = c.api_client_overrides(); - assert_eq!(o.api_url.as_deref(), Some("https://api.socket.dev")); - assert_eq!( - o.proxy_url.as_deref(), - Some("https://patches-api.socket.dev") - ); + assert!(o.api_url.is_none(), "unset --api-url must not override"); + assert!(o.proxy_url.is_none(), "unset --proxy-url must not override"); assert!(o.api_token.is_none()); assert!(o.org_slug.is_none()); diff --git a/crates/socket-patch-cli/tests/cli_parse_apply.rs b/crates/socket-patch-cli/tests/cli_parse_apply.rs index 0add2674..c220363c 100644 --- a/crates/socket-patch-cli/tests/cli_parse_apply.rs +++ b/crates/socket-patch-cli/tests/cli_parse_apply.rs @@ -88,11 +88,13 @@ fn defaults_match_contract() { // The remaining global defaults from the contract table. These were // previously unpinned, which let a dangerous default-value drift slip // through silently — e.g. `--break-lock` defaulting to `true` would make - // `apply` steal a live lock, or the API/proxy URLs silently retargeting. - assert_eq!(a.common.api_url, "https://api.socket.dev"); + // `apply` steal a live lock. The API/proxy URLs parse to `None` (no clap + // default) — the documented production URLs are applied by + // `get_api_client_with_overrides` after env + socket-cli config fallback. + assert_eq!(a.common.api_url, None); assert_eq!(a.common.api_token, None); assert_eq!(a.common.org, None); - assert_eq!(a.common.proxy_url, "https://patches-api.socket.dev"); + assert_eq!(a.common.proxy_url, None); assert!(!a.common.yes); assert!(!a.common.debug); assert!(!a.common.no_telemetry); @@ -413,8 +415,9 @@ fn api_url_long() { assert_eq!( parse_apply(&["--api-url", "https://api.example.test"]) .common - .api_url, - "https://api.example.test" + .api_url + .as_deref(), + Some("https://api.example.test") ); } @@ -434,8 +437,9 @@ fn proxy_url_long() { assert_eq!( parse_apply(&["--proxy-url", "https://proxy.example.test"]) .common - .proxy_url, - "https://proxy.example.test" + .proxy_url + .as_deref(), + Some("https://proxy.example.test") ); } diff --git a/crates/socket-patch-cli/tests/cli_parse_get.rs b/crates/socket-patch-cli/tests/cli_parse_get.rs index c229608e..6097fb52 100644 --- a/crates/socket-patch-cli/tests/cli_parse_get.rs +++ b/crates/socket-patch-cli/tests/cli_parse_get.rs @@ -126,10 +126,10 @@ struct Snap { identifier: String, cwd: PathBuf, manifest_path: String, - api_url: String, + api_url: Option, api_token: Option, org: Option, - proxy_url: String, + proxy_url: Option, ecosystems: Option>, download_mode: String, vendor_source: String, @@ -206,10 +206,10 @@ fn expected_defaults(identifier: &str) -> Snap { identifier: identifier.to_string(), cwd: PathBuf::from("."), manifest_path: ".socket/manifest.json".to_string(), - api_url: "https://api.socket.dev".to_string(), + api_url: None, // no clap default — resolved in core api_token: None, org: None, - proxy_url: "https://patches-api.socket.dev".to_string(), + proxy_url: None, // no clap default — resolved in core ecosystems: None, download_mode: "diff".to_string(), vendor_source: "auto".to_string(), @@ -388,7 +388,7 @@ fn ghsa_flag_sets_ghsa() { fn api_url_flag_sets_api_url() { let a = parse_get(&["some-id", "--api-url", "https://api.example.com"]); let mut want = expected_defaults("some-id"); - want.api_url = "https://api.example.com".to_string(); + want.api_url = Some("https://api.example.com".to_string()); assert_eq!(snapshot(&a), want); } diff --git a/crates/socket-patch-cli/tests/cli_parse_repair.rs b/crates/socket-patch-cli/tests/cli_parse_repair.rs index f03bda68..453d36dc 100644 --- a/crates/socket-patch-cli/tests/cli_parse_repair.rs +++ b/crates/socket-patch-cli/tests/cli_parse_repair.rs @@ -136,10 +136,10 @@ fn parse_gc(extra: &[&str]) -> RepairArgs { struct Snap { cwd: PathBuf, manifest_path: String, - api_url: String, + api_url: Option, api_token: Option, org: Option, - proxy_url: String, + proxy_url: Option, ecosystems: Option>, download_mode: String, vendor_source: String, @@ -201,10 +201,10 @@ fn expected_defaults() -> Snap { Snap { cwd: PathBuf::from("."), manifest_path: ".socket/manifest.json".to_string(), - api_url: "https://api.socket.dev".to_string(), + api_url: None, // no clap default — resolved in core api_token: None, org: None, - proxy_url: "https://patches-api.socket.dev".to_string(), + proxy_url: None, // no clap default — resolved in core ecosystems: None, download_mode: "diff".to_string(), vendor_source: "auto".to_string(), diff --git a/crates/socket-patch-cli/tests/cli_parse_rollback.rs b/crates/socket-patch-cli/tests/cli_parse_rollback.rs index f0d582f6..46e2a552 100644 --- a/crates/socket-patch-cli/tests/cli_parse_rollback.rs +++ b/crates/socket-patch-cli/tests/cli_parse_rollback.rs @@ -72,13 +72,13 @@ fn defaults_no_positional() { assert_eq!(args.common.global_prefix, None); assert!(!args.one_off); assert_eq!(args.common.org, None); - assert_eq!(args.common.api_url, "https://api.socket.dev"); + assert_eq!(args.common.api_url, None); // default applied in core resolver assert_eq!(args.common.api_token, None); assert_eq!(args.common.ecosystems, None); assert!(!args.common.json); assert!(!args.common.verbose); // Remaining global defaults the contract pins but the original test omitted. - assert_eq!(args.common.proxy_url, "https://patches-api.socket.dev"); + assert_eq!(args.common.proxy_url, None); // default applied in core resolver assert_eq!(args.common.download_mode, "diff"); assert!(!args.common.yes); assert_eq!(args.common.lock_timeout, None); @@ -203,7 +203,7 @@ fn org_long() { #[test] fn api_url_long() { let args = parse_rollback(&["--api-url", "https://api"]); - assert_eq!(args.common.api_url, "https://api"); + assert_eq!(args.common.api_url.as_deref(), Some("https://api")); } #[test] @@ -270,7 +270,7 @@ fn yes_long() { #[test] fn proxy_url_long() { let args = parse_rollback(&["--proxy-url", "https://proxy.example"]); - assert_eq!(args.common.proxy_url, "https://proxy.example"); + assert_eq!(args.common.proxy_url.as_deref(), Some("https://proxy.example")); } #[test] diff --git a/crates/socket-patch-cli/tests/cli_parse_scan.rs b/crates/socket-patch-cli/tests/cli_parse_scan.rs index 6edfd6bb..1c416564 100644 --- a/crates/socket-patch-cli/tests/cli_parse_scan.rs +++ b/crates/socket-patch-cli/tests/cli_parse_scan.rs @@ -122,7 +122,7 @@ fn defaults_match_contract() { assert!(!args.common.yes); assert!(!args.common.global); assert_eq!(args.common.global_prefix, None); - assert_eq!(args.common.api_url, "https://api.socket.dev"); + assert_eq!(args.common.api_url, None); // default applied in core resolver assert_eq!(args.common.api_token, None); assert_eq!(args.common.ecosystems, None); assert!( @@ -249,7 +249,7 @@ fn global_prefix_flag() { #[serial_test::serial] fn api_url_flag() { let args = parse_scan(&["--api-url", "https://api"]); - assert_eq!(args.common.api_url, "https://api"); + assert_eq!(args.common.api_url.as_deref(), Some("https://api")); } #[test] diff --git a/crates/socket-patch-cli/tests/cli_parse_vendor.rs b/crates/socket-patch-cli/tests/cli_parse_vendor.rs index 31db6683..7dcaa0c4 100644 --- a/crates/socket-patch-cli/tests/cli_parse_vendor.rs +++ b/crates/socket-patch-cli/tests/cli_parse_vendor.rs @@ -157,10 +157,10 @@ fn parse_vendor_with_env(env: &[(&str, &str)], extra: &[&str]) -> Result, api_token: Option, org: Option, - proxy_url: String, + proxy_url: Option, ecosystems: Option>, download_mode: String, offline: bool, @@ -228,10 +228,10 @@ fn expected_defaults() -> Snap { Snap { cwd: PathBuf::from("."), manifest_path: ".socket/manifest.json".to_string(), - api_url: "https://api.socket.dev".to_string(), + api_url: None, // no clap default — resolved in core api_token: None, org: None, - proxy_url: "https://patches-api.socket.dev".to_string(), + proxy_url: None, // no clap default — resolved in core ecosystems: None, download_mode: "diff".to_string(), offline: false, diff --git a/crates/socket-patch-cli/tests/cli_parse_vex.rs b/crates/socket-patch-cli/tests/cli_parse_vex.rs index ec6e1f1b..2a530f84 100644 --- a/crates/socket-patch-cli/tests/cli_parse_vex.rs +++ b/crates/socket-patch-cli/tests/cli_parse_vex.rs @@ -193,10 +193,10 @@ fn empty_vex_compact_env_parses_as_false_on_scan() { struct Snap { cwd: PathBuf, manifest_path: String, - api_url: String, + api_url: Option, api_token: Option, org: Option, - proxy_url: String, + proxy_url: Option, ecosystems: Option>, download_mode: String, vendor_source: String, @@ -266,10 +266,10 @@ fn expected_defaults() -> Snap { Snap { cwd: PathBuf::from("."), manifest_path: ".socket/manifest.json".to_string(), - api_url: "https://api.socket.dev".to_string(), + api_url: None, // no clap default — resolved in core api_token: None, org: None, - proxy_url: "https://patches-api.socket.dev".to_string(), + proxy_url: None, // no clap default — resolved in core ecosystems: None, download_mode: "diff".to_string(), vendor_source: "auto".to_string(), diff --git a/crates/socket-patch-cli/tests/in_process_cargo_apply.rs b/crates/socket-patch-cli/tests/in_process_cargo_apply.rs index 8fbf8c53..e71f5c99 100644 --- a/crates/socket-patch-cli/tests/in_process_cargo_apply.rs +++ b/crates/socket-patch-cli/tests/in_process_cargo_apply.rs @@ -228,7 +228,7 @@ async fn cargo_fetch_scan_sync_patches_real_file() { global: true, // use global registry; cargo crawler then probes CARGO_HOME global_prefix: None, - api_url: server.uri(), + api_url: Some(server.uri()), api_token: Some("fake".to_string()), ecosystems: Some(vec!["cargo".to_string()]), download_mode: "diff".to_string(), @@ -342,7 +342,7 @@ async fn cargo_apply_refuses_on_before_hash_mismatch() { yes: true, global: true, global_prefix: None, - api_url: server.uri(), + api_url: Some(server.uri()), api_token: Some("fake".to_string()), ecosystems: Some(vec!["cargo".to_string()]), download_mode: "diff".to_string(), @@ -443,7 +443,7 @@ async fn cargo_crawler_finds_real_fetched_crate() { yes: true, global: true, global_prefix: None, - api_url: server.uri(), + api_url: Some(server.uri()), api_token: Some("fake".to_string()), ecosystems: Some(vec!["cargo".to_string()]), download_mode: "diff".to_string(), diff --git a/crates/socket-patch-cli/tests/in_process_gem_apply.rs b/crates/socket-patch-cli/tests/in_process_gem_apply.rs index a34cc86a..15f7fa4b 100644 --- a/crates/socket-patch-cli/tests/in_process_gem_apply.rs +++ b/crates/socket-patch-cli/tests/in_process_gem_apply.rs @@ -198,7 +198,7 @@ async fn gem_install_scan_sync_patches_real_file() { yes: true, global: false, global_prefix: None, - api_url: server.uri(), + api_url: Some(server.uri()), api_token: Some("fake".to_string()), ecosystems: Some(vec!["gem".to_string()]), download_mode: "diff".to_string(), @@ -309,7 +309,7 @@ async fn gem_crawler_finds_real_installed_gem() { yes: true, global: false, global_prefix: None, - api_url: server.uri(), + api_url: Some(server.uri()), api_token: Some("fake".to_string()), ecosystems: Some(vec!["gem".to_string()]), download_mode: "diff".to_string(), diff --git a/crates/socket-patch-cli/tests/in_process_gem_multi_platform.rs b/crates/socket-patch-cli/tests/in_process_gem_multi_platform.rs index 7c237498..69aa5d55 100644 --- a/crates/socket-patch-cli/tests/in_process_gem_multi_platform.rs +++ b/crates/socket-patch-cli/tests/in_process_gem_multi_platform.rs @@ -220,7 +220,7 @@ fn scan_args(cwd: &Path, api_url: String, all_releases: bool) -> ScanArgs { yes: true, global: false, global_prefix: None, - api_url, + api_url: Some(api_url), api_token: Some("fake".to_string()), ecosystems: Some(vec!["gem".to_string()]), download_mode: "diff".to_string(), @@ -477,7 +477,7 @@ async fn remove_base_purl_clears_all_platforms_and_rolls_back() { common: socket_patch_cli::args::GlobalArgs { cwd: tmp.path().to_path_buf(), org: Some(ORG.to_string()), - api_url: server.uri(), + api_url: Some(server.uri()), api_token: Some("fake".to_string()), json: true, yes: true, @@ -520,7 +520,7 @@ async fn rollback_all_over_broad_manifest_succeeds() { common: socket_patch_cli::args::GlobalArgs { cwd: tmp.path().to_path_buf(), org: Some(ORG.to_string()), - api_url: server.uri(), + api_url: Some(server.uri()), api_token: Some("fake".to_string()), json: true, ecosystems: Some(vec!["gem".to_string()]), diff --git a/crates/socket-patch-cli/tests/in_process_get.rs b/crates/socket-patch-cli/tests/in_process_get.rs index ca859448..8ac31c65 100644 --- a/crates/socket-patch-cli/tests/in_process_get.rs +++ b/crates/socket-patch-cli/tests/in_process_get.rs @@ -185,7 +185,7 @@ async fn get_by_uuid_save_only_writes_manifest() { let tmp = tempfile::tempdir().unwrap(); let mut args = default_args(UUID, tmp.path()); - args.common.api_url = url; + args.common.api_url = Some(url); let code = run(args).await; assert_eq!(code, 0, "expected exit 0"); @@ -201,7 +201,7 @@ async fn get_by_uuid_writes_blob_to_socket_dir() { let tmp = tempfile::tempdir().unwrap(); let mut args = default_args(UUID, tmp.path()); - args.common.api_url = url; + args.common.api_url = Some(url); let code = run(args).await; assert_eq!(code, 0); @@ -225,7 +225,7 @@ async fn get_by_uuid_404_emits_not_found() { let tmp = tempfile::tempdir().unwrap(); let mut args = default_args(UUID, tmp.path()); - args.common.api_url = url; + args.common.api_url = Some(url); let code = run(args).await; assert_eq!( @@ -250,7 +250,7 @@ async fn get_by_uuid_500_handled_gracefully() { let tmp = tempfile::tempdir().unwrap(); let mut args = default_args(UUID, tmp.path()); - args.common.api_url = url; + args.common.api_url = Some(url); let code = run(args).await; // A 500 from the view endpoint is a fetch error: it flows through @@ -274,7 +274,7 @@ async fn get_by_cve_resolves_and_saves() { let tmp = tempfile::tempdir().unwrap(); let mut args = default_args("CVE-2024-12345", tmp.path()); - args.common.api_url = url; + args.common.api_url = Some(url); let code = run(args).await; assert_eq!(code, 0); @@ -289,7 +289,7 @@ async fn get_by_cve_no_match_no_manifest_written() { let tmp = tempfile::tempdir().unwrap(); let mut args = default_args("CVE-2099-99999", tmp.path()); - args.common.api_url = url; + args.common.api_url = Some(url); // An empty search result is a clean "nothing to do": exit 0 with no // side effects. Asserting the exit code (not `let _ =`) catches a @@ -309,7 +309,7 @@ async fn get_by_ghsa_resolves_and_saves() { let tmp = tempfile::tempdir().unwrap(); let mut args = default_args(ghsa, tmp.path()); - args.common.api_url = url; + args.common.api_url = Some(url); let code = run(args).await; assert_eq!(code, 0); @@ -330,7 +330,7 @@ async fn get_by_purl_single_patch_auto_selects() { let tmp = tempfile::tempdir().unwrap(); let mut args = default_args(PURL, tmp.path()); - args.common.api_url = url; + args.common.api_url = Some(url); let code = run(args).await; assert_eq!(code, 0); @@ -365,7 +365,7 @@ async fn get_by_purl_multi_patch_in_json_mode_errors() { let tmp = tempfile::tempdir().unwrap(); let mut args = default_args(purl, tmp.path()); - args.common.api_url = url; + args.common.api_url = Some(url); let code = run(args).await; // Two distinct free patches for one PURL + --json: `select_patches` @@ -393,7 +393,7 @@ async fn get_with_id_flag_forces_uuid_path() { let tmp = tempfile::tempdir().unwrap(); let mut args = default_args(UUID, tmp.path()); - args.common.api_url = url; + args.common.api_url = Some(url); args.id = true; let code = run(args).await; @@ -416,7 +416,7 @@ async fn get_with_explicit_cve_flag() { let tmp = tempfile::tempdir().unwrap(); let mut args = default_args(cve, tmp.path()); - args.common.api_url = url; + args.common.api_url = Some(url); args.cve = true; assert_eq!(run(args).await, 0); @@ -433,7 +433,7 @@ async fn get_with_explicit_ghsa_flag() { let tmp = tempfile::tempdir().unwrap(); let mut args = default_args(ghsa, tmp.path()); - args.common.api_url = url; + args.common.api_url = Some(url); args.ghsa = true; assert_eq!(run(args).await, 0); @@ -468,7 +468,7 @@ async fn get_with_explicit_package_no_install_short_circuits() { let tmp = tempfile::tempdir().unwrap(); let mut args = default_args(name, tmp.path()); - args.common.api_url = url; + args.common.api_url = Some(url); args.package = true; let code = run(args).await; @@ -507,7 +507,7 @@ async fn get_with_explicit_package_flag_resolves_installed_and_saves() { // Identifier is the installed package name; --package forces the package // resolution path rather than treating it as a PURL/UUID. let mut args = default_args("in-process-test", tmp.path()); - args.common.api_url = url; + args.common.api_url = Some(url); args.package = true; let code = run(args).await; @@ -566,7 +566,7 @@ async fn get_one_off_with_save_only_errors() { let tmp = tempfile::tempdir().unwrap(); let mut args = default_args(UUID, tmp.path()); - args.common.api_url = url; + args.common.api_url = Some(url); args.one_off = true; args.save_only = true; @@ -594,7 +594,7 @@ async fn get_one_off_is_an_honest_not_implemented_error() { let tmp = tempfile::tempdir().unwrap(); let mut args = default_args(UUID, tmp.path()); - args.common.api_url = url; + args.common.api_url = Some(url); args.one_off = true; args.save_only = false; @@ -613,7 +613,7 @@ async fn get_one_off_is_an_honest_not_implemented_error() { async fn get_unreachable_api_handled_gracefully() { let tmp = tempfile::tempdir().unwrap(); let mut args = default_args(UUID, tmp.path()); - args.common.api_url = "http://127.0.0.1:1".to_string(); // unreachable + args.common.api_url = Some("http://127.0.0.1:1".to_string()); // unreachable let code = run(args).await; // A connection refused on the view endpoint is a fetch error and must // surface as exit 1 (via `report_fetch_failure`). The previous @@ -634,7 +634,7 @@ async fn get_uuid_non_json_save_only() { let tmp = tempfile::tempdir().unwrap(); let mut args = default_args(UUID, tmp.path()); - args.common.api_url = url; + args.common.api_url = Some(url); args.common.json = false; assert_eq!(run(args).await, 0); @@ -653,7 +653,7 @@ async fn get_download_mode_package() { let tmp = tempfile::tempdir().unwrap(); let mut args = default_args(UUID, tmp.path()); - args.common.api_url = url; + args.common.api_url = Some(url); args.common.download_mode = "package".to_string(); assert_eq!(run(args).await, 0); // save_only short-circuits before apply, so download_mode is not @@ -669,7 +669,7 @@ async fn get_download_mode_file() { let tmp = tempfile::tempdir().unwrap(); let mut args = default_args(UUID, tmp.path()); - args.common.api_url = url; + args.common.api_url = Some(url); args.common.download_mode = "file".to_string(); assert_eq!(run(args).await, 0); assert_patch_saved(tmp.path(), PURL, UUID); @@ -683,7 +683,7 @@ async fn get_invalid_download_mode_handled() { let tmp = tempfile::tempdir().unwrap(); let mut args = default_args(UUID, tmp.path()); - args.common.api_url = url; + args.common.api_url = Some(url); args.common.download_mode = "nonsense".to_string(); // FINDING: an invalid download mode is NOT validated on the save_only diff --git a/crates/socket-patch-cli/tests/in_process_get_corrupt_manifest.rs b/crates/socket-patch-cli/tests/in_process_get_corrupt_manifest.rs index eb878d8c..5185f12e 100644 --- a/crates/socket-patch-cli/tests/in_process_get_corrupt_manifest.rs +++ b/crates/socket-patch-cli/tests/in_process_get_corrupt_manifest.rs @@ -53,10 +53,10 @@ async fn uuid_get_with_corrupt_manifest_fails_without_clobbering() { identifier: UUID.to_string(), common: GlobalArgs { cwd: tmp.path().to_path_buf(), - api_url: server.uri(), + api_url: Some(server.uri()), api_token: Some("fake-token".to_string()), org: Some(ORG.to_string()), - proxy_url: server.uri(), + proxy_url: Some(server.uri()), json: true, no_telemetry: true, ..GlobalArgs::default() diff --git a/crates/socket-patch-cli/tests/in_process_get_manifest_path.rs b/crates/socket-patch-cli/tests/in_process_get_manifest_path.rs index b361b985..2a1b1c70 100644 --- a/crates/socket-patch-cli/tests/in_process_get_manifest_path.rs +++ b/crates/socket-patch-cli/tests/in_process_get_manifest_path.rs @@ -83,7 +83,7 @@ fn get_args(identifier: &str, cwd: &Path, api_url: String) -> GetArgs { cwd: cwd.to_path_buf(), yes: true, api_token: Some("fake-token-for-tests".to_string()), - api_url, + api_url: Some(api_url), json: true, no_telemetry: true, download_mode: "diff".to_string(), diff --git a/crates/socket-patch-cli/tests/in_process_get_uuid_fallback.rs b/crates/socket-patch-cli/tests/in_process_get_uuid_fallback.rs index ea7ac3ad..fe5a8d1d 100644 --- a/crates/socket-patch-cli/tests/in_process_get_uuid_fallback.rs +++ b/crates/socket-patch-cli/tests/in_process_get_uuid_fallback.rs @@ -57,10 +57,10 @@ async fn stale_token_uuid_get_falls_back_to_proxy_end_to_end() { identifier: UUID.to_string(), common: GlobalArgs { cwd: tmp.path().to_path_buf(), - api_url: server.uri(), + api_url: Some(server.uri()), api_token: Some("stale-token".to_string()), org: Some(ORG.to_string()), - proxy_url: server.uri(), + proxy_url: Some(server.uri()), json: true, no_telemetry: true, ..GlobalArgs::default() diff --git a/crates/socket-patch-cli/tests/in_process_pypi_apply.rs b/crates/socket-patch-cli/tests/in_process_pypi_apply.rs index 703cbf24..4d40f010 100644 --- a/crates/socket-patch-cli/tests/in_process_pypi_apply.rs +++ b/crates/socket-patch-cli/tests/in_process_pypi_apply.rs @@ -255,7 +255,7 @@ async fn pypi_install_scan_sync_patches_real_file() { yes: true, global: false, global_prefix: None, - api_url: server.uri(), + api_url: Some(server.uri()), api_token: Some("fake".to_string()), ecosystems: Some(vec!["pypi".to_string()]), download_mode: "diff".to_string(), @@ -330,7 +330,7 @@ async fn pypi_scan_then_apply_force_patches_real_file() { yes: true, global: false, global_prefix: None, - api_url: server.uri(), + api_url: Some(server.uri()), api_token: Some("fake".to_string()), ecosystems: Some(vec!["pypi".to_string()]), download_mode: "diff".to_string(), @@ -437,7 +437,7 @@ async fn pypi_apply_dry_run_does_not_modify_file() { yes: true, global: false, global_prefix: None, - api_url: server.uri(), + api_url: Some(server.uri()), api_token: Some("fake".to_string()), ecosystems: Some(vec!["pypi".to_string()]), download_mode: "diff".to_string(), @@ -568,7 +568,7 @@ async fn pypi_crawler_finds_real_installed_six() { yes: true, global: false, global_prefix: None, - api_url: server.uri(), + api_url: Some(server.uri()), api_token: Some("fake".to_string()), ecosystems: Some(vec!["pypi".to_string()]), download_mode: "diff".to_string(), diff --git a/crates/socket-patch-cli/tests/in_process_pypi_multi_release.rs b/crates/socket-patch-cli/tests/in_process_pypi_multi_release.rs index e7a61c93..2b996a86 100644 --- a/crates/socket-patch-cli/tests/in_process_pypi_multi_release.rs +++ b/crates/socket-patch-cli/tests/in_process_pypi_multi_release.rs @@ -298,7 +298,7 @@ fn scan_args(tmp: &Path, api_url: String, all_releases: bool) -> ScanArgs { yes: true, global: false, global_prefix: None, - api_url, + api_url: Some(api_url), api_token: Some("fake".to_string()), ecosystems: Some(vec!["pypi".to_string()]), download_mode: "diff".to_string(), @@ -509,7 +509,7 @@ async fn remove_base_purl_clears_all_variants_and_rolls_back() { common: socket_patch_cli::args::GlobalArgs { cwd: tmp.path().to_path_buf(), org: Some(ORG.to_string()), - api_url: fx.server.uri(), + api_url: Some(fx.server.uri()), api_token: Some("fake".to_string()), json: true, yes: true, @@ -568,7 +568,7 @@ async fn rollback_all_over_broad_manifest_succeeds() { common: socket_patch_cli::args::GlobalArgs { cwd: tmp.path().to_path_buf(), org: Some(ORG.to_string()), - api_url: fx.server.uri(), + api_url: Some(fx.server.uri()), api_token: Some("fake".to_string()), json: true, ecosystems: Some(vec!["pypi".to_string()]), diff --git a/crates/socket-patch-cli/tests/in_process_python_envs.rs b/crates/socket-patch-cli/tests/in_process_python_envs.rs index 66f93a89..3c78cb03 100644 --- a/crates/socket-patch-cli/tests/in_process_python_envs.rs +++ b/crates/socket-patch-cli/tests/in_process_python_envs.rs @@ -121,7 +121,7 @@ fn default_args(cwd: &Path, api_url: String) -> ScanArgs { yes: true, global: false, global_prefix: None, - api_url, + api_url: Some(api_url), api_token: Some("fake".to_string()), ecosystems: Some(vec!["pypi".to_string()]), download_mode: "diff".to_string(), diff --git a/crates/socket-patch-cli/tests/in_process_redirect.rs b/crates/socket-patch-cli/tests/in_process_redirect.rs index 1e7ad5f3..9e83540b 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect.rs @@ -33,7 +33,7 @@ fn redirect_args(cwd: &Path, api_url: String) -> ScanArgs { cwd: cwd.to_path_buf(), org: Some(ORG.to_string()), api_token: Some("fake".to_string()), - api_url, + api_url: Some(api_url), json: true, yes: true, ..socket_patch_cli::args::GlobalArgs::default() diff --git a/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs b/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs index 5623660e..8229322d 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs @@ -34,7 +34,7 @@ fn hosted_args(cwd: &Path, api_url: String) -> ScanArgs { cwd: cwd.to_path_buf(), org: Some(ORG.to_string()), api_token: Some("fake".to_string()), - api_url, + api_url: Some(api_url), json: true, yes: true, ..socket_patch_cli::args::GlobalArgs::default() diff --git a/crates/socket-patch-cli/tests/in_process_remote_ecosystems_apply.rs b/crates/socket-patch-cli/tests/in_process_remote_ecosystems_apply.rs index 50164be9..08223aee 100644 --- a/crates/socket-patch-cli/tests/in_process_remote_ecosystems_apply.rs +++ b/crates/socket-patch-cli/tests/in_process_remote_ecosystems_apply.rs @@ -80,7 +80,7 @@ fn default_scan_args(cwd: &Path, eco: &str, api_url: String) -> ScanArgs { global: true, // bypass per-ecosystem project-marker check global_prefix: None, - api_url, + api_url: Some(api_url), api_token: Some("fake".to_string()), ecosystems: Some(vec![eco.to_string()]), download_mode: "diff".to_string(), diff --git a/crates/socket-patch-cli/tests/in_process_scan.rs b/crates/socket-patch-cli/tests/in_process_scan.rs index 2d18e033..6b9dcb81 100644 --- a/crates/socket-patch-cli/tests/in_process_scan.rs +++ b/crates/socket-patch-cli/tests/in_process_scan.rs @@ -206,7 +206,7 @@ async fn scan_empty_project_json() { let tmp = tempfile::tempdir().unwrap(); write_root_package_json(tmp.path()); let mut args = default_args(tmp.path()); - args.common.api_url = server.uri(); + args.common.api_url = Some(server.uri()); assert_eq!(run_scrubbed(args).await, 0); // An empty project crawls zero packages, so the batch API must never @@ -230,7 +230,7 @@ async fn scan_installed_package_discovers_patch() { write_root_package_json(tmp.path()); write_npm_package(tmp.path(), "in-proc-scan", "1.0.0"); let mut args = default_args(tmp.path()); - args.common.api_url = server.uri(); + args.common.api_url = Some(server.uri()); assert_eq!(run_scrubbed(args).await, 0); // The installed package must actually be discovered by the crawler and @@ -261,7 +261,7 @@ async fn scan_apply_dry_run_does_not_write() { write_root_package_json(tmp.path()); write_npm_package(tmp.path(), "in-proc-scan", "1.0.0"); let mut args = default_args(tmp.path()); - args.common.api_url = server.uri(); + args.common.api_url = Some(server.uri()); args.apply = true; args.common.dry_run = true; @@ -299,7 +299,7 @@ async fn scan_apply_wet_writes_manifest_and_blob() { write_root_package_json(tmp.path()); write_npm_package(tmp.path(), "in-proc-scan", "1.0.0"); let mut args = default_args(tmp.path()); - args.common.api_url = server.uri(); + args.common.api_url = Some(server.uri()); args.apply = true; let code = run_scrubbed(args).await; @@ -374,7 +374,7 @@ async fn scan_prune_only_dry_run_reports_orphans() { .unwrap(); let mut args = default_args(tmp.path()); - args.common.api_url = server.uri(); + args.common.api_url = Some(server.uri()); args.prune = true; args.common.dry_run = true; @@ -432,7 +432,7 @@ async fn scan_prune_only_wet_removes_orphans() { .unwrap(); let mut args = default_args(tmp.path()); - args.common.api_url = server.uri(); + args.common.api_url = Some(server.uri()); args.prune = true; assert_eq!(run_scrubbed(args).await, 0); @@ -470,7 +470,7 @@ async fn scan_sync_full_cycle_against_clean_project() { write_root_package_json(tmp.path()); write_npm_package(tmp.path(), "in-proc-scan", "1.0.0"); let mut args = default_args(tmp.path()); - args.common.api_url = server.uri(); + args.common.api_url = Some(server.uri()); args.sync = true; let code = run_scrubbed(args).await; @@ -517,7 +517,7 @@ async fn scan_small_batch_size_chunks_requests() { write_npm_package(tmp.path(), "pkg-c", "3.0.0"); let mut args = default_args(tmp.path()); - args.common.api_url = server.uri(); + args.common.api_url = Some(server.uri()); args.batch_size = 1; // force 3 separate API calls assert_eq!(run_scrubbed(args).await, 0); // The whole point of this test: batch_size=1 over 3 discovered packages @@ -571,7 +571,7 @@ async fn scan_ecosystems_filter_excludes_others() { write_npm_package(tmp.path(), "npm-pkg", "1.0.0"); let mut args = default_args(tmp.path()); - args.common.api_url = server.uri(); + args.common.api_url = Some(server.uri()); args.common.ecosystems = Some(vec!["pypi".to_string()]); assert_eq!(run_scrubbed(args).await, 0); // The npm package must be filtered out by `--ecosystems pypi`. With no @@ -603,7 +603,7 @@ async fn scan_non_json_with_patches_prints_table() { write_root_package_json(tmp.path()); write_npm_package(tmp.path(), "in-proc-scan", "1.0.0"); let mut args = default_args(tmp.path()); - args.common.api_url = server.uri(); + args.common.api_url = Some(server.uri()); args.common.json = false; let code = run_scrubbed(args).await; @@ -637,7 +637,7 @@ async fn scan_non_json_empty_project_friendly_message() { let tmp = tempfile::tempdir().unwrap(); write_root_package_json(tmp.path()); let mut args = default_args(tmp.path()); - args.common.api_url = server.uri(); + args.common.api_url = Some(server.uri()); args.common.json = false; assert_eq!(run_scrubbed(args).await, 0); @@ -682,7 +682,7 @@ async fn scan_api_500_does_not_panic() { write_root_package_json(tmp.path()); write_npm_package(tmp.path(), "in-proc-scan", "1.0.0"); let mut args = default_args(tmp.path()); - args.common.api_url = server.uri(); + args.common.api_url = Some(server.uri()); let code = run_scrubbed(args).await; @@ -715,7 +715,7 @@ async fn scan_unreachable_api_does_not_panic() { write_root_package_json(tmp.path()); write_npm_package(tmp.path(), "in-proc-scan", "1.0.0"); let mut args = default_args(tmp.path()); - args.common.api_url = "http://127.0.0.1:1".to_string(); + args.common.api_url = Some("http://127.0.0.1:1".to_string()); let code = run_scrubbed(args).await; @@ -755,7 +755,7 @@ async fn scan_apply_all_detail_queries_failed_is_an_error() { write_root_package_json(tmp.path()); write_npm_package(tmp.path(), "in-proc-scan", "1.0.0"); let mut args = default_args(tmp.path()); - args.common.api_url = server.uri(); + args.common.api_url = Some(server.uri()); args.apply = true; let code = run_scrubbed(args).await; @@ -790,7 +790,7 @@ async fn scan_batch_size_zero_does_not_panic() { write_root_package_json(tmp.path()); write_npm_package(tmp.path(), "in-proc-scan", "1.0.0"); let mut args = default_args(tmp.path()); - args.common.api_url = server.uri(); + args.common.api_url = Some(server.uri()); args.batch_size = 0; // No panic, and the discovered package still reaches the batch endpoint @@ -866,7 +866,7 @@ async fn scan_prune_with_ecosystem_filter_keeps_other_ecosystem() { .unwrap(); let mut args = default_args(tmp.path()); - args.common.api_url = server.uri(); + args.common.api_url = Some(server.uri()); args.common.ecosystems = Some(vec!["npm".to_string()]); args.prune = true; @@ -925,7 +925,7 @@ async fn scan_ignores_ambient_virtual_env() { let tmp = tempfile::tempdir().unwrap(); write_root_package_json(tmp.path()); let mut args = default_args(tmp.path()); - args.common.api_url = server.uri(); + args.common.api_url = Some(server.uri()); std::env::set_var("VIRTUAL_ENV", decoy.path()); let code = run_scrubbed(args).await; @@ -979,7 +979,7 @@ async fn scan_non_json_dry_run_does_not_mutate() { let before = std::fs::read_to_string(socket.join("manifest.json")).unwrap(); let mut args = default_args(tmp.path()); - args.common.api_url = server.uri(); + args.common.api_url = Some(server.uri()); args.common.json = false; // interactive path args.prune = true; args.common.dry_run = true; From f467bd86afee774e5c73a62d5b3cb602c83fce87 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 21 Jul 2026 14:17:02 -0400 Subject: [PATCH 2/5] feat(config): read socket-cli's persisted config.json as a fallback layer New socket_patch_core::utils::socket_cli_config reads the JS socket-cli login state (/socket/settings/config.json, base64 JSON, read-only) and exposes it as the resolution layer below env vars and above built-in defaults for apiToken/defaultOrg/apiBaseUrl. One 'socket login' now configures socket-patch too. - get_api_client_with_overrides: flag > env > config > default per key; SOCKET_NO_API_TOKEN vetoes ambient tokens (env+config, explicit --api-token still wins); SOCKET_NO_CONFIG disables the layer. - telemetry: resolve_telemetry_endpoint now shares resolve_api_base_url() with client construction so the two can't disagree about the API host. - env_compat: SOCKET_CLI_API_TOKEN / SOCKET_CLI_ORG_SLUG / SOCKET_CLI_API_BASE_URL / SOCKET_CLI_NO_API_TOKEN accepted as silent peer aliases (canonical SOCKET_* names win; no deprecation warning). - corrupt/unreadable config warns once on stderr and is ignored; missing file is silent; --json stdout purity unaffected. Co-Authored-By: Claude Fable 5 --- crates/socket-patch-cli/src/main.rs | 16 +- crates/socket-patch-core/src/api/client.rs | 84 +++- .../socket-patch-core/src/utils/env_compat.rs | 96 ++++ crates/socket-patch-core/src/utils/mod.rs | 1 + .../src/utils/socket_cli_config.rs | 428 ++++++++++++++++++ .../socket-patch-core/src/utils/telemetry.rs | 10 +- 6 files changed, 610 insertions(+), 25 deletions(-) create mode 100644 crates/socket-patch-core/src/utils/socket_cli_config.rs diff --git a/crates/socket-patch-cli/src/main.rs b/crates/socket-patch-cli/src/main.rs index e824e981..9340f439 100644 --- a/crates/socket-patch-cli/src/main.rs +++ b/crates/socket-patch-cli/src/main.rs @@ -1,5 +1,6 @@ use socket_patch_cli::{commands, parse_with_uuid_fallback, Commands}; -use socket_patch_core::utils::env_compat::promote_legacy_env_vars; +use socket_patch_core::utils::env_compat::{promote_legacy_env_vars, promote_peer_env_vars}; +use socket_patch_core::utils::socket_cli_config; /// Restore the default SIGPIPE disposition. The Rust runtime starts every /// process with SIGPIPE ignored, so once a pipeline consumer exits @@ -31,6 +32,19 @@ async fn main() { // names. A one-shot deprecation warning fires per legacy name set. promote_legacy_env_vars(); + // Then accept the JS socket-cli's SOCKET_CLI_* peer names (silently — + // they are aliases, not deprecations) so `socket login` / socket-cli + // env setups work for socket-patch unchanged. Canonical names win. + promote_peer_env_vars(); + + // SOCKET_NO_API_TOKEN (or its SOCKET_CLI_ alias, promoted above) + // suppresses ambient tokens: scrub the env var before clap parses so + // only an explicit `--api-token` flag can authenticate. Core applies + // the same veto to its own env/config fallback layers. + if socket_cli_config::no_api_token_veto() { + std::env::remove_var("SOCKET_API_TOKEN"); + } + // Then drop exported-but-empty SOCKET_* flag vars — global and // subcommand-local (`SOCKET_CWD=` means "unset", not "crash the // parse"). Must run after the promotion so a blanked legacy name is diff --git a/crates/socket-patch-core/src/api/client.rs b/crates/socket-patch-core/src/api/client.rs index abffa7a4..ab0445fd 100644 --- a/crates/socket-patch-core/src/api/client.rs +++ b/crates/socket-patch-core/src/api/client.rs @@ -5,8 +5,9 @@ use reqwest::StatusCode; use serde::Serialize; use crate::api::types::*; -use crate::constants::{DEFAULT_SOCKET_API_URL, USER_AGENT as USER_AGENT_VALUE}; +use crate::constants::USER_AGENT as USER_AGENT_VALUE; use crate::utils::env_compat::{is_debug_enabled, proxy_url_from_env}; +use crate::utils::socket_cli_config; /// Log debug messages when debug mode is enabled. fn debug_log(message: &str) { @@ -1042,24 +1043,29 @@ pub struct ApiClientEnvOverrides { pub proxy_url: Option, } -/// Get an API client configured from environment variables. +/// Get an API client configured from environment variables (and, below +/// them, the socket-cli config file — see +/// [`crate::utils::socket_cli_config`]). /// -/// If `SOCKET_API_TOKEN` is not set, the client will use the public patch -/// API proxy which provides free access to free-tier patches without -/// authentication. +/// If no API token is found (env, then socket-cli config), the client will +/// use the public patch API proxy which provides free access to free-tier +/// patches without authentication. /// -/// When `SOCKET_API_TOKEN` is set but no org slug is provided (neither via -/// argument nor `SOCKET_ORG_SLUG` env var), the function will attempt to -/// auto-resolve the org slug by querying `GET /v0/organizations`. +/// When a token is set but no org slug is provided (argument, +/// `SOCKET_ORG_SLUG` env var, or socket-cli config `defaultOrg`), the +/// function will attempt to auto-resolve the org slug by querying +/// `GET /v0/organizations`. /// /// # Environment variables /// /// | Variable | Purpose | /// |---|---| -/// | `SOCKET_API_URL` | Override the API URL (default `https://api.socket.dev`) | -/// | `SOCKET_API_TOKEN` | API token for authenticated access | +/// | `SOCKET_API_URL` | Override the API URL (default `https://api.socket.dev`; socket-cli config `apiBaseUrl` sits between) | +/// | `SOCKET_API_TOKEN` | API token for authenticated access (socket-cli config `apiToken` is the fallback) | /// | `SOCKET_PROXY_URL` | Override the public proxy URL (default `https://patches-api.socket.dev`). Legacy: `SOCKET_PATCH_PROXY_URL`. | -/// | `SOCKET_ORG_SLUG` | Organization slug | +/// | `SOCKET_ORG_SLUG` | Organization slug (socket-cli config `defaultOrg` is the fallback) | +/// | `SOCKET_NO_API_TOKEN` | Truthy: ignore ambient tokens (env + config); only an explicit override authenticates | +/// | `SOCKET_NO_CONFIG` | Truthy: disable the socket-cli config fallback layer entirely | /// /// Returns `(client, use_public_proxy)`. pub async fn get_api_client_from_env(org_slug: Option<&str>) -> (ApiClient, bool) { @@ -1076,22 +1082,60 @@ pub async fn get_api_client_from_env(org_slug: Option<&str>) -> (ApiClient, bool /// `--api-token`, `--org`, `--proxy-url` flags via [`crate::utils`] in the /// CLI crate. pub async fn get_api_client_with_overrides(overrides: ApiClientEnvOverrides) -> (ApiClient, bool) { + // Per-key fallback chain: explicit override (CLI flag) → env var → + // socket-cli config file → built-in default. Empty strings mean + // "unset" at every layer. `SOCKET_NO_API_TOKEN` vetoes the *ambient* + // token sources (env + config) so unauthenticated behavior can be + // forced without unsetting anything; an explicit override still wins. let api_token = overrides .api_token - .or_else(|| std::env::var("SOCKET_API_TOKEN").ok()) - .filter(|t| !t.is_empty()); + .filter(|t| !t.is_empty()) + .or_else(|| { + if socket_cli_config::no_api_token_veto() { + debug_log("api token: suppressed by SOCKET_NO_API_TOKEN"); + return None; + } + std::env::var("SOCKET_API_TOKEN") + .ok() + .filter(|t| !t.is_empty()) + .or_else(|| { + socket_cli_config::load() + .and_then(|c| c.api_token.clone()) + .inspect(|_| { + debug_log("api token: from socket-cli config (`socket login`)"); + }) + }) + }); let resolved_org_slug = overrides .org_slug - .or_else(|| std::env::var("SOCKET_ORG_SLUG").ok()) + .filter(|s| !s.is_empty()) // Treat an empty slug as "not provided" (mirroring the api_token // handling above). Otherwise `SOCKET_ORG_SLUG=""` would be taken as // an explicit slug, skip auto-resolution, and build broken // `/v0/orgs//patches/...` URLs with an empty slug segment. - .filter(|s| !s.is_empty()); + .or_else(|| { + std::env::var("SOCKET_ORG_SLUG") + .ok() + .filter(|s| !s.is_empty()) + }) + .or_else(|| { + socket_cli_config::load() + .and_then(|c| c.default_org.clone()) + .inspect(|slug| { + debug_log(&format!("org slug: `{slug}` from socket-cli config")); + }) + }); if api_token.is_none() { - let proxy_url = overrides.proxy_url.unwrap_or_else(proxy_url_from_env); - eprintln!("No SOCKET_API_TOKEN set. Using public patch API proxy (free patches only)."); + let proxy_url = overrides + .proxy_url + .filter(|u| !u.is_empty()) + .unwrap_or_else(proxy_url_from_env); + eprintln!( + "No SOCKET_API_TOKEN set (and no socket-cli login found) — using the \ + public patch API proxy (free patches only). Run `socket login` or set \ + SOCKET_API_TOKEN to access org patches." + ); let client = ApiClient::new(ApiClientOptions { api_url: proxy_url, api_token: None, @@ -1111,8 +1155,10 @@ pub async fn get_api_client_with_overrides(overrides: ApiClientEnvOverrides) -> let api_url = overrides .api_url - .or_else(|| std::env::var("SOCKET_API_URL").ok()) - .unwrap_or_else(|| DEFAULT_SOCKET_API_URL.to_string()); + .filter(|u| !u.is_empty()) + // Env → socket-cli config `apiBaseUrl` → default; shared with the + // telemetry endpoint resolver so the two can't disagree. + .unwrap_or_else(socket_cli_config::resolve_api_base_url); // Auto-resolve org slug if not provided let final_org_slug = if resolved_org_slug.is_some() { diff --git a/crates/socket-patch-core/src/utils/env_compat.rs b/crates/socket-patch-core/src/utils/env_compat.rs index c73118b1..9bfabd95 100644 --- a/crates/socket-patch-core/src/utils/env_compat.rs +++ b/crates/socket-patch-core/src/utils/env_compat.rs @@ -103,6 +103,49 @@ pub fn promote_legacy_env_vars() { ]); } +/// Peer env-var aliases accepted from the sibling JS Socket CLI, so an +/// environment configured for `socket` (e.g. a CI job exporting +/// `SOCKET_CLI_API_TOKEN`) works for `socket-patch` unchanged. +/// +/// First entry is the canonical `SOCKET_*` name (what clap and core read); +/// second is the accepted `SOCKET_CLI_*` peer name. Unlike +/// [`promote_legacy_env_vars`] these are **not** deprecated — promotion is +/// silent and the canonical name simply wins when both are set. The list is +/// deliberately tight: `SOCKET_CLI_CONFIG` (ephemeral JSON override), +/// `SOCKET_CLI_API_PROXY` (an HTTP forward proxy — reqwest already honors +/// `HTTP_PROXY`/`HTTPS_PROXY`), and `SOCKET_CLI_DEBUG` are intentionally +/// not mirrored. +pub const PEER_ENV_ALIASES: &[(&str, &str)] = &[ + ("SOCKET_API_TOKEN", "SOCKET_CLI_API_TOKEN"), + ("SOCKET_ORG_SLUG", "SOCKET_CLI_ORG_SLUG"), + ("SOCKET_API_URL", "SOCKET_CLI_API_BASE_URL"), + ("SOCKET_NO_API_TOKEN", "SOCKET_CLI_NO_API_TOKEN"), +]; + +/// Silently copy each set-and-non-empty [`PEER_ENV_ALIASES`] value onto its +/// canonical `SOCKET_*` name when the canonical name is unset or empty. +/// Call once, early in `main`, right after [`promote_legacy_env_vars`] and +/// before the empty-var scrub / clap parse. +pub fn promote_peer_env_vars() { + promote_aliases(PEER_ENV_ALIASES); +} + +/// Core of [`promote_peer_env_vars`], parameterized over the alias table so +/// tests can use isolated env-var names. +fn promote_aliases(aliases: &[(&str, &str)]) { + for &(canonical, alias) in aliases { + let canonical_set = matches!(std::env::var(canonical).as_deref(), Ok(v) if !v.is_empty()); + if canonical_set { + continue; + } + if let Ok(value) = std::env::var(alias) { + if !value.is_empty() { + std::env::set_var(canonical, value); + } + } + } +} + /// Core of [`promote_legacy_env_vars`], parameterized over the rename table so /// it can be exercised in tests with isolated env-var names (the real names are /// read concurrently by other tests in this binary). @@ -266,4 +309,57 @@ mod tests { assert_eq!(std::env::var(NEW).ok(), None); std::env::remove_var(LEGACY); } + + /// Peer-alias promotion copies a set alias onto the unset canonical + /// name — and, unlike the legacy shim, records **no** deprecation + /// warning (peer names are supported, not deprecated). + #[test] + fn peer_alias_promotes_silently_when_canonical_unset() { + const CANONICAL: &str = "SOCKET_TEST_PEER_PROMOTE"; + const ALIAS: &str = "SOCKET_TEST_PEER_PROMOTE_CLI"; + std::env::remove_var(CANONICAL); + std::env::set_var(ALIAS, "from-alias"); + promote_aliases(&[(CANONICAL, ALIAS)]); + assert_eq!(std::env::var(CANONICAL).ok().as_deref(), Some("from-alias")); + assert!( + !WARNED.lock().unwrap().contains(ALIAS), + "peer promotion must not register a deprecation warning" + ); + std::env::remove_var(CANONICAL); + std::env::remove_var(ALIAS); + } + + /// The canonical name wins when both are set — the alias never clobbers. + #[test] + fn peer_alias_does_not_clobber_canonical() { + const CANONICAL: &str = "SOCKET_TEST_PEER_KEEP"; + const ALIAS: &str = "SOCKET_TEST_PEER_KEEP_CLI"; + std::env::set_var(CANONICAL, "canonical-value"); + std::env::set_var(ALIAS, "alias-value"); + promote_aliases(&[(CANONICAL, ALIAS)]); + assert_eq!( + std::env::var(CANONICAL).ok().as_deref(), + Some("canonical-value") + ); + std::env::remove_var(CANONICAL); + std::env::remove_var(ALIAS); + } + + /// Empty == unset on both sides: an empty canonical is filled from the + /// alias, and an empty alias is never promoted. + #[test] + fn peer_alias_treats_empty_as_unset() { + const CANONICAL: &str = "SOCKET_TEST_PEER_EMPTY"; + const ALIAS: &str = "SOCKET_TEST_PEER_EMPTY_CLI"; + std::env::set_var(CANONICAL, ""); + std::env::set_var(ALIAS, "alias-value"); + promote_aliases(&[(CANONICAL, ALIAS)]); + assert_eq!(std::env::var(CANONICAL).ok().as_deref(), Some("alias-value")); + std::env::remove_var(CANONICAL); + + std::env::set_var(ALIAS, ""); + promote_aliases(&[(CANONICAL, ALIAS)]); + assert_eq!(std::env::var(CANONICAL).ok(), None); + std::env::remove_var(ALIAS); + } } diff --git a/crates/socket-patch-core/src/utils/mod.rs b/crates/socket-patch-core/src/utils/mod.rs index ce437bae..041be6a1 100644 --- a/crates/socket-patch-core/src/utils/mod.rs +++ b/crates/socket-patch-core/src/utils/mod.rs @@ -5,5 +5,6 @@ pub mod fuzzy_match; pub mod process; pub mod purl; pub(crate) mod serde; +pub mod socket_cli_config; pub mod telemetry; pub mod uri; diff --git a/crates/socket-patch-core/src/utils/socket_cli_config.rs b/crates/socket-patch-core/src/utils/socket_cli_config.rs new file mode 100644 index 00000000..2c7c5518 --- /dev/null +++ b/crates/socket-patch-core/src/utils/socket_cli_config.rs @@ -0,0 +1,428 @@ +//! Read-only fallback reader for the JS Socket CLI's persisted config. +//! +//! `socket login` / `socket config set` (the npm `socket` CLI) persist a +//! base64-encoded JSON object at `/socket/settings/config.json`: +//! +//! - Linux: `$XDG_DATA_HOME` or `~/.local/share` +//! - macOS: `$XDG_DATA_HOME` or `~/Library/Application Support` +//! - Windows: `%LOCALAPPDATA%` or `%USERPROFILE%\AppData\Local` +//! +//! socket-patch reads exactly three keys — `apiToken`, `defaultOrg` (with +//! its socket-cli alias `org`), and `apiBaseUrl` — as the resolution layer +//! *below* env vars and *above* built-in defaults, so a single +//! `socket login` configures every Socket tool. The file is **never +//! written**: socket-cli owns it, and its other keys (`apiProxy`, +//! `enforcedOrgs`, `skipAskToPersistDefaultOrg`) encode socket-cli UX +//! policy with no socket-patch analog and are deliberately ignored. +//! +//! Failure semantics: a missing file (or unresolvable data dir) is silent — +//! that is the normal case. A present-but-unreadable or undecodable file +//! warns once on stderr (even under `--silent`/`--json`, matching the +//! legacy-env deprecation warnings) and is then treated as absent; the +//! fallback layer must never break a working command. Diagnostics go to +//! stderr only, so `--json` stdout stays machine-parseable. +//! +//! `SOCKET_NO_CONFIG` (truthy) disables the layer entirely — the escape +//! hatch for hermetic tests and for users who want pure flag+env behavior. + +use std::path::PathBuf; +use std::sync::OnceLock; + +use base64::Engine as _; + +/// The subset of socket-cli's `LocalConfig` that socket-patch honors. +/// Values are non-empty strings; empty/missing/non-string JSON values are +/// normalized to `None` at parse time (empty == unset, the repo-wide rule). +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct SocketCliConfig { + pub api_token: Option, + pub default_org: Option, + pub api_base_url: Option, +} + +/// Read an env var, treating empty (or non-Unicode) as unset. The repo has +/// shipped empty-`HOME` bugs before; an exported-but-blank `XDG_DATA_HOME` +/// must not resolve paths against the filesystem root. +fn env_non_empty(name: &str) -> Option { + std::env::var(name).ok().filter(|v| !v.is_empty()) +} + +/// Truthy check for the config-layer toggles (`SOCKET_NO_CONFIG`, +/// `SOCKET_NO_API_TOKEN`). Accepts the same affirmative vocabulary as the +/// CLI's `parse_bool_flag` (`1`/`true`/`yes`/`on`, case-insensitive); +/// anything else — including unset and empty — is false. +fn env_flag(name: &str) -> bool { + matches!( + std::env::var(name) + .unwrap_or_default() + .trim() + .to_ascii_lowercase() + .as_str(), + "1" | "true" | "yes" | "on" | "y" | "t" + ) +} + +/// `SOCKET_NO_CONFIG` — disable the socket-cli config fallback layer. +pub fn is_config_disabled() -> bool { + env_flag("SOCKET_NO_CONFIG") +} + +/// `SOCKET_NO_API_TOKEN` — ignore ambient API tokens (env var and +/// socket-cli config); only an explicit `--api-token` flag authenticates. +/// Mirrors socket-cli's `SOCKET_CLI_NO_API_TOKEN` (aliased in the CLI). +pub fn no_api_token_veto() -> bool { + env_flag("SOCKET_NO_API_TOKEN") +} + +/// The platform data dir socket-cli resolves in its `getSocketAppDataPath` +/// (`packages/cli/src/constants/paths.mts`) — mirrored exactly so both +/// tools find the same file. +fn data_home() -> Option { + #[cfg(windows)] + { + env_non_empty("LOCALAPPDATA").map(PathBuf::from).or_else(|| { + env_non_empty("USERPROFILE").map(|p| PathBuf::from(p).join("AppData").join("Local")) + }) + } + #[cfg(target_os = "macos")] + { + env_non_empty("XDG_DATA_HOME").map(PathBuf::from).or_else(|| { + env_non_empty("HOME") + .map(|h| PathBuf::from(h).join("Library").join("Application Support")) + }) + } + #[cfg(all(unix, not(target_os = "macos")))] + { + env_non_empty("XDG_DATA_HOME") + .map(PathBuf::from) + .or_else(|| env_non_empty("HOME").map(|h| PathBuf::from(h).join(".local/share"))) + } +} + +/// Full path of socket-cli's persisted config, or `None` when no data dir +/// resolves (e.g. `HOME` unset in a stripped container) — silently absent. +pub fn config_json_path() -> Option { + data_home().map(|d| d.join("socket").join("settings").join("config.json")) +} + +/// Decode the config file body: base64(JSON) per socket-cli, with a plain-JSON +/// fallback for robustness (hand-edited or future-format files). Unknown keys +/// are ignored and known keys with non-string values are treated as unset — +/// the allowlist posture socket-cli itself applies when reading. +fn parse_config_bytes(raw: &[u8]) -> Result { + let text = std::str::from_utf8(raw).map_err(|e| format!("not UTF-8: {e}"))?; + let trimmed = text.trim(); + let value: serde_json::Value = match base64::engine::general_purpose::STANDARD + .decode(trimmed) + .ok() + .and_then(|decoded| serde_json::from_slice(&decoded).ok()) + { + Some(v) => v, + // Lenient fallback: accept the payload as plain JSON. + None => serde_json::from_str(trimmed) + .map_err(|e| format!("neither base64-encoded JSON nor plain JSON: {e}"))?, + }; + let obj = value + .as_object() + .ok_or_else(|| "top-level JSON value is not an object".to_string())?; + let string_key = |key: &str| -> Option { + obj.get(key) + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(str::to_string) + }; + Ok(SocketCliConfig { + api_token: string_key("apiToken"), + // socket-cli treats `org` as a convenience alias for `defaultOrg`; + // prefer the canonical key when both are present. + default_org: string_key("defaultOrg").or_else(|| string_key("org")), + api_base_url: string_key("apiBaseUrl"), + }) +} + +/// Read the config from disk. `None` covers every failure path; a corrupt +/// or unreadable file warns (callers cache this, so it fires once per +/// process). +fn read_from_disk() -> Option { + let path = config_json_path()?; + let raw = match std::fs::read(&path) { + Ok(raw) => raw, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return None, + Err(e) => { + eprintln!( + "[socket-patch] warning: unreadable socket-cli config at {}: {e}; ignoring it", + path.display() + ); + return None; + } + }; + match parse_config_bytes(&raw) { + Ok(config) => Some(config), + Err(e) => { + eprintln!( + "[socket-patch] warning: could not parse socket-cli config at {}: {e}; \ + ignoring it (re-run `socket login` to rewrite it)", + path.display() + ); + None + } + } +} + +/// The socket-cli config, if present and enabled. The disk read is done at +/// most once per process; the `SOCKET_NO_CONFIG` gate is checked on every +/// call so tests (and wrapper re-execs) can flip it after startup. +pub fn load() -> Option<&'static SocketCliConfig> { + if is_config_disabled() { + return None; + } + static CACHE: OnceLock> = OnceLock::new(); + CACHE.get_or_init(read_from_disk).as_ref() +} + +/// Resolve the authenticated API base URL through the full fallback chain: +/// `SOCKET_API_URL` env → socket-cli config `apiBaseUrl` → +/// [`DEFAULT_SOCKET_API_URL`](crate::constants::DEFAULT_SOCKET_API_URL). +/// +/// Shared by API-client construction and the telemetry endpoint resolver so +/// the two can never disagree about which host is "the API". (An explicit +/// `--api-url` override is applied by the caller *before* this fallback.) +pub fn resolve_api_base_url() -> String { + env_non_empty("SOCKET_API_URL") + .or_else(|| load().and_then(|c| c.api_base_url.clone())) + .unwrap_or_else(|| crate::constants::DEFAULT_SOCKET_API_URL.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn b64(json: &str) -> Vec { + base64::engine::general_purpose::STANDARD + .encode(json) + .into_bytes() + } + + #[test] + fn parses_base64_encoded_json() { + let cfg = parse_config_bytes(&b64( + r#"{"apiToken":"sktsec_tok","defaultOrg":"acme","apiBaseUrl":"https://api.example"}"#, + )) + .unwrap(); + assert_eq!(cfg.api_token.as_deref(), Some("sktsec_tok")); + assert_eq!(cfg.default_org.as_deref(), Some("acme")); + assert_eq!(cfg.api_base_url.as_deref(), Some("https://api.example")); + } + + /// socket-cli writes the file without a trailing newline, but editors + /// add one; whitespace around the base64 payload must not matter. + #[test] + fn tolerates_surrounding_whitespace() { + let mut raw = b"\n ".to_vec(); + raw.extend_from_slice(&b64(r#"{"apiToken":"t"}"#)); + raw.extend_from_slice(b"\n"); + let cfg = parse_config_bytes(&raw).unwrap(); + assert_eq!(cfg.api_token.as_deref(), Some("t")); + } + + /// Lenient fallback: a plain-JSON body (hand-edited file) still parses. + #[test] + fn falls_back_to_plain_json() { + let cfg = parse_config_bytes(br#"{"defaultOrg":"acme"}"#).unwrap(); + assert_eq!(cfg.default_org.as_deref(), Some("acme")); + assert_eq!(cfg.api_token, None); + } + + /// `org` is socket-cli's alias for `defaultOrg`; the canonical key wins + /// when both are present. + #[test] + fn default_org_beats_org_alias() { + let cfg = parse_config_bytes(br#"{"defaultOrg":"canon","org":"alias"}"#).unwrap(); + assert_eq!(cfg.default_org.as_deref(), Some("canon")); + let cfg = parse_config_bytes(br#"{"org":"alias"}"#).unwrap(); + assert_eq!(cfg.default_org.as_deref(), Some("alias")); + } + + /// Unknown keys are ignored; known keys with non-string or empty values + /// are unset — never an error (allowlist posture). + #[test] + fn ignores_unknown_keys_and_non_string_values() { + let cfg = parse_config_bytes( + br#"{"apiToken":42,"apiBaseUrl":"","enforcedOrgs":["a"],"future":{"x":1}}"#, + ) + .unwrap(); + assert_eq!(cfg, SocketCliConfig::default()); + } + + #[test] + fn rejects_garbage_and_non_object_json() { + assert!(parse_config_bytes(b"!!! not base64 or json").is_err()); + assert!(parse_config_bytes(b"[1,2,3]").is_err()); + assert!(parse_config_bytes(&[0xff, 0xfe]).is_err()); + } + + /// Base64 that decodes to garbage (truncated/double-encoded) must fall + /// through to the plain-JSON attempt and then error, not panic. + #[test] + fn base64_of_non_json_errors() { + assert!(parse_config_bytes(&b64("definitely not json")).is_err()); + } + + // Env-mutating tests below are serialized: XDG_DATA_HOME / HOME / + // SOCKET_NO_CONFIG are process-global and shared with other suites. + + fn with_env(pairs: &[(&str, Option<&str>)], f: impl FnOnce()) { + let saved: Vec<(&str, Option)> = pairs + .iter() + .map(|&(k, _)| (k, std::env::var(k).ok())) + .collect(); + for &(k, v) in pairs { + match v { + Some(v) => std::env::set_var(k, v), + None => std::env::remove_var(k), + } + } + f(); + for (k, v) in saved { + match v { + Some(v) => std::env::set_var(k, v), + None => std::env::remove_var(k), + } + } + } + + #[test] + #[serial_test::serial] + #[cfg(all(unix, not(target_os = "macos")))] + fn path_prefers_xdg_data_home_then_home() { + with_env( + &[("XDG_DATA_HOME", Some("/xdg")), ("HOME", Some("/home/u"))], + || { + assert_eq!( + config_json_path(), + Some(PathBuf::from("/xdg/socket/settings/config.json")) + ); + }, + ); + with_env( + &[("XDG_DATA_HOME", None), ("HOME", Some("/home/u"))], + || { + assert_eq!( + config_json_path(), + Some(PathBuf::from( + "/home/u/.local/share/socket/settings/config.json" + )) + ); + }, + ); + } + + #[test] + #[serial_test::serial] + #[cfg(target_os = "macos")] + fn path_prefers_xdg_data_home_then_home() { + with_env( + &[("XDG_DATA_HOME", Some("/xdg")), ("HOME", Some("/Users/u"))], + || { + assert_eq!( + config_json_path(), + Some(PathBuf::from("/xdg/socket/settings/config.json")) + ); + }, + ); + with_env( + &[("XDG_DATA_HOME", None), ("HOME", Some("/Users/u"))], + || { + assert_eq!( + config_json_path(), + Some(PathBuf::from( + "/Users/u/Library/Application Support/socket/settings/config.json" + )) + ); + }, + ); + } + + /// Empty env values are unset — an exported-but-blank `XDG_DATA_HOME` + /// (or `HOME`) must not resolve against the filesystem root, and with + /// no base dir at all the layer is silently absent. + #[test] + #[serial_test::serial] + #[cfg(unix)] + fn empty_env_is_unset_and_no_base_dir_is_none() { + with_env( + &[("XDG_DATA_HOME", Some("")), ("HOME", Some("/home/u"))], + || { + let path = config_json_path().expect("HOME fallback"); + assert!( + path.starts_with("/home/u"), + "blank XDG_DATA_HOME must fall through to HOME: {path:?}" + ); + }, + ); + with_env(&[("XDG_DATA_HOME", None), ("HOME", Some(""))], || { + assert_eq!(config_json_path(), None); + }); + with_env(&[("XDG_DATA_HOME", None), ("HOME", None)], || { + assert_eq!(config_json_path(), None); + }); + } + + #[test] + #[serial_test::serial] + fn socket_no_config_disables_load() { + with_env(&[("SOCKET_NO_CONFIG", Some("1"))], || { + assert!(load().is_none()); + }); + with_env(&[("SOCKET_NO_CONFIG", Some("yes"))], || { + assert!(is_config_disabled()); + }); + with_env(&[("SOCKET_NO_CONFIG", Some(""))], || { + assert!(!is_config_disabled()); + }); + with_env(&[("SOCKET_NO_CONFIG", Some("0"))], || { + assert!(!is_config_disabled()); + }); + } + + #[test] + #[serial_test::serial] + fn resolve_api_base_url_layers() { + // Env wins outright. + with_env( + &[ + ("SOCKET_API_URL", Some("https://env.example")), + ("SOCKET_NO_CONFIG", Some("1")), + ], + || { + assert_eq!(resolve_api_base_url(), "https://env.example"); + }, + ); + // No env, config disabled → built-in default. + with_env( + &[ + ("SOCKET_API_URL", None), + ("SOCKET_NO_CONFIG", Some("1")), + ], + || { + assert_eq!( + resolve_api_base_url(), + crate::constants::DEFAULT_SOCKET_API_URL + ); + }, + ); + // Empty env is unset. + with_env( + &[ + ("SOCKET_API_URL", Some("")), + ("SOCKET_NO_CONFIG", Some("1")), + ], + || { + assert_eq!( + resolve_api_base_url(), + crate::constants::DEFAULT_SOCKET_API_URL + ); + }, + ); + } +} diff --git a/crates/socket-patch-core/src/utils/telemetry.rs b/crates/socket-patch-core/src/utils/telemetry.rs index afb7c079..c10bc8e4 100644 --- a/crates/socket-patch-core/src/utils/telemetry.rs +++ b/crates/socket-patch-core/src/utils/telemetry.rs @@ -3,7 +3,7 @@ use std::collections::HashMap; use once_cell::sync::Lazy; use uuid::Uuid; -use crate::constants::{DEFAULT_SOCKET_API_URL, USER_AGENT}; +use crate::constants::USER_AGENT; use crate::utils::env_compat::{is_debug_enabled, proxy_url_from_env, read_env_with_legacy}; use crate::utils::fs::home_dir; use crate::vex::time::unix_to_ymdhms; @@ -243,10 +243,10 @@ fn resolve_telemetry_endpoint(api_token: Option<&str>, org_slug: Option<&str>) - match (token, slug) { (Some(_token), Some(slug)) => { - let api_url = std::env::var("SOCKET_API_URL") - .ok() - .filter(|u| !u.is_empty()) - .unwrap_or_else(|| DEFAULT_SOCKET_API_URL.to_string()); + // Same env → socket-cli config → default chain as API-client + // construction, so telemetry can't target a different host than + // the client that produced the event. + let api_url = crate::utils::socket_cli_config::resolve_api_base_url(); // Trim trailing slashes like `ApiClient::new` does, so a base URL // of `https://host/` doesn't produce a malformed `//v0/...` path. let api_url = api_url.trim_end_matches('/'); From c311471695e026e0fa2c46097806e6256b52930a Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 21 Jul 2026 14:27:10 -0400 Subject: [PATCH 3/5] test(config): hermeticity guards + e2e coverage for the config fallback layer - .cargo/config.toml [env] exports SOCKET_NO_CONFIG=1 so a developer's real 'socket login' can never authenticate a test run; every env scrub loop in the e2e harnesses now skips SOCKET_NO_CONFIG so the guard survives into spawned binaries, and targeted token scrubs also drop SOCKET_CLI_API_TOKEN. - new tests/cli_config_fallback.rs (9 cases): config token/apiBaseUrl authenticate, defaultOrg skips org auto-resolve with telemetry following the config host+token, per-key env-beats-config, alias honored + canonical wins, corrupt config warns on stderr with --json stdout still parseable, SOCKET_NO_CONFIG gate, SOCKET_NO_API_TOKEN veto incl. --api-token surviving it, missing file silent. - client.rs unit tests for the veto (ambient suppressed, override wins). Co-Authored-By: Claude Fable 5 --- .cargo/config.toml | 11 + crates/socket-patch-cli/src/args.rs | 4 +- .../socket-patch-cli/src/commands/repair.rs | 8 +- .../socket-patch-cli/src/commands/vendor.rs | 3 +- .../tests/apply_invariants.rs | 3 + .../socket-patch-cli/tests/apply_network.rs | 2 +- .../tests/cli_config_fallback.rs | 417 ++++++++++++++++++ .../tests/cli_dry_run_paths_e2e.rs | 10 + .../tests/cli_parse_rollback.rs | 5 +- .../tests/cli_rollback_silent.rs | 4 +- crates/socket-patch-cli/tests/common/mod.rs | 8 +- crates/socket-patch-cli/tests/e2e_cargo.rs | 1 + .../tests/e2e_embedded_vex.rs | 2 +- crates/socket-patch-cli/tests/e2e_gem.rs | 1 + .../tests/e2e_golang_build.rs | 2 +- .../tests/e2e_golang_redirect.rs | 1 + crates/socket-patch-cli/tests/e2e_maven.rs | 1 + crates/socket-patch-cli/tests/e2e_npm.rs | 3 +- crates/socket-patch-cli/tests/e2e_nuget.rs | 1 + .../tests/e2e_redirect_bun_build.rs | 2 +- .../tests/e2e_redirect_cargo_build.rs | 2 +- .../tests/e2e_redirect_npm_build.rs | 2 +- .../tests/e2e_redirect_rush_sim.rs | 2 +- .../tests/e2e_redirect_yarn_berry_build.rs | 2 +- crates/socket-patch-cli/tests/e2e_scan.rs | 3 +- .../tests/e2e_vendor_bun_build.rs | 2 +- .../tests/e2e_vendor_cargo_build.rs | 2 +- .../tests/e2e_vendor_composer_build.rs | 2 +- .../tests/e2e_vendor_gem_build.rs | 2 +- .../tests/e2e_vendor_golang_build.rs | 2 +- .../tests/e2e_vendor_npm_build.rs | 2 +- .../tests/e2e_vendor_pnpm_build.rs | 5 +- .../tests/e2e_vendor_pypi_build.rs | 2 +- .../tests/e2e_vendor_yarn_berry_build.rs | 2 +- .../tests/e2e_vendor_yarn_classic_build.rs | 2 +- crates/socket-patch-cli/tests/e2e_vex.rs | 2 +- .../tests/e2e_vex_redirect.rs | 2 +- .../socket-patch-cli/tests/e2e_vex_vendor.rs | 2 +- .../tests/ecosystem_dispatch_e2e.rs | 3 +- .../tests/get_batch_paths_e2e.rs | 3 +- .../tests/global_packages_e2e.rs | 3 +- .../tests/in_process_redirect.rs | 3 +- .../tests/in_process_variant_apply_failure.rs | 3 +- .../tests/in_process_vendor.rs | 2 +- .../tests/interactive_prompts_e2e.rs | 11 +- .../socket-patch-cli/tests/remove_network.rs | 4 +- .../tests/repair_invariants.rs | 9 +- .../tests/repair_vendor_flavors_e2e.rs | 3 +- .../tests/rollback_invariants.rs | 4 +- .../socket-patch-cli/tests/scan_vendor_e2e.rs | 4 +- .../tests/setup_contract_gaps.rs | 2 +- .../tests/setup_invariants.rs | 3 +- .../tests/setup_matrix_deno.rs | 4 +- .../tests/setup_matrix_gem.rs | 21 +- .../tests/setup_matrix_maven.rs | 4 +- .../tests/setup_matrix_npm.rs | 4 +- .../tests/setup_matrix_nuget.rs | 4 +- .../tests/setup_matrix_pypi.rs | 4 +- .../tests/setup_pth_invariants.rs | 7 +- .../socket-patch-cli/tests/telemetry_e2e.rs | 2 +- crates/socket-patch-core/src/api/client.rs | 75 +++- .../src/patch/vendor/maven_repo.rs | 5 +- .../socket-patch-core/src/patch/vendor/mod.rs | 8 +- .../src/patch/vendor/npm_lock.rs | 8 +- .../src/patch/vendor/nuget_feed.rs | 37 +- .../src/patch/vendor/pnpm_lock.rs | 7 +- .../src/patch/vendor/pypi_uv.rs | 18 +- .../src/patch/vendor/verify.rs | 18 +- .../src/patch/vendor/yarn_berry_lock.rs | 12 +- .../socket-patch-core/src/utils/env_compat.rs | 5 +- .../src/utils/socket_cli_config.rs | 23 +- crates/socket-patch-core/src/vex/product.rs | 5 +- .../tests/crawler_npm_e2e.rs | 3 +- 73 files changed, 703 insertions(+), 157 deletions(-) create mode 100644 crates/socket-patch-cli/tests/cli_config_fallback.rs diff --git a/.cargo/config.toml b/.cargo/config.toml index 179dd631..b4a6b084 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -13,3 +13,14 @@ rustflags = ["-C", "link-arg=/STACK:8388608"] [target.'cfg(all(windows, target_env = "gnu"))'] rustflags = ["-C", "link-args=-Wl,--stack,8388608"] + +# Keep a developer's real `socket login` state out of every test run: the +# CLI reads the JS socket-cli's persisted config.json as a fallback token +# source (crates/socket-patch-core/src/utils/socket_cli_config.rs), so an +# ambient login would silently flip "no token → public proxy" tests onto +# the authenticated path. Test child processes inherit this from the test +# binary; the env scrub loops in tests deliberately skip this variable. +# To exercise the config layer, tests (and manual runs) override it with a +# falsy value, e.g. `SOCKET_NO_CONFIG=0`. +[env] +SOCKET_NO_CONFIG = "1" diff --git a/crates/socket-patch-cli/src/args.rs b/crates/socket-patch-cli/src/args.rs index 8148a2b9..c3e43ffc 100644 --- a/crates/socket-patch-cli/src/args.rs +++ b/crates/socket-patch-cli/src/args.rs @@ -18,9 +18,7 @@ use std::path::{Path, PathBuf}; use clap::Args; use socket_patch_core::api::client::ApiClientEnvOverrides; -use socket_patch_core::constants::{ - DEFAULT_PATCH_MANIFEST_PATH, -}; +use socket_patch_core::constants::DEFAULT_PATCH_MANIFEST_PATH; use socket_patch_core::crawlers::Ecosystem; use socket_patch_core::patch::vendor::VendorSource; diff --git a/crates/socket-patch-cli/src/commands/repair.rs b/crates/socket-patch-cli/src/commands/repair.rs index 96dd4a94..ba7d0291 100644 --- a/crates/socket-patch-cli/src/commands/repair.rs +++ b/crates/socket-patch-cli/src/commands/repair.rs @@ -450,10 +450,10 @@ async fn repair_inner( if !args.common.json { eprintln!("Warning: {label} cleanup failed: {e}"); } - env.record(PatchEvent::artifact(PatchAction::Skipped).with_reason( - "cleanup_failed", - format!("{label} cleanup failed: {e}"), - )); + env.record( + PatchEvent::artifact(PatchAction::Skipped) + .with_reason("cleanup_failed", format!("{label} cleanup failed: {e}")), + ); } } } diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index b2a284bf..02d2289f 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -338,8 +338,7 @@ pub async fn run(args: VendorArgs) -> i32 { } } else { let params = args.vex.to_build_params(); - match generate_vex_from_manifest_path(&args.common, ¶ms, &manifest_path).await - { + match generate_vex_from_manifest_path(&args.common, ¶ms, &manifest_path).await { Ok(summary) => { env.vex = Some(VexSummary { path: vex_path.display().to_string(), diff --git a/crates/socket-patch-cli/tests/apply_invariants.rs b/crates/socket-patch-cli/tests/apply_invariants.rs index b7ea3aac..08afa4bf 100644 --- a/crates/socket-patch-cli/tests/apply_invariants.rs +++ b/crates/socket-patch-cli/tests/apply_invariants.rs @@ -123,6 +123,7 @@ fn run_apply(cwd: &Path, extra: &[&str]) -> (i32, String) { .args(&args) .current_dir(cwd) .env_remove("SOCKET_API_TOKEN") + .env_remove("SOCKET_CLI_API_TOKEN") .output() .expect("run socket-patch"); ( @@ -478,6 +479,7 @@ fn apply_with_no_socket_dir_silent_emits_nothing() { .args(["apply", "--silent"]) .current_dir(tmp.path()) .env_remove("SOCKET_API_TOKEN") + .env_remove("SOCKET_CLI_API_TOKEN") .output() .expect("run socket-patch"); assert_eq!(out.status.code(), Some(0)); @@ -497,6 +499,7 @@ fn apply_with_no_socket_dir_silent_emits_nothing() { .args(["apply"]) .current_dir(tmp2.path()) .env_remove("SOCKET_API_TOKEN") + .env_remove("SOCKET_CLI_API_TOKEN") .output() .expect("run socket-patch"); assert_eq!(loud.status.code(), Some(0)); diff --git a/crates/socket-patch-cli/tests/apply_network.rs b/crates/socket-patch-cli/tests/apply_network.rs index 0bada1a3..db60c7d7 100644 --- a/crates/socket-patch-cli/tests/apply_network.rs +++ b/crates/socket-patch-cli/tests/apply_network.rs @@ -841,7 +841,7 @@ async fn offline_apply_with_token_makes_zero_network_requests() { // what the scenario needs: token set, org absent, both URLs pinned to // the mock so any traffic — API or proxy path — is captured. for (k, _) in std::env::vars_os() { - if k.to_string_lossy().starts_with("SOCKET_") { + if k.to_string_lossy().starts_with("SOCKET_") && k.to_string_lossy() != "SOCKET_NO_CONFIG" { cmd.env_remove(&k); } } diff --git a/crates/socket-patch-cli/tests/cli_config_fallback.rs b/crates/socket-patch-cli/tests/cli_config_fallback.rs new file mode 100644 index 00000000..ddf9713c --- /dev/null +++ b/crates/socket-patch-cli/tests/cli_config_fallback.rs @@ -0,0 +1,417 @@ +//! E2E tests for the socket-cli config fallback layer. +//! +//! The binary reads the JS socket-cli's persisted login state +//! (`/socket/settings/config.json`, base64-encoded JSON) as the +//! resolution layer below env vars for `apiToken` / `defaultOrg` / +//! `apiBaseUrl` (see `socket_patch_core::utils::socket_cli_config`), plus +//! the `SOCKET_CLI_*` peer env aliases and the `SOCKET_NO_CONFIG` / +//! `SOCKET_NO_API_TOKEN` toggles. +//! +//! These run the compiled binary as a subprocess: the config file is read +//! once per process (`OnceLock`), so in-process testing could not exercise +//! different fixtures, and the data-dir env vars are process-global. Each +//! test points the platform data-dir env var (`XDG_DATA_HOME`, or +//! `%LOCALAPPDATA%` on Windows) at a private tempdir fixture. +//! +//! NOTE: the workspace `.cargo/config.toml` exports `SOCKET_NO_CONFIG=1` so +//! a developer's real `socket login` can never leak into the test suite; +//! tests here that exercise the layer explicitly re-enable it with a falsy +//! `SOCKET_NO_CONFIG=0` on the child. + +use std::path::Path; +use std::process::Command; + +use base64::Engine as _; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +const BINARY: &str = env!("CARGO_BIN_EXE_socket-patch"); + +/// The platform env var that positions the socket-cli data dir. +const DATA_DIR_VAR: &str = if cfg!(windows) { + "LOCALAPPDATA" +} else { + "XDG_DATA_HOME" +}; + +/// A shape-valid API token (`sktsec_<44 chars>_api`) so the token-shape +/// warning never muddies stderr assertions. Distinct fillers distinguish +/// which layer supplied the token in Authorization-header assertions. +fn token(filler: char) -> String { + format!("sktsec_{}_api", filler.to_string().repeat(44)) +} + +/// Write a socket-cli `config.json` fixture (base64-encoded, as the real +/// tool persists it) under `data_dir/socket/settings/`. +fn write_config(data_dir: &Path, json: &serde_json::Value) { + let dir = data_dir.join("socket").join("settings"); + std::fs::create_dir_all(&dir).unwrap(); + let encoded = base64::engine::general_purpose::STANDARD.encode(json.to_string()); + std::fs::write(dir.join("config.json"), encoded).unwrap(); +} + +/// Build a hermetic `socket-patch scan --json -e npm` command: every +/// ambient `SOCKET_*` var is scrubbed (including the inherited +/// `SOCKET_NO_CONFIG=1` guard — tests re-add exactly what they need), the +/// data dir points at `data_dir`, and the project dir is an empty npm +/// project so the crawl finds nothing and no batch request fires. +fn scan_cmd(project: &Path, data_dir: &Path) -> Command { + let mut cmd = Command::new(BINARY); + cmd.args(["scan", "--json", "-e", "npm", "--cwd"]) + .arg(project); + for (key, _) in std::env::vars_os() { + let name = key.to_string_lossy(); + if name.starts_with("SOCKET_") { + cmd.env_remove(&key); + } + } + // Ambient VIRTUAL_ENV would be harmless under `-e npm`, but scrub it + // anyway to mirror the other scan harnesses. + cmd.env_remove("VIRTUAL_ENV"); + cmd.env(DATA_DIR_VAR, data_dir); + // Re-enable the config layer (the workspace-level SOCKET_NO_CONFIG=1 + // guard was scrubbed above; set an explicit falsy value so the intent + // is visible). Gate tests override this with "1". + cmd.env("SOCKET_NO_CONFIG", "0"); + // Telemetry is fire-and-forget to the *real* proxy when a run is + // unauthenticated — keep these tests off the network. The one test + // that exercises telemetry-follows-config re-enables it explicitly. + cmd.env("SOCKET_TELEMETRY_DISABLED", "1"); + cmd +} + +/// Empty npm project: a lone package.json with no dependencies, so the +/// crawler discovers zero packages and scan exits 0 without a batch POST. +fn write_empty_project(root: &Path) { + std::fs::write( + root.join("package.json"), + r#"{ "name": "config-fallback-fixture", "version": "0.0.0" }"#, + ) + .unwrap(); +} + +/// Mock `GET /v0/organizations` (the org auto-resolve round-trip that fires +/// on authenticated client construction when no org slug is configured). +async fn mock_organizations(server: &MockServer) { + Mock::given(method("GET")) + .and(path("/v0/organizations")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "organizations": { + "org-1": { + "id": "org-1", + "name": "Config Fixture Org", + "image": null, + "plan": "free", + "slug": "config-fixture-org" + } + } + }))) + .mount(server) + .await; +} + +struct RunOutput { + code: Option, + stdout: String, + stderr: String, +} + +fn run(mut cmd: Command) -> RunOutput { + let out = cmd.output().expect("run socket-patch"); + RunOutput { + code: out.status.code(), + stdout: String::from_utf8_lossy(&out.stdout).into_owned(), + stderr: String::from_utf8_lossy(&out.stderr).into_owned(), + } +} + +/// Authorization header of the (single expected) `/v0/organizations` call. +async fn recorded_bearer(server: &MockServer) -> Option { + let reqs = server.received_requests().await.unwrap_or_default(); + reqs.iter() + .find(|r| r.url.path() == "/v0/organizations") + .and_then(|r| r.headers.get("authorization")) + .map(|v| v.to_str().unwrap_or_default().to_string()) +} + +/// The public-proxy notice that must appear iff no token was resolved. +const PROXY_NOTICE: &str = "No SOCKET_API_TOKEN set"; + +/// A config-supplied token + apiBaseUrl authenticate the client: with no +/// org anywhere the binary must hit the fixture's `/v0/organizations` with +/// the config token as bearer. +#[tokio::test] +async fn config_token_and_api_base_url_authenticate() { + let server = MockServer::start().await; + mock_organizations(&server).await; + let data = tempfile::tempdir().unwrap(); + let project = tempfile::tempdir().unwrap(); + write_empty_project(project.path()); + write_config( + data.path(), + &serde_json::json!({ "apiToken": token('c'), "apiBaseUrl": server.uri() }), + ); + + let out = run(scan_cmd(project.path(), data.path())); + assert_eq!(out.code, Some(0), "stderr:\n{}", out.stderr); + assert!( + !out.stderr.contains(PROXY_NOTICE), + "config token must select the authenticated path; stderr:\n{}", + out.stderr + ); + assert_eq!( + recorded_bearer(&server).await.as_deref(), + Some(format!("Bearer {}", token('c')).as_str()), + "org auto-resolve must hit the config apiBaseUrl with the config token" + ); +} + +/// `defaultOrg` from the config skips the org auto-resolve round-trip — +/// and telemetry (deliberately enabled here) follows the same config +/// resolution: the event POSTs to the config `apiBaseUrl` under the +/// config org with the config token. This pins the shared +/// `resolve_api_base_url` chain between client construction and +/// `resolve_telemetry_endpoint`. +#[tokio::test] +async fn config_default_org_skips_auto_resolve_and_telemetry_follows() { + let server = MockServer::start().await; + let data = tempfile::tempdir().unwrap(); + let project = tempfile::tempdir().unwrap(); + write_empty_project(project.path()); + write_config( + data.path(), + &serde_json::json!({ + "apiToken": token('c'), + "apiBaseUrl": server.uri(), + "defaultOrg": "cfg-org" + }), + ); + + let mut cmd = scan_cmd(project.path(), data.path()); + cmd.env("SOCKET_TELEMETRY_DISABLED", "0"); + let out = run(cmd); + assert_eq!(out.code, Some(0), "stderr:\n{}", out.stderr); + let reqs = server.received_requests().await.unwrap_or_default(); + assert!( + !reqs.iter().any(|r| r.url.path() == "/v0/organizations"), + "defaultOrg from config must skip org auto-resolve; saw {reqs:?}" + ); + let telemetry = reqs + .iter() + .find(|r| r.url.path() == "/v0/orgs/cfg-org/telemetry") + .expect("telemetry must POST to the config apiBaseUrl under the config org"); + assert_eq!( + telemetry + .headers + .get("authorization") + .map(|v| v.to_str().unwrap_or_default().to_string()) + .as_deref(), + Some(format!("Bearer {}", token('c')).as_str()), + "telemetry must carry the config token" + ); +} + +/// The env var beats the config file for the same key — but only for that +/// key: the token comes from `SOCKET_API_TOKEN` while `apiBaseUrl` still +/// resolves from the config (per-key layering). +#[tokio::test] +async fn env_token_beats_config_token_per_key() { + let server = MockServer::start().await; + mock_organizations(&server).await; + let data = tempfile::tempdir().unwrap(); + let project = tempfile::tempdir().unwrap(); + write_empty_project(project.path()); + write_config( + data.path(), + &serde_json::json!({ "apiToken": token('c'), "apiBaseUrl": server.uri() }), + ); + + let mut cmd = scan_cmd(project.path(), data.path()); + cmd.env("SOCKET_API_TOKEN", token('e')); + let out = run(cmd); + assert_eq!(out.code, Some(0), "stderr:\n{}", out.stderr); + assert_eq!( + recorded_bearer(&server).await.as_deref(), + Some(format!("Bearer {}", token('e')).as_str()), + "env token must beat the config token while apiBaseUrl still comes from config" + ); +} + +/// `SOCKET_CLI_API_TOKEN` (the socket-cli peer alias) is honored when the +/// canonical name is unset — and loses to it when both are set. +#[tokio::test] +async fn socket_cli_alias_token_honored_canonical_wins() { + let server = MockServer::start().await; + mock_organizations(&server).await; + let data = tempfile::tempdir().unwrap(); + let project = tempfile::tempdir().unwrap(); + write_empty_project(project.path()); + + // Alias alone authenticates. + let mut cmd = scan_cmd(project.path(), data.path()); + cmd.env("SOCKET_CLI_API_TOKEN", token('l')) + .env("SOCKET_API_URL", server.uri()); + let out = run(cmd); + assert_eq!(out.code, Some(0), "stderr:\n{}", out.stderr); + assert_eq!( + recorded_bearer(&server).await.as_deref(), + Some(format!("Bearer {}", token('l')).as_str()), + "SOCKET_CLI_API_TOKEN alone must authenticate" + ); + + // Canonical beats alias. + server.reset().await; + mock_organizations(&server).await; + let mut cmd = scan_cmd(project.path(), data.path()); + cmd.env("SOCKET_CLI_API_TOKEN", token('l')) + .env("SOCKET_API_TOKEN", token('e')) + .env("SOCKET_API_URL", server.uri()); + let out = run(cmd); + assert_eq!(out.code, Some(0), "stderr:\n{}", out.stderr); + assert_eq!( + recorded_bearer(&server).await.as_deref(), + Some(format!("Bearer {}", token('e')).as_str()), + "canonical SOCKET_API_TOKEN must win over the SOCKET_CLI_ alias" + ); +} + +/// A corrupt config file warns on stderr, is treated as absent (public +/// proxy), and never pollutes `--json` stdout. +#[tokio::test] +async fn corrupt_config_warns_and_keeps_json_stdout_clean() { + let data = tempfile::tempdir().unwrap(); + let project = tempfile::tempdir().unwrap(); + write_empty_project(project.path()); + let dir = data.path().join("socket").join("settings"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("config.json"), "!!! neither base64 nor json").unwrap(); + + let out = run(scan_cmd(project.path(), data.path())); + assert_eq!( + out.code, + Some(0), + "a corrupt config must never break the run" + ); + assert!( + out.stderr.contains("could not parse socket-cli config") + && out.stderr.contains("config.json"), + "stderr must carry the parse warning naming the file; got:\n{}", + out.stderr + ); + assert!( + out.stderr.contains(PROXY_NOTICE), + "with the config unusable the run falls back to the public proxy; stderr:\n{}", + out.stderr + ); + serde_json::from_str::(&out.stdout).unwrap_or_else(|e| { + panic!( + "--json stdout must stay parseable despite the warning ({e}); stdout:\n{}", + out.stdout + ) + }); +} + +/// `SOCKET_NO_CONFIG=1` disables the layer: a fully valid login fixture is +/// ignored and the run uses the public proxy without touching the network. +#[tokio::test] +async fn socket_no_config_ignores_valid_login() { + let server = MockServer::start().await; + let data = tempfile::tempdir().unwrap(); + let project = tempfile::tempdir().unwrap(); + write_empty_project(project.path()); + write_config( + data.path(), + &serde_json::json!({ "apiToken": token('c'), "apiBaseUrl": server.uri() }), + ); + + let mut cmd = scan_cmd(project.path(), data.path()); + cmd.env("SOCKET_NO_CONFIG", "1"); + let out = run(cmd); + assert_eq!(out.code, Some(0), "stderr:\n{}", out.stderr); + assert!( + out.stderr.contains(PROXY_NOTICE), + "SOCKET_NO_CONFIG=1 must ignore the config token; stderr:\n{}", + out.stderr + ); + let reqs = server.received_requests().await.unwrap_or_default(); + assert!( + reqs.is_empty(), + "no request may reach the config apiBaseUrl" + ); +} + +/// `SOCKET_NO_API_TOKEN=1` vetoes ambient tokens — both the config file's +/// and the env var's — forcing the public proxy. +#[tokio::test] +async fn socket_no_api_token_vetoes_ambient_tokens() { + let server = MockServer::start().await; + let data = tempfile::tempdir().unwrap(); + let project = tempfile::tempdir().unwrap(); + write_empty_project(project.path()); + write_config( + data.path(), + &serde_json::json!({ "apiToken": token('c'), "apiBaseUrl": server.uri() }), + ); + + for ambient_env_token in [None, Some(token('e'))] { + let mut cmd = scan_cmd(project.path(), data.path()); + cmd.env("SOCKET_NO_API_TOKEN", "1"); + if let Some(t) = &ambient_env_token { + cmd.env("SOCKET_API_TOKEN", t); + } + let out = run(cmd); + assert_eq!(out.code, Some(0), "stderr:\n{}", out.stderr); + assert!( + out.stderr.contains(PROXY_NOTICE), + "SOCKET_NO_API_TOKEN must veto ambient tokens (env token set: {}); stderr:\n{}", + ambient_env_token.is_some(), + out.stderr + ); + } + let reqs = server.received_requests().await.unwrap_or_default(); + assert!(reqs.is_empty(), "vetoed runs must not authenticate"); +} + +/// An explicit `--api-token` flag survives the veto — `SOCKET_NO_API_TOKEN` +/// only suppresses *ambient* tokens. +#[tokio::test] +async fn explicit_flag_token_survives_veto() { + let server = MockServer::start().await; + mock_organizations(&server).await; + let data = tempfile::tempdir().unwrap(); + let project = tempfile::tempdir().unwrap(); + write_empty_project(project.path()); + + let mut cmd = scan_cmd(project.path(), data.path()); + cmd.args(["--api-token", &token('f')]) + .env("SOCKET_NO_API_TOKEN", "1") + .env("SOCKET_API_URL", server.uri()); + let out = run(cmd); + assert_eq!(out.code, Some(0), "stderr:\n{}", out.stderr); + assert!( + !out.stderr.contains(PROXY_NOTICE), + "an explicit --api-token must authenticate despite the veto; stderr:\n{}", + out.stderr + ); + assert_eq!( + recorded_bearer(&server).await.as_deref(), + Some(format!("Bearer {}", token('f')).as_str()), + ); +} + +/// A missing config file is completely silent — no warning, public proxy. +#[tokio::test] +async fn missing_config_is_silent() { + let data = tempfile::tempdir().unwrap(); + let project = tempfile::tempdir().unwrap(); + write_empty_project(project.path()); + + let out = run(scan_cmd(project.path(), data.path())); + assert_eq!(out.code, Some(0), "stderr:\n{}", out.stderr); + assert!( + !out.stderr.contains("socket-cli config"), + "a missing config must not warn; stderr:\n{}", + out.stderr + ); + assert!(out.stderr.contains(PROXY_NOTICE)); +} diff --git a/crates/socket-patch-cli/tests/cli_dry_run_paths_e2e.rs b/crates/socket-patch-cli/tests/cli_dry_run_paths_e2e.rs index b76a5e73..7e8893ba 100644 --- a/crates/socket-patch-cli/tests/cli_dry_run_paths_e2e.rs +++ b/crates/socket-patch-cli/tests/cli_dry_run_paths_e2e.rs @@ -99,6 +99,7 @@ fn apply_dry_run_empty_manifest_emits_dry_run_envelope() { .args(["apply", "--json", "--dry-run"]) .current_dir(tmp.path()) .env_remove("SOCKET_API_TOKEN") + .env_remove("SOCKET_CLI_API_TOKEN") .output() .expect("run apply"); let stdout = String::from_utf8_lossy(&out.stdout); @@ -168,6 +169,7 @@ fn apply_dry_run_with_real_patch_verifies_without_mutating() { .args(["apply", "--json", "--dry-run", "--offline"]) .current_dir(tmp.path()) .env_remove("SOCKET_API_TOKEN") + .env_remove("SOCKET_CLI_API_TOKEN") .output() .expect("run apply --dry-run"); let stdout = String::from_utf8_lossy(&out.stdout); @@ -251,6 +253,7 @@ fn apply_dry_run_with_real_patch_verifies_without_mutating() { .args(["apply", "--json", "--offline"]) .current_dir(tmp.path()) .env_remove("SOCKET_API_TOKEN") + .env_remove("SOCKET_CLI_API_TOKEN") .output() .expect("run apply (real)"); let stdout2 = String::from_utf8_lossy(&out2.stdout); @@ -336,6 +339,7 @@ fn apply_dry_run_human_count_excludes_vendored() { .args(["apply", "--json", "--dry-run", "--offline"]) .current_dir(tmp.path()) .env_remove("SOCKET_API_TOKEN") + .env_remove("SOCKET_CLI_API_TOKEN") .output() .expect("run apply --json --dry-run"); let stdout = String::from_utf8_lossy(&out.stdout); @@ -366,6 +370,7 @@ fn apply_dry_run_human_count_excludes_vendored() { .args(["apply", "--dry-run", "--offline"]) .current_dir(tmp.path()) .env_remove("SOCKET_API_TOKEN") + .env_remove("SOCKET_CLI_API_TOKEN") .output() .expect("run apply --dry-run"); assert_eq!(out.status.code(), Some(0)); @@ -386,6 +391,7 @@ fn repair_dry_run_offline_emits_dry_run_envelope() { .args(["repair", "--json", "--dry-run", "--offline"]) .current_dir(tmp.path()) .env_remove("SOCKET_API_TOKEN") + .env_remove("SOCKET_CLI_API_TOKEN") .output() .expect("run repair"); let stdout = String::from_utf8_lossy(&out.stdout); @@ -413,6 +419,7 @@ fn rollback_with_empty_manifest_emits_envelope() { .args(["rollback", "--json", "--offline"]) .current_dir(tmp.path()) .env_remove("SOCKET_API_TOKEN") + .env_remove("SOCKET_CLI_API_TOKEN") .output() .expect("run rollback"); let stdout = String::from_utf8_lossy(&out.stdout); @@ -452,6 +459,7 @@ fn remove_with_no_socket_dir_emits_manifest_not_found() { ]) .current_dir(tmp.path()) .env_remove("SOCKET_API_TOKEN") + .env_remove("SOCKET_CLI_API_TOKEN") .output() .expect("run remove"); let stdout = String::from_utf8_lossy(&out.stdout); @@ -479,6 +487,7 @@ fn list_with_empty_manifest_emits_empty_envelope() { .args(["list", "--json"]) .current_dir(tmp.path()) .env_remove("SOCKET_API_TOKEN") + .env_remove("SOCKET_CLI_API_TOKEN") .output() .expect("run list"); let stdout = String::from_utf8_lossy(&out.stdout); @@ -506,6 +515,7 @@ fn apply_silent_no_manifest_produces_no_output() { .args(["apply", "--silent"]) .current_dir(tmp.path()) .env_remove("SOCKET_API_TOKEN") + .env_remove("SOCKET_CLI_API_TOKEN") .output() .expect("run apply"); assert_eq!(out.status.code(), Some(0)); diff --git a/crates/socket-patch-cli/tests/cli_parse_rollback.rs b/crates/socket-patch-cli/tests/cli_parse_rollback.rs index 46e2a552..e51cfb05 100644 --- a/crates/socket-patch-cli/tests/cli_parse_rollback.rs +++ b/crates/socket-patch-cli/tests/cli_parse_rollback.rs @@ -270,7 +270,10 @@ fn yes_long() { #[test] fn proxy_url_long() { let args = parse_rollback(&["--proxy-url", "https://proxy.example"]); - assert_eq!(args.common.proxy_url.as_deref(), Some("https://proxy.example")); + assert_eq!( + args.common.proxy_url.as_deref(), + Some("https://proxy.example") + ); } #[test] diff --git a/crates/socket-patch-cli/tests/cli_rollback_silent.rs b/crates/socket-patch-cli/tests/cli_rollback_silent.rs index 149f6b01..726fba49 100644 --- a/crates/socket-patch-cli/tests/cli_rollback_silent.rs +++ b/crates/socket-patch-cli/tests/cli_rollback_silent.rs @@ -38,7 +38,9 @@ fn run_rollback(cwd: &Path, args: &[&str]) -> (i32, String, String) { let mut cmd = Command::new(binary()); cmd.arg("rollback").args(args).current_dir(cwd); for (key, _) in std::env::vars_os() { - if key.to_string_lossy().starts_with("SOCKET_") { + if key.to_string_lossy().starts_with("SOCKET_") + && key.to_string_lossy() != "SOCKET_NO_CONFIG" + { cmd.env_remove(&key); } } diff --git a/crates/socket-patch-cli/tests/common/mod.rs b/crates/socket-patch-cli/tests/common/mod.rs index 795e32c6..6c63abec 100644 --- a/crates/socket-patch-cli/tests/common/mod.rs +++ b/crates/socket-patch-cli/tests/common/mod.rs @@ -92,10 +92,16 @@ pub fn run_with_env(cwd: &Path, args: &[&str], env: &[(&str, &str)]) -> (i32, St // stays opted out. for (key, _) in std::env::vars_os() { let name = key.to_string_lossy(); - if name.starts_with("SOCKET_") && !name.contains("TELEMETRY") { + if name.starts_with("SOCKET_") && !name.contains("TELEMETRY") && name != "SOCKET_NO_CONFIG" + { cmd.env_remove(&key); } } + // Belt-and-braces on top of the `.cargo/config.toml` `[env]` default: + // a developer's real `socket login` (the socket-cli config.json token + // fallback) must never authenticate a test child — it would flip every + // "no token → public proxy" assertion onto the authed path. + cmd.env("SOCKET_NO_CONFIG", "1"); // Caller-supplied env lands last so explicit injections (runtime // gates, discovery roots) survive the scrub. for (k, v) in env { diff --git a/crates/socket-patch-cli/tests/e2e_cargo.rs b/crates/socket-patch-cli/tests/e2e_cargo.rs index ae574ce4..1b11ad2c 100644 --- a/crates/socket-patch-cli/tests/e2e_cargo.rs +++ b/crates/socket-patch-cli/tests/e2e_cargo.rs @@ -63,6 +63,7 @@ async fn run(args: &[&str], cwd: &Path, proxy_url: &str) -> Output { .current_dir(&cwd) .env("CARGO_HOME", cwd.join(".cargo")) .env_remove("SOCKET_API_TOKEN") + .env_remove("SOCKET_CLI_API_TOKEN") .env_remove("SOCKET_API_URL") .env_remove("SOCKET_OFFLINE") .env_remove("SOCKET_PROXY_URL") diff --git a/crates/socket-patch-cli/tests/e2e_embedded_vex.rs b/crates/socket-patch-cli/tests/e2e_embedded_vex.rs index 077832f5..80857f0c 100644 --- a/crates/socket-patch-cli/tests/e2e_embedded_vex.rs +++ b/crates/socket-patch-cli/tests/e2e_embedded_vex.rs @@ -48,7 +48,7 @@ fn binary() -> &'static str { fn cli() -> Command { let mut cmd = Command::new(binary()); for (key, _) in std::env::vars() { - if key.starts_with("SOCKET_") { + if key.starts_with("SOCKET_") && key != "SOCKET_NO_CONFIG" { cmd.env_remove(key); } } diff --git a/crates/socket-patch-cli/tests/e2e_gem.rs b/crates/socket-patch-cli/tests/e2e_gem.rs index 366dc964..85a1e55c 100644 --- a/crates/socket-patch-cli/tests/e2e_gem.rs +++ b/crates/socket-patch-cli/tests/e2e_gem.rs @@ -67,6 +67,7 @@ fn run(cwd: &Path, args: &[&str]) -> (i32, String, String) { .args(args) .current_dir(cwd) .env_remove("SOCKET_API_TOKEN") + .env_remove("SOCKET_CLI_API_TOKEN") .output() .expect("failed to execute socket-patch binary"); diff --git a/crates/socket-patch-cli/tests/e2e_golang_build.rs b/crates/socket-patch-cli/tests/e2e_golang_build.rs index f08a5cb3..cc7f8ee9 100644 --- a/crates/socket-patch-cli/tests/e2e_golang_build.rs +++ b/crates/socket-patch-cli/tests/e2e_golang_build.rs @@ -50,7 +50,7 @@ fn run_socket(cwd: &Path, args: &[&str], modcache: &Path) -> (i32, String, Strin let mut cmd = Command::new(binary()); cmd.args(args).current_dir(cwd); for (k, _) in std::env::vars_os() { - if k.to_string_lossy().starts_with("SOCKET_") { + if k.to_string_lossy().starts_with("SOCKET_") && k.to_string_lossy() != "SOCKET_NO_CONFIG" { cmd.env_remove(&k); } } diff --git a/crates/socket-patch-cli/tests/e2e_golang_redirect.rs b/crates/socket-patch-cli/tests/e2e_golang_redirect.rs index d861f3e2..666220ce 100644 --- a/crates/socket-patch-cli/tests/e2e_golang_redirect.rs +++ b/crates/socket-patch-cli/tests/e2e_golang_redirect.rs @@ -93,6 +93,7 @@ fn run_cli(root: &Path, gomodcache: &Path, args: &[&str]) -> (i32, String, Strin .env_remove("SOCKET_DRY_RUN") .env_remove("SOCKET_MANIFEST_PATH") .env_remove("SOCKET_API_TOKEN") + .env_remove("SOCKET_CLI_API_TOKEN") .output() .expect("failed to execute socket-patch binary"); ( diff --git a/crates/socket-patch-cli/tests/e2e_maven.rs b/crates/socket-patch-cli/tests/e2e_maven.rs index 4e0cb76e..16d95dfa 100644 --- a/crates/socket-patch-cli/tests/e2e_maven.rs +++ b/crates/socket-patch-cli/tests/e2e_maven.rs @@ -73,6 +73,7 @@ async fn run(args: &[&str], cwd: &Path, m2_repo: &Path, proxy_url: &str) -> Outp .env("SOCKET_EXPERIMENTAL_MAVEN", "1") // Keep the run hermetic: no ambient token, no inherited repo path. .env_remove("SOCKET_API_TOKEN") + .env_remove("SOCKET_CLI_API_TOKEN") .env_remove("M2_HOME") .env_remove("SOCKET_API_URL") .env_remove("SOCKET_OFFLINE") diff --git a/crates/socket-patch-cli/tests/e2e_npm.rs b/crates/socket-patch-cli/tests/e2e_npm.rs index 804fdd7a..b18ca124 100644 --- a/crates/socket-patch-cli/tests/e2e_npm.rs +++ b/crates/socket-patch-cli/tests/e2e_npm.rs @@ -78,7 +78,8 @@ fn run(cwd: &Path, args: &[&str]) -> (i32, String, String) { // opt-outs are deliberately kept so an opted-out dev stays opted out. for (key, _) in std::env::vars_os() { let name = key.to_string_lossy(); - if name.starts_with("SOCKET_") && !name.contains("TELEMETRY") { + if name.starts_with("SOCKET_") && !name.contains("TELEMETRY") && name != "SOCKET_NO_CONFIG" + { cmd.env_remove(&key); } } diff --git a/crates/socket-patch-cli/tests/e2e_nuget.rs b/crates/socket-patch-cli/tests/e2e_nuget.rs index 0950b85a..5c3ec8ef 100644 --- a/crates/socket-patch-cli/tests/e2e_nuget.rs +++ b/crates/socket-patch-cli/tests/e2e_nuget.rs @@ -70,6 +70,7 @@ async fn run(args: &[&str], cwd: &Path, nuget_packages: &Path, proxy_url: &str) // is what makes these tests actually exercise the NuGet code path. .env("SOCKET_EXPERIMENTAL_NUGET", "1") .env_remove("SOCKET_API_TOKEN") + .env_remove("SOCKET_CLI_API_TOKEN") .env_remove("SOCKET_API_URL") .env_remove("SOCKET_OFFLINE") .env_remove("SOCKET_PROXY_URL") diff --git a/crates/socket-patch-cli/tests/e2e_redirect_bun_build.rs b/crates/socket-patch-cli/tests/e2e_redirect_bun_build.rs index 818d619e..fa953921 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_bun_build.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_bun_build.rs @@ -69,7 +69,7 @@ fn has_command(cmd: &str) -> bool { fn scrub_socket_env(cmd: &mut Command) { for (k, _) in std::env::vars_os() { - if k.to_string_lossy().starts_with("SOCKET_") { + if k.to_string_lossy().starts_with("SOCKET_") && k.to_string_lossy() != "SOCKET_NO_CONFIG" { cmd.env_remove(&k); } } diff --git a/crates/socket-patch-cli/tests/e2e_redirect_cargo_build.rs b/crates/socket-patch-cli/tests/e2e_redirect_cargo_build.rs index ba722623..65cfb0c5 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_cargo_build.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_cargo_build.rs @@ -87,7 +87,7 @@ fn run_socket(cwd: &Path, args: &[&str], cargo_home: &Path) -> (i32, String, Str let mut cmd = Command::new(binary()); cmd.args(args).current_dir(cwd); for (k, _) in std::env::vars_os() { - if k.to_string_lossy().starts_with("SOCKET_") { + if k.to_string_lossy().starts_with("SOCKET_") && k.to_string_lossy() != "SOCKET_NO_CONFIG" { cmd.env_remove(&k); } } diff --git a/crates/socket-patch-cli/tests/e2e_redirect_npm_build.rs b/crates/socket-patch-cli/tests/e2e_redirect_npm_build.rs index 66f68fe3..c5189cf0 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_npm_build.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_npm_build.rs @@ -74,7 +74,7 @@ fn run_socket(cwd: &Path, args: &[&str]) -> (i32, String, String) { let mut cmd = Command::new(binary()); cmd.args(args).current_dir(cwd); for (k, _) in std::env::vars_os() { - if k.to_string_lossy().starts_with("SOCKET_") { + if k.to_string_lossy().starts_with("SOCKET_") && k.to_string_lossy() != "SOCKET_NO_CONFIG" { cmd.env_remove(&k); } } diff --git a/crates/socket-patch-cli/tests/e2e_redirect_rush_sim.rs b/crates/socket-patch-cli/tests/e2e_redirect_rush_sim.rs index 5db22d4c..e028f49e 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_rush_sim.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_rush_sim.rs @@ -75,7 +75,7 @@ fn has_command(cmd: &str) -> bool { fn scrub_socket_env(cmd: &mut Command) { for (k, _) in std::env::vars_os() { - if k.to_string_lossy().starts_with("SOCKET_") { + if k.to_string_lossy().starts_with("SOCKET_") && k.to_string_lossy() != "SOCKET_NO_CONFIG" { cmd.env_remove(&k); } } diff --git a/crates/socket-patch-cli/tests/e2e_redirect_yarn_berry_build.rs b/crates/socket-patch-cli/tests/e2e_redirect_yarn_berry_build.rs index 303fdeaf..1209da52 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_yarn_berry_build.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_yarn_berry_build.rs @@ -96,7 +96,7 @@ fn scrub_socket_env(cmd: &mut Command) { cmd.env("YARN_NODE_LINKER", "pnp"); for (k, _) in std::env::vars_os() { let key = k.to_string_lossy(); - if key.starts_with("SOCKET_") || key.starts_with("YARN_") { + if (key.starts_with("SOCKET_") || key.starts_with("YARN_")) && key != "SOCKET_NO_CONFIG" { cmd.env_remove(&k); } } diff --git a/crates/socket-patch-cli/tests/e2e_scan.rs b/crates/socket-patch-cli/tests/e2e_scan.rs index c888df3b..0290c213 100644 --- a/crates/socket-patch-cli/tests/e2e_scan.rs +++ b/crates/socket-patch-cli/tests/e2e_scan.rs @@ -111,7 +111,8 @@ fn run(cwd: &Path, args: &[&str]) -> (i32, String, String) { // so an opted-out dev stays opted out. for (key, _) in std::env::vars_os() { let name = key.to_string_lossy(); - if name.starts_with("SOCKET_") && !name.contains("TELEMETRY") { + if name.starts_with("SOCKET_") && !name.contains("TELEMETRY") && name != "SOCKET_NO_CONFIG" + { cmd.env_remove(&key); } } diff --git a/crates/socket-patch-cli/tests/e2e_vendor_bun_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_bun_build.rs index ba588024..a629c1d9 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_bun_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_bun_build.rs @@ -69,7 +69,7 @@ fn bun(cwd: &Path, args: &[&str], cache_dir: &Path) -> Output { fn scrub_socket_env(cmd: &mut Command) { for (k, _) in std::env::vars_os() { let k = k.to_string_lossy(); - if k.starts_with("SOCKET_") { + if k.starts_with("SOCKET_") && k != "SOCKET_NO_CONFIG" { cmd.env_remove(k.as_ref()); } } diff --git a/crates/socket-patch-cli/tests/e2e_vendor_cargo_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_cargo_build.rs index e35b96f2..ac392151 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_cargo_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_cargo_build.rs @@ -61,7 +61,7 @@ fn run_socket(cwd: &Path, args: &[&str], cargo_home: &Path) -> (i32, String, Str let mut cmd = Command::new(binary()); cmd.args(args).current_dir(cwd); for (k, _) in std::env::vars_os() { - if k.to_string_lossy().starts_with("SOCKET_") { + if k.to_string_lossy().starts_with("SOCKET_") && k.to_string_lossy() != "SOCKET_NO_CONFIG" { cmd.env_remove(&k); } } diff --git a/crates/socket-patch-cli/tests/e2e_vendor_composer_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_composer_build.rs index b3dbe8f1..46e16d0f 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_composer_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_composer_build.rs @@ -67,7 +67,7 @@ fn run_socket(cwd: &Path, args: &[&str]) -> (i32, String, String) { let mut cmd = Command::new(binary()); cmd.args(args).current_dir(cwd); for (k, _) in std::env::vars_os() { - if k.to_string_lossy().starts_with("SOCKET_") { + if k.to_string_lossy().starts_with("SOCKET_") && k.to_string_lossy() != "SOCKET_NO_CONFIG" { cmd.env_remove(&k); } } diff --git a/crates/socket-patch-cli/tests/e2e_vendor_gem_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_gem_build.rs index 85808ff9..c2d8d732 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_gem_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_gem_build.rs @@ -82,7 +82,7 @@ fn run_socket(cwd: &Path, args: &[&str]) -> (i32, String, String) { let mut cmd = Command::new(binary()); cmd.args(args).current_dir(cwd); for (k, _) in std::env::vars_os() { - if k.to_string_lossy().starts_with("SOCKET_") { + if k.to_string_lossy().starts_with("SOCKET_") && k.to_string_lossy() != "SOCKET_NO_CONFIG" { cmd.env_remove(&k); } } diff --git a/crates/socket-patch-cli/tests/e2e_vendor_golang_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_golang_build.rs index 23e75e5d..3c82d996 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_golang_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_golang_build.rs @@ -45,7 +45,7 @@ fn run_socket(cwd: &Path, args: &[&str], modcache: &Path) -> (i32, String, Strin let mut cmd = Command::new(binary()); cmd.args(args).current_dir(cwd); for (k, _) in std::env::vars_os() { - if k.to_string_lossy().starts_with("SOCKET_") { + if k.to_string_lossy().starts_with("SOCKET_") && k.to_string_lossy() != "SOCKET_NO_CONFIG" { cmd.env_remove(&k); } } diff --git a/crates/socket-patch-cli/tests/e2e_vendor_npm_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_npm_build.rs index 9a890ae7..defa3517 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_npm_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_npm_build.rs @@ -56,7 +56,7 @@ fn run_socket(cwd: &Path, args: &[&str]) -> (i32, String, String) { let mut cmd = Command::new(binary()); cmd.args(args).current_dir(cwd); for (k, _) in std::env::vars_os() { - if k.to_string_lossy().starts_with("SOCKET_") { + if k.to_string_lossy().starts_with("SOCKET_") && k.to_string_lossy() != "SOCKET_NO_CONFIG" { cmd.env_remove(&k); } } diff --git a/crates/socket-patch-cli/tests/e2e_vendor_pnpm_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_pnpm_build.rs index 9d8386a2..c3540242 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_pnpm_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_pnpm_build.rs @@ -80,9 +80,10 @@ fn scrub_socket_env(cmd: &mut Command) { cmd.env("npm_config_node_linker", "pnp"); for (k, _) in std::env::vars_os() { let key = k.to_string_lossy(); - if key.starts_with("SOCKET_") + if (key.starts_with("SOCKET_") || key.starts_with("PNPM_") - || key.to_ascii_lowercase().starts_with("npm_config_") + || key.to_ascii_lowercase().starts_with("npm_config_")) + && key != "SOCKET_NO_CONFIG" { cmd.env_remove(&k); } diff --git a/crates/socket-patch-cli/tests/e2e_vendor_pypi_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_pypi_build.rs index 9aa3db16..6f7536b9 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_pypi_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_pypi_build.rs @@ -51,7 +51,7 @@ fn run_socket(cwd: &Path, args: &[&str]) -> (i32, String, String) { let mut cmd = Command::new(binary()); cmd.args(args).current_dir(cwd); for (k, _) in std::env::vars_os() { - if k.to_string_lossy().starts_with("SOCKET_") { + if k.to_string_lossy().starts_with("SOCKET_") && k.to_string_lossy() != "SOCKET_NO_CONFIG" { cmd.env_remove(&k); } } diff --git a/crates/socket-patch-cli/tests/e2e_vendor_yarn_berry_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_yarn_berry_build.rs index d770a699..63118da2 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_yarn_berry_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_yarn_berry_build.rs @@ -82,7 +82,7 @@ fn scrub_socket_env(cmd: &mut Command) { cmd.env("YARN_NODE_LINKER", "pnp"); for (k, _) in std::env::vars_os() { let key = k.to_string_lossy(); - if key.starts_with("SOCKET_") || key.starts_with("YARN_") { + if (key.starts_with("SOCKET_") || key.starts_with("YARN_")) && key != "SOCKET_NO_CONFIG" { cmd.env_remove(&k); } } diff --git a/crates/socket-patch-cli/tests/e2e_vendor_yarn_classic_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_yarn_classic_build.rs index a5763a62..996536a3 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_yarn_classic_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_yarn_classic_build.rs @@ -81,7 +81,7 @@ fn corepack(cwd: &Path, pm: &str, args: &[&str], extra_env: &[(&str, &str)]) -> fn scrub_socket_env(cmd: &mut Command) { for (k, _) in std::env::vars_os() { let k = k.to_string_lossy(); - if k.starts_with("SOCKET_") { + if k.starts_with("SOCKET_") && k != "SOCKET_NO_CONFIG" { cmd.env_remove(k.as_ref()); } } diff --git a/crates/socket-patch-cli/tests/e2e_vex.rs b/crates/socket-patch-cli/tests/e2e_vex.rs index 939c9614..c9da9be3 100644 --- a/crates/socket-patch-cli/tests/e2e_vex.rs +++ b/crates/socket-patch-cli/tests/e2e_vex.rs @@ -64,7 +64,7 @@ fn binary() -> &'static str { fn cli() -> Command { let mut cmd = Command::new(binary()); for (key, _) in std::env::vars() { - if key.starts_with("SOCKET_") { + if key.starts_with("SOCKET_") && key != "SOCKET_NO_CONFIG" { cmd.env_remove(key); } } diff --git a/crates/socket-patch-cli/tests/e2e_vex_redirect.rs b/crates/socket-patch-cli/tests/e2e_vex_redirect.rs index 7833aaec..375d9f28 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_redirect.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_redirect.rs @@ -40,7 +40,7 @@ fn binary() -> &'static str { fn cli() -> Command { let mut cmd = Command::new(binary()); for (key, _) in std::env::vars() { - if key.starts_with("SOCKET_") { + if key.starts_with("SOCKET_") && key != "SOCKET_NO_CONFIG" { cmd.env_remove(key); } } diff --git a/crates/socket-patch-cli/tests/e2e_vex_vendor.rs b/crates/socket-patch-cli/tests/e2e_vex_vendor.rs index c4d06e59..20379496 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_vendor.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_vendor.rs @@ -50,7 +50,7 @@ fn binary() -> &'static str { fn cli() -> Command { let mut cmd = Command::new(binary()); for (key, _) in std::env::vars() { - if key.starts_with("SOCKET_") { + if key.starts_with("SOCKET_") && key != "SOCKET_NO_CONFIG" { cmd.env_remove(key); } } diff --git a/crates/socket-patch-cli/tests/ecosystem_dispatch_e2e.rs b/crates/socket-patch-cli/tests/ecosystem_dispatch_e2e.rs index 0235fb08..abe10792 100644 --- a/crates/socket-patch-cli/tests/ecosystem_dispatch_e2e.rs +++ b/crates/socket-patch-cli/tests/ecosystem_dispatch_e2e.rs @@ -90,7 +90,8 @@ fn scrub_socket_env(cmd: &mut Command) { } for (key, _) in std::env::vars_os() { let name = key.to_string_lossy(); - if name.starts_with("SOCKET_") && !name.contains("TELEMETRY") { + if name.starts_with("SOCKET_") && !name.contains("TELEMETRY") && name != "SOCKET_NO_CONFIG" + { cmd.env_remove(&key); } } diff --git a/crates/socket-patch-cli/tests/get_batch_paths_e2e.rs b/crates/socket-patch-cli/tests/get_batch_paths_e2e.rs index 9f1a9020..102059cf 100644 --- a/crates/socket-patch-cli/tests/get_batch_paths_e2e.rs +++ b/crates/socket-patch-cli/tests/get_batch_paths_e2e.rs @@ -39,7 +39,8 @@ const UUID_B: &str = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; fn scrub_socket_env(cmd: &mut Command) { for (key, _) in std::env::vars_os() { let name = key.to_string_lossy(); - if name.starts_with("SOCKET_") && !name.contains("TELEMETRY") { + if name.starts_with("SOCKET_") && !name.contains("TELEMETRY") && name != "SOCKET_NO_CONFIG" + { cmd.env_remove(&key); } } diff --git a/crates/socket-patch-cli/tests/global_packages_e2e.rs b/crates/socket-patch-cli/tests/global_packages_e2e.rs index 78646c18..0249c65e 100644 --- a/crates/socket-patch-cli/tests/global_packages_e2e.rs +++ b/crates/socket-patch-cli/tests/global_packages_e2e.rs @@ -50,7 +50,8 @@ fn cli(cwd: &Path) -> Command { .env_remove("SOCKET_MANIFEST_PATH"); for (key, _) in std::env::vars_os() { let name = key.to_string_lossy(); - if name.starts_with("SOCKET_") && !name.contains("TELEMETRY") { + if name.starts_with("SOCKET_") && !name.contains("TELEMETRY") && name != "SOCKET_NO_CONFIG" + { cmd.env_remove(&key); } } diff --git a/crates/socket-patch-cli/tests/in_process_redirect.rs b/crates/socket-patch-cli/tests/in_process_redirect.rs index 9e83540b..3ee2069d 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect.rs @@ -933,7 +933,8 @@ fn scrubbed_cli() -> std::process::Command { .env_remove("SOCKET_MANIFEST_PATH"); for (key, _) in std::env::vars_os() { let name = key.to_string_lossy(); - if name.starts_with("SOCKET_") && !name.contains("TELEMETRY") { + if name.starts_with("SOCKET_") && !name.contains("TELEMETRY") && name != "SOCKET_NO_CONFIG" + { cmd.env_remove(&key); } } diff --git a/crates/socket-patch-cli/tests/in_process_variant_apply_failure.rs b/crates/socket-patch-cli/tests/in_process_variant_apply_failure.rs index e70f6319..1692b184 100644 --- a/crates/socket-patch-cli/tests/in_process_variant_apply_failure.rs +++ b/crates/socket-patch-cli/tests/in_process_variant_apply_failure.rs @@ -64,7 +64,8 @@ fn run_apply_scrubbed(args: &[&str]) -> std::process::Output { .env_remove("VIRTUAL_ENV"); for (key, _) in std::env::vars_os() { let name = key.to_string_lossy(); - if name.starts_with("SOCKET_") && !name.contains("TELEMETRY") { + if name.starts_with("SOCKET_") && !name.contains("TELEMETRY") && name != "SOCKET_NO_CONFIG" + { cmd.env_remove(&key); } } diff --git a/crates/socket-patch-cli/tests/in_process_vendor.rs b/crates/socket-patch-cli/tests/in_process_vendor.rs index ba1ef544..65a343c7 100644 --- a/crates/socket-patch-cli/tests/in_process_vendor.rs +++ b/crates/socket-patch-cli/tests/in_process_vendor.rs @@ -219,7 +219,7 @@ fn run_cli(cwd: &Path, args: &[&str], extra_env: &[(&str, &str)]) -> (i32, Strin let mut cmd = Command::new(env!("CARGO_BIN_EXE_socket-patch")); cmd.args(args).current_dir(cwd); for (key, _) in std::env::vars() { - if key.starts_with("SOCKET_") { + if key.starts_with("SOCKET_") && key != "SOCKET_NO_CONFIG" { cmd.env_remove(key); } } diff --git a/crates/socket-patch-cli/tests/interactive_prompts_e2e.rs b/crates/socket-patch-cli/tests/interactive_prompts_e2e.rs index f77a52c3..8ded6cff 100644 --- a/crates/socket-patch-cli/tests/interactive_prompts_e2e.rs +++ b/crates/socket-patch-cli/tests/interactive_prompts_e2e.rs @@ -84,7 +84,8 @@ fn run_in_pty_bytes(args: &[&str], cwd: &Path, input: &[u8], timeout: Duration) // so an opted-out dev stays opted out. for (key, _) in std::env::vars_os() { let name = key.to_string_lossy(); - if name.starts_with("SOCKET_") && !name.contains("TELEMETRY") { + if name.starts_with("SOCKET_") && !name.contains("TELEMETRY") && name != "SOCKET_NO_CONFIG" + { cmd.env_remove(&key); } } @@ -266,12 +267,8 @@ fn setup_interactive_non_utf8_answer_aborts_without_panic() { "#; std::fs::write(tmp.path().join("package.json"), original).unwrap(); - let (code, output) = run_in_pty_bytes( - &["setup"], - tmp.path(), - b"\xE9\n", - Duration::from_secs(15), - ); + let (code, output) = + run_in_pty_bytes(&["setup"], tmp.path(), b"\xE9\n", Duration::from_secs(15)); assert!( !output.contains("panicked"), "non-UTF-8 answer must not panic the CLI; got: {output}" diff --git a/crates/socket-patch-cli/tests/remove_network.rs b/crates/socket-patch-cli/tests/remove_network.rs index 04c18f05..dc300b45 100644 --- a/crates/socket-patch-cli/tests/remove_network.rs +++ b/crates/socket-patch-cli/tests/remove_network.rs @@ -108,7 +108,9 @@ fn run_remove(cwd: &Path, api_url: &str, extra: &[&str]) -> (i32, String) { // 0, and deleted the entry, failing both tests for the wrong // reason. The controlled set must be seeded *after* scrubbing. for (key, _) in std::env::vars_os() { - if key.to_string_lossy().starts_with("SOCKET_") { + if key.to_string_lossy().starts_with("SOCKET_") + && key.to_string_lossy() != "SOCKET_NO_CONFIG" + { cmd.env_remove(&key); } } diff --git a/crates/socket-patch-cli/tests/repair_invariants.rs b/crates/socket-patch-cli/tests/repair_invariants.rs index 8487532b..c6cc4149 100644 --- a/crates/socket-patch-cli/tests/repair_invariants.rs +++ b/crates/socket-patch-cli/tests/repair_invariants.rs @@ -43,7 +43,9 @@ fn socket_cmd(cwd: &Path) -> Command { let mut cmd = Command::new(binary()); cmd.current_dir(cwd); for (name, _) in std::env::vars_os() { - if name.to_string_lossy().starts_with("SOCKET_") { + if name.to_string_lossy().starts_with("SOCKET_") + && name.to_string_lossy() != "SOCKET_NO_CONFIG" + { cmd.env_remove(name); } } @@ -511,7 +513,10 @@ fn repair_cleanup_failure_is_reported_in_json_and_silent_modes() { // informational skip event (warn-and-continue semantics preserved: // status stays success, exit stays 0, nothing was removed). let (code, stdout) = run_repair(tmp.path(), &[]); - assert_eq!(code, 0, "json: cleanup failure stays non-fatal; stdout=\n{stdout}"); + assert_eq!( + code, 0, + "json: cleanup failure stays non-fatal; stdout=\n{stdout}" + ); let v: serde_json::Value = serde_json::from_str(&stdout).expect("envelope JSON"); assert_eq!(v["status"], "success"); assert_eq!(v["summary"]["removed"], 0); diff --git a/crates/socket-patch-cli/tests/repair_vendor_flavors_e2e.rs b/crates/socket-patch-cli/tests/repair_vendor_flavors_e2e.rs index 64782803..d7eae377 100644 --- a/crates/socket-patch-cli/tests/repair_vendor_flavors_e2e.rs +++ b/crates/socket-patch-cli/tests/repair_vendor_flavors_e2e.rs @@ -540,7 +540,8 @@ async fn ledger_gone_drifted_copy_fails_closed(flavor: Flavor) { &["repair", "--download-mode", "file"], ); assert_eq!( - code, 1, + code, + 1, "{}: a rebuild that cannot match the lockfile's recorded integrity must fail closed: \ stdout={stdout} stderr={stderr}", flavor.tag() diff --git a/crates/socket-patch-cli/tests/rollback_invariants.rs b/crates/socket-patch-cli/tests/rollback_invariants.rs index c7c170bc..fb9fbbda 100644 --- a/crates/socket-patch-cli/tests/rollback_invariants.rs +++ b/crates/socket-patch-cli/tests/rollback_invariants.rs @@ -31,7 +31,9 @@ fn rollback_cmd(cwd: &Path) -> Command { let mut cmd = Command::new(binary()); cmd.arg("rollback").current_dir(cwd); for (key, _) in std::env::vars_os() { - if key.to_string_lossy().starts_with("SOCKET_") { + if key.to_string_lossy().starts_with("SOCKET_") + && key.to_string_lossy() != "SOCKET_NO_CONFIG" + { cmd.env_remove(&key); } } diff --git a/crates/socket-patch-cli/tests/scan_vendor_e2e.rs b/crates/socket-patch-cli/tests/scan_vendor_e2e.rs index d59669ca..97962f21 100644 --- a/crates/socket-patch-cli/tests/scan_vendor_e2e.rs +++ b/crates/socket-patch-cli/tests/scan_vendor_e2e.rs @@ -160,7 +160,9 @@ fn run_cli_env(root: &Path, argv: &[&str], extra_env: &[(&str, &str)]) -> (i32, // `scan_vendor_emits_no_telemetry_even_with_endpoint_env` seeds those // endpoint vars deliberately and proves the kill-switch still holds. for (key, _) in std::env::vars_os() { - if key.to_string_lossy().starts_with("SOCKET_") { + if key.to_string_lossy().starts_with("SOCKET_") + && key.to_string_lossy() != "SOCKET_NO_CONFIG" + { cmd.env_remove(&key); } } diff --git a/crates/socket-patch-cli/tests/setup_contract_gaps.rs b/crates/socket-patch-cli/tests/setup_contract_gaps.rs index d8780eea..087499e3 100644 --- a/crates/socket-patch-cli/tests/setup_contract_gaps.rs +++ b/crates/socket-patch-cli/tests/setup_contract_gaps.rs @@ -28,7 +28,7 @@ fn run(cwd: &Path, home: &Path, args: &[&str]) -> (i32, String) { let mut cmd = Command::new(binary()); cmd.args(args).current_dir(cwd); for (name, _) in std::env::vars() { - if name.starts_with("SOCKET_") { + if name.starts_with("SOCKET_") && name != "SOCKET_NO_CONFIG" { cmd.env_remove(name); } } diff --git a/crates/socket-patch-cli/tests/setup_invariants.rs b/crates/socket-patch-cli/tests/setup_invariants.rs index c3b13b64..e8ac4c3b 100644 --- a/crates/socket-patch-cli/tests/setup_invariants.rs +++ b/crates/socket-patch-cli/tests/setup_invariants.rs @@ -65,7 +65,8 @@ fn setup_command(cwd: &Path, args: &[&str]) -> Command { cmd.args(args).current_dir(cwd); for (key, _) in std::env::vars_os() { let name = key.to_string_lossy(); - if name.starts_with("SOCKET_") && !name.contains("TELEMETRY") { + if name.starts_with("SOCKET_") && !name.contains("TELEMETRY") && name != "SOCKET_NO_CONFIG" + { cmd.env_remove(&key); } } diff --git a/crates/socket-patch-cli/tests/setup_matrix_deno.rs b/crates/socket-patch-cli/tests/setup_matrix_deno.rs index fb1c20ad..7776c300 100644 --- a/crates/socket-patch-cli/tests/setup_matrix_deno.rs +++ b/crates/socket-patch-cli/tests/setup_matrix_deno.rs @@ -94,7 +94,9 @@ mod host_guard { let mut cmd = Command::new(binary()); cmd.args(args).current_dir(cwd); for (key, _) in std::env::vars_os() { - if key.to_string_lossy().starts_with("SOCKET_") { + if key.to_string_lossy().starts_with("SOCKET_") + && key.to_string_lossy() != "SOCKET_NO_CONFIG" + { cmd.env_remove(&key); } } diff --git a/crates/socket-patch-cli/tests/setup_matrix_gem.rs b/crates/socket-patch-cli/tests/setup_matrix_gem.rs index 7aef360b..06711f6a 100644 --- a/crates/socket-patch-cli/tests/setup_matrix_gem.rs +++ b/crates/socket-patch-cli/tests/setup_matrix_gem.rs @@ -91,7 +91,9 @@ mod host_guard { // every invocation with a clap parse error (exit 2) and turned this // guard red for an environmental reason. for (key, _) in std::env::vars_os() { - if key.to_string_lossy().starts_with("SOCKET_") { + if key.to_string_lossy().starts_with("SOCKET_") + && key.to_string_lossy() != "SOCKET_NO_CONFIG" + { cmd.env_remove(&key); } } @@ -332,15 +334,26 @@ mod host_guard { Gemfile (exit 1), not report no_files (exit 0).\n{out}\n{err}" ); assert_eq!( - json_str(&parse_json(&out, "check (subdir)"), "status", "check (subdir)"), + json_str( + &parse_json(&out, "check (subdir)"), + "status", + "check (subdir)" + ), "needs_configuration" ); // setup from the subdir: wires the ROOT project. let (code, out, err) = run(&sub, &["setup", "--yes", "--json"]); - assert_eq!(code, 0, "setup from a subdirectory must exit 0.\n{out}\n{err}"); assert_eq!( - json_str(&parse_json(&out, "setup (subdir)"), "status", "setup (subdir)"), + code, 0, + "setup from a subdirectory must exit 0.\n{out}\n{err}" + ); + assert_eq!( + json_str( + &parse_json(&out, "setup (subdir)"), + "status", + "setup (subdir)" + ), "success" ); let body = gemfile_body(root); diff --git a/crates/socket-patch-cli/tests/setup_matrix_maven.rs b/crates/socket-patch-cli/tests/setup_matrix_maven.rs index 8e5baa1a..81d8a65f 100644 --- a/crates/socket-patch-cli/tests/setup_matrix_maven.rs +++ b/crates/socket-patch-cli/tests/setup_matrix_maven.rs @@ -128,7 +128,9 @@ mod host_guard { let mut cmd = Command::new(binary()); cmd.args(args).current_dir(cwd); for (key, _) in std::env::vars_os() { - if key.to_string_lossy().starts_with("SOCKET_") { + if key.to_string_lossy().starts_with("SOCKET_") + && key.to_string_lossy() != "SOCKET_NO_CONFIG" + { cmd.env_remove(&key); } } diff --git a/crates/socket-patch-cli/tests/setup_matrix_npm.rs b/crates/socket-patch-cli/tests/setup_matrix_npm.rs index 2652fe57..23cad7b1 100644 --- a/crates/socket-patch-cli/tests/setup_matrix_npm.rs +++ b/crates/socket-patch-cli/tests/setup_matrix_npm.rs @@ -122,7 +122,9 @@ mod host_guard { let mut cmd = Command::new(binary()); cmd.args(args).current_dir(cwd); for (key, _) in std::env::vars_os() { - if key.to_string_lossy().starts_with("SOCKET_") { + if key.to_string_lossy().starts_with("SOCKET_") + && key.to_string_lossy() != "SOCKET_NO_CONFIG" + { cmd.env_remove(&key); } } diff --git a/crates/socket-patch-cli/tests/setup_matrix_nuget.rs b/crates/socket-patch-cli/tests/setup_matrix_nuget.rs index adc0357f..059f5dc8 100644 --- a/crates/socket-patch-cli/tests/setup_matrix_nuget.rs +++ b/crates/socket-patch-cli/tests/setup_matrix_nuget.rs @@ -122,7 +122,9 @@ mod host_guard { let mut cmd = Command::new(binary()); cmd.args(args).current_dir(cwd); for (key, _) in std::env::vars_os() { - if key.to_string_lossy().starts_with("SOCKET_") { + if key.to_string_lossy().starts_with("SOCKET_") + && key.to_string_lossy() != "SOCKET_NO_CONFIG" + { cmd.env_remove(&key); } } diff --git a/crates/socket-patch-cli/tests/setup_matrix_pypi.rs b/crates/socket-patch-cli/tests/setup_matrix_pypi.rs index 848624f0..3d484d44 100644 --- a/crates/socket-patch-cli/tests/setup_matrix_pypi.rs +++ b/crates/socket-patch-cli/tests/setup_matrix_pypi.rs @@ -134,7 +134,9 @@ mod host_guard { let mut cmd = Command::new(binary()); cmd.args(args).current_dir(cwd); for (key, _) in std::env::vars_os() { - if key.to_string_lossy().starts_with("SOCKET_") { + if key.to_string_lossy().starts_with("SOCKET_") + && key.to_string_lossy() != "SOCKET_NO_CONFIG" + { cmd.env_remove(&key); } } diff --git a/crates/socket-patch-cli/tests/setup_pth_invariants.rs b/crates/socket-patch-cli/tests/setup_pth_invariants.rs index dffc2830..a2fdeb25 100644 --- a/crates/socket-patch-cli/tests/setup_pth_invariants.rs +++ b/crates/socket-patch-cli/tests/setup_pth_invariants.rs @@ -584,12 +584,7 @@ fn poetry_2x_without_no_update_falls_back_to_bare_lock() { let log = tmp.path().join("poetry-argv.log"); // Poetry 2.x: `--no-update` was removed (pin-preserving became the // default), so that spelling exits non-zero. - write_pm_shim( - &tmp.path().join("bin"), - "poetry", - &log, - Some("--no-update"), - ); + write_pm_shim(&tmp.path().join("bin"), "poetry", &log, Some("--no-update")); let (code, v) = run_setup_with_shims(tmp.path(), &tmp.path().join("bin")); assert_eq!(code, 0, "setup must succeed: {v}"); diff --git a/crates/socket-patch-cli/tests/telemetry_e2e.rs b/crates/socket-patch-cli/tests/telemetry_e2e.rs index 5f24922b..5a40b7af 100644 --- a/crates/socket-patch-cli/tests/telemetry_e2e.rs +++ b/crates/socket-patch-cli/tests/telemetry_e2e.rs @@ -87,7 +87,7 @@ fn run_cmd( // dev shell must not vacuously green the count-0 asserts. for (key, _) in std::env::vars_os() { let name = key.to_string_lossy(); - if name.starts_with("SOCKET_") { + if name.starts_with("SOCKET_") && name != "SOCKET_NO_CONFIG" { cmd.env_remove(&key); } } diff --git a/crates/socket-patch-core/src/api/client.rs b/crates/socket-patch-core/src/api/client.rs index ab0445fd..5908b7c1 100644 --- a/crates/socket-patch-core/src/api/client.rs +++ b/crates/socket-patch-core/src/api/client.rs @@ -1087,25 +1087,22 @@ pub async fn get_api_client_with_overrides(overrides: ApiClientEnvOverrides) -> // "unset" at every layer. `SOCKET_NO_API_TOKEN` vetoes the *ambient* // token sources (env + config) so unauthenticated behavior can be // forced without unsetting anything; an explicit override still wins. - let api_token = overrides - .api_token - .filter(|t| !t.is_empty()) - .or_else(|| { - if socket_cli_config::no_api_token_veto() { - debug_log("api token: suppressed by SOCKET_NO_API_TOKEN"); - return None; - } - std::env::var("SOCKET_API_TOKEN") - .ok() - .filter(|t| !t.is_empty()) - .or_else(|| { - socket_cli_config::load() - .and_then(|c| c.api_token.clone()) - .inspect(|_| { - debug_log("api token: from socket-cli config (`socket login`)"); - }) - }) - }); + let api_token = overrides.api_token.filter(|t| !t.is_empty()).or_else(|| { + if socket_cli_config::no_api_token_veto() { + debug_log("api token: suppressed by SOCKET_NO_API_TOKEN"); + return None; + } + std::env::var("SOCKET_API_TOKEN") + .ok() + .filter(|t| !t.is_empty()) + .or_else(|| { + socket_cli_config::load() + .and_then(|c| c.api_token.clone()) + .inspect(|_| { + debug_log("api token: from socket-cli config (`socket login`)"); + }) + }) + }); let resolved_org_slug = overrides .org_slug .filter(|s| !s.is_empty()) @@ -1576,6 +1573,46 @@ mod tests { use super::*; use std::collections::HashMap; + /// `SOCKET_NO_API_TOKEN` must veto an env-supplied token: the client + /// falls back to the public proxy exactly as if no token were set. + /// Serialized: SOCKET_* env is process-global (the socket_cli_config + /// suite touches SOCKET_NO_CONFIG concurrently otherwise). + #[tokio::test] + #[serial_test::serial] + async fn no_api_token_veto_forces_public_proxy() { + let saved_token = std::env::var("SOCKET_API_TOKEN").ok(); + std::env::set_var("SOCKET_API_TOKEN", "sktsec_ambient_api"); + std::env::set_var("SOCKET_NO_API_TOKEN", "1"); + let (client, use_public_proxy) = + get_api_client_with_overrides(ApiClientEnvOverrides::default()).await; + std::env::remove_var("SOCKET_NO_API_TOKEN"); + match saved_token { + Some(v) => std::env::set_var("SOCKET_API_TOKEN", v), + None => std::env::remove_var("SOCKET_API_TOKEN"), + } + assert!(use_public_proxy, "vetoed env token must select the proxy"); + assert!(client.api_token.is_none()); + } + + /// An explicit override (the `--api-token` flag) survives the veto — + /// `SOCKET_NO_API_TOKEN` suppresses only ambient sources. The org + /// override skips auto-resolution so no network fires. + #[tokio::test] + #[serial_test::serial] + async fn explicit_token_override_survives_veto() { + std::env::set_var("SOCKET_NO_API_TOKEN", "1"); + let raw = format!("sktsec_{}_api", "x".repeat(44)); + let (client, use_public_proxy) = get_api_client_with_overrides(ApiClientEnvOverrides { + api_token: Some(raw.clone()), + org_slug: Some("test-org".to_string()), + ..ApiClientEnvOverrides::default() + }) + .await; + std::env::remove_var("SOCKET_NO_API_TOKEN"); + assert!(!use_public_proxy, "an explicit token must authenticate"); + assert_eq!(client.api_token.as_deref(), Some(raw.as_str())); + } + #[test] fn test_urlencoding_basic() { assert_eq!(urlencoding_encode("hello"), "hello"); diff --git a/crates/socket-patch-core/src/patch/vendor/maven_repo.rs b/crates/socket-patch-core/src/patch/vendor/maven_repo.rs index 043adfef..9c162997 100644 --- a/crates/socket-patch-core/src/patch/vendor/maven_repo.rs +++ b/crates/socket-patch-core/src/patch/vendor/maven_repo.rs @@ -2052,10 +2052,7 @@ mod tests { .permissions() .mode() & 0o7777; - assert_eq!( - mode, 0o600, - "wiring must not reset the user's pom.xml mode" - ); + assert_eq!(mode, 0o600, "wiring must not reset the user's pom.xml mode"); } #[cfg(unix)] diff --git a/crates/socket-patch-core/src/patch/vendor/mod.rs b/crates/socket-patch-core/src/patch/vendor/mod.rs index f6ec886b..2ffc5a41 100644 --- a/crates/socket-patch-core/src/patch/vendor/mod.rs +++ b/crates/socket-patch-core/src/patch/vendor/mod.rs @@ -730,11 +730,9 @@ mod staging_tests { let tmp = tempfile::tempdir().unwrap(); std::fs::write(tmp.path().join("index.js"), b"whatever").unwrap(); let record = one_file_record("index.js"); - assert!( - missing_existing_patch_files(tmp.path(), &record.files) - .await - .is_empty() - ); + assert!(missing_existing_patch_files(tmp.path(), &record.files) + .await + .is_empty()); } /// End-to-end through the vendor staging entrypoint: without `--force`, diff --git a/crates/socket-patch-core/src/patch/vendor/npm_lock.rs b/crates/socket-patch-core/src/patch/vendor/npm_lock.rs index 38f5f2a1..243f685c 100644 --- a/crates/socket-patch-core/src/patch/vendor/npm_lock.rs +++ b/crates/socket-patch-core/src/patch/vendor/npm_lock.rs @@ -297,8 +297,7 @@ pub async fn vendor_npm( Ok(out) => out, Err(e) => return done_failure(purl, format!("cannot serialize {lock_name}: {e}")), }; - if let Err(e) = atomic_write_bytes_preserving_mode(&project_root.join(&lock_name), &out).await - { + if let Err(e) = atomic_write_bytes_preserving_mode(&project_root.join(&lock_name), &out).await { return done_failure(purl, format!("cannot write {lock_name}: {e}")); } @@ -1380,8 +1379,9 @@ mod tests { async fn bundled_deps_refusal_survives_package_json_bom() { let fx = fixture().await; let mut pkg_json = b"\xEF\xBB\xBF".to_vec(); - pkg_json - .extend_from_slice(br#"{"name":"left-pad","version":"1.3.0","bundleDependencies":["dep"]}"#); + pkg_json.extend_from_slice( + br#"{"name":"left-pad","version":"1.3.0","bundleDependencies":["dep"]}"#, + ); tokio::fs::write(fx.installed().join("package.json"), pkg_json) .await .unwrap(); diff --git a/crates/socket-patch-core/src/patch/vendor/nuget_feed.rs b/crates/socket-patch-core/src/patch/vendor/nuget_feed.rs index c5ae487d..58109415 100644 --- a/crates/socket-patch-core/src/patch/vendor/nuget_feed.rs +++ b/crates/socket-patch-core/src/patch/vendor/nuget_feed.rs @@ -312,8 +312,7 @@ pub async fn vendor_nuget( .await { result.success = false; - result.error = - Some(format!("failed to rewrite {PACKAGES_LOCK}: {e}")); + result.error = Some(format!("failed to rewrite {PACKAGES_LOCK}: {e}")); return done(result, None, warnings); } } @@ -2265,9 +2264,7 @@ mod tests { edit.new_text ); assert!( - vis.contains( - " \n " - ), + vis.contains(" \n "), "the catch-all must target the seeded active source: {}", edit.new_text ); @@ -2279,8 +2276,7 @@ mod tests { async fn wired_rebuild_reports_vendored_nupkg_path() { let (dir, blobs, installed, record) = fixture(true, None).await; let root = dir.path(); - let (r1, _e, _w) = - unwrap_done(run_vendor(root, &blobs, &installed, &record, false).await); + let (r1, _e, _w) = unwrap_done(run_vendor(root, &blobs, &installed, &record, false).await); assert!(r1.success); tokio::fs::remove_file(root.join(copy_rel())).await.unwrap(); @@ -2323,7 +2319,9 @@ mod tests { let (r1, e1, w1) = unwrap_done(run_vendor(root, &blobs, &installed, &record, false).await); assert!(r1.success, "{:?}", r1.error); assert!(e1.is_some()); - assert!(w1.iter().any(|w| w.code == "vendor_nuget_lock_entry_absent")); + assert!(w1 + .iter() + .any(|w| w.code == "vendor_nuget_lock_entry_absent")); let (r2, e2, w2) = unwrap_done(run_vendor(root, &blobs, &installed, &record, false).await); assert!(r2.success, "{:?}", r2.error); @@ -2345,8 +2343,7 @@ mod tests { async fn wired_rerun_with_corrupt_lock_fails() { let (dir, blobs, installed, record) = fixture(true, None).await; let root = dir.path(); - let (r1, _e, _w) = - unwrap_done(run_vendor(root, &blobs, &installed, &record, false).await); + let (r1, _e, _w) = unwrap_done(run_vendor(root, &blobs, &installed, &record, false).await); assert!(r1.success); // The lock rots after vendoring. The fresh path fails closed on an // unparseable lock; the wired rebuild leg must not silently skip the @@ -2354,10 +2351,12 @@ mod tests { tokio::fs::write(root.join(PACKAGES_LOCK), b"{ not json") .await .unwrap(); - let (r2, e2, _w2) = - unwrap_done(run_vendor(root, &blobs, &installed, &record, false).await); + let (r2, e2, _w2) = unwrap_done(run_vendor(root, &blobs, &installed, &record, false).await); assert!(e2.is_none()); - assert!(!r2.success, "a corrupt lock must fail the rerun, not vanish"); + assert!( + !r2.success, + "a corrupt lock must fail the rerun, not vanish" + ); assert!( r2.error.as_deref().unwrap_or("").contains("unparseable"), "{:?}", @@ -2375,8 +2374,7 @@ mod tests { use std::os::unix::fs::PermissionsExt as _; let (dir, blobs, installed, record) = fixture(true, None).await; let root = dir.path(); - let (r1, _e, _w) = - unwrap_done(run_vendor(root, &blobs, &installed, &record, false).await); + let (r1, _e, _w) = unwrap_done(run_vendor(root, &blobs, &installed, &record, false).await); assert!(r1.success); tokio::fs::remove_file(root.join(copy_rel())).await.unwrap(); @@ -2519,8 +2517,7 @@ mod tests { use std::os::unix::fs::PermissionsExt as _; let (dir, blobs, installed, record) = fixture(true, None).await; let root = dir.path(); - let (r1, _e, _w) = - unwrap_done(run_vendor(root, &blobs, &installed, &record, false).await); + let (r1, _e, _w) = unwrap_done(run_vendor(root, &blobs, &installed, &record, false).await); assert!(r1.success); tokio::fs::set_permissions( root.join(PACKAGES_LOCK), @@ -2597,7 +2594,11 @@ mod tests { let wired = tokio::fs::read_to_string(root.join("nuget.config")) .await .unwrap(); - let edited = wired.replacen("", "\n", 1); + let edited = wired.replacen( + "", + "\n", + 1, + ); assert_ne!(edited, wired); tokio::fs::write(root.join("nuget.config"), &edited) .await diff --git a/crates/socket-patch-core/src/patch/vendor/pnpm_lock.rs b/crates/socket-patch-core/src/patch/vendor/pnpm_lock.rs index d3fc358d..7339a69b 100644 --- a/crates/socket-patch-core/src/patch/vendor/pnpm_lock.rs +++ b/crates/socket-patch-core/src/patch/vendor/pnpm_lock.rs @@ -1670,7 +1670,8 @@ async fn commit_pair( .map_err(|e| format!("cannot write {PACKAGE_JSON}: {e}"))?; } if let Some(bytes) = new_lock { - if let Err(e) = atomic_write_bytes_preserving_mode(&project_root.join(PNPM_LOCK), bytes).await + if let Err(e) = + atomic_write_bytes_preserving_mode(&project_root.join(PNPM_LOCK), bytes).await { if new_pkg.is_some() { // Unwind (best effort): a failure here leaves the desync pair @@ -3086,7 +3087,9 @@ snapshots: assert!(detail.contains("left-pad@1.3.0"), "{detail}"); assert_eq!(fx.read(PNPM_LOCK).await, lock, "lock untouched"); assert!( - !fx.root().join(format!(".socket/vendor/npm/{UUID}")).exists(), + !fx.root() + .join(format!(".socket/vendor/npm/{UUID}")) + .exists(), "refusal stages no artifact" ); } diff --git a/crates/socket-patch-core/src/patch/vendor/pypi_uv.rs b/crates/socket-patch-core/src/patch/vendor/pypi_uv.rs index 70751e88..cbcc04dc 100644 --- a/crates/socket-patch-core/src/patch/vendor/pypi_uv.rs +++ b/crates/socket-patch-core/src/patch/vendor/pypi_uv.rs @@ -683,8 +683,7 @@ pub(super) async fn revert_uv(entry: &VendorEntry, root: &Path, dry_run: bool) - if !dry_run { // Reverse of the wire order: the lock first, then the pyproject. - if let Err(e) = atomic_write_bytes_preserving_mode(&lock_path, lock_text.as_bytes()).await - { + if let Err(e) = atomic_write_bytes_preserving_mode(&lock_path, lock_text.as_bytes()).await { return RevertOutcome { success: false, warnings, @@ -1685,12 +1684,9 @@ wheels = [ use std::os::unix::fs::PermissionsExt; let tmp = write_pair(DIRECT_REGISTRY_PYPROJECT, DIRECT_REGISTRY_LOCK).await; for f in ["pyproject.toml", "uv.lock"] { - tokio::fs::set_permissions( - tmp.path().join(f), - std::fs::Permissions::from_mode(0o600), - ) - .await - .unwrap(); + tokio::fs::set_permissions(tmp.path().join(f), std::fs::Permissions::from_mode(0o600)) + .await + .unwrap(); } let mode_of = |f: &str| { let path = tmp.path().join(f); @@ -1717,7 +1713,11 @@ wheels = [ ) .await .unwrap(); - assert_eq!(mode_of("pyproject.toml").await, 0o600, "wire reset the mode"); + assert_eq!( + mode_of("pyproject.toml").await, + 0o600, + "wire reset the mode" + ); assert_eq!(mode_of("uv.lock").await, 0o600, "wire reset the mode"); let entry = entry_for(wiring, meta); diff --git a/crates/socket-patch-core/src/patch/vendor/verify.rs b/crates/socket-patch-core/src/patch/vendor/verify.rs index 44e0f2dd..0fb826ea 100644 --- a/crates/socket-patch-core/src/patch/vendor/verify.rs +++ b/crates/socket-patch-core/src/patch/vendor/verify.rs @@ -616,17 +616,23 @@ mod tests { let eocd = bytes.len() - 22; assert_eq!(&bytes[eocd..eocd + 4], b"PK\x05\x06", "EOCD not found"); let cd_count = u16::from_le_bytes([bytes[eocd + 10], bytes[eocd + 11]]) as usize; - let mut off = - u32::from_le_bytes(bytes[eocd + 16..eocd + 20].try_into().unwrap()) as usize; + let mut off = u32::from_le_bytes(bytes[eocd + 16..eocd + 20].try_into().unwrap()) as usize; for _ in 0..cd_count { - assert_eq!(&bytes[off..off + 4], b"PK\x01\x02", "central header not found"); + assert_eq!( + &bytes[off..off + 4], + b"PK\x01\x02", + "central header not found" + ); let name_len = u16::from_le_bytes([bytes[off + 28], bytes[off + 29]]) as usize; let extra_len = u16::from_le_bytes([bytes[off + 30], bytes[off + 31]]) as usize; let comment_len = u16::from_le_bytes([bytes[off + 32], bytes[off + 33]]) as usize; - let lho = - u32::from_le_bytes(bytes[off + 42..off + 46].try_into().unwrap()) as usize; + let lho = u32::from_le_bytes(bytes[off + 42..off + 46].try_into().unwrap()) as usize; bytes[off + 24..off + 28].fill(0); - assert_eq!(&bytes[lho..lho + 4], b"PK\x03\x04", "local header not found"); + assert_eq!( + &bytes[lho..lho + 4], + b"PK\x03\x04", + "local header not found" + ); bytes[lho + 22..lho + 26].fill(0); off += 46 + name_len + extra_len + comment_len; } diff --git a/crates/socket-patch-core/src/patch/vendor/yarn_berry_lock.rs b/crates/socket-patch-core/src/patch/vendor/yarn_berry_lock.rs index 3901744b..e64a66a3 100644 --- a/crates/socket-patch-core/src/patch/vendor/yarn_berry_lock.rs +++ b/crates/socket-patch-core/src/patch/vendor/yarn_berry_lock.rs @@ -490,7 +490,9 @@ pub async fn revert_yarn_berry( ); } if changed { - if let Err(e) = atomic_write_bytes_preserving_mode(&lock_path, text.as_bytes()).await { + if let Err(e) = + atomic_write_bytes_preserving_mode(&lock_path, text.as_bytes()).await + { return RevertOutcome::failed(format!("cannot write {YARN_LOCK}: {e}")); } } @@ -534,7 +536,9 @@ pub async fn revert_yarn_berry( let indent = detect_indent(&String::from_utf8_lossy(&bytes)); match serialize_json(&pkg, &indent) { Ok(out) => { - if let Err(e) = atomic_write_bytes_preserving_mode(&pkg_path, &out).await { + if let Err(e) = + atomic_write_bytes_preserving_mode(&pkg_path, &out).await + { return RevertOutcome::failed(format!( "cannot write {PACKAGE_JSON}: {e}" )); @@ -647,7 +651,9 @@ async fn commit_pair( atomic_write_bytes_preserving_mode(&pkg_path, new_pkg) .await .map_err(|e| format!("cannot write {PACKAGE_JSON}: {e}"))?; - if let Err(e) = atomic_write_bytes_preserving_mode(&project_root.join(YARN_LOCK), new_lock).await { + if let Err(e) = + atomic_write_bytes_preserving_mode(&project_root.join(YARN_LOCK), new_lock).await + { return match atomic_write_bytes_preserving_mode(&pkg_path, orig_pkg).await { Ok(()) => Err(format!( "cannot write {YARN_LOCK}: {e} ({PACKAGE_JSON} restored)" diff --git a/crates/socket-patch-core/src/utils/env_compat.rs b/crates/socket-patch-core/src/utils/env_compat.rs index 9bfabd95..59dcc71a 100644 --- a/crates/socket-patch-core/src/utils/env_compat.rs +++ b/crates/socket-patch-core/src/utils/env_compat.rs @@ -354,7 +354,10 @@ mod tests { std::env::set_var(CANONICAL, ""); std::env::set_var(ALIAS, "alias-value"); promote_aliases(&[(CANONICAL, ALIAS)]); - assert_eq!(std::env::var(CANONICAL).ok().as_deref(), Some("alias-value")); + assert_eq!( + std::env::var(CANONICAL).ok().as_deref(), + Some("alias-value") + ); std::env::remove_var(CANONICAL); std::env::set_var(ALIAS, ""); diff --git a/crates/socket-patch-core/src/utils/socket_cli_config.rs b/crates/socket-patch-core/src/utils/socket_cli_config.rs index 2c7c5518..e4d746b2 100644 --- a/crates/socket-patch-core/src/utils/socket_cli_config.rs +++ b/crates/socket-patch-core/src/utils/socket_cli_config.rs @@ -80,16 +80,20 @@ pub fn no_api_token_veto() -> bool { fn data_home() -> Option { #[cfg(windows)] { - env_non_empty("LOCALAPPDATA").map(PathBuf::from).or_else(|| { - env_non_empty("USERPROFILE").map(|p| PathBuf::from(p).join("AppData").join("Local")) - }) + env_non_empty("LOCALAPPDATA") + .map(PathBuf::from) + .or_else(|| { + env_non_empty("USERPROFILE").map(|p| PathBuf::from(p).join("AppData").join("Local")) + }) } #[cfg(target_os = "macos")] { - env_non_empty("XDG_DATA_HOME").map(PathBuf::from).or_else(|| { - env_non_empty("HOME") - .map(|h| PathBuf::from(h).join("Library").join("Application Support")) - }) + env_non_empty("XDG_DATA_HOME") + .map(PathBuf::from) + .or_else(|| { + env_non_empty("HOME") + .map(|h| PathBuf::from(h).join("Library").join("Application Support")) + }) } #[cfg(all(unix, not(target_os = "macos")))] { @@ -400,10 +404,7 @@ mod tests { ); // No env, config disabled → built-in default. with_env( - &[ - ("SOCKET_API_URL", None), - ("SOCKET_NO_CONFIG", Some("1")), - ], + &[("SOCKET_API_URL", None), ("SOCKET_NO_CONFIG", Some("1"))], || { assert_eq!( resolve_api_base_url(), diff --git a/crates/socket-patch-core/src/vex/product.rs b/crates/socket-patch-core/src/vex/product.rs index 057737c3..71b19dd6 100644 --- a/crates/socket-patch-core/src/vex/product.rs +++ b/crates/socket-patch-core/src/vex/product.rs @@ -1486,7 +1486,10 @@ mod tests { #[test] fn scan_origin_url_strips_hash_comment_without_space() { let cfg = "[remote \"origin\"]\n\turl = https://host/a#frag\n"; - assert_eq!(scan_remote_origin_url(cfg).as_deref(), Some("https://host/a")); + assert_eq!( + scan_remote_origin_url(cfg).as_deref(), + Some("https://host/a") + ); } /// A double-quoted value is unquoted, matching git. diff --git a/crates/socket-patch-core/tests/crawler_npm_e2e.rs b/crates/socket-patch-core/tests/crawler_npm_e2e.rs index fd7e49a1..abff1bda 100644 --- a/crates/socket-patch-core/tests/crawler_npm_e2e.rs +++ b/crates/socket-patch-core/tests/crawler_npm_e2e.rs @@ -1192,7 +1192,8 @@ async fn read_package_json_rejects_fifo_without_hanging() { assert_eq!(direct, None, "a FIFO is not a valid package.json"); let crawler = NpmCrawler; - let Ok(crawled) = tokio::time::timeout(deadline, crawler.crawl_all(&options_at(tmp.path()))).await + let Ok(crawled) = + tokio::time::timeout(deadline, crawler.crawl_all(&options_at(tmp.path()))).await else { release_and_panic("crawl_all (scan)"); }; From 917d34b9f83442ddd0db950be4d04862569d683a Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 21 Jul 2026 14:30:26 -0400 Subject: [PATCH 4/5] docs(config): configuration design doc + README/CLI_CONTRACT/CHANGELOG MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/design/configuration.md: the settled configuration model — per-key precedence (flag > env > SOCKET_CLI_* alias > socket-cli config.json > default), read-only pledge, rejected alternatives (.env loading, new config file, repo-level endpoints/credentials), and deferred items (manifest setup.defaults, the env cleanup sweep, token-file/keychain sourcing). - README: 'Configuration sources' subsection under Global Options. - CLI_CONTRACT: peer-alias paragraph, config-layer toggles table, and a 'Persisted configuration' section with contract properties incl. 'repo-level files never carry endpoints/credentials/interlocks'. - CHANGELOG: Unreleased Added + Changed entries. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 22 ++++ README.md | 17 +++ crates/socket-patch-cli/CLI_CONTRACT.md | 42 +++++- .../src/utils/socket_cli_config.rs | 8 +- docs/design/configuration.md | 122 ++++++++++++++++++ 5 files changed, 209 insertions(+), 2 deletions(-) create mode 100644 docs/design/configuration.md diff --git a/CHANGELOG.md b/CHANGELOG.md index a2ec3e85..7eb77aba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,23 @@ in this file — see `.github/workflows/release.yml` (`version` job). ### Added +- **`socket login` now configures socket-patch.** The JS Socket CLI's + persisted config (`/socket/settings/config.json`) is read — + never written — as a fallback layer below env vars for `apiToken`, + `defaultOrg`, and `apiBaseUrl`: precedence per key is CLI flag > env var + > socket-cli config > built-in default. Four `SOCKET_CLI_*` env names + are accepted as silent peer aliases (`SOCKET_CLI_API_TOKEN`, + `SOCKET_CLI_ORG_SLUG`, `SOCKET_CLI_API_BASE_URL`, + `SOCKET_CLI_NO_API_TOKEN`); the canonical `SOCKET_*` names win. Two new + env-only toggles: `SOCKET_NO_API_TOKEN` ignores ambient tokens (env + + config; an explicit `--api-token` still authenticates) and + `SOCKET_NO_CONFIG` disables the config layer. A corrupt config file + warns once on stderr and is ignored; `--json` stdout is unaffected. The + telemetry endpoint now resolves the API base through the same chain as + client construction, so a config-supplied `apiBaseUrl` applies to both. + Design notes: `docs/design/configuration.md`. + + - **Hosted patch mode: `scan --mode hosted` (a.k.a. the hidden `--redirect`).** The third patch-application mode: instead of applying in place (agent) or committing artifacts (vendored), `scan` rewrites lockfiles / registry @@ -244,6 +261,11 @@ in this file — see `.github/workflows/release.yml` (`version` job). ### Changed +- `--api-url` / `--proxy-url` no longer carry clap-level defaults: with + neither flag nor env var set they parse as unset and the documented + default URLs are applied at API-client construction (after the + socket-cli config layer). Observable behavior is unchanged unless a + socket-cli login exists. - **All ecosystem feature flags removed — every ecosystem is always compiled in.** The `cargo`, `golang`, `maven`, `composer`, `nuget`, and `deno` Cargo features are gone from both crates; npm, PyPI, Ruby gems, Go, Cargo, NuGet, diff --git a/README.md b/README.md index 5682c15d..d5780779 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,23 @@ These flags are accepted by **every** subcommand — they are flattened into eac Each flag has a matching `SOCKET_*` environment variable. **Precedence is CLI arg > env var > default**, so a flag on the command line always wins over the environment. +### Configuration sources + +For the three authentication settings, the [Socket CLI](https://docs.socket.dev/docs/socket-cli)'s persisted login sits between the env var and the built-in default — run `socket login` (or `socket config set apiToken` / `defaultOrg`) once and socket-patch picks it up too. Resolution is per key, and an empty value means "unset" at every layer: + +``` +--api-token / --org / --api-url + 1. CLI flag + 2. Env var SOCKET_API_TOKEN / SOCKET_ORG_SLUG / SOCKET_API_URL + 3. Peer alias env SOCKET_CLI_API_TOKEN / SOCKET_CLI_ORG_SLUG / SOCKET_CLI_API_BASE_URL + 4. socket-cli config /socket/settings/config.json — read-only + (Linux: ~/.local/share; macOS: ~/Library/Application Support, + both after $XDG_DATA_HOME; Windows: %LOCALAPPDATA%) + 5. Built-in default no token → public proxy; org → auto-resolve; https://api.socket.dev +``` + +Two env-only toggles adjust this: `SOCKET_NO_API_TOKEN=1` ignores ambient tokens (env + config; an explicit `--api-token` still wins), and `SOCKET_NO_CONFIG=1` disables the config-file layer entirely. socket-patch never *writes* the config file, and a corrupt one only produces a stderr warning — it never breaks a command or pollutes `--json` output. socket-patch does **not** read `.env` files or any per-repository config for endpoints or credentials: a cloned repo must never be able to redirect where patches come from or spend your token. + | Flag | Env var | Description | |------|---------|-------------| | `--cwd ` | `SOCKET_CWD` | Working directory (default: `.`). The manifest path is resolved relative to this. | diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index d5ca249b..7cae0b8c 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -24,7 +24,7 @@ This document defines the **public surface** of the `socket-patch` binary. Anyth ## Global arguments -In v3.0 every subcommand accepts the same set of "global" flags via a single shared `GlobalArgs` struct that's `#[command(flatten)]`-ed into each per-command struct (`crates/socket-patch-cli/src/args.rs`). Subcommands that don't actually consume a given flag accept it silently — e.g. `list --global` parses fine and is a no-op. Every flag also has an environment-variable binding; precedence is **CLI arg > env var > default**. +In v3.0 every subcommand accepts the same set of "global" flags via a single shared `GlobalArgs` struct that's `#[command(flatten)]`-ed into each per-command struct (`crates/socket-patch-cli/src/args.rs`). Subcommands that don't actually consume a given flag accept it silently — e.g. `list --global` parses fine and is a no-op. Every flag also has an environment-variable binding; precedence is **CLI arg > env var > default** — and for exactly three keys (`--api-token`, `--org`, `--api-url`) the JS socket-cli's persisted login sits between env var and default: **CLI arg > env var (canonical, then `SOCKET_CLI_*` alias) > socket-cli `config.json` > default**. See "Persisted configuration" under Environment variables. | Long | Short | Env var | Default | Type | Semantic | |---|---|---|---|---|---| @@ -599,6 +599,10 @@ worse, lets a warm cache silently serve unpatched bytes): All v3.0 env vars use the `SOCKET_*` prefix. Three legacy `SOCKET_PATCH_*` names are still honored at runtime for compatibility: on first read of any of the three the binary emits a one-shot deprecation warning to stderr (the warning fires unconditionally — even under `--silent` / `--json` — because it's a transition signal users need to see). The legacy names will be removed in the next major release. +Four `SOCKET_CLI_*` names from the sibling JS Socket CLI are additionally accepted as **peer aliases** (supported, not deprecated — no warning): `SOCKET_CLI_API_TOKEN` → `SOCKET_API_TOKEN`, `SOCKET_CLI_ORG_SLUG` → `SOCKET_ORG_SLUG`, `SOCKET_CLI_API_BASE_URL` → `SOCKET_API_URL`, `SOCKET_CLI_NO_API_TOKEN` → `SOCKET_NO_API_TOKEN`. The canonical `SOCKET_*` name always wins when both are set; promotion is silent and happens in-process before clap parses. Other socket-cli names (`SOCKET_CLI_CONFIG`, `SOCKET_CLI_API_PROXY`, `SOCKET_CLI_DEBUG`) are deliberately **not** honored. + +Empty string means unset at every layer: exported-but-empty flag-bound vars are scrubbed before clap parses, and the API-client resolution filters empty values at each fallback step. + | Env var | CLI equivalent | Default | Notes | |---|---|---|---| | `SOCKET_CWD` | `--cwd` | `.` | — | @@ -637,6 +641,42 @@ All v3.0 env vars use the `SOCKET_*` prefix. Three legacy `SOCKET_PATCH_*` names | `SOCKET_VEX` | `apply --vex` / `scan --vex` / `vendor --vex` | (none) | Embedded OpenVEX output path. The `SOCKET_VEX_*` knobs (`_PRODUCT`, `_NO_VERIFY`, `_DOC_ID`, `_COMPACT`) are shared with the standalone `vex` command; on the host commands they bind to `--vex-product` etc. | | `SOCKET_VEX_OUTPUT` | `vex --output` / `-O` | (none) | Local to the standalone `vex`: document output path (required with `--json`). | +### Config-layer toggles (env-only) + +| Env var | Default | Notes | +|---|---|---| +| `SOCKET_NO_CONFIG` | `false` | Truthy (`1`/`true`/`yes`/`on`): disable the socket-cli persisted-config fallback layer entirely — pure flag+env behavior. Also the test-hermeticity switch (the workspace `.cargo/config.toml` exports it as `1` for every cargo-run process). | +| `SOCKET_NO_API_TOKEN` | `false` | Truthy: ignore **ambient** API tokens (the `SOCKET_API_TOKEN` env var and the socket-cli config token); only an explicit `--api-token` flag authenticates. Peer alias: `SOCKET_CLI_NO_API_TOKEN`. | + +### Persisted configuration (socket-cli `config.json`) + +The binary reads — **never writes** — the JS Socket CLI's persisted config, so a single `socket login` (or `socket config set apiToken/defaultOrg`) configures socket-patch too. The file is `/socket/settings/config.json`, a base64-encoded JSON object: + +| Platform | Location | +|---|---| +| Linux | `$XDG_DATA_HOME` or `~/.local/share`, + `/socket/settings/config.json` | +| macOS | `$XDG_DATA_HOME` or `~/Library/Application Support`, + `/socket/settings/config.json` | +| Windows | `%LOCALAPPDATA%` or `%USERPROFILE%\AppData\Local`, + `\socket\settings\config.json` | + +Exactly three keys are honored, each slotting **below** the env var and **above** the built-in default for its setting, resolved per key independently: + +| Config key | Feeds | Env var above it | +|---|---|---| +| `apiToken` | `--api-token` | `SOCKET_API_TOKEN` | +| `defaultOrg` (alias `org`; `defaultOrg` wins) | `--org` | `SOCKET_ORG_SLUG` | +| `apiBaseUrl` | `--api-url` | `SOCKET_API_URL` | + +Contract properties: + +- **Read-only pledge**: socket-patch never creates, modifies, or deletes this file; socket-cli owns it. There is no `socket-patch login`/`config` subcommand — use `socket login`. +- Other socket-cli keys (`apiProxy`, `enforcedOrgs`, `skipAskToPersistDefaultOrg`) and unknown keys are ignored. Non-string or empty values for the three honored keys count as unset. For an HTTP forward proxy use the standard `HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY` vars, which the HTTP client honors; socket-cli's `apiProxy` is deliberately not mapped (and is unrelated to `--proxy-url`, which is the public patch *endpoint*). +- Missing file / unresolvable data dir: silent (the normal case). Present but unreadable or undecodable (not base64(JSON), with a plain-JSON leniency fallback): a one-shot stderr warning naming the path, then treated as absent — never fatal, and `--json` stdout stays clean (all diagnostics are stderr-only). +- The file is read lazily at most once per process, only when a key is still unresolved after flag + env. +- The telemetry endpoint resolver shares the same `apiBaseUrl` chain as API-client construction (`resolve_api_base_url`), so telemetry can never target a different host than the client. +- `--offline` semantics are unchanged: reading the local file is not network contact; a config-sourced token is inert offline. +- **Repo-level files never carry endpoints, credentials, or interlock-disablers**: configuration for those comes only from flags, env vars, this user-level file, and built-in defaults — never from files inside the repository being patched (manifest, socket.yml, `.env`, …). +- `--debug` names the source on stderr whenever a setting resolves from the socket-cli config (the token value itself is never echoed). + ### Registry override env vars Env-only knobs (no CLI flag) read by the vendor auto-fetch / artifact-rebuild paths in `socket-patch-core` (`src/patch/vendor/registry_fetch.rs`, `src/patch/vendor/maven_repo.rs`). Each is the enterprise-mirror / test escape hatch for one registry base; trailing slashes are trimmed and an exported-but-empty value falls back to the default. Lock-recorded URLs (npm/yarn/composer/gem/uv `resolved`/dist URLs) are used verbatim and bypass these. diff --git a/crates/socket-patch-core/src/utils/socket_cli_config.rs b/crates/socket-patch-core/src/utils/socket_cli_config.rs index e4d746b2..2753b79e 100644 --- a/crates/socket-patch-core/src/utils/socket_cli_config.rs +++ b/crates/socket-patch-core/src/utils/socket_cli_config.rs @@ -193,7 +193,13 @@ pub fn load() -> Option<&'static SocketCliConfig> { /// `--api-url` override is applied by the caller *before* this fallback.) pub fn resolve_api_base_url() -> String { env_non_empty("SOCKET_API_URL") - .or_else(|| load().and_then(|c| c.api_base_url.clone())) + .or_else(|| { + load().and_then(|c| c.api_base_url.clone()).inspect(|url| { + if crate::utils::env_compat::is_debug_enabled() { + eprintln!("[socket-patch debug] api base url: `{url}` from socket-cli config"); + } + }) + }) .unwrap_or_else(|| crate::constants::DEFAULT_SOCKET_API_URL.to_string()) } diff --git a/docs/design/configuration.md b/docs/design/configuration.md new file mode 100644 index 00000000..d3007b3e --- /dev/null +++ b/docs/design/configuration.md @@ -0,0 +1,122 @@ +# Configuration design: env vars, the socket-cli config file, and what we deliberately don't read + +Status: **implemented** (v3.5). This document records the settled design so +future configuration surface grows inside it instead of inventing new +mechanisms. + +## Problem + +socket-patch's configuration was flags + `SOCKET_*` env vars only. That +architecture is correct for a CLI in the package-manager class, but it had +no story for "configure once, use everywhere": a user who ran +`socket login` with the JS Socket CLI still had to export +`SOCKET_API_TOKEN` for socket-patch. Meanwhile `.env`-style repo-local +config kept coming up as a "simpler setup" suggestion. + +## Decisions + +### 1. Flag > env > socket-cli config > default — per key + +Every flag keeps its clap `env =` binding (`SOCKET_*` prefix, CLI arg wins). +For exactly three settings the JS socket-cli's persisted login state is a +fallback layer between env and default: + +``` +apiToken / org / apiBaseUrl: + 1. CLI flag --api-token / --org / --api-url + 2. Canonical env SOCKET_API_TOKEN / SOCKET_ORG_SLUG / SOCKET_API_URL + 3. Peer alias env SOCKET_CLI_API_TOKEN / SOCKET_CLI_ORG_SLUG / SOCKET_CLI_API_BASE_URL + (silent in-process promotion before clap; canonical wins) + 4. socket-cli config /socket/settings/config.json (READ-ONLY) + keys: apiToken, defaultOrg (accepts alias "org"), apiBaseUrl + 5. Built-in default no token → public proxy; org → auto-resolve; + url → https://api.socket.dev + +Vetoes: + SOCKET_NO_API_TOKEN (alias SOCKET_CLI_NO_API_TOKEN) — ambient tokens + (layers 2–4) yield none; an explicit --api-token flag still wins. + SOCKET_NO_CONFIG — layer 4 disabled entirely (also the test-hermeticity + switch; the workspace .cargo/config.toml exports it for all cargo runs). + +Empty string == unset at every layer (repo-wide rule). +``` + +Implementation: `socket_patch_core::utils::socket_cli_config` (path +resolution mirrors socket-cli's `getSocketAppDataPath`, lenient +base64→JSON→plain-JSON decode, allowlist copy, `OnceLock` disk cache with +the gate checked per call), consumed by `get_api_client_with_overrides` +(`api/client.rs`) and — for `apiBaseUrl` — by the shared +`resolve_api_base_url()` that the telemetry endpoint resolver also uses, so +client and telemetry can never disagree about the API host. The +`--api-url`/`--proxy-url` clap defaults were removed (fields are +`Option`) so the layer isn't dead code; the documented defaults are +applied at client construction. + +### 2. The file is socket-cli's; we only read it + +No `socket-patch login`, no `socket-patch config set`, no writes ever. The +file (base64-encoded JSON) is written by `socket login` / `socket config +set`. Corrupt or unreadable → one-shot stderr warning naming the path, then +treated as absent; missing → silent. `--json` stdout purity holds because +all diagnostics are stderr-only. Keys other than the three above +(`apiProxy`, `enforcedOrgs`, `skipAskToPersistDefaultOrg`) are socket-cli +UX policy and are ignored. + +### 3. Alignment across Socket tools + +- The python `socketsecurity` CLI already accepts `SOCKET_API_TOKEN`, so + the canonical names are the cross-tool bridge; no `SOCKET_SECURITY_*` + aliases were added. +- `socket.yml` stays a scanning-product surface (projectIgnorePaths / + issueRules / githubApp); socket-patch does not read it. +- `SOCKET_PROXY_URL` (the public patch **endpoint**) must never be + conflated with socket-cli's `apiProxy` (an HTTP **forward proxy**). + Forward-proxy behavior comes from the standard + `HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY` vars, which reqwest honors. + +## Explicitly rejected + +| Idea | Why not | +|---|---| +| Auto-loading `.env` / `.env.local` | Trust boundary: the tool mutates installed packages while holding an API token; a file in a *cloned repo* must never redirect endpoints, disable interlocks, or spend the token. Also the wrong convention class — npm/cargo/pip/git read no `.env`; dotenv is an app-runtime convention. Users who want it have direnv/mise/dotenvx. | +| A new socket-patch config file (`.socket/config.toml`, …) | Duplicates socket-cli's persisted config; one more file format to trust, document, and migrate. | +| Writing to socket-cli's `config.json` | No login flow here; shared mutable state and format drift for zero benefit. | +| Honoring endpoints/credentials from repo-level files (manifest, socket.yml) | Same trust boundary as `.env`. Stated as a contract property in `CLI_CONTRACT.md`. | +| `SOCKET_CLI_CONFIG` (ephemeral full-JSON config override) | Imports socket-cli's whole config vocabulary as a permanent compat contract. | +| Mapping `apiProxy` → anything | Forward-proxy vs patch-endpoint semantic trap; `HTTP_PROXY` et al. already work. | +| `enforcedOrgs` / `skipAskToPersistDefaultOrg` | Interactive socket-cli UX policy with no socket-patch analog. | + +## Deferred (designated homes, no implementation yet) + +- **Project-level behavioral defaults** (`ecosystems`, `downloadMode`, + `vendorSource`): if demand materializes, they go in the manifest `setup` + block (`setup.defaults`, camelCase) — the manifest already controls what + gets patched, so behavioral defaults there grant no new capability, and + the serde struct simply has no fields for URLs/credentials/interlocks. + Requires teaching the TS zod twin + (`npm/socket-patch/src/schema/manifest-schema.ts`) to model `setup`. + Precedence would be flag > env > `setup.defaults` > default. +- **Env cleanup sweep** (separate task, agreed 2026-07-21): unify the four + bool-parsing dialects (`parse_bool_flag` vs stock `BoolishValueParser` on + `--all-releases`, bare clap bool on `get --one-off`, `env_truthy`'s + `1|true`-only match on the experimental gates and core's `SOCKET_OFFLINE` + reader); honor `NO_COLOR` (and `FORCE_COLOR`/`CLICOLOR_FORCE`) in + `output.rs`, which today keys only off `is_terminal()`; document + `HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY` support in the README. +- **`SOCKET_API_TOKEN_FILE` / keychain sourcing** for the token — the + conventional next step for secret hygiene; not urgent now that the + config-file path exists. + +## Test strategy (how this stays true) + +- `tests/cli_config_fallback.rs` spawns the binary against fixture + `config.json` files (fresh process per case — the disk read is cached per + process) and pins: config token/apiBaseUrl authenticate, `defaultOrg` + skips org auto-resolve with telemetry following the config host+token, + env-beats-config per key, alias honored with canonical winning, corrupt + config warns while `--json` stdout parses, both toggles, and the + missing-file silence. +- Hermeticity: `.cargo/config.toml` `[env]` exports `SOCKET_NO_CONFIG=1` so + a developer's real login can never authenticate a test; the e2e env + scrub loops deliberately skip that variable so the guard survives into + spawned binaries. From a7e0fd6698143b2c775db20f85488c093a29719a Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 21 Jul 2026 14:39:38 -0400 Subject: [PATCH 5/5] fix(config): probe legacy ~/.local/share config path on macOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Older socket-cli releases wrote the Linux-style data dir on every platform — real macOS logins exist at ~/.local/share/socket/settings/ config.json (verified on a dev machine where ~/Library/Application Support/socket/settings/ exists but is empty). With XDG_DATA_HOME unset, macOS now probes the native Application Support path first and the legacy path second; first existing file wins, and a corrupt preferred file stops the probe rather than silently resurrecting older credentials from the fallback location. Co-Authored-By: Claude Fable 5 --- README.md | 5 +- crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- .../src/utils/socket_cli_config.rs | 147 +++++++++++------- docs/design/configuration.md | 8 +- 4 files changed, 98 insertions(+), 64 deletions(-) diff --git a/README.md b/README.md index d5780779..8f99903d 100644 --- a/README.md +++ b/README.md @@ -153,8 +153,9 @@ For the three authentication settings, the [Socket CLI](https://docs.socket.dev/ 2. Env var SOCKET_API_TOKEN / SOCKET_ORG_SLUG / SOCKET_API_URL 3. Peer alias env SOCKET_CLI_API_TOKEN / SOCKET_CLI_ORG_SLUG / SOCKET_CLI_API_BASE_URL 4. socket-cli config /socket/settings/config.json — read-only - (Linux: ~/.local/share; macOS: ~/Library/Application Support, - both after $XDG_DATA_HOME; Windows: %LOCALAPPDATA%) + (Linux: ~/.local/share; macOS: ~/Library/Application Support + then legacy ~/.local/share, both after $XDG_DATA_HOME; + Windows: %LOCALAPPDATA%) 5. Built-in default no token → public proxy; org → auto-resolve; https://api.socket.dev ``` diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 7cae0b8c..0e14257a 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -655,7 +655,7 @@ The binary reads — **never writes** — the JS Socket CLI's persisted config, | Platform | Location | |---|---| | Linux | `$XDG_DATA_HOME` or `~/.local/share`, + `/socket/settings/config.json` | -| macOS | `$XDG_DATA_HOME` or `~/Library/Application Support`, + `/socket/settings/config.json` | +| macOS | `$XDG_DATA_HOME` or `~/Library/Application Support`, + `/socket/settings/config.json`; when `$XDG_DATA_HOME` is unset the legacy `~/.local/share` location is probed second (older socket-cli releases wrote the Linux-style path on every platform) | | Windows | `%LOCALAPPDATA%` or `%USERPROFILE%\AppData\Local`, + `\socket\settings\config.json` | Exactly three keys are honored, each slotting **below** the env var and **above** the built-in default for its setting, resolved per key independently: diff --git a/crates/socket-patch-core/src/utils/socket_cli_config.rs b/crates/socket-patch-core/src/utils/socket_cli_config.rs index 2753b79e..e0eafbb6 100644 --- a/crates/socket-patch-core/src/utils/socket_cli_config.rs +++ b/crates/socket-patch-core/src/utils/socket_cli_config.rs @@ -4,7 +4,9 @@ //! base64-encoded JSON object at `/socket/settings/config.json`: //! //! - Linux: `$XDG_DATA_HOME` or `~/.local/share` -//! - macOS: `$XDG_DATA_HOME` or `~/Library/Application Support` +//! - macOS: `$XDG_DATA_HOME` or `~/Library/Application Support`, +//! then the legacy `~/.local/share` (older socket-cli wrote the +//! Linux-style path on every platform) //! - Windows: `%LOCALAPPDATA%` or `%USERPROFILE%\AppData\Local` //! //! socket-patch reads exactly three keys — `apiToken`, `defaultOrg` (with @@ -74,10 +76,20 @@ pub fn no_api_token_veto() -> bool { env_flag("SOCKET_NO_API_TOKEN") } -/// The platform data dir socket-cli resolves in its `getSocketAppDataPath` -/// (`packages/cli/src/constants/paths.mts`) — mirrored exactly so both -/// tools find the same file. -fn data_home() -> Option { +/// Candidate config file paths, most-preferred first, mirroring +/// socket-cli's `getSocketAppDataPath` (`packages/cli/src/constants/paths.mts`) +/// so both tools find the same file. Empty when no data dir resolves +/// (e.g. `HOME` unset in a stripped container) — silently absent. +/// +/// macOS gets two candidates: current socket-cli resolves +/// `$XDG_DATA_HOME` else `~/Library/Application Support`, but earlier +/// releases wrote the Linux-style `~/.local/share` path on every +/// platform and real logins exist there in the wild, so the native +/// location is probed first and the legacy one second. +pub fn config_json_paths() -> Vec { + fn config_json(data_dir: PathBuf) -> PathBuf { + data_dir.join("socket").join("settings").join("config.json") + } #[cfg(windows)] { env_non_empty("LOCALAPPDATA") @@ -85,30 +97,38 @@ fn data_home() -> Option { .or_else(|| { env_non_empty("USERPROFILE").map(|p| PathBuf::from(p).join("AppData").join("Local")) }) + .map(config_json) + .into_iter() + .collect() } #[cfg(target_os = "macos")] { - env_non_empty("XDG_DATA_HOME") - .map(PathBuf::from) - .or_else(|| { - env_non_empty("HOME") - .map(|h| PathBuf::from(h).join("Library").join("Application Support")) - }) + if let Some(xdg) = env_non_empty("XDG_DATA_HOME") { + return vec![config_json(PathBuf::from(xdg))]; + } + match env_non_empty("HOME") { + Some(home) => vec![ + config_json( + PathBuf::from(&home) + .join("Library") + .join("Application Support"), + ), + config_json(PathBuf::from(home).join(".local/share")), + ], + None => Vec::new(), + } } #[cfg(all(unix, not(target_os = "macos")))] { env_non_empty("XDG_DATA_HOME") .map(PathBuf::from) .or_else(|| env_non_empty("HOME").map(|h| PathBuf::from(h).join(".local/share"))) + .map(config_json) + .into_iter() + .collect() } } -/// Full path of socket-cli's persisted config, or `None` when no data dir -/// resolves (e.g. `HOME` unset in a stripped container) — silently absent. -pub fn config_json_path() -> Option { - data_home().map(|d| d.join("socket").join("settings").join("config.json")) -} - /// Decode the config file body: base64(JSON) per socket-cli, with a plain-JSON /// fallback for robustness (hand-edited or future-format files). Unknown keys /// are ignored and known keys with non-string values are treated as unset — @@ -144,33 +164,37 @@ fn parse_config_bytes(raw: &[u8]) -> Result { }) } -/// Read the config from disk. `None` covers every failure path; a corrupt -/// or unreadable file warns (callers cache this, so it fires once per -/// process). +/// Read the config from disk: the first candidate whose file exists wins. +/// `None` covers every failure path; a present-but-unusable file warns and +/// stops the probe — falling through to a stale lower-priority file would +/// silently resurrect old credentials. (Callers cache this, so the warning +/// fires once per process.) fn read_from_disk() -> Option { - let path = config_json_path()?; - let raw = match std::fs::read(&path) { - Ok(raw) => raw, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return None, - Err(e) => { - eprintln!( - "[socket-patch] warning: unreadable socket-cli config at {}: {e}; ignoring it", - path.display() - ); - return None; - } - }; - match parse_config_bytes(&raw) { - Ok(config) => Some(config), - Err(e) => { - eprintln!( - "[socket-patch] warning: could not parse socket-cli config at {}: {e}; \ - ignoring it (re-run `socket login` to rewrite it)", - path.display() - ); - None - } + for path in config_json_paths() { + let raw = match std::fs::read(&path) { + Ok(raw) => raw, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue, + Err(e) => { + eprintln!( + "[socket-patch] warning: unreadable socket-cli config at {}: {e}; ignoring it", + path.display() + ); + return None; + } + }; + return match parse_config_bytes(&raw) { + Ok(config) => Some(config), + Err(e) => { + eprintln!( + "[socket-patch] warning: could not parse socket-cli config at {}: {e}; \ + ignoring it (re-run `socket login` to rewrite it)", + path.display() + ); + None + } + }; } + None } /// The socket-cli config, if present and enabled. The disk read is done at @@ -309,8 +333,8 @@ mod tests { &[("XDG_DATA_HOME", Some("/xdg")), ("HOME", Some("/home/u"))], || { assert_eq!( - config_json_path(), - Some(PathBuf::from("/xdg/socket/settings/config.json")) + config_json_paths(), + vec![PathBuf::from("/xdg/socket/settings/config.json")] ); }, ); @@ -318,15 +342,19 @@ mod tests { &[("XDG_DATA_HOME", None), ("HOME", Some("/home/u"))], || { assert_eq!( - config_json_path(), - Some(PathBuf::from( + config_json_paths(), + vec![PathBuf::from( "/home/u/.local/share/socket/settings/config.json" - )) + )] ); }, ); } + /// macOS: `XDG_DATA_HOME` wins outright; otherwise the native + /// Application Support path is probed first with the legacy + /// Linux-style `~/.local/share` path second (older socket-cli + /// releases wrote there on every platform — real logins exist). #[test] #[serial_test::serial] #[cfg(target_os = "macos")] @@ -335,8 +363,8 @@ mod tests { &[("XDG_DATA_HOME", Some("/xdg")), ("HOME", Some("/Users/u"))], || { assert_eq!( - config_json_path(), - Some(PathBuf::from("/xdg/socket/settings/config.json")) + config_json_paths(), + vec![PathBuf::from("/xdg/socket/settings/config.json")] ); }, ); @@ -344,10 +372,13 @@ mod tests { &[("XDG_DATA_HOME", None), ("HOME", Some("/Users/u"))], || { assert_eq!( - config_json_path(), - Some(PathBuf::from( - "/Users/u/Library/Application Support/socket/settings/config.json" - )) + config_json_paths(), + vec![ + PathBuf::from( + "/Users/u/Library/Application Support/socket/settings/config.json" + ), + PathBuf::from("/Users/u/.local/share/socket/settings/config.json"), + ] ); }, ); @@ -363,18 +394,18 @@ mod tests { with_env( &[("XDG_DATA_HOME", Some("")), ("HOME", Some("/home/u"))], || { - let path = config_json_path().expect("HOME fallback"); + let paths = config_json_paths(); assert!( - path.starts_with("/home/u"), - "blank XDG_DATA_HOME must fall through to HOME: {path:?}" + !paths.is_empty() && paths.iter().all(|p| p.starts_with("/home/u")), + "blank XDG_DATA_HOME must fall through to HOME: {paths:?}" ); }, ); with_env(&[("XDG_DATA_HOME", None), ("HOME", Some(""))], || { - assert_eq!(config_json_path(), None); + assert_eq!(config_json_paths(), Vec::::new()); }); with_env(&[("XDG_DATA_HOME", None), ("HOME", None)], || { - assert_eq!(config_json_path(), None); + assert_eq!(config_json_paths(), Vec::::new()); }); } diff --git a/docs/design/configuration.md b/docs/design/configuration.md index d3007b3e..80fd4b63 100644 --- a/docs/design/configuration.md +++ b/docs/design/configuration.md @@ -42,9 +42,11 @@ Empty string == unset at every layer (repo-wide rule). ``` Implementation: `socket_patch_core::utils::socket_cli_config` (path -resolution mirrors socket-cli's `getSocketAppDataPath`, lenient -base64→JSON→plain-JSON decode, allowlist copy, `OnceLock` disk cache with -the gate checked per call), consumed by `get_api_client_with_overrides` +resolution mirrors socket-cli's `getSocketAppDataPath` — plus, on macOS, a +second probe of the legacy `~/.local/share` location that older socket-cli +releases wrote on every platform; lenient base64→JSON→plain-JSON decode, +allowlist copy, `OnceLock` disk cache with the gate checked per call), +consumed by `get_api_client_with_overrides` (`api/client.rs`) and — for `apiBaseUrl` — by the shared `resolve_api_base_url()` that the telemetry endpoint resolver also uses, so client and telemetry can never disagree about the API host. The