diff --git a/src/commands/interactive/connection.rs b/src/commands/interactive/connection.rs index 68f787655..5fcb679bf 100644 --- a/src/commands/interactive/connection.rs +++ b/src/commands/interactive/connection.rs @@ -819,6 +819,7 @@ Host beta #[tokio::test] async fn connect_to_node_resolves_each_alias_auth_policy_before_authentication() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); // The shared fallback intentionally carries alpha's policy. The beta // connection must still stop with beta's BatchMode decision before any // network connection is attempted. @@ -839,6 +840,7 @@ Host beta #[tokio::test] async fn connect_to_node_pty_resolves_each_alias_auth_policy_before_authentication() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); // Reverse the shared fallback. The alpha PTY path must use alpha's // PasswordAuthentication policy instead of inheriting beta's BatchMode. let (command, alpha, beta) = alias_auth_command("beta"); @@ -886,6 +888,7 @@ Host beta #[test] fn interactive_jump_selection_is_cli_then_ssh_config_then_yaml() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let node = Node::new("effective-target".to_string(), 22, "user".to_string()) .with_original_host("target-alias".to_string()); let resolve = |ssh_config: SshConfig, cli_jump: Option<&str>| { @@ -937,6 +940,7 @@ Host target-alias #[test] fn interactive_jump_chain_keeps_distinct_bastion_and_target_socket_policies() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let ssh_config = SshConfig::parse( r#" Host bastion diff --git a/src/commands/interactive/utils.rs b/src/commands/interactive/utils.rs index 713ce511f..b5b3fc9eb 100644 --- a/src/commands/interactive/utils.rs +++ b/src/commands/interactive/utils.rs @@ -80,6 +80,7 @@ mod tests { #[test] fn ssh_policy_shell_uses_raw_runner_when_remote_pty_is_disabled() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let cmd = InteractiveCommand { single_node: true, multiplex: false, @@ -159,6 +160,7 @@ mod tests { #[test] fn test_format_prompt() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let cmd = InteractiveCommand { single_node: false, multiplex: true, diff --git a/src/commands/network_ping_tests.rs b/src/commands/network_ping_tests.rs index e2e558154..17356b04a 100644 --- a/src/commands/network_ping_tests.rs +++ b/src/commands/network_ping_tests.rs @@ -155,6 +155,7 @@ async fn probe_resolves_the_expanded_hostname_not_the_ssh_alias() { #[tokio::test] async fn run_expands_cluster_alias_through_ssh_config_hostname() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let address = IpAddr::from([192, 0, 2, 11]); let mut probe = FakeProbe::default(); probe @@ -190,6 +191,7 @@ async fn run_expands_cluster_alias_through_ssh_config_hostname() { #[tokio::test] async fn mocked_probe_is_family_aware_and_bounded() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let mut probe = FakeProbe::default(); for index in 1..=4 { let host = format!("host{index}"); diff --git a/src/config/tests.rs b/src/config/tests.rs index efe18f8b7..d7006d2ce 100644 --- a/src/config/tests.rs +++ b/src/config/tests.rs @@ -63,6 +63,7 @@ fn test_expand_tilde() { #[test] fn test_config_parsing() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let yaml = r#" defaults: user: admin diff --git a/src/executor/connection_manager.rs b/src/executor/connection_manager.rs index b9d4a10ef..fa7f71ab7 100644 --- a/src/executor/connection_manager.rs +++ b/src/executor/connection_manager.rs @@ -341,6 +341,7 @@ mod tests { #[test] fn command_and_transfer_selection_is_cli_then_ssh_config_then_yaml() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let config_jump = SshConfig::parse( r#" Host target-alias @@ -383,6 +384,7 @@ Host target-alias #[test] fn command_and_transfer_proxy_jump_uses_original_host_alias() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let ssh_config = SshConfig::parse( r#" Host target-alias @@ -406,6 +408,7 @@ Host effective-target /// Test that CLI jump hosts take precedence over SSH config #[test] fn test_resolve_effective_jump_hosts_cli_precedence() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let ssh_config_content = r#" Host example.com ProxyJump bastion.example.com @@ -433,6 +436,7 @@ Host example.com /// Test that SSH config ProxyJump is used when no CLI jump hosts #[test] fn test_resolve_effective_jump_hosts_ssh_config_fallback() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let ssh_config_content = r#" Host example.com ProxyJump bastion.example.com @@ -446,6 +450,7 @@ Host example.com /// Test that no jump hosts is returned when neither CLI nor SSH config specifies one #[test] fn test_resolve_effective_jump_hosts_none() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let ssh_config = SshConfig::new(); let result = resolve_effective_jump_hosts(None, Some(&ssh_config), "example.com"); @@ -455,6 +460,7 @@ Host example.com /// Test that no jump hosts is returned when SSH config is not provided #[test] fn test_resolve_effective_jump_hosts_no_ssh_config() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let result = resolve_effective_jump_hosts(None, None, "example.com"); assert_eq!(result, None); } @@ -462,6 +468,7 @@ Host example.com /// Test multi-hop ProxyJump chain from SSH config #[test] fn test_resolve_effective_jump_hosts_multi_hop() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let ssh_config_content = r#" Host internal.example.com ProxyJump jump1.example.com,jump2.example.com @@ -478,6 +485,7 @@ Host internal.example.com /// Test ProxyJump with port specification #[test] fn test_resolve_effective_jump_hosts_with_port() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let ssh_config_content = r#" Host internal.example.com ProxyJump bastion.example.com:2222 @@ -491,6 +499,7 @@ Host internal.example.com /// Test ProxyJump with user@host:port format #[test] fn test_resolve_effective_jump_hosts_with_user_and_port() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let ssh_config_content = r#" Host internal.example.com ProxyJump admin@bastion.example.com:2222 @@ -504,6 +513,7 @@ Host internal.example.com /// Test wildcard pattern matching for ProxyJump #[test] fn test_resolve_effective_jump_hosts_wildcard() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let ssh_config_content = r#" Host db.internal.example.com ProxyJump db-gateway.example.com @@ -527,6 +537,7 @@ Host *.internal.example.com /// Test that unmatched hosts get no ProxyJump #[test] fn test_resolve_effective_jump_hosts_no_match() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let ssh_config_content = r#" Host *.internal.example.com ProxyJump gateway.example.com @@ -540,6 +551,7 @@ Host *.internal.example.com #[test] fn direct_proxy_jump_values_normalize_to_an_empty_chain() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); for directive in ["none", "direct", "NONE", "DIRECT"] { let ssh_config_content = format!( r#" @@ -665,6 +677,7 @@ Host *.example.com #[tokio::test] async fn command_and_all_transfer_paths_never_dial_proxy_jump_none() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap(); let port = listener.local_addr().unwrap().port(); drop(listener); @@ -697,6 +710,7 @@ Host direct-target #[tokio::test] async fn all_connection_paths_prefer_config_then_cli_over_yaml() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let ssh_config = SshConfig::parse( r#" Host target-alias @@ -733,6 +747,7 @@ Host target-alias /// Test complex multi-hop chain with user and ports #[test] fn test_resolve_effective_jump_hosts_complex_chain() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let ssh_config_content = r#" Host production.internal ProxyJump user1@jump1.example.com:22,user2@jump2.example.com:2222,jump3.example.com diff --git a/src/executor/parallel.rs b/src/executor/parallel.rs index 13c0edaf1..12140e760 100644 --- a/src/executor/parallel.rs +++ b/src/executor/parallel.rs @@ -2021,6 +2021,7 @@ mod tests { #[test] fn connection_config_for_node_resolves_each_node_host_block() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let ssh_config = SshConfig::parse( r#" Host v4node diff --git a/src/jump/chain.rs b/src/jump/chain.rs index 427d75f2e..96f9f6419 100644 --- a/src/jump/chain.rs +++ b/src/jump/chain.rs @@ -590,6 +590,7 @@ mod tests { #[test] fn test_chain_materializes_jump_alias_endpoint_and_preserves_cli_fields() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let ssh_config = SshConfig::parse( r#" Host bastion @@ -629,6 +630,7 @@ Host templated #[test] fn test_chain_resolves_connection_config_per_jump_host() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let ssh_config = SshConfig::parse( r#" Host bastion diff --git a/src/jump/chain/auth.rs b/src/jump/chain/auth.rs index cc92f2a8d..9cb8d801d 100644 --- a/src/jump/chain/auth.rs +++ b/src/jump/chain/auth.rs @@ -125,6 +125,7 @@ mod tests { #[tokio::test] #[serial_test::serial] async fn test_determine_auth_method_fallback_to_key_file() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); // Clear SSH_AUTH_SOCK to ensure agent is "unavailable"; guard restores on drop. let _sock = EnvGuard::remove("SSH_AUTH_SOCK"); diff --git a/src/ssh/auth.rs b/src/ssh/auth.rs index c5f485ea9..285df74fa 100644 --- a/src/ssh/auth.rs +++ b/src/ssh/auth.rs @@ -738,6 +738,7 @@ mod tests { #[test] fn default_identities_include_the_fido_key_files() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let ctx = AuthContext::new("user".to_string(), "host".to_string()).unwrap(); let names = ctx .default_key_paths() @@ -864,6 +865,7 @@ mod tests { #[tokio::test] async fn test_determine_method_with_key_file() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let temp_dir = TempDir::new().unwrap(); let key_path = temp_dir.path().join("test_key"); write_test_key(&key_path); @@ -891,6 +893,7 @@ mod tests { #[tokio::test] async fn test_timing_attack_mitigation() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let ctx = AuthContext::new("user".to_string(), "host".to_string()).unwrap(); // Measure time for failed authentication @@ -1061,6 +1064,7 @@ mod tests { #[tokio::test] async fn disabled_and_unpreferred_methods_are_never_planned() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let home = TempDir::new().unwrap(); let key_path = home.path().join("identity"); write_test_key(&key_path); @@ -1099,6 +1103,7 @@ mod tests { #[tokio::test] async fn batch_mode_never_plans_password_or_passphrase_prompts() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let password_policy = SshAuthenticationPolicy { preferred_authentications: vec!["password".into()], pubkey_authentication: false, @@ -1156,6 +1161,7 @@ mod tests { #[tokio::test] async fn later_invalid_keys_and_password_prompt_stay_lazy_and_ordered() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let home = TempDir::new().unwrap(); let first_key = home.path().join("first-key"); let encrypted_key = home.path().join("encrypted-key"); @@ -1204,6 +1210,7 @@ mod tests { } #[tokio::test] async fn number_of_password_prompts_is_the_exact_attempt_bound() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let policy = SshAuthenticationPolicy { preferred_authentications: vec!["password".into()], pubkey_authentication: false, @@ -1222,6 +1229,7 @@ mod tests { #[tokio::test] async fn matching_user_certificate_precedes_plain_key() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let home = TempDir::new().unwrap(); let key_path = home.path().join("user-key"); let certificate_path = home.path().join("user-cert.pub"); @@ -1254,6 +1262,7 @@ mod tests { #[tokio::test] async fn automatic_certificate_companion_precedes_plain_identity() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let home = TempDir::new().unwrap(); let key_path = home.path().join("automatic-key"); let certificate_path = AuthContext::automatic_certificate_paths(&key_path)[1].clone(); @@ -1352,6 +1361,7 @@ mod tests { #[tokio::test] #[serial] async fn explicit_cli_identity_uses_public_companion_when_private_identity_is_offline() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let home = TempDir::new().unwrap(); let _socket = EnvGuard::remove("SSH_AUTH_SOCK"); let private_path = home.path().join("cli-key"); @@ -1381,6 +1391,7 @@ mod tests { #[tokio::test] #[serial] async fn offline_cli_identity_preserves_base_path_for_automatic_certificate() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let home = TempDir::new().unwrap(); let _socket = EnvGuard::remove("SSH_AUTH_SOCK"); let private_path = home.path().join("cli-key"); @@ -1455,6 +1466,7 @@ mod tests { #[tokio::test] #[serial] async fn mismatched_local_certificate_remains_a_retryable_agent_candidate() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let home = TempDir::new().unwrap(); let _socket = EnvGuard::remove("SSH_AUTH_SOCK"); let key_path = home.path().join("configured-key"); @@ -1489,6 +1501,7 @@ mod tests { #[tokio::test] async fn missing_and_malformed_cli_identities_leave_later_keys_reachable() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let home = TempDir::new().unwrap(); let missing = home.path().join("missing-key"); let malformed = home.path().join("malformed-key"); @@ -1519,6 +1532,7 @@ mod tests { #[tokio::test] #[serial] async fn identities_only_public_identity_keeps_matching_agent_certificate_first() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let home = TempDir::new().unwrap(); let private_path = home.path().join("listed-key"); let public_path = home.path().join("listed-key.pub"); @@ -1555,6 +1569,7 @@ mod tests { #[tokio::test] async fn encrypted_rsa_companion_is_filtered_before_any_passphrase_prompt() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let home = TempDir::new().unwrap(); let rsa_path = home.path().join("encrypted-rsa"); crate::keygen::generate_rsa(&rsa_path, 2048, None).unwrap(); diff --git a/src/ssh/client/connection.rs b/src/ssh/client/connection.rs index b5e320269..863e29e9d 100644 --- a/src/ssh/client/connection.rs +++ b/src/ssh/client/connection.rs @@ -394,6 +394,7 @@ mod tests { #[test] fn command_and_transfer_jump_chain_preserves_alias_target_policy() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let ssh_config = SshConfig::parse( r#" Host bastion @@ -483,6 +484,7 @@ Host effective-target #[tokio::test] async fn test_determine_auth_method_with_key() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let temp_dir = TempDir::new().unwrap(); let key_path = temp_dir.path().join("test_key"); write_test_key(&key_path); diff --git a/src/ssh/known_hosts.rs b/src/ssh/known_hosts.rs index c7362144f..c862d315c 100644 --- a/src/ssh/known_hosts.rs +++ b/src/ssh/known_hosts.rs @@ -490,6 +490,7 @@ mod tests { #[test] fn test_get_default_known_hosts_path() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let path = get_default_known_hosts_path(); assert!(path.is_some()); if let Some(p) = path { @@ -499,6 +500,7 @@ mod tests { #[test] fn test_get_check_method() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); // Test with No mode let method = get_check_method(StrictHostKeyChecking::No); assert!(matches!(method, ServerCheckMethod::NoCheck)); @@ -535,6 +537,7 @@ mod tests { #[test] fn configured_known_hosts_files_preserve_order_and_expand_tokens() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let config = SshConnectionConfig::new().with_known_hosts_files( Some(vec![ "/tmp/user-first".to_string(), @@ -568,6 +571,7 @@ mod tests { #[test] fn accept_new_records_only_to_first_expanded_user_file() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let config = SshConnectionConfig::new().with_known_hosts_files( Some(vec!["/tmp/%h-first".to_string(), "/tmp/second".to_string()]), Some(vec!["/tmp/global".to_string()]), @@ -595,6 +599,7 @@ mod tests { #[test] fn configured_empty_stores_do_not_fall_back_to_default() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let config = SshConnectionConfig::new().with_known_hosts_files( Some(vec!["none".to_string(), "/dev/null".to_string()]), None, @@ -607,6 +612,7 @@ mod tests { #[test] fn resolver_preserves_multiple_known_hosts_paths_and_empty_sentinels() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let ssh_config = crate::ssh::SshConfig::parse( "Host target\n HostKeyAlias config-alias.example.com\n UserKnownHostsFile ~/.ssh/one /dev/null none\n GlobalKnownHostsFile %d/global %h-%p-%r-%u\n", ) @@ -630,6 +636,7 @@ mod tests { #[test] fn cli_host_key_alias_overrides_effective_host_config() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let ssh_config = crate::ssh::SshConfig::parse( "Host target\n HostKeyAlias config-alias.example.com\n UserKnownHostsFile /tmp/known_hosts\n", ) @@ -662,6 +669,7 @@ mod tests { #[test] fn expands_home_directory_tokens_and_tilde() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let home = dirs::home_dir().expect("test environment must have a home directory"); let config = SshConnectionConfig::new(); assert_eq!( @@ -676,6 +684,7 @@ mod tests { #[test] fn expands_full_known_hosts_token_set_and_environment() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let config = SshConnectionConfig::new() .with_original_host("alias") .with_host_key_alias(Some("key-alias".to_string())) diff --git a/src/ssh/session_policy_tests.rs b/src/ssh/session_policy_tests.rs index ee9ac50f6..68abba18a 100644 --- a/src/ssh/session_policy_tests.rs +++ b/src/ssh/session_policy_tests.rs @@ -20,6 +20,7 @@ fn node() -> Node { #[test] fn environment_requests_are_sorted_removed_bounded_and_setenv_wins() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let mut config = SshHostConfig { send_env: vec![ "TEST_*".into(), @@ -69,6 +70,7 @@ fn environment_requests_are_sorted_removed_bounded_and_setenv_wins() { #[test] fn remote_command_conflicts_and_session_request_kinds_are_exact() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let mut config = SshHostConfig { remote_command: Some("echo %n %% %h".into()), ..Default::default() @@ -110,6 +112,7 @@ fn remote_command_conflicts_and_session_request_kinds_are_exact() { #[test] fn request_tty_obeys_cli_precedence_and_config_modes() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let mut config = SshHostConfig { request_tty: Some("force".into()), ..Default::default() @@ -141,6 +144,7 @@ fn request_tty_obeys_cli_precedence_and_config_modes() { #[test] fn stdin_null_disables_automatic_pty_but_not_forced_pty() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let config = SshHostConfig { stdin_null: Some(true), request_tty: Some("yes".into()), @@ -157,6 +161,7 @@ fn stdin_null_disables_automatic_pty_but_not_forced_pty() { #[test] fn forward_agent_is_an_explicit_session_policy_independent_of_authentication() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let enabled = SessionPolicy::resolve( &SshHostConfig { forward_agent: Some(true), @@ -183,6 +188,7 @@ fn forward_agent_is_an_explicit_session_policy_independent_of_authentication() { #[test] fn session_purpose_tracks_the_resolved_pty_policy() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let interactive = SessionPolicy::resolve( &SshHostConfig { request_tty: Some("force".into()), @@ -213,6 +219,7 @@ fn session_purpose_tracks_the_resolved_pty_policy() { #[cfg(unix)] #[tokio::test] async fn local_command_runs_once_and_propagates_failure() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let denied = SshHostConfig { permit_local_command: Some(false), local_command: Some("exit 99".into()), @@ -250,6 +257,7 @@ async fn local_command_runs_once_and_propagates_failure() { #[test] fn environment_name_value_and_request_count_bounds_are_enforced() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); assert!(validate_environment("BAD=NAME", "value").is_err()); assert!(validate_environment(&"N".repeat(MAX_ENVIRONMENT_NAME_BYTES + 1), "value").is_err()); assert!(validate_environment("NAME", &"v".repeat(MAX_ENVIRONMENT_VALUE_BYTES + 1)).is_err()); @@ -292,6 +300,7 @@ fn environment_name_value_and_request_count_bounds_are_enforced() { #[test] fn directive_token_sets_jump_hash_and_setenv_dollar_expansion_are_exact() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let jump_spec = "jump@example.net:2200"; let mut config = SshHostConfig { proxy_jump: Some("ignored-config-jump".into()), @@ -410,6 +419,7 @@ fn directive_token_sets_jump_hash_and_setenv_dollar_expansion_are_exact() { #[test] fn expanded_shell_values_and_command_size_remain_bounded() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let mut config = SshHostConfig { remote_command: Some("echo %h".into()), ..Default::default() @@ -431,6 +441,7 @@ fn expanded_shell_values_and_command_size_remain_bounded() { #[cfg(unix)] #[test] fn local_command_rejects_spaced_remote_user_before_shell_execution() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let directory = tempfile::tempdir().unwrap(); let marker = directory.path().join("remote-user-injection"); let hostile_node = Node::new( diff --git a/src/ssh/ssh_config/dump_tests.rs b/src/ssh/ssh_config/dump_tests.rs index d82dc1f8b..553485bed 100644 --- a/src/ssh/ssh_config/dump_tests.rs +++ b/src/ssh/ssh_config/dump_tests.rs @@ -57,6 +57,7 @@ Host target #[test] fn path_keywords_follow_openssh_dump_expansion_categories() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let source = r#" Host target HostName final.example @@ -82,6 +83,7 @@ Host target #[test] fn proxy_jump_none_is_omitted_from_config_dump() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let config = SshConfig::parse("Host target\n ProxyJump none\n").unwrap(); let output = render_resolved_config("target", &config.find_host_config("target")).unwrap(); assert!(!output.lines().any(|line| line.starts_with("proxyjump "))); @@ -90,6 +92,7 @@ fn proxy_jump_none_is_omitted_from_config_dump() { #[test] fn renderer_rejects_line_injection() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let mut config = SshHostConfig { hostname: Some("safe.example\nport 1".to_string()), ..Default::default() @@ -104,6 +107,7 @@ fn renderer_rejects_line_injection() { #[test] fn command_tokens_expand_only_at_the_openssh_dump_stages() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let config = SshConfig::parse( "LocalCommand printf '%h local'\nRemoteCommand printf '%h remote'\nKnownHostsCommand printf '%h known'\nProxyCommand printf '%h|${HOME}'\n", ) @@ -121,6 +125,7 @@ fn command_tokens_expand_only_at_the_openssh_dump_stages() { #[test] fn quoted_scalar_and_list_elements_round_trip_without_collapsing() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let source = r#" Host target IdentityFile "/tmp/identity a#b" @@ -155,6 +160,7 @@ Host target #[test] fn forwarding_arguments_are_serialized_individually() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let source = r#" Host target LocalForward "/tmp/local socket#one" "/tmp/destination 'quoted'" @@ -184,6 +190,7 @@ Host target #[test] fn final_pass_forward_dedup_keeps_the_first_argument_boundaries() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let config = SshConfig::parse( "Host target\n LocalForward \"a b\" c\nMatch final\n LocalForward a \"b c\"\n", ) @@ -200,6 +207,7 @@ fn final_pass_forward_dedup_keeps_the_first_argument_boundaries() { #[test] fn configured_canonicalization_values_render_and_round_trip() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let config = SshConfig::parse( "CanonicalizeHostname yes\nCanonicalizeFallbackLocal no\nCanonicalizeMaxDots 4\nCanonicalDomains one.example two.example\nCanonicalizePermittedCNAMEs a:b c:d\nMatch canonical\n Port 2201\n", ) @@ -218,6 +226,7 @@ fn configured_canonicalization_values_render_and_round_trip() { #[test] fn default_dump_has_the_audited_full_keyword_shape() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let output = render_resolved_config("host", &SshHostConfig::default()).unwrap(); let keywords = output .lines() @@ -269,6 +278,7 @@ fn default_dump_has_the_audited_full_keyword_shape() { #[test] fn signature_algorithm_modifiers_resolve_against_independent_defaults() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let cases = [ ( "HostbasedAcceptedAlgorithms +ssh-rsa\n", @@ -319,6 +329,7 @@ fn signature_algorithm_modifiers_resolve_against_independent_defaults() { #[test] fn signature_algorithm_policy_rejects_unknown_names_and_empty_results() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let unknown = SshConfig::parse("CASignatureAlgorithms +not-a-real-key\n").unwrap(); let error = render_resolved_config("host", &unknown.find_host_config("host")).unwrap_err(); assert!( @@ -338,6 +349,7 @@ fn signature_algorithm_policy_rejects_unknown_names_and_empty_results() { #[test] fn revoked_host_keys_list_is_dumped_on_one_line() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let parsed = SshConfig::parse("Host host\n RevokedHostKeys /etc/ssh/revoked_a /etc/ssh/revoked_%h\n") .unwrap(); @@ -364,6 +376,7 @@ fn revoked_host_keys_list_is_dumped_on_one_line() { #[test] fn security_key_provider_renders_its_raw_value_and_first_match_wins() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let config = SshConfig::parse( "Host target\n SecurityKeyProvider $MY_SK_PROVIDER\nHost *\n SecurityKeyProvider /usr/lib/libsk-libfido2.so\n", ) diff --git a/src/ssh/ssh_config/env_cache/tests.rs b/src/ssh/ssh_config/env_cache/tests.rs index c68b5a073..50e0454b7 100644 --- a/src/ssh/ssh_config/env_cache/tests.rs +++ b/src/ssh/ssh_config/env_cache/tests.rs @@ -42,6 +42,7 @@ fn test_cache_entry_expiration() { #[test] fn test_env_cache_basic_operations() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let cache = EnvironmentCache::new(); // Test getting a safe environment variable @@ -60,6 +61,7 @@ fn test_env_cache_basic_operations() { #[test] fn test_env_cache_unsafe_variable_blocked() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let cache = EnvironmentCache::new(); // Try to access a dangerous variable @@ -77,6 +79,7 @@ fn test_env_cache_unsafe_variable_blocked() { #[test] fn test_env_cache_ttl_expiration() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let config = EnvCacheConfig { ttl: Duration::from_millis(50), enabled: true, @@ -99,6 +102,7 @@ fn test_env_cache_ttl_expiration() { #[test] fn test_env_cache_size_limit() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let config = EnvCacheConfig { ttl: Duration::from_secs(60), enabled: true, @@ -117,6 +121,7 @@ fn test_env_cache_size_limit() { #[test] fn test_env_cache_clear_and_refresh() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let cache = EnvironmentCache::new(); // Cache some variables @@ -137,6 +142,7 @@ fn test_env_cache_clear_and_refresh() { #[test] fn test_env_cache_maintenance() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let config = EnvCacheConfig { ttl: Duration::from_millis(50), enabled: true, @@ -159,6 +165,7 @@ fn test_env_cache_maintenance() { #[test] fn test_env_cache_disabled() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let config = EnvCacheConfig { ttl: Duration::from_secs(60), enabled: false, @@ -202,6 +209,7 @@ fn test_env_cache_safe_variables_list() { #[test] fn test_env_cache_concurrent_access() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let cache = Arc::new(EnvironmentCache::new()); let counter = Arc::new(AtomicUsize::new(0)); diff --git a/src/ssh/ssh_config/include/mod.rs b/src/ssh/ssh_config/include/mod.rs index 252188732..a8c56685a 100644 --- a/src/ssh/ssh_config/include/mod.rs +++ b/src/ssh/ssh_config/include/mod.rs @@ -952,6 +952,7 @@ mod tests { #[tokio::test] async fn nested_relative_includes_keep_the_origin_anchor() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let temp_dir = TempDir::new().unwrap(); let anchor = temp_dir.path().join("anchor"); let elsewhere = temp_dir.path().join("elsewhere"); @@ -990,6 +991,7 @@ mod tests { #[tokio::test] async fn percent_h_uses_streaming_effective_hostname() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let temp_dir = TempDir::new().unwrap(); let main = temp_dir.path().join("config"); let content = "Host alias\n HostName effective.example\n Include %h.conf\n"; @@ -1020,6 +1022,7 @@ mod tests { #[test] fn include_percent_tokens_use_current_streaming_context() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let initial = super::super::types::SshHostConfig { hostname: Some("effective.example".to_string()), user: Some("deploy".to_string()), @@ -1061,6 +1064,7 @@ mod tests { #[tokio::test] async fn include_depth_accepts_sixteen_edges_and_rejects_seventeen() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); assert!(resolve_include_chain(16).await.is_ok()); let error = resolve_include_chain(17).await.unwrap_err(); assert!(format!("{error:?}").contains("Maximum include depth (16)")); diff --git a/src/ssh/ssh_config/include/resolver.rs b/src/ssh/ssh_config/include/resolver.rs index 4acd53d88..c4dac87c0 100644 --- a/src/ssh/ssh_config/include/resolver.rs +++ b/src/ssh/ssh_config/include/resolver.rs @@ -142,6 +142,7 @@ mod tests { #[test] fn test_parse_include_line() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); // Test space syntax assert_eq!( parse_include_line("Include ~/.ssh/config.d/*").unwrap(), @@ -178,6 +179,7 @@ mod tests { #[tokio::test] async fn test_resolve_include_pattern_glob() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); use crate::ssh::ssh_config::include::IncludeContext; let temp_dir = TempDir::new().unwrap(); @@ -229,6 +231,7 @@ mod tests { #[tokio::test] async fn system_source_rejects_tilde_include_while_user_source_accepts_it() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let anchor = tempfile::tempdir().unwrap(); let system = IncludeContext::with_anchor(anchor.path().to_path_buf(), false); assert!( @@ -247,6 +250,7 @@ mod tests { #[tokio::test] async fn test_include_with_tilde_expansion() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); // Test that tilde expansion is handled let patterns = parse_include_line("Include ~/.ssh/config.d/*.conf") .unwrap() diff --git a/src/ssh/ssh_config/integration_tests/certificate_forwarding_integration_test.rs b/src/ssh/ssh_config/integration_tests/certificate_forwarding_integration_test.rs index 9ab642fbd..cc80438fe 100644 --- a/src/ssh/ssh_config/integration_tests/certificate_forwarding_integration_test.rs +++ b/src/ssh/ssh_config/integration_tests/certificate_forwarding_integration_test.rs @@ -38,6 +38,7 @@ pub(crate) mod tests { #[tokio::test] async fn test_include_with_certificate_options() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let temp_dir = TempDir::new().unwrap(); // Create an included file with certificate options @@ -98,6 +99,7 @@ Host web.prod.example.com #[tokio::test] async fn test_include_with_forwarding_options() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let temp_dir = TempDir::new().unwrap(); // Create an included file with forwarding options @@ -138,6 +140,7 @@ Host app.secure.example.com #[tokio::test] async fn test_match_with_certificate_options() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); use crate::ssh::ssh_config::match_directive::MatchCondition; use crate::ssh::ssh_config::types::ConfigBlock; @@ -214,6 +217,7 @@ Host app.secure.example.com #[tokio::test] async fn test_complex_include_match_host_combination() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let temp_dir = TempDir::new().unwrap(); // Create a base config with Match @@ -273,6 +277,7 @@ Host web.prod.corp.example.com #[tokio::test] async fn test_nested_includes_with_all_new_options() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let temp_dir = TempDir::new().unwrap(); // Deep include: base authentication @@ -352,6 +357,7 @@ Host web1.prod.example.com #[tokio::test] async fn test_all_new_options_in_real_scenario() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let temp_dir = TempDir::new().unwrap(); // Create a comprehensive config file diff --git a/src/ssh/ssh_config/integration_tests/env_cache_integration_test.rs b/src/ssh/ssh_config/integration_tests/env_cache_integration_test.rs index 3a5e6333b..dbf6ac40c 100644 --- a/src/ssh/ssh_config/integration_tests/env_cache_integration_test.rs +++ b/src/ssh/ssh_config/integration_tests/env_cache_integration_test.rs @@ -26,6 +26,7 @@ mod tests { #[test] fn test_path_expansion_uses_cache() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); // Clear the global cache to start fresh GLOBAL_ENV_CACHE.clear(); @@ -68,6 +69,7 @@ mod tests { #[test] fn test_path_expansion_with_multiple_variables() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); // Clear cache GLOBAL_ENV_CACHE.clear(); @@ -98,6 +100,7 @@ mod tests { #[test] fn test_path_expansion_security_with_unsafe_variables() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); // Clear cache GLOBAL_ENV_CACHE.clear(); @@ -150,6 +153,7 @@ mod tests { #[test] fn test_cache_performance_improvement() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); // Create a cache with very short TTL for testing let cache_config = EnvCacheConfig { ttl: Duration::from_secs(10), @@ -191,6 +195,7 @@ mod tests { #[test] fn test_cache_with_nonexistent_variable() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let cache = EnvironmentCache::new(); // Try to get a safe variable that doesn't exist @@ -226,6 +231,7 @@ mod tests { #[test] #[serial] fn test_cache_ttl_behavior() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); // Create cache with very short TTL let config = EnvCacheConfig { ttl: Duration::from_millis(50), diff --git a/src/ssh/ssh_config/match_directive/exec.rs b/src/ssh/ssh_config/match_directive/exec.rs index 975a2c92c..eb09f3f46 100644 --- a/src/ssh/ssh_config/match_directive/exec.rs +++ b/src/ssh/ssh_config/match_directive/exec.rs @@ -345,6 +345,7 @@ mod tests { #[test] fn direct_exec_keeps_expanded_whitespace_in_one_argument_and_rejects_dynamic_programs() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let directory = tempfile::tempdir().unwrap(); let marker = directory.path().join("dynamic-program-ran"); let hostile = format!("touch {}", marker.display()); @@ -362,6 +363,7 @@ mod tests { #[test] #[cfg(unix)] fn shell_operators_and_substitutions_follow_openssh_semantics() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let context = MatchContext::new("example.com".to_string(), None).unwrap(); assert!( execute_match_command( @@ -377,6 +379,7 @@ mod tests { #[test] #[cfg(unix)] fn test_exec_timeout() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); use std::time::Instant; let context = MatchContext::new("example.com".to_string(), None).unwrap(); @@ -400,6 +403,7 @@ mod tests { #[test] #[cfg(unix)] fn timeout_kills_descendant_process_group() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let directory = tempfile::tempdir().unwrap(); let marker = directory.path().join("descendant-survived"); let context = MatchContext::new("example.com".to_string(), None).unwrap(); @@ -416,6 +420,7 @@ mod tests { #[test] #[cfg(unix)] fn test_exec_nonexistent_command() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let context = MatchContext::new("example.com".to_string(), None).unwrap(); // Test command that doesn't exist @@ -428,6 +433,7 @@ mod tests { #[test] #[cfg(unix)] fn test_exec_exit_code_handling() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let context = MatchContext::new("example.com".to_string(), None).unwrap(); // Test command that exits with success (0) @@ -442,6 +448,7 @@ mod tests { #[test] #[cfg(windows)] fn test_exec_uses_platform_shell_on_windows() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let context = MatchContext::new("example.com".to_string(), None).unwrap(); assert!(execute_match_command("exit /B 0", &context).unwrap()); } diff --git a/src/ssh/ssh_config/match_directive/mod.rs b/src/ssh/ssh_config/match_directive/mod.rs index 367323ab2..d5ec026a0 100644 --- a/src/ssh/ssh_config/match_directive/mod.rs +++ b/src/ssh/ssh_config/match_directive/mod.rs @@ -480,6 +480,7 @@ mod tests { #[test] fn test_match_host_condition() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let context = MatchContext::new("web1.example.com".to_string(), Some("testuser".to_string())) .unwrap(); @@ -493,6 +494,7 @@ mod tests { #[test] fn test_match_user_condition() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let context = MatchContext::new("example.com".to_string(), Some("admin".to_string())).unwrap(); @@ -511,6 +513,7 @@ mod tests { #[test] fn test_match_localuser_condition() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let context = MatchContext::new("example.com".to_string(), None).unwrap(); let local_user = whoami::username().unwrap(); @@ -523,6 +526,7 @@ mod tests { #[test] fn test_match_all_condition() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let context = MatchContext::new("example.com".to_string(), None).unwrap(); let condition = MatchCondition::All; @@ -538,6 +542,7 @@ mod tests { #[test] fn test_match_block() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let mut block = MatchBlock::new(10); block .conditions @@ -564,6 +569,7 @@ mod tests { #[test] fn test_match_host_with_negation() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); // A negated pattern vetoes a positive wildcard match. let context_internal = MatchContext::new("web.internal.com".to_string(), Some("testuser".to_string())) @@ -596,6 +602,7 @@ mod tests { #[test] fn test_match_user_multiple_patterns() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let context = MatchContext::new("example.com".to_string(), Some("admin".to_string())).unwrap(); @@ -609,6 +616,7 @@ mod tests { #[test] fn test_match_localuser_with_wildcards() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let context = MatchContext::new("example.com".to_string(), None).unwrap(); let local_user = whoami::username().unwrap(); @@ -648,6 +656,7 @@ mod tests { #[test] fn test_match_block_all_conditions() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); // Test Match all alone (should match everything) let mut block = MatchBlock::new(10); block.conditions.push(MatchCondition::All); @@ -679,6 +688,7 @@ mod tests { #[test] #[cfg(unix)] fn trusted_shell_policy_is_explicit_and_negated_exec_is_preserved() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let direct = MatchContext::new("example.com".to_string(), None).unwrap(); let shell = direct.clone().with_trusted_shell_exec(); let shell_expression = MatchCondition::Exec("false || true".to_string()); @@ -695,6 +705,7 @@ mod tests { #[test] fn only_positive_final_requests_the_second_pass() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let context = MatchContext::new("example.com".to_string(), None).unwrap(); let positive_after_false = MatchBlock { conditions: vec![ diff --git a/src/ssh/ssh_config/mod.rs b/src/ssh/ssh_config/mod.rs index 39fbbbfe9..f5bb6bd02 100644 --- a/src/ssh/ssh_config/mod.rs +++ b/src/ssh/ssh_config/mod.rs @@ -729,6 +729,7 @@ mod tests { #[cfg(unix)] #[tokio::test] async fn public_host_loader_rejects_unsafe_tokens_before_match_exec() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let temp_dir = TempDir::new().unwrap(); let config_path = temp_dir.path().join("config"); let host_marker = temp_dir.path().join("host-marker"); @@ -766,6 +767,7 @@ mod tests { #[tokio::test] async fn later_config_files_use_earlier_effective_include_context() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let temp_dir = TempDir::new().unwrap(); let user_anchor = temp_dir.path().join("user"); let system_anchor = temp_dir.path().join("system"); @@ -809,6 +811,7 @@ mod tests { #[cfg(unix)] #[tokio::test] async fn file_match_exec_runs_once_per_pass_and_is_cached_for_resolution() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let temp_dir = TempDir::new().unwrap(); let marker = temp_dir.path().join("match-exec-marker"); let config_path = temp_dir.path().join("config"); @@ -831,6 +834,7 @@ mod tests { #[cfg(unix)] #[tokio::test] async fn cli_port_is_available_to_match_exec_and_include_tokens() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let temp_dir = TempDir::new().unwrap(); let config_path = temp_dir.path().join("config"); write_config( @@ -858,6 +862,7 @@ mod tests { #[cfg(unix)] #[tokio::test] async fn cli_canonicalization_does_not_fix_hostname_before_explicit_file() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); use clap::Parser as _; use sha1::{Digest as _, Sha1}; @@ -939,6 +944,7 @@ mod tests { #[cfg(unix)] #[tokio::test] async fn later_source_match_and_include_use_all_prior_source_tokens_once() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let temp_dir = TempDir::new().unwrap(); let user_anchor = temp_dir.path().join("user"); let system_anchor = temp_dir.path().join("system"); @@ -1008,6 +1014,7 @@ mod tests { #[cfg(unix)] #[tokio::test] async fn final_pass_match_and_include_use_first_pass_tokens_and_hash_once() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); use sha1::{Digest as _, Sha1}; let temp_dir = TempDir::new().unwrap(); @@ -1070,6 +1077,7 @@ mod tests { #[cfg(unix)] #[tokio::test] async fn outer_match_exec_guards_include_without_reexecution() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let temp_dir = TempDir::new().unwrap(); let marker = temp_dir.path().join("outer-marker"); let config_path = temp_dir.path().join("config"); @@ -1101,6 +1109,7 @@ mod tests { #[cfg(unix)] #[tokio::test] async fn positive_final_requests_pass_independently_of_exec_order() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let temp_dir = TempDir::new().unwrap(); let before = temp_dir.path().join("before-final"); let after = temp_dir.path().join("after-final"); @@ -1136,6 +1145,7 @@ mod tests { #[cfg(unix)] #[tokio::test] async fn generic_runtime_does_not_inherit_config_dump_shell_policy() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let temp_dir = TempDir::new().unwrap(); let config_path = temp_dir.path().join("config"); write_config( @@ -1149,6 +1159,7 @@ mod tests { #[tokio::test] async fn later_sources_expand_raw_hostname_percent_h_once_for_includes() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let temp_dir = TempDir::new().unwrap(); let user_anchor = temp_dir.path().join("user"); let system_anchor = temp_dir.path().join("system"); @@ -1181,6 +1192,7 @@ mod tests { #[tokio::test] async fn final_pass_keeps_entry_hostname_and_preserves_first_include_values() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); #[cfg(unix)] use std::os::unix::fs::PermissionsExt; @@ -1218,6 +1230,7 @@ mod tests { #[test] fn canonical_only_activates_when_nonnegated_final_requests_second_pass() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let without_final = SshConfig::parse("Match canonical\n Port 2201\n").unwrap(); assert_eq!(without_final.find_host_config("host").port, None); @@ -1231,6 +1244,7 @@ mod tests { #[tokio::test] async fn final_inside_inactive_include_scope_does_not_request_second_pass() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let directory = TempDir::new().unwrap(); let main = directory.path().join("config"); let child = directory.path().join("child.conf"); @@ -1257,6 +1271,7 @@ mod tests { #[test] fn canonicalization_value_requests_canonical_match_pass_without_dns() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let config = SshConfig::parse("CanonicalizeHostname yes\nMatch canonical\n Port 2201\n").unwrap(); let resolved = config.find_host_config("alias"); @@ -1272,6 +1287,7 @@ mod tests { #[tokio::test] async fn all_first_pass_sources_precede_every_final_pass_source() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let directory = TempDir::new().unwrap(); let user = directory.path().join("user.conf"); let system = directory.path().join("system.conf"); @@ -1325,6 +1341,7 @@ mod tests { #[cfg(unix)] #[tokio::test] async fn system_first_pass_can_obtain_hostname_after_user_requests_final() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let directory = TempDir::new().unwrap(); let user = directory.path().join("user.conf"); let system = directory.path().join("system.conf"); @@ -1417,6 +1434,7 @@ mod tests { #[test] fn final_pass_refreshes_user_before_each_match_but_keeps_entry_hostname() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let config = SshConfig::parse( "Match final\n User final-user\n HostName final.example\nMatch user final-user host final.example\n Port 2202\n", ) @@ -1429,6 +1447,7 @@ mod tests { #[test] fn final_replay_does_not_duplicate_identical_additive_values() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let config = SshConfig::parse( "IdentityFile /tmp/key\nSendEnv LANG\nLocalForward 8080 localhost:80\nMatch final\n User final\n", ) @@ -1441,6 +1460,7 @@ mod tests { #[test] fn test_parse_basic_host_config() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let config_content = r#" Host example.com User testuser @@ -1486,6 +1506,7 @@ Host db*.example.com #[test] fn test_find_host_config_uses_source_order_first_obtained_values() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let config_content = r#" Host *.example.com User defaultuser @@ -1519,6 +1540,7 @@ Host web1.example.com #[test] fn cli_option_overlay_precedes_files_and_repeated_scalars_keep_first_value() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let mut config = SshConfig::parse( "Host *\n User file-user\n Port 22\n ConnectionAttempts 1\n TCPKeepAlive yes\n", ) @@ -1586,6 +1608,7 @@ Host test.example.com #[test] fn test_parse_certificate_and_forwarding_options() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); // Test parsing of certificate authentication and advanced port forwarding options let config_content = r#" Host *.secure.example.com @@ -1637,6 +1660,7 @@ Host web1.secure.example.com #[test] fn test_merge_certificate_and_forwarding_options() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); // Test that certificate and forwarding options are properly merged according to SSH config precedence let config_content = r#" Host *.example.com @@ -1832,6 +1856,7 @@ Host desktop-server #[test] fn test_merge_host_key_and_network_options() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); // Test that host key verification and network options are properly merged according to SSH config precedence let config_content = r#" Host * @@ -2131,6 +2156,7 @@ Host test #[test] fn test_get_compression_option() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); // Issue #219: the resolved `Compression` directive must be readable // through the public API so callers can wire it into the russh // client config (see SshConnectionConfig::to_russh_config). @@ -2150,6 +2176,7 @@ Host plain.example.com } #[test] fn known_hosts_command_environment_expansion_reaches_runtime() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let config = SshConfig::parse( "Host test\n KnownHostsCommand /usr/bin/helper %I ${PATH} %f %K %t\n", ) diff --git a/src/ssh/ssh_config/parser/options/security_tests.rs b/src/ssh/ssh_config/parser/options/security_tests.rs index ac2b2efe3..2bc1e2720 100644 --- a/src/ssh/ssh_config/parser/options/security_tests.rs +++ b/src/ssh/ssh_config/parser/options/security_tests.rs @@ -22,6 +22,7 @@ fn revoked_host_keys(source: &str) -> anyhow::Result> { #[test] fn revoked_host_keys_accepts_a_list_of_files() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); assert_eq!( revoked_host_keys("Host *\n RevokedHostKeys /etc/ssh/revoked_a ~/.ssh/revoked_b\n") .unwrap(), @@ -44,6 +45,7 @@ fn revoked_host_keys_accepts_a_list_of_files() { #[test] fn revoked_host_keys_none_is_valid_only_as_the_sole_value() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); for sole in ["none", "NONE", "/dev/null"] { assert_eq!( revoked_host_keys(&format!("Host *\n RevokedHostKeys {sole}\n")).unwrap(), diff --git a/src/ssh/ssh_config/parser/tests.rs b/src/ssh/ssh_config/parser/tests.rs index 2293c3876..8955c910d 100644 --- a/src/ssh/ssh_config/parser/tests.rs +++ b/src/ssh/ssh_config/parser/tests.rs @@ -286,6 +286,7 @@ Match exec "test -f /tmp/vpn" #[test] fn test_parse_include_directive_skipped() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); // Include directives should be skipped in direct parse mode let content = r#" Include ~/.ssh/config.d/* @@ -570,6 +571,7 @@ Host db.example.com #[test] fn test_parse_certificate_file() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let content = r#" Host example.com CertificateFile ~/.ssh/id_rsa-cert.pub @@ -593,6 +595,7 @@ Host example.com #[test] fn test_parse_certificate_file_with_equals() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); // Test Option=Value syntax let content = r#" Host example.com @@ -893,6 +896,7 @@ Host example.com #[test] fn test_parse_all_new_options_combined() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); // Test all new options together in a realistic scenario let content = r#" Host secure.example.com @@ -919,6 +923,7 @@ Host secure.example.com #[test] fn test_parse_new_options_with_mixed_syntax() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); // Test mixing Option=Value and Option Value syntax let content = r#" Host example.com @@ -1163,6 +1168,7 @@ Host example.com #[test] fn test_parse_identity_agent_socket_path() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let content = r#" Host example.com IdentityAgent ~/.1password/agent.sock @@ -1559,6 +1565,7 @@ Host example.com #[test] fn test_parse_auth_security_combined() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); // Test all authentication and security management options together let content = r#" Host secure-server @@ -1920,6 +1927,7 @@ Host proxy.example.com #[tokio::test] async fn test_includes_preserve_global_and_host_first_obtained_context() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); use tempfile::TempDir; let temp_dir = TempDir::new().unwrap(); diff --git a/src/ssh/ssh_config/resolver_tests.rs b/src/ssh/ssh_config/resolver_tests.rs index 08f6f0095..5c374cf17 100644 --- a/src/ssh/ssh_config/resolver_tests.rs +++ b/src/ssh/ssh_config/resolver_tests.rs @@ -22,6 +22,7 @@ mod tests { #[test] fn test_certificate_file_merging_across_host_blocks() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let content = r#" Host * CertificateFile ~/.ssh/global-cert.pub @@ -48,6 +49,7 @@ Host example.com #[test] fn test_certificate_file_deduplication() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let content = r#" Host * CertificateFile ~/.ssh/shared-cert.pub @@ -77,6 +79,7 @@ Host example.com #[test] fn test_certificate_file_limit_during_merge() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); // Create a config with more than 100 certificate files let mut config_lines = vec!["Host example.com".to_string()]; for i in 0..110 { @@ -93,6 +96,7 @@ Host example.com #[test] fn test_permit_remote_open_keeps_first_obtained_list_across_host_blocks() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let content = r#" Host * PermitRemoteOpen localhost:8080 @@ -108,6 +112,7 @@ Host example.com #[test] fn test_permit_remote_open_none_cannot_be_broadened_by_later_blocks() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let content = r#" Host * PermitRemoteOpen none @@ -123,6 +128,7 @@ Host example.com #[test] fn test_cli_permit_remote_open_none_cannot_be_broadened_by_file() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let mut config = SshConfig::parse("Host example.com\n PermitRemoteOpen any\n").unwrap(); config .apply_cli_options(&["PermitRemoteOpen=none".to_string()]) @@ -138,6 +144,7 @@ Host example.com /// silently widen an explicit restriction. #[test] fn test_certificate_only_host_key_algorithms_survive_resolution() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let content = r#" Host example.com HostKeyAlgorithms sk-ssh-ed25519-cert-v01@openssh.com @@ -153,6 +160,7 @@ Host example.com #[test] fn test_ca_signature_algorithms_keeps_first_obtained_list() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let content = r#" Host * CASignatureAlgorithms ssh-rsa,ssh-dss @@ -171,6 +179,7 @@ Host example.com #[test] fn test_hostbased_accepted_algorithms_keeps_first_obtained_list() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let content = r#" Host * HostbasedAcceptedAlgorithms ssh-rsa,ssh-dss @@ -189,6 +198,7 @@ Host example.com #[test] fn test_gateway_ports_keeps_first_obtained_value() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let content = r#" Host * GatewayPorts no @@ -205,6 +215,7 @@ Host example.com #[test] fn test_exit_on_forward_failure_keeps_first_obtained_value() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let content = r#" Host * ExitOnForwardFailure no @@ -221,6 +232,7 @@ Host example.com #[test] fn test_hostbased_authentication_keeps_first_obtained_value() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let content = r#" Host * HostbasedAuthentication no @@ -237,6 +249,7 @@ Host example.com #[test] fn test_multiple_host_blocks_with_priority() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); // SSH config: the first matching value wins; additive lists accumulate // Lists accumulate across all matches let content = r#" @@ -283,6 +296,7 @@ Host * #[test] fn later_host_user_does_not_retroactively_change_earlier_match() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let hosts = parse( "Match user audit-no-such-user\n Port 2200\nHost *\n User audit-no-such-user\n", ) @@ -295,6 +309,7 @@ Host * #[test] fn match_final_cannot_supply_the_first_hostname() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let hosts = parse("Host alias\nMatch final\n HostName final.example\n").unwrap(); let config = find_host_config(&hosts, "alias"); @@ -303,6 +318,7 @@ Host * #[test] fn hostname_percent_h_expands_once_from_original_destination() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let hosts = parse("Host alias\n HostName %h.example\n").unwrap(); let config = find_host_config(&hosts, "alias"); @@ -315,6 +331,7 @@ Host * #[test] fn test_all_new_options_together() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let content = r#" Host secure.example.com CertificateFile ~/.ssh/user-cert.pub @@ -343,6 +360,7 @@ Host secure.example.com #[test] fn test_empty_vs_unset_options() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let content = r#" Host example.com User testuser @@ -362,6 +380,7 @@ Host example.com #[test] fn test_partial_option_set() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let content = r#" Host example.com CertificateFile ~/.ssh/cert.pub @@ -384,6 +403,7 @@ Host example.com #[test] fn test_proxy_settings_keep_first_obtained_values() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); // OpenSSH keeps the first value obtained across matching host blocks. let content = r#" Host * @@ -407,6 +427,7 @@ Host example.com #[test] fn test_proxy_use_fdpass_inherits_from_global() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); // Test that ProxyUseFdpass is inherited from global config when not overridden let content = r#" Host * @@ -431,6 +452,7 @@ Host example.com #[test] #[cfg(target_os = "macos")] fn test_use_keychain_host_specific_value_precedes_global_default() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let content = r#" Host example.com UseKeychain yes @@ -448,6 +470,7 @@ Host * #[test] #[cfg(target_os = "macos")] fn test_use_keychain_inherits_from_global() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); // Test that UseKeychain is inherited from global config when not overridden let content = r#" Host * @@ -467,6 +490,7 @@ Host example.com #[test] #[cfg(target_os = "macos")] fn test_use_keychain_first_match_wins() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); // SSH config merges all matching blocks while preserving the first value obtained. let content = r#" Host example.com @@ -485,6 +509,7 @@ Host example.com #[test] #[cfg(target_os = "macos")] fn test_use_keychain_with_identity_files() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); // UseKeychain is commonly used with IdentityFile let content = r#" Host * @@ -515,6 +540,7 @@ Host example.com #[test] #[cfg(target_os = "macos")] fn test_use_keychain_default_none() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); // Verify that UseKeychain defaults to None when not specified let content = r#" Host example.com diff --git a/src/ssh/ssh_config/security/tests.rs b/src/ssh/ssh_config/security/tests.rs index ff1e0bb3d..4dcbd119e 100644 --- a/src/ssh/ssh_config/security/tests.rs +++ b/src/ssh/ssh_config/security/tests.rs @@ -59,6 +59,7 @@ fn test_proxy_command_rejects_invalid_structure() { #[test] fn test_validate_control_path_legitimate() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let legitimate_paths = vec![ "/tmp/ssh-control-%h-%p-%r", "~/.ssh/control-%h-%p-%r", @@ -94,6 +95,7 @@ fn test_validate_control_path_malicious() { #[test] fn test_secure_validate_path_traversal() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let traversal_paths = vec![ "../../../etc/passwd", "/home/user/../../../etc/shadow", diff --git a/src/ssh/ssh_config/security_fix_tests.rs b/src/ssh/ssh_config/security_fix_tests.rs index d628ec18a..3e493f71d 100644 --- a/src/ssh/ssh_config/security_fix_tests.rs +++ b/src/ssh/ssh_config/security_fix_tests.rs @@ -21,6 +21,7 @@ Host example.com #[test] fn test_certificate_file_blocks_private_key() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); // Test that private keys without -cert.pub extension are blocked let content = r#" Host example.com @@ -37,6 +38,7 @@ Host example.com #[test] fn test_certificate_file_allows_valid_cert() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); // Test that valid certificate files are allowed let content = r#" Host example.com @@ -50,6 +52,7 @@ Host example.com #[test] fn test_max_certificate_files_during_merge() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); // Test that certificate files are limited during merging let mut content = String::new(); @@ -80,6 +83,7 @@ Host example.com #[test] fn test_deduplication_of_certificate_files() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); // Test that duplicate certificate files are deduplicated let content = r#" Host *.example.com @@ -114,6 +118,7 @@ Host test.example.com #[test] fn test_max_permit_remote_open_entries() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); // Test that PermitRemoteOpen entries are limited let mut content = String::from("Host example.com\n"); @@ -249,6 +254,7 @@ Host example.com #[test] fn test_sensitive_paths_in_certificate() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); // Test various sensitive paths are blocked let sensitive_paths = vec![ "/etc/shadow", diff --git a/src/ssh/tokio_client/connection.rs b/src/ssh/tokio_client/connection.rs index 76cb9ceda..c75892dc8 100644 --- a/src/ssh/tokio_client/connection.rs +++ b/src/ssh/tokio_client/connection.rs @@ -4300,6 +4300,7 @@ mod known_host_policy_tests { #[tokio::test] async fn check_host_ip_rejects_a_conflicting_address_key() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let directory = tempdir().unwrap(); let path = directory.path().join("known_hosts"); let offered = key(); @@ -4339,6 +4340,7 @@ mod known_host_policy_tests { #[tokio::test] async fn hashed_alias_write_uses_alias_identity_and_selected_path() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let directory = tempdir().unwrap(); let path = directory.path().join("selected-known-hosts"); let offered = key(); @@ -4386,6 +4388,7 @@ mod known_host_policy_tests { } #[test] fn order_eligibility_preserves_raw_host_key_algorithm_modifiers() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); for (directive, expected) in [ (None, true), (Some("+ssh-rsa"), true), @@ -4489,6 +4492,7 @@ mod known_host_policy_tests { } #[tokio::test] async fn dns_secure_only_match_skips_lookup_address_and_tofu() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let directory = tempdir().unwrap(); let path = directory.path().join("known_hosts"); let method = @@ -4522,6 +4526,7 @@ mod known_host_policy_tests { #[tokio::test] async fn dns_mismatch_bogus_and_insecure_outcomes_fall_back_to_tofu() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let directory = tempdir().unwrap(); let path = directory.path().join("known_hosts"); let method = @@ -4560,6 +4565,7 @@ mod known_host_policy_tests { #[tokio::test] async fn check_host_ip_conflict_leaves_unknown_hostname_unrecorded() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let directory = tempdir().unwrap(); let path = directory.path().join("known_hosts"); let offered = key(); @@ -4590,6 +4596,7 @@ mod known_host_policy_tests { #[tokio::test] async fn check_host_ip_records_both_unknown_identities_atomically() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let directory = tempdir().unwrap(); let offered = key(); for hash_known_hosts in [false, true] { @@ -4749,6 +4756,7 @@ mod revoked_host_keys_policy_tests { #[tokio::test] async fn revoked_host_keys_list_refuses_a_key_listed_only_in_the_first_file() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let (_dir, value, [only_in_first, _, unlisted]) = two_revocation_files(); let result = check_with_revoked_host_keys(&value, &only_in_first).await; assert!( @@ -4761,6 +4769,7 @@ mod revoked_host_keys_policy_tests { #[tokio::test] async fn revoked_host_keys_list_refuses_a_key_listed_only_in_the_second_file() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let (_dir, value, [_, only_in_second, _]) = two_revocation_files(); let result = check_with_revoked_host_keys(&value, &only_in_second).await; assert!( @@ -4771,6 +4780,7 @@ mod revoked_host_keys_policy_tests { #[tokio::test] async fn unreadable_or_krl_file_anywhere_in_the_list_refuses_every_host_key() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let dir = tempfile::tempdir().unwrap(); let empty = dir.path().join("empty"); std::fs::write(&empty, "# nothing revoked\n").unwrap(); @@ -4838,6 +4848,7 @@ mod host_certificate_policy_tests { /// falls back to known_hosts or trust on first use. #[tokio::test] async fn certificate_only_policy_refuses_the_downgraded_plain_key() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let (ca, subject) = (generate_key(), generate_key()); let cert = host_certificate(&subject, &ca); let dir = tempfile::tempdir().unwrap(); @@ -4878,6 +4889,7 @@ mod host_certificate_policy_tests { /// known_hosts stores. #[tokio::test] async fn pinned_key_ignores_certificate_authorities() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let (ca, subject, pinned) = (generate_key(), generate_key(), generate_key()); let cert = host_certificate(&subject, &ca); let dir = tempfile::tempdir().unwrap(); @@ -4924,6 +4936,7 @@ mod host_certificate_policy_tests { /// OpenSSH, and wrapping the revoked key in a certificate does not skip it. #[tokio::test] async fn no_check_still_honors_revoked_host_keys_for_certificates() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let (ca, subject) = (generate_key(), generate_key()); let cert = host_certificate(&subject, &ca); let dir = tempfile::tempdir().unwrap(); @@ -5021,6 +5034,7 @@ mod host_certificate_policy_tests { #[cfg(unix)] #[tokio::test] async fn known_hosts_command_certificate_authority_vouches_for_the_certificate() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let (ca, subject) = (generate_key(), generate_key()); let cert = certificate_for(&subject, &ca, "node1.example.com"); let dir = tempfile::tempdir().unwrap(); @@ -5063,6 +5077,7 @@ mod host_certificate_policy_tests { #[cfg(unix)] #[tokio::test] async fn known_hosts_command_certificate_with_wrong_principal_is_not_downgraded() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let (ca, subject) = (generate_key(), generate_key()); let cert = certificate_for(&subject, &ca, "other.example.com"); let dir = tempfile::tempdir().unwrap(); @@ -5083,6 +5098,7 @@ mod host_certificate_policy_tests { #[cfg(unix)] #[tokio::test] async fn known_hosts_command_revoked_ca_refuses_the_certificate() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let (ca, subject) = (generate_key(), generate_key()); let cert = certificate_for(&subject, &ca, "node1.example.com"); let dir = tempfile::tempdir().unwrap(); @@ -5105,6 +5121,7 @@ mod host_certificate_policy_tests { #[cfg(unix)] #[tokio::test] async fn known_hosts_command_output_is_reused_for_the_plain_key_fallback() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let (ca, subject) = (generate_key(), generate_key()); let cert = certificate_for(&subject, &ca, "node1.example.com"); let dir = tempfile::tempdir().unwrap(); @@ -5113,7 +5130,9 @@ mod host_certificate_policy_tests { let result = handler(method, policy).verify_server_key(&cert).await; assert!( matches!(result, Ok(true)), - "the downgraded plain key matches the command output, got {result:?}" + "the downgraded plain key matches the command output, got {result:?}; invocations: {:?}; command output: {:?}", + invocations(&log), + std::fs::read_to_string(dir.path().join("command-output")) ); let calls = invocations(&log); assert_eq!(calls.len(), 1, "{calls:?}"); @@ -5151,6 +5170,7 @@ mod negotiated_host_key_type_tests { /// any host-key policy runs. #[tokio::test] async fn check_server_key_refuses_a_key_of_another_type_than_negotiated() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let offered = PublicKeyOrCertificate::from(ed25519().public_key().clone()); let p256 = Algorithm::Ecdsa { curve: EcdsaCurve::NistP256, @@ -5179,6 +5199,7 @@ mod negotiated_host_key_type_tests { #[tokio::test] async fn check_server_key_refuses_a_certificate_for_another_key_type() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let mut builder = Builder::new_with_random_nonce(&mut rand::rng(), ed25519().public_key(), 0, u64::MAX) .unwrap(); diff --git a/src/ssh/tokio_client/connection_tests.rs b/src/ssh/tokio_client/connection_tests.rs index a293a65e4..fed9a23e5 100644 --- a/src/ssh/tokio_client/connection_tests.rs +++ b/src/ssh/tokio_client/connection_tests.rs @@ -137,6 +137,7 @@ fn test_with_address_family_is_chainable_and_leaves_other_settings_alone() { #[test] fn test_connection_config_resolver_applies_ssh_config_per_host() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let ssh_config = SshConfig::parse( r#" Host v4node @@ -180,6 +181,7 @@ Host v6node #[test] fn test_connection_config_resolver_preserves_cli_precedence() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let ssh_config = SshConfig::parse( r#" Host target @@ -206,6 +208,7 @@ Host target #[test] fn auth_banner_suppression_resolves_from_quiet_cli_or_log_level() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let ssh_config = SshConfig::parse("Host quiet-config\n LogLevel QUIET\nHost normal\n LogLevel INFO\n") .unwrap(); @@ -226,6 +229,7 @@ fn auth_banner_suppression_resolves_from_quiet_cli_or_log_level() { #[test] fn debug_log_level_enables_kex_diagnostics_without_changing_info() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let ssh_config = SshConfig::parse("Host debug\n LogLevel DEBUG3\nHost normal\n LogLevel INFO\n") .unwrap(); @@ -242,6 +246,7 @@ fn debug_log_level_enables_kex_diagnostics_without_changing_info() { #[test] fn stdio_forward_defaults_clear_forwards_but_preserves_explicit_no() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let implicit = SshConfig::parse("Host target\n LocalForward 127.0.0.1:2200 example.com:22\n").unwrap(); let implicit = SshConnectionConfigResolver::new() @@ -273,6 +278,7 @@ fn stdio_forward_defaults_clear_forwards_but_preserves_explicit_no() { /// list and the function returns before the first `TcpStream::connect`. #[tokio::test] async fn test_forced_ipv6_with_no_ipv6_candidate_fails_with_specific_error() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let addr: std::net::SocketAddr = "127.0.0.1:22".parse().expect("valid IPv4 socket address"); let config = SshConnectionConfig::new().with_address_family(AddressFamily::V6); @@ -301,6 +307,7 @@ async fn test_forced_ipv6_with_no_ipv6_candidate_fails_with_specific_error() { #[tokio::test] async fn test_forced_ipv4_with_no_ipv4_candidate_fails_with_specific_error() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let addr: std::net::SocketAddr = "[::1]:22".parse().expect("valid IPv6 socket address"); let config = SshConnectionConfig::new().with_address_family(AddressFamily::V4); @@ -329,6 +336,7 @@ async fn test_forced_ipv4_with_no_ipv4_candidate_fails_with_specific_error() { #[test] fn test_proxy_resolution_keeps_first_ssh_config_directive() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let command_first = SshConfig::parse( r#" Host target @@ -361,6 +369,7 @@ Host target #[test] fn test_cli_proxy_jump_overrides_ssh_config_proxy_command() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let ssh_config = SshConfig::parse( r#" Host target @@ -381,6 +390,7 @@ Host target #[test] fn test_proxy_tokens_use_effective_alias_original_and_resolved_hosts() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let ssh_config = SshConfig::parse( r#" Host original-host @@ -408,6 +418,7 @@ Host original-host #[test] fn test_proxy_none_disables_yaml_jump_fallback() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); for directive in [ "ProxyCommand none", "ProxyJump none", @@ -426,6 +437,7 @@ fn test_proxy_none_disables_yaml_jump_fallback() { #[test] fn test_yaml_jump_is_used_only_as_proxy_fallback() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let config = SshConnectionConfigResolver::new() .with_yaml_proxy_jump(Some("yaml-bastion.example.com".to_string())) .resolve_for_host("target"); @@ -439,6 +451,7 @@ fn test_yaml_jump_is_used_only_as_proxy_fallback() { #[cfg(unix)] #[tokio::test] async fn test_proxy_command_routes_ssh_transport_through_child() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); use tokio::io::{AsyncReadExt, AsyncWriteExt}; let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) @@ -489,6 +502,7 @@ async fn test_proxy_command_routes_ssh_transport_through_child() { #[cfg(unix)] #[tokio::test] async fn test_proxy_command_failure_is_actionable_at_connection_boundary() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let proxy = ProxyCommandConfig::new( "sh -c 'printf connection\\ proxy\\ failed >&2; exit 23'", "target-alias", @@ -520,6 +534,7 @@ async fn test_proxy_command_failure_is_actionable_at_connection_boundary() { #[tokio::test] async fn test_proxy_use_fdpass_is_rejected_at_connection_boundary() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let proxy = ProxyCommandConfig::new("unused", "target-alias").with_fdpass(true); let config = SshConnectionConfig::new().with_proxy_mode(Some(ProxyMode::Command(proxy))); let error = Client::connect_with_ssh_config( @@ -544,6 +559,7 @@ async fn test_proxy_use_fdpass_is_rejected_at_connection_boundary() { #[tokio::test] async fn test_unforced_family_does_not_produce_the_family_error() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); // Port 0 is never connectable, so this fails fast at the TCP layer without // depending on anything being reachable. let addr: std::net::SocketAddr = "127.0.0.1:0".parse().expect("valid IPv4 socket address"); @@ -593,6 +609,7 @@ impl super::ToSocketAddrsWithHostname for CountingAddress { #[tokio::test] async fn connection_attempts_reresolves_dns_for_each_carrier_round() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); let address = CountingAddress { calls: std::sync::Arc::clone(&calls), @@ -624,6 +641,7 @@ async fn connection_attempts_reresolves_dns_for_each_carrier_round() { #[tokio::test] async fn connection_attempts_preserves_the_last_typed_tcp_failure() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); let address = CountingAddress { calls: std::sync::Arc::clone(&calls), @@ -655,6 +673,7 @@ async fn connection_attempts_preserves_the_last_typed_tcp_failure() { #[tokio::test] async fn connection_attempts_never_retries_ssh_handshake_failure() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let socket_addr = listener.local_addr().unwrap(); let server = tokio::spawn(async move { @@ -715,6 +734,7 @@ async fn tcp_keep_alive_sets_and_verifies_the_os_socket_option() { #[test] fn configured_algorithms_reach_the_russh_transport_preferences() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let ssh_config = SshConfig::parse( "Host target\n Ciphers aes128-ctr\n MACs hmac-sha2-256\n KexAlgorithms curve25519-sha256\n HostKeyAlgorithms ssh-ed25519\n PubkeyAcceptedAlgorithms rsa-sha2-*\n", ) @@ -745,6 +765,7 @@ fn configured_algorithms_reach_the_russh_transport_preferences() { /// plain list empty instead of falling back to the defaults. #[test] fn certificate_only_host_key_algorithms_are_never_widened() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); for name in [ "sk-ssh-ed25519-cert-v01@openssh.com", "sk-ecdsa-sha2-nistp256-cert-v01@openssh.com", @@ -795,6 +816,7 @@ fn connection_attempts_rejects_zero() { #[test] fn resolved_authentication_directives_reach_runtime_policy() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let ssh_config = SshConfig::parse( "Host target\n IdentityFile ~/.ssh/config-key\n CertificateFile ~/.ssh/config-key-cert.pub\n IdentitiesOnly yes\n PreferredAuthentications password,publickey\n PubkeyAuthentication no\n PasswordAuthentication yes\n NumberOfPasswordPrompts 2\n BatchMode yes\n PubkeyAcceptedAlgorithms ssh-ed25519\n", ) @@ -817,6 +839,7 @@ fn resolved_authentication_directives_reach_runtime_policy() { #[test] fn cli_identities_precede_ssh_config_identities() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let ssh_config = SshConfig::parse("Host target\n IdentityFile /config-key\n").unwrap(); let policy = SshConnectionConfigResolver::new() .with_ssh_config(Some(ssh_config)) @@ -836,6 +859,7 @@ fn cli_identities_precede_ssh_config_identities() { #[test] fn authentication_yes_no_defaults_and_overrides_are_preserved() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let defaults = SshConnectionConfigResolver::new() .resolve_for_host("target") .auth_policy; @@ -860,6 +884,7 @@ fn authentication_yes_no_defaults_and_overrides_are_preserved() { #[test] fn source_binding_and_ipqos_reach_the_connection_config() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let ssh_config = SshConfig::parse( "Host target\n BindAddress 127.0.0.1\n BindInterface lo\n IPQoS ef cs1\n", ) @@ -890,6 +915,7 @@ fn source_binding_and_ipqos_reach_the_connection_config() { #[test] fn forward_agent_socket_path_reaches_the_connection_config() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let ssh_config = SshConfig::parse("Host target\n ForwardAgent $FORWARD_AGENT_SOCK\n").unwrap(); let config = SshConnectionConfigResolver::new() @@ -1015,6 +1041,7 @@ fn ipv6_qos_sets_the_exact_traffic_class_byte() { #[test] fn rekey_limit_reaches_the_russh_transport() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let ssh_config = SshConfig::parse("Host target\n RekeyLimit 4K 30s\n").unwrap(); let connection_config = SshConnectionConfigResolver::new() .with_ssh_config(Some(ssh_config)) @@ -1035,6 +1062,7 @@ fn rekey_limit_reaches_the_russh_transport() { #[test] fn rekey_limit_default_none_preserves_russh_nonce_safety_ceiling() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let ssh_config = SshConfig::parse("Host target\n RekeyLimit default none\n").unwrap(); let connection_config = SshConnectionConfigResolver::new() .with_ssh_config(Some(ssh_config)) diff --git a/src/ssh/tokio_client/host_verification.rs b/src/ssh/tokio_client/host_verification.rs index da0402ab5..0668b81e1 100644 --- a/src/ssh/tokio_client/host_verification.rs +++ b/src/ssh/tokio_client/host_verification.rs @@ -1987,6 +1987,7 @@ mod tests { #[tokio::test] async fn test_strict_mode_rejects_revoked_key() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let (_dir, path, path_str) = temp_known_hosts(); let revoked = generate_key(); std::fs::write( @@ -2049,6 +2050,7 @@ mod tests { #[tokio::test] async fn test_strict_mode_hostname_matching_is_case_insensitive() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let (_dir, _path, path_str) = temp_known_hosts(); let key = generate_key(); @@ -2372,6 +2374,7 @@ mod tests { #[tokio::test] async fn test_strict_mode_missing_known_hosts_rejects_unknown_host() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); // Regression test for #239's second defect: a missing known_hosts // file in strict mode must behave as an empty file (unknown host // rejected), never as disabled verification. @@ -2388,6 +2391,7 @@ mod tests { #[tokio::test] async fn test_strict_mode_reports_changed_key_specifically() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let (_dir, _path, path_str) = temp_known_hosts(); let original = generate_key(); let imposter = generate_key(); @@ -2419,6 +2423,7 @@ mod tests { #[tokio::test] async fn test_strict_mode_accepts_key_matching_any_recorded_entry() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let (_dir, path, path_str) = temp_known_hosts(); let stale = generate_key(); let current = generate_key(); @@ -2450,6 +2455,7 @@ mod tests { #[tokio::test] async fn test_no_check_accepts_without_touching_filesystem() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let (_dir, path, _path_str) = temp_known_hosts(); let key = generate_key(); @@ -2461,6 +2467,7 @@ mod tests { #[tokio::test] async fn test_accept_new_through_client_handler() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); // The handler arm must reach the TOFU path end to end. let (_dir, path, path_str) = temp_known_hosts(); let key = generate_key(); @@ -2558,6 +2565,7 @@ mod tests { #[tokio::test] async fn test_strict_mode_rejects_hostname_that_cannot_be_recorded() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); // Strict mode never records, so it cannot be poisoned, but it shares // the guard so both modes fail for the same stated reason instead of // reporting a misleading "not in known_hosts" for a hostname that @@ -2584,6 +2592,7 @@ mod tests { /// is accepted, and the certified key becomes the verified server key. #[tokio::test] async fn test_host_certificate_from_trusted_ca_is_accepted() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let ca = generate_key(); let subject = generate_key(); let cert = host_certificate(&subject, &ca); @@ -2637,6 +2646,7 @@ mod tests { /// downgraded to its plain key and checked like any other host key. #[tokio::test] async fn test_host_certificate_without_matching_ca_uses_plain_key_rules() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let ca = generate_key(); let subject = generate_key(); let cert = host_certificate(&subject, &ca); @@ -2670,6 +2680,7 @@ mod tests { /// certificate is refused even when its plain key is pinned. #[tokio::test] async fn test_host_certificate_with_trusted_ca_but_wrong_principal_is_refused() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let ca = generate_key(); let subject = generate_key(); let mut builder = russh::keys::ssh_key::certificate::Builder::new_with_random_nonce( @@ -2707,6 +2718,7 @@ mod tests { /// stays permissive for certificates too. #[tokio::test] async fn test_host_certificate_is_accepted_under_no_check() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let ca = generate_key(); let subject = generate_key(); let cert = host_certificate(&subject, &ca); @@ -2733,6 +2745,7 @@ mod tests { #[tokio::test] async fn test_host_key_alias_changes_lookup_identity_at_non_default_port() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let (_alias_dir, _alias_path, alias_store) = temp_known_hosts(); let (_target_dir, _target_path, target_store) = temp_known_hosts(); let key = generate_key(); @@ -2808,6 +2821,7 @@ mod tests { #[tokio::test] async fn test_host_key_alias_accept_new_records_unbracketed_alias() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let (_dir, path, path_str) = temp_known_hosts(); let key = generate_key(); let mut handler = ClientHandler::new( @@ -2839,6 +2853,7 @@ mod tests { #[tokio::test] async fn test_known_hosts_files_distinguishes_matching_and_empty_user_store() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let (_matching_dir, _matching_path, matching) = temp_known_hosts(); let (_empty_dir, _empty_path, empty) = temp_known_hosts(); let expected = generate_key(); @@ -2871,6 +2886,7 @@ mod tests { #[tokio::test] async fn test_known_hosts_files_accepts_match_after_earlier_conflict() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let (_first_dir, _first_path, first) = temp_known_hosts(); let (_second_dir, _second_path, second) = temp_known_hosts(); let expected = generate_key(); @@ -2893,6 +2909,7 @@ mod tests { #[tokio::test] async fn test_known_hosts_files_with_no_stores_fails_closed_as_unknown() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let key = generate_key(); let mut handler = handler_for(ServerCheckMethod::KnownHostsFiles(Vec::new())); assert!(matches!( @@ -2903,6 +2920,7 @@ mod tests { #[tokio::test] async fn test_accept_new_files_records_only_explicit_user_write_path() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let (_user_dir, user_path, user) = temp_known_hosts(); let (_global_dir, global_path, global) = temp_known_hosts(); let (_second_user_dir, second_user_path, second_user) = temp_known_hosts(); @@ -2926,6 +2944,7 @@ mod tests { #[tokio::test] async fn test_accept_new_files_uses_global_match_without_writing_user_file() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let (_user_dir, user_path, user) = temp_known_hosts(); let (_global_dir, _global_path, global) = temp_known_hosts(); let key = generate_key(); @@ -2951,6 +2970,7 @@ mod tests { #[tokio::test] async fn test_accept_new_files_without_write_path_keeps_process_pin() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let original = generate_key(); let changed = generate_key(); let method = ServerCheckMethod::AcceptNewKnownHostsFiles { diff --git a/src/ssh/tokio_client/known_hosts_command.rs b/src/ssh/tokio_client/known_hosts_command.rs index 9ce94bf0b..53210509d 100644 --- a/src/ssh/tokio_client/known_hosts_command.rs +++ b/src/ssh/tokio_client/known_hosts_command.rs @@ -387,6 +387,7 @@ mod tests { #[tokio::test] #[cfg(unix)] async fn success_empty_nonzero_and_output_bounds_are_enforced() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let success = run_known_hosts_command("/usr/bin/printf 'key-line\\n'", &context()) .await .unwrap(); @@ -413,6 +414,7 @@ mod tests { #[tokio::test] #[cfg(unix)] async fn timeout_is_fail_closed() { + let _home_lock = crate::test_helpers::EnvGuard::lock_home(); let error = run_known_hosts_command("/bin/sleep 10", &context()) .await .unwrap_err(); diff --git a/src/test_helpers/env_guard.rs b/src/test_helpers/env_guard.rs index fb936339c..e7ba917b6 100644 --- a/src/test_helpers/env_guard.rs +++ b/src/test_helpers/env_guard.rs @@ -31,6 +31,12 @@ //! tests racing against `EnvGuard` mutations are undefined behaviour at the //! libc level on glibc/musl/macOS. //! +//! HOME has an additional shared mutex: `set/remove("HOME", ...)` acquire it +//! automatically. A HOME-only reader must hold `EnvGuard::lock_home()` instead +//! of relying on a serial annotation. This includes indirect readers in SSH +//! path/token expansion, configuration resolution, and authentication. Never +//! take both guards in one test: the HOME mutex is not reentrant. +//! //! Note: `#[serial]` only serializes against other `#[serial]` and //! `#[parallel]` tests. Tests with neither attribute may run concurrently //! with serial tests and are not covered by the serial ordering guarantee. @@ -145,7 +151,15 @@ impl EnvGuard { } /// Lock the process-global `HOME` environment variable for tests that only - /// read it. + /// read it, including indirect reads through path expansion, MatchContext, + /// SSH configuration resolution/dumping, session policy, default identity + /// lookup, and KnownHostsCommand context construction. Acquire at test entry + /// and retain it until every spawned reader has finished. + /// + /// This mutex is NOT reentrant: use either this guard or `set/remove("HOME", + /// ...)` for a test, never both, and do not acquire it again in helpers. + /// HOME writers must retain their existing serial annotation; readers use + /// this shared lock even when they do not have a serial annotation. #[allow(dead_code)] pub fn lock_home() -> HomeEnvLock { HomeEnvLock { diff --git a/tools/test-home-races.py b/tools/test-home-races.py new file mode 100644 index 000000000..f12967f41 --- /dev/null +++ b/tools/test-home-races.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""Repeat HOME readers and a HOME writer together, preserving failure diagnostics. + +Build with `cargo test --lib --no-run`, then pass the emitted test executable: + python3 tools/test-home-races.py target/debug/deps/bssh-HASH /tmp/home-races +Each process still runs parallel test threads; process-level parallelism alone +cannot reproduce a race on process-local environment variables. +""" + +import argparse +import concurrent.futures +import json +import os +from pathlib import Path +import subprocess +import time + +FILTERS = [ + "ssh::known_hosts::tests", + "known_hosts_command_output_is_reused_for_the_plain_key_fallback", + "config::tests::test_expand_tilde", +] + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("binary", type=Path) + parser.add_argument("output", type=Path) + parser.add_argument("--runs", type=int, default=1000) + parser.add_argument("--jobs", type=int, default=8) + args = parser.parse_args() + if args.runs < 1 or args.jobs < 1: + parser.error("runs and jobs must be positive") + binary = args.binary.resolve(strict=True) + # Never overwrite evidence from an earlier baseline or verification run. + args.output.mkdir(parents=True, exist_ok=False) + command = [str(binary), *FILTERS, "--test-threads=8", "--nocapture"] + listing = subprocess.check_output( + [str(binary), *FILTERS, "--list"], text=True, timeout=30 + ) + for name in ["expands_home_directory_tokens_and_tilde", + "expands_full_known_hosts_token_set_and_environment", + "known_hosts_command_output_is_reused_for_the_plain_key_fallback", + "config::tests::test_expand_tilde"]: + if name not in listing: + parser.error(f"test binary is missing {name}") + (args.output / "tests.txt").write_text(listing) + environment = dict(os.environ, RUST_BACKTRACE="1") + + def run(index): + try: + result = subprocess.run( + command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + env=environment, timeout=30, check=False, + ) + output, code = result.stdout, result.returncode + except subprocess.TimeoutExpired as error: + output = (error.stdout or b"") + b"\nTimed out after 30 seconds\n" + code = 124 + (args.output / f"{index:04}.log").write_bytes(output) + return code + + started = time.monotonic() + with concurrent.futures.ThreadPoolExecutor(max_workers=args.jobs) as pool: + codes = list(pool.map(run, range(args.runs))) + summary = { + "command": command, "jobs": args.jobs, "runs": len(codes), + "failed_runs": [i for i, code in enumerate(codes) if code != 0], + "seconds": round(time.monotonic() - started, 2), + } + (args.output / "summary.json").write_text(json.dumps(summary, indent=2) + "\n") + print(json.dumps(summary, indent=2)) + return int(bool(summary["failed_runs"])) + + +if __name__ == "__main__": + raise SystemExit(main())