From e7a06378abe8bd76a43c7b3f8ace59a0bee582ca Mon Sep 17 00:00:00 2001 From: John McChesney TenEyck Jr Date: Sat, 2 May 2026 00:11:25 -0500 Subject: [PATCH 1/3] Bound external provider fallback execution --- docs/INSTALLATION.md | 37 ++++ rust/src/client.rs | 8 + rust/src/native_app.rs | 389 +++++++++++++++++++++++++++++++++++------ rust/src/types.rs | 30 ++++ rust/src/utils.rs | 24 +++ 5 files changed, 431 insertions(+), 57 deletions(-) diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index 3c33e8b..8c43950 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -107,6 +107,43 @@ Healthy v2 bootstrap state usually looks like: apw login https://example.com ``` +## External fallback provider limits + +The external CLI fallback is opt-in and only runs when the native APW app path +cannot return a credential. Configure it in `~/.apw/config.json` with an +absolute executable path: + +```json +{ + "fallbackProvider": "bitwarden", + "fallbackProviderPath": "/opt/homebrew/bin/bw" +} +``` + +Supported providers are `bitwarden` and `1password`. + +External provider executions are bounded by default: + +- `fallbackProviderTimeoutMs`: per-process timeout in milliseconds. Default: + `5000`. Values less than `1` fall back to the default. A timed-out provider + process is killed and the credential request fails with a clear timeout + error. +- `fallbackProviderMaxInvocations`: maximum external provider process + invocations per APW session. Default: `10`. Set `0` to block external + provider invocations for the current session. When the limit is exceeded, APW + returns a clear error instead of executing the provider again. + +Example with explicit limits: + +```json +{ + "fallbackProvider": "1password", + "fallbackProviderPath": "/opt/homebrew/bin/op", + "fallbackProviderTimeoutMs": 3000, + "fallbackProviderMaxInvocations": 6 +} +``` + ## Diagnostics ### Machine-readable status diff --git a/rust/src/client.rs b/rust/src/client.rs index b09e3d4..0d1d73e 100644 --- a/rust/src/client.rs +++ b/rust/src/client.rs @@ -618,6 +618,8 @@ impl ApplePasswordManager { bridge_last_error: None, fallback_provider: None, fallback_provider_path: None, + fallback_provider_timeout_ms: None, + fallback_provider_max_invocations: None, created_at: Utc::now().timestamp().to_string(), }); @@ -1064,6 +1066,8 @@ impl ApplePasswordManager { bridge_last_error: None, fallback_provider: None, fallback_provider_path: None, + fallback_provider_timeout_ms: None, + fallback_provider_max_invocations: None, created_at: Utc::now().timestamp().to_string(), }); @@ -2672,6 +2676,8 @@ mod tests { secret_source: Some(SecretSource::File), fallback_provider: None, fallback_provider_path: None, + fallback_provider_timeout_ms: None, + fallback_provider_max_invocations: None, created_at: chrono::Utc::now().to_rfc3339(), runtime_mode: RuntimeMode::Auto, last_launch_status: None, @@ -2764,6 +2770,8 @@ mod tests { secret_source: Some(SecretSource::File), fallback_provider: None, fallback_provider_path: None, + fallback_provider_timeout_ms: None, + fallback_provider_max_invocations: None, created_at: (chrono::Utc::now() - chrono::Duration::days(45)).to_rfc3339(), runtime_mode: RuntimeMode::Auto, last_launch_status: None, diff --git a/rust/src/native_app.rs b/rust/src/native_app.rs index 132104b..1d66ee9 100644 --- a/rust/src/native_app.rs +++ b/rust/src/native_app.rs @@ -1,6 +1,6 @@ use crate::error::{APWError, Result}; use crate::logging; -use crate::types::{ExternalFallbackProvider, Status, MAX_MESSAGE_BYTES, VERSION}; +use crate::types::{APWConfigV1, ExternalFallbackProvider, Status, MAX_MESSAGE_BYTES, VERSION}; use crate::utils::read_config_file_or_empty; use serde_json::{json, Value}; use std::env; @@ -12,7 +12,7 @@ use std::os::unix::net::UnixStream; use std::os::unix::process::CommandExt; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; -use std::time::Duration; +use std::time::{Duration, Instant}; const NATIVE_APP_BUNDLE_NAME: &str = "APW.app"; const NATIVE_APP_EXECUTABLE_NAME: &str = "APW"; @@ -25,6 +25,9 @@ const NATIVE_APP_FILE_MODE: u32 = 0o600; const MAX_BROKER_BYTES: usize = MAX_MESSAGE_BYTES; const MAX_BROKER_LOG_BYTES: u64 = 10 * 1024 * 1024; const NATIVE_APP_SOCKET_TIMEOUT_MS: u64 = 3_000; +const EXTERNAL_PROVIDER_DEFAULT_TIMEOUT_MS: u64 = 5_000; +const EXTERNAL_PROVIDER_DEFAULT_MAX_INVOCATIONS: u32 = 10; +const EXTERNAL_PROVIDER_POLL_MS: u64 = 20; const CONNECT_RETRIES: usize = 10; const CONNECT_RETRY_DELAY_MS: u64 = 200; @@ -124,6 +127,10 @@ pub fn native_app_broker_log_path() -> PathBuf { native_app_runtime_dir().join(NATIVE_APP_BROKER_LOG_NAME) } +fn native_app_fallback_provider_state_path() -> PathBuf { + native_app_runtime_dir().join("fallback-provider-session.json") +} + pub fn native_app_install_dir() -> PathBuf { native_app_runtime_dir().join("installed") } @@ -697,6 +704,181 @@ fn native_app_request(intent: &str, url: &str) -> Result { Ok(payload) } +struct ExternalProviderLimits { + session_id: String, + timeout_ms: u64, + max_invocations: u32, +} + +struct ExternalProviderCommandOutput { + success: bool, + stdout: Vec, + stderr: Vec, +} + +fn external_provider_limits(config: &APWConfigV1) -> ExternalProviderLimits { + ExternalProviderLimits { + session_id: config.created_at.clone(), + timeout_ms: config + .fallback_provider_timeout_ms + .filter(|value| *value > 0) + .unwrap_or(EXTERNAL_PROVIDER_DEFAULT_TIMEOUT_MS), + max_invocations: config + .fallback_provider_max_invocations + .unwrap_or(EXTERNAL_PROVIDER_DEFAULT_MAX_INVOCATIONS), + } +} + +fn reserve_external_provider_invocation( + provider: ExternalFallbackProvider, + limits: &ExternalProviderLimits, +) -> Result<()> { + ensure_runtime_dir()?; + let path = native_app_fallback_provider_state_path(); + let state = fs::read_to_string(&path) + .ok() + .and_then(|content| serde_json::from_str::(&content).ok()); + let current_count = state + .as_ref() + .filter(|value| value.get("sessionId").and_then(Value::as_str) == Some(&limits.session_id)) + .and_then(|value| value.get("invocations").and_then(Value::as_u64)) + .unwrap_or(0); + + if current_count >= u64::from(limits.max_invocations) { + return Err(APWError::new( + Status::GenericError, + format!( + "External fallback provider `{}` invocation limit exceeded for this APW session (limit: {}). Reauthenticate or increase `fallbackProviderMaxInvocations`.", + provider.as_str(), + limits.max_invocations + ), + )); + } + + let updated = json!({ + "sessionId": limits.session_id, + "provider": provider.as_str(), + "invocations": current_count + 1, + }); + fs::write( + &path, + serde_json::to_vec_pretty(&updated).map_err(|error| { + APWError::new( + Status::GenericError, + format!("Failed to encode fallback provider invocation state: {error}"), + ) + })?, + ) + .map_err(|error| { + APWError::new( + Status::InvalidConfig, + format!("Failed to write fallback provider invocation state: {error}"), + ) + })?; + set_permissions(&path, NATIVE_APP_FILE_MODE)?; + Ok(()) +} + +fn run_external_provider_command( + provider: ExternalFallbackProvider, + path: &Path, + config: &APWConfigV1, + args: &[&str], +) -> Result { + let limits = external_provider_limits(config); + reserve_external_provider_invocation(provider, &limits)?; + + let mut command = Command::new(path); + command + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + // SAFETY: `pre_exec` runs after fork and before exec. The closure only calls + // `libc::setsid()`, which is async-signal-safe and avoids orphaning provider + // subprocesses when a timeout kills the process group. + unsafe { + command.pre_exec(|| { + if libc::setsid() == -1 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } + + let mut child = command.spawn().map_err(|error| { + APWError::new( + Status::ProcessNotRunning, + format!( + "Failed to execute {} CLI at {}: {error}", + provider.as_str(), + path.display() + ), + ) + })?; + let started = Instant::now(); + let timeout = Duration::from_millis(limits.timeout_ms); + + let status = loop { + if let Some(status) = child.try_wait().map_err(|error| { + APWError::new( + Status::ProcessNotRunning, + format!( + "Failed to inspect {} CLI at {}: {error}", + provider.as_str(), + path.display() + ), + ) + })? { + break status; + } + + if started.elapsed() >= timeout { + let pid = child.id() as i32; + unsafe { + libc::kill(-pid, libc::SIGKILL); + } + let _ = child.kill(); + let _ = child.wait(); + return Err(APWError::new( + Status::CommunicationTimeout, + format!( + "External fallback provider `{}` timed out after {}ms and was killed.", + provider.as_str(), + limits.timeout_ms + ), + )); + } + + std::thread::sleep(Duration::from_millis(EXTERNAL_PROVIDER_POLL_MS)); + }; + + let mut stdout = Vec::new(); + if let Some(mut pipe) = child.stdout.take() { + pipe.read_to_end(&mut stdout).map_err(|error| { + APWError::new( + Status::ProtoInvalidResponse, + format!("Failed to read {} CLI stdout: {error}", provider.as_str()), + ) + })?; + } + let mut stderr = Vec::new(); + if let Some(mut pipe) = child.stderr.take() { + pipe.read_to_end(&mut stderr).map_err(|error| { + APWError::new( + Status::ProtoInvalidResponse, + format!("Failed to read {} CLI stderr: {error}", provider.as_str()), + ) + })?; + } + + Ok(ExternalProviderCommandOutput { + success: status.success(), + stdout, + stderr, + }) +} + fn external_provider_login(url: &str) -> Result> { let config = read_config_file_or_empty(); let Some(provider) = config.fallback_provider else { @@ -730,34 +912,28 @@ fn external_provider_login(url: &str) -> Result> { let payload = match provider { ExternalFallbackProvider::OnePassword => { - load_1password_credential(&provider_path, &host, url)? + load_1password_credential(&provider_path, &config, &host, url)? } ExternalFallbackProvider::Bitwarden => { - load_bitwarden_credential(&provider_path, &host, url)? + load_bitwarden_credential(&provider_path, &config, &host, url)? } }; Ok(Some(payload)) } -fn load_1password_credential(path: &Path, host: &str, raw_url: &str) -> Result { - let list_output = Command::new(path) - .arg("item") - .arg("list") - .arg("--categories") - .arg("LOGIN") - .arg("--format") - .arg("json") - .output() - .map_err(|error| { - APWError::new( - Status::ProcessNotRunning, - format!( - "Failed to execute 1Password CLI at {}: {error}", - path.display() - ), - ) - })?; - if !list_output.status.success() { +fn load_1password_credential( + path: &Path, + config: &APWConfigV1, + host: &str, + raw_url: &str, +) -> Result { + let list_output = run_external_provider_command( + ExternalFallbackProvider::OnePassword, + path, + config, + &["item", "list", "--categories", "LOGIN", "--format", "json"], + )?; + if !list_output.success { return Err(APWError::new( Status::NoResults, format!( @@ -794,23 +970,13 @@ fn load_1password_credential(path: &Path, host: &str, raw_url: &str) -> Result Result Result { - let output = Command::new(path) - .arg("list") - .arg("items") - .arg("--search") - .arg(host) - .output() - .map_err(|error| { - APWError::new( - Status::ProcessNotRunning, - format!( - "Failed to execute Bitwarden CLI at {}: {error}", - path.display() - ), - ) - })?; - if !output.status.success() { +fn load_bitwarden_credential( + path: &Path, + config: &APWConfigV1, + host: &str, + raw_url: &str, +) -> Result { + let output = run_external_provider_command( + ExternalFallbackProvider::Bitwarden, + path, + config, + &["list", "items", "--search", host], + )?; + if !output.success { return Err(APWError::new( Status::NoResults, format!( @@ -1288,6 +1450,119 @@ else: }); } + #[test] + #[serial] + fn external_provider_timeout_kills_provider_process() { + with_temp_home(|| { + let provider_dir = TempDir::new().unwrap(); + let provider_path = provider_dir.path().join("bw"); + let pid_path = provider_dir.path().join("provider.pid"); + fs::write( + &provider_path, + format!( + r#"#!/usr/bin/env python3 +import os +import pathlib +import time + +pathlib.Path({pid_path:?}).write_text(str(os.getpid()), encoding="utf-8") +time.sleep(10) +"#, + pid_path = pid_path.display().to_string() + ), + ) + .unwrap(); + let mut permissions = fs::metadata(&provider_path).unwrap().permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&provider_path, permissions).unwrap(); + + let config_root = home_dir().join(".apw"); + fs::create_dir_all(&config_root).unwrap(); + let config = APWConfigV1 { + username: "demo".to_string(), + shared_key: "demo-shared-key".to_string(), + fallback_provider: Some(ExternalFallbackProvider::Bitwarden), + fallback_provider_path: Some(provider_path.display().to_string()), + fallback_provider_timeout_ms: Some(500), + fallback_provider_max_invocations: Some(5), + ..APWConfigV1::default() + }; + fs::write( + config_root.join("config.json"), + serde_json::to_vec_pretty(&config).unwrap(), + ) + .unwrap(); + + let started = Instant::now(); + let error = native_app_login("https://vault.example.com").unwrap_err(); + assert_eq!(error.code, Status::CommunicationTimeout); + assert!(error.message.contains("timed out after 500ms")); + assert!(error.message.contains("was killed")); + assert!(started.elapsed() < Duration::from_secs(3)); + + let pid: i32 = fs::read_to_string(pid_path).unwrap().parse().unwrap(); + assert_eq!(unsafe { libc::kill(pid, 0) }, -1); + }); + } + + #[test] + #[serial] + fn external_provider_invocation_limit_returns_clear_error() { + with_temp_home(|| { + let provider_dir = TempDir::new().unwrap(); + let provider_path = provider_dir.path().join("bw"); + fs::write( + &provider_path, + r#"#!/usr/bin/env python3 +import json +import sys + +if sys.argv[1:] == ["list", "items", "--search", "vault.example.com"]: + print(json.dumps([ + { + "name": "Work Vault", + "login": { + "username": "alice@example.com", + "password": "secret-bitwarden", + "uris": [{"uri": "https://vault.example.com/login"}] + } + } + ])) +else: + raise SystemExit(1) +"#, + ) + .unwrap(); + let mut permissions = fs::metadata(&provider_path).unwrap().permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&provider_path, permissions).unwrap(); + + let config_root = home_dir().join(".apw"); + fs::create_dir_all(&config_root).unwrap(); + let config = APWConfigV1 { + username: "demo".to_string(), + shared_key: "demo-shared-key".to_string(), + fallback_provider: Some(ExternalFallbackProvider::Bitwarden), + fallback_provider_path: Some(provider_path.display().to_string()), + fallback_provider_max_invocations: Some(1), + ..APWConfigV1::default() + }; + fs::write( + config_root.join("config.json"), + serde_json::to_vec_pretty(&config).unwrap(), + ) + .unwrap(); + + let payload = native_app_login("https://vault.example.com").unwrap(); + assert_eq!(payload["source"], "bitwarden"); + + let error = native_app_login("https://vault.example.com").unwrap_err(); + assert_eq!(error.code, Status::GenericError); + assert!(error.message.contains("invocation limit exceeded")); + assert!(error.message.contains("fallbackProviderMaxInvocations")); + }); + } + #[test] #[serial] fn invalid_socket_permissions_fall_back_to_direct_exec() { diff --git a/rust/src/types.rs b/rust/src/types.rs index b4279f9..77bd9e2 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -179,6 +179,20 @@ pub struct APWConfigV1 { skip_serializing_if = "Option::is_none" )] pub fallback_provider_path: Option, + #[serde( + rename = "fallbackProviderTimeoutMs", + alias = "fallback_provider_timeout_ms", + default, + skip_serializing_if = "Option::is_none" + )] + pub fallback_provider_timeout_ms: Option, + #[serde( + rename = "fallbackProviderMaxInvocations", + alias = "fallback_provider_max_invocations", + default, + skip_serializing_if = "Option::is_none" + )] + pub fallback_provider_max_invocations: Option, #[serde(rename = "createdAt")] pub created_at: String, } @@ -202,6 +216,8 @@ impl Default for APWConfigV1 { secret_source: Some(SecretSource::File), fallback_provider: None, fallback_provider_path: None, + fallback_provider_timeout_ms: None, + fallback_provider_max_invocations: None, created_at: Utc::now().to_rfc3339(), } } @@ -267,6 +283,18 @@ pub struct APWRuntimeConfig { skip_serializing_if = "Option::is_none" )] pub fallback_provider_path: Option, + #[serde( + rename = "fallbackProviderTimeoutMs", + default, + skip_serializing_if = "Option::is_none" + )] + pub fallback_provider_timeout_ms: Option, + #[serde( + rename = "fallbackProviderMaxInvocations", + default, + skip_serializing_if = "Option::is_none" + )] + pub fallback_provider_max_invocations: Option, #[serde(rename = "createdAt")] pub created_at: String, } @@ -289,6 +317,8 @@ impl Default for APWRuntimeConfig { bridge_last_error: None, fallback_provider: None, fallback_provider_path: None, + fallback_provider_timeout_ms: None, + fallback_provider_max_invocations: None, created_at: Utc::now().to_rfc3339(), } } diff --git a/rust/src/utils.rs b/rust/src/utils.rs index 98f9802..7bf3ff7 100644 --- a/rust/src/utils.rs +++ b/rust/src/utils.rs @@ -214,6 +214,8 @@ fn normalize_legacy_config(raw: APWConfig) -> APWConfigV1 { }, fallback_provider: None, fallback_provider_path: None, + fallback_provider_timeout_ms: None, + fallback_provider_max_invocations: None, last_launch_status: None, last_launch_error: None, last_launch_strategy: None, @@ -248,6 +250,8 @@ pub fn read_config_file_or_empty() -> APWConfigV1 { secret_source: Some(SecretSource::File), fallback_provider: None, fallback_provider_path: None, + fallback_provider_timeout_ms: None, + fallback_provider_max_invocations: None, created_at: Utc.timestamp_nanos(0).to_rfc3339(), }) } @@ -276,6 +280,8 @@ pub fn read_config(opts: Option) -> Result bridge_last_error: None, fallback_provider: None, fallback_provider_path: None, + fallback_provider_timeout_ms: None, + fallback_provider_max_invocations: None, created_at: Utc.timestamp_nanos(0).to_rfc3339(), }); } @@ -305,6 +311,8 @@ pub fn read_config(opts: Option) -> Result bridge_last_error: None, fallback_provider: None, fallback_provider_path: None, + fallback_provider_timeout_ms: None, + fallback_provider_max_invocations: None, created_at: Utc.timestamp_nanos(0).to_rfc3339(), }); } @@ -333,6 +341,8 @@ pub fn read_config(opts: Option) -> Result bridge_last_error: raw.bridge_last_error, fallback_provider: raw.fallback_provider, fallback_provider_path: raw.fallback_provider_path, + fallback_provider_timeout_ms: raw.fallback_provider_timeout_ms, + fallback_provider_max_invocations: raw.fallback_provider_max_invocations, created_at: raw.created_at, }); } @@ -399,6 +409,8 @@ pub fn read_config(opts: Option) -> Result bridge_last_error: raw.bridge_last_error, fallback_provider: raw.fallback_provider, fallback_provider_path: raw.fallback_provider_path, + fallback_provider_timeout_ms: raw.fallback_provider_timeout_ms, + fallback_provider_max_invocations: raw.fallback_provider_max_invocations, created_at: raw.created_at, }) } @@ -623,6 +635,12 @@ pub fn write_config(input: WriteConfigInput) -> Result { fallback_provider_path: existing .as_ref() .and_then(|value| value.fallback_provider_path.clone()), + fallback_provider_timeout_ms: existing + .as_ref() + .and_then(|value| value.fallback_provider_timeout_ms), + fallback_provider_max_invocations: existing + .as_ref() + .and_then(|value| value.fallback_provider_max_invocations), }; let mut serialized = serde_json::to_string_pretty(&updated).map_err(|error| { @@ -907,6 +925,8 @@ mod tests { secret_source: Some(SecretSource::File), fallback_provider: None, fallback_provider_path: None, + fallback_provider_timeout_ms: None, + fallback_provider_max_invocations: None, created_at: (chrono::Utc::now() - chrono::Duration::days(40)).to_rfc3339(), runtime_mode: RuntimeMode::Auto, last_launch_status: None, @@ -964,6 +984,8 @@ mod tests { secret_source: Some(SecretSource::Keychain), fallback_provider: None, fallback_provider_path: None, + fallback_provider_timeout_ms: None, + fallback_provider_max_invocations: None, created_at: chrono::Utc::now().to_rfc3339(), runtime_mode: RuntimeMode::Auto, last_launch_status: None, @@ -1038,6 +1060,8 @@ mod tests { secret_source: Some(SecretSource::File), fallback_provider: None, fallback_provider_path: None, + fallback_provider_timeout_ms: None, + fallback_provider_max_invocations: None, created_at: chrono::Utc::now().to_rfc3339(), runtime_mode: RuntimeMode::Auto, last_launch_status: None, From 5dc4a0e95f04970bd6b5a087beda8f1886eacd95 Mon Sep 17 00:00:00 2001 From: John McChesney TenEyck Jr Date: Wed, 6 May 2026 22:16:21 -0500 Subject: [PATCH 2/3] Fix external fallback security regressions --- rust/tests/security_regressions.rs | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/rust/tests/security_regressions.rs b/rust/tests/security_regressions.rs index 2af518a..aeb77ff 100644 --- a/rust/tests/security_regressions.rs +++ b/rust/tests/security_regressions.rs @@ -225,8 +225,10 @@ fn login_rejects_relative_external_provider_path() { install_native_app_no_results(home); write_fallback_provider_config(home, "bw"); - let (status, stdout, stderr) = - run_command(home, &["--json", "login", "https://vault.example.com"]); + let (status, stdout, stderr) = run_command( + home, + &["--json", "login", "--external-fallback", "https://vault.example.com"], + ); assert_eq!( status, 102, @@ -247,8 +249,10 @@ fn login_rejects_tilde_external_provider_path() { install_native_app_no_results(home); write_fallback_provider_config(home, "~/bin/bw"); - let (status, stdout, stderr) = - run_command(home, &["--json", "login", "https://vault.example.com"]); + let (status, stdout, stderr) = run_command( + home, + &["--json", "login", "--external-fallback", "https://vault.example.com"], + ); assert_eq!( status, 102, @@ -274,8 +278,10 @@ fn login_rejects_world_writable_external_provider_path() { .expect("failed to chmod fallback provider"); write_fallback_provider_config(home, &provider_path.display().to_string()); - let (status, stdout, stderr) = - run_command(home, &["--json", "login", "https://vault.example.com"]); + let (status, stdout, stderr) = run_command( + home, + &["--json", "login", "--external-fallback", "https://vault.example.com"], + ); assert_eq!( status, 102, @@ -303,8 +309,10 @@ fn login_rejects_external_provider_symlink_to_insecure_target() { symlink(&provider_path, &provider_link).expect("failed to create provider symlink"); write_fallback_provider_config(home, &provider_link.display().to_string()); - let (status, stdout, stderr) = - run_command(home, &["--json", "login", "https://vault.example.com"]); + let (status, stdout, stderr) = run_command( + home, + &["--json", "login", "--external-fallback", "https://vault.example.com"], + ); assert_eq!( status, 102, From 7bbd470428a11233fb6e25ae8b270e2c2023e344 Mon Sep 17 00:00:00 2001 From: John McChesney TenEyck Jr Date: Wed, 6 May 2026 22:17:50 -0500 Subject: [PATCH 3/3] Format external fallback regression tests --- rust/tests/security_regressions.rs | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/rust/tests/security_regressions.rs b/rust/tests/security_regressions.rs index aeb77ff..6a32f7f 100644 --- a/rust/tests/security_regressions.rs +++ b/rust/tests/security_regressions.rs @@ -227,7 +227,12 @@ fn login_rejects_relative_external_provider_path() { let (status, stdout, stderr) = run_command( home, - &["--json", "login", "--external-fallback", "https://vault.example.com"], + &[ + "--json", + "login", + "--external-fallback", + "https://vault.example.com", + ], ); assert_eq!( @@ -251,7 +256,12 @@ fn login_rejects_tilde_external_provider_path() { let (status, stdout, stderr) = run_command( home, - &["--json", "login", "--external-fallback", "https://vault.example.com"], + &[ + "--json", + "login", + "--external-fallback", + "https://vault.example.com", + ], ); assert_eq!( @@ -280,7 +290,12 @@ fn login_rejects_world_writable_external_provider_path() { let (status, stdout, stderr) = run_command( home, - &["--json", "login", "--external-fallback", "https://vault.example.com"], + &[ + "--json", + "login", + "--external-fallback", + "https://vault.example.com", + ], ); assert_eq!( @@ -311,7 +326,12 @@ fn login_rejects_external_provider_symlink_to_insecure_target() { let (status, stdout, stderr) = run_command( home, - &["--json", "login", "--external-fallback", "https://vault.example.com"], + &[ + "--json", + "login", + "--external-fallback", + "https://vault.example.com", + ], ); assert_eq!(