refactor: Add EnvGuard RAII wrapper for safe env var handling in tests (#179)#181
Conversation
Introduce `EnvGuard` in `src/test_helpers/env_guard.rs` that wraps the unsafe `std::env::set_var` / `remove_var` calls behind a safe RAII type. On drop, the guard restores the prior value (or unset state), so tests cannot leak environment mutations across runs. Wire the module via `#[cfg(test)] pub(crate) mod test_helpers;` in `src/lib.rs`, and expose the same implementation to integration tests through `tests/common/mod.rs` using `#[path]` include. This keeps one source of truth without publishing `EnvGuard` as crate public API. This is Phase 1 of issue #179: the foundation that later phases use to replace 177 ad-hoc `env::set_var` / `remove_var` call sites across 17 test-bearing files. Refs #179
Replace all `env::set_var` / `env::remove_var` call sites in unit
and integration tests with `EnvGuard::set` / `EnvGuard::remove`
plus `#[serial]` from the `serial_test` crate. This removes all
ad-hoc `unsafe {}` blocks left over from the Rust 2024 edition
migration (commit 73b7857) and the hand-rolled save/restore logic
that scattered environment mutation code across the codebase.
Phase 2 — src/ unit tests (12 files):
- src/cli/pdsh.rs (doctest marked `ignore`)
- src/ssh/ssh_config/integration_tests/env_cache_integration_test.rs
- src/security/sudo.rs
- src/ssh/auth.rs
- src/cli/mode_detection_tests.rs
- src/jump/parser/tests.rs
- src/config/tests.rs
- src/server/config/loader.rs
- src/ssh/client/connection.rs
- src/executor/rank_detector.rs
- src/jump/chain/auth.rs
Phase 3 — tests/ integration tests (5 files):
- tests/interactive_test.rs
- tests/pdsh_compat_test.rs
- tests/jump_host_config_test.rs
- tests/exit_code_integration_test.rs
- tests/backendai_env_test.rs (also removes the hand-rolled
`ENV_MUTEX` / `once_cell::sync::Lazy<Mutex<()>>` pattern;
`#[serial]` on `#[tokio::test]` replaces it)
Also annotate `EnvGuard::set` / `EnvGuard::remove` with
`#[allow(dead_code)]` so integration-test binaries that use only
one constructor don't emit warnings when the file is included via
`#[path]` from `tests/common/mod.rs`.
Collapse two pre-existing nested `if let` chains in
`tests/glob_pattern_test.rs` and `tests/pty_stress_test.rs` that
`cargo clippy -D warnings` flagged once the 2024-edition test
build started compiling — these were hidden by the E0133 errors
before this PR. Fix applied via `cargo clippy --fix`.
Validation:
- `cargo check --lib --tests`: 120 E0133 errors -> 0
- `cargo clippy --all-targets --all-features -- -D warnings`: clean
- `cargo test --lib`: 1189 passed (keychain_macos tests skipped —
they require interactive Keychain authorization, unrelated to
this change)
- All 5 modified integration-test binaries pass independently
- Zero `unsafe { env::set_var ... }` / `unsafe { env::remove_var ... }`
remain outside `src/test_helpers/env_guard.rs`
- Zero `ENV_MUTEX` references remain in `tests/`
Closes #179
Implementation Review SummaryIntent
Findings
No CRITICAL or HIGH findings. Codex review (codex-cli 0.120.0) was consulted and surfaced the same SAFETY-comment observation. Findings AddressedNone — sole MEDIUM finding is documentation-only and non-blocking; deferred to a follow-up SAFETY-comment tightening. Remaining Items
Verification
Build/Test Validation (commit
|
The previous SAFETY comments on the three unsafe blocks in src/test_helpers/env_guard.rs overstated the guarantee provided by serial_test::serial: that attribute only serializes against other #[serial]/#[parallel] tests, not against unannotated tests that may still run concurrently and race on environment reads. - Expand the set SAFETY comment to clearly state the full caller contract: all tests observing or mutating the same variable must also be #[serial] or share a matching #[serial(key)] group. - Update the remove and Drop::drop comments to reference the full comment and module-level soundness contract. - Add a ## Soundness contract section to the module-level doc that spells out the UB risk on glibc/musl/macOS when unannotated tests race against EnvGuard mutations. - Add a "Test Environment-Variable Mutation Pattern" subsection to ARCHITECTURE.md documenting where EnvGuard lives, the soundness contract, when to use #[serial] vs #[serial(key)], and how integration tests access EnvGuard via tests/common/mod.rs. Addresses the MEDIUM finding from pr-reviewer and pr-security-checker on PR #181 (closes #179).
Add five focused tests inside src/test_helpers/env_guard.rs to verify the Drop-based restore semantics directly: - set_restores_prior_value_on_drop: absent-before-guard → value during → absent after drop - remove_restores_prior_value_on_drop: present value → absent during → value restored after drop - chained_set_guards_restore_in_lifo_order: nested guards restore in reverse construction order, each to what it saved - remove_on_already_unset_variable_is_noop: removing an already-absent variable leaves it absent after drop (no spurious value) - set_over_existing_value_restores_original: set-over-existing restores the pre-existing value, not the absent state All tests use uniquely prefixed BSSH_ENVGUARD_TEST_VAR_* names to avoid clashing with other tests, and carry #[serial] per the soundness contract. No outer #[cfg(test)] needed since the file is already gated with #![cfg(test)]. Raises lib test count from 1189 to 1194 (closes #179).
PR Finalization CompleteSummaryMEDIUM finding addressed: The SAFETY comments in Changes by file:
Commits pushed:
Final verification:
Ready for merge. |
Closes #179
Summary
Introduce
EnvGuard, an RAII wrapper that encapsulates theunsafestd::env::set_var/std::env::remove_varcalls and restores theprior value on drop, then convert 16 test files to use it together
with
#[serial]from the already-presentserial_testdev-dependency.This finishes the Rust 2024 edition migration that commit
73b78571started but left in a broken state: the test build previously failed
with 120
E0133errors oncargo check --lib --tests. It nowbuilds cleanly with zero such errors.
Phases
Phase 1 — Foundation
New files:
src/test_helpers/env_guard.rs—EnvGuardstruct withset/removeconstructors and aDropimpl that restores theOsString-preserving original. Gated with#![cfg(test)]at themodule declaration in
src/lib.rs. Allunsafe {}blocks livehere under documented
SAFETY:comments.src/test_helpers/mod.rs— module exports.tests/common/mod.rs— re-exportsEnvGuardto integration testsvia a
#[path = "../../src/test_helpers/env_guard.rs"]include.This keeps one source of truth without making
EnvGuardpublicAPI of the
bsshcrate.Changes to existing files:
src/lib.rs— adds#[cfg(test)] pub(crate) mod test_helpers;.Phase 2 — src/ unit tests (11 files)
src/cli/pdsh.rs— doctest markedignoresrc/ssh/ssh_config/integration_tests/env_cache_integration_test.rssrc/security/sudo.rssrc/ssh/auth.rssrc/cli/mode_detection_tests.rssrc/jump/parser/tests.rssrc/config/tests.rssrc/server/config/loader.rssrc/ssh/client/connection.rssrc/executor/rank_detector.rssrc/jump/chain/auth.rsEvery
env::set_var/env::remove_varcall in these files is noweither removed (when the adjacent save/restore boilerplate is no
longer needed) or wrapped in
EnvGuard::set/EnvGuard::remove.Every test that touches environment variables has
#[serial]applied.
Phase 3 — tests/ integration tests (5 files)
tests/interactive_test.rstests/pdsh_compat_test.rstests/jump_host_config_test.rstests/exit_code_integration_test.rstests/backendai_env_test.rsEach file gains
mod common;+use common::EnvGuard;. Thebackendai_env_test.rsconversion also deletes the hand-rolledENV_MUTEX/once_cell::sync::Lazy<Mutex<()>>pattern at the topof the file and every
let _guard = ENV_MUTEX.lock().await;callsite —
#[serial]on#[tokio::test]replaces it.serial_test's#[serial]attribute works correctly on async Tokio tests, verifiedby running the converted tests.
Phase 4 — Finalization (PR review MEDIUM finding)
Both
pr-reviewerandpr-security-checkerflagged the same MEDIUMissue: the original
SAFETY:comments on the threeunsafeblocksoverstated what
serial_test::serialguarantees — it only serializesagainst other
#[serial]/#[parallel]tests, not against unannotatedtests that may still run concurrently and race on environment reads.
Addressed in two commits on top of the original PR work:
docs: tighten EnvGuard SAFETY comments and add soundness contractsetSAFETY comment to state the full caller contractremoveandDrop::dropcomments to reference the fullcomment and module-level soundness section
## Soundness contractsection to the module-level rustdocexplicitly naming the UB risk on glibc/musl/macOS
ARCHITECTURE.mdcovering whereEnvGuardlives, the soundnesscontract, how integration tests access it, and
#[serial]vs#[serial(key)]guidancetest: add unit tests for EnvGuard RAII behavioursemantics: set-from-absent, remove-with-restore, chained LIFO
guards, remove-on-already-unset (no-op), and set-over-existing
#[serial]and use uniqueBSSH_ENVGUARD_TEST_VAR_*names to avoid clashing with other tests
Validation
cargo build --all-targets— cleancargo clippy --all-targets --all-features -- -D warnings— cleancargo fmt --all -- --check— cleancargo test --lib -- --skip keychain_macos— 1194 passed, 0 failed, 9 ignoredunsafe { env::set_var ... }outsidesrc/test_helpers/env_guard.rsENV_MUTEXreferences intests/