Skip to main content

oxdock_sys_test_utils/
lib.rs

1/// Shared test helpers used by multiple crates' tests.
2///
3/// Keep functionality here minimal and test-only.
4use std::collections::HashMap;
5use std::env;
6use std::sync::{Mutex, MutexGuard, OnceLock};
7use std::thread::{ThreadId, current};
8
9/// Per-key locks so simultaneous guards for *different* variables keep
10/// working, while mutations of the *same* variable serialize process-wide.
11/// `std::env` access mutates process-global state and is not thread-safe,
12/// so every guard holds its key's lock for its entire lifetime.
13static KEY_LOCKS: OnceLock<Mutex<HashMap<&'static str, &'static Mutex<()>>>> = OnceLock::new();
14
15/// Which thread currently holds each key's lock, so same-thread re-entrancy
16/// can fail fast with a clear message instead of deadlocking.
17static KEY_OWNERS: OnceLock<Mutex<HashMap<&'static str, ThreadId>>> = OnceLock::new();
18
19fn key_owners() -> &'static Mutex<HashMap<&'static str, ThreadId>> {
20    KEY_OWNERS.get_or_init(|| Mutex::new(HashMap::new()))
21}
22
23#[allow(clippy::disallowed_methods)] // Box::leak: one lock per distinct key; bounded by test-suite vocabulary
24fn key_lock(key: &'static str) -> &'static Mutex<()> {
25    let locks = KEY_LOCKS.get_or_init(|| Mutex::new(HashMap::new()));
26    let mut map = locks
27        .lock()
28        .unwrap_or_else(|poisoned| poisoned.into_inner());
29    map.entry(key)
30        .or_insert_with(|| Box::leak(Box::new(Mutex::new(()))))
31}
32
33fn acquire_key_lock(key: &'static str) -> MutexGuard<'static, ()> {
34    let thread = current();
35    {
36        let owners = key_owners()
37            .lock()
38            .unwrap_or_else(|poisoned| poisoned.into_inner());
39        if owners.get(key) == Some(&thread.id()) {
40            panic!("TestEnvGuard: environment variable {key} is already guarded on this thread");
41        }
42    }
43
44    let guard = key_lock(key)
45        .lock()
46        .unwrap_or_else(|poisoned| poisoned.into_inner());
47    key_owners()
48        .lock()
49        .unwrap_or_else(|poisoned| poisoned.into_inner())
50        .insert(key, thread.id());
51    guard
52}
53
54pub struct TestEnvGuard {
55    key: &'static str,
56    value: Option<String>,
57    _lock: MutexGuard<'static, ()>,
58}
59
60impl TestEnvGuard {
61    /// Set `key` to `value`, restoring the prior state on drop.
62    ///
63    /// Blocks while another thread holds a live guard for the same variable;
64    /// panics if the same thread nests a second guard for it.
65    pub fn set(key: &'static str, value: &str) -> Self {
66        let lock = acquire_key_lock(key);
67        let prev = env::var(key).ok();
68        unsafe { env::set_var(key, value) };
69        Self {
70            key,
71            value: prev,
72            _lock: lock,
73        }
74    }
75
76    /// Remove `key` from the environment, restoring the prior state on drop:
77    /// re-set to its previous value if it had one, otherwise left absent.
78    ///
79    /// Blocking/panic behavior matches [`TestEnvGuard::set`].
80    pub fn remove(key: &'static str) -> Self {
81        let lock = acquire_key_lock(key);
82        let prev = env::var(key).ok();
83        unsafe { env::remove_var(key) };
84        Self {
85            key,
86            value: prev,
87            _lock: lock,
88        }
89    }
90}
91
92impl Drop for TestEnvGuard {
93    fn drop(&mut self) {
94        match &self.value {
95            Some(value) => unsafe { env::set_var(self.key, value) },
96            None => unsafe { env::remove_var(self.key) },
97        }
98        key_owners()
99            .lock()
100            .unwrap_or_else(|poisoned| poisoned.into_inner())
101            .remove(self.key);
102    }
103}
104
105#[allow(clippy::disallowed_types)]
106use std::path::Path;
107
108/// Detect whether the current process can create filesystem symlinks under
109/// the provided target directory. Accepts a `&Path` to avoid depending on
110/// `oxdock-fs` and creating a circular crate dependency.
111#[allow(clippy::disallowed_methods, clippy::disallowed_types)]
112pub fn can_create_symlinks(target: &Path) -> bool {
113    #[cfg(unix)]
114    {
115        let _ = target;
116        true
117    }
118
119    #[cfg(windows)]
120    {
121        use std::fs;
122        use std::os::windows::fs::symlink_dir;
123        let test_src = target.join("__oxdock_test_symlink_src");
124        let test_dst = target.join("__oxdock_test_symlink_dst");
125        // Localized allowance: sys test helper may create/remove test dirs.
126        let _ = fs::create_dir_all(&test_src);
127        let ok = symlink_dir(&test_src, &test_dst).is_ok();
128        let _ = fs::remove_dir_all(&test_dst);
129        let _ = fs::remove_dir_all(&test_src);
130        ok
131    }
132
133    #[cfg(not(any(unix, windows)))]
134    {
135        let _ = target;
136        false
137    }
138}
139
140/// Build a process [`std::process::ExitStatus`] from a raw exit code.
141///
142/// Single definition shared by the mock manager, the Miri synthetic backend,
143/// and executor tests (all need to fabricate statuses without spawning).
144#[allow(clippy::disallowed_methods, clippy::disallowed_types)]
145pub fn exit_status_from_code(code: i32) -> std::process::ExitStatus {
146    #[cfg(unix)]
147    {
148        use std::os::unix::process::ExitStatusExt;
149        ExitStatusExt::from_raw(code << 8)
150    }
151    #[cfg(windows)]
152    {
153        use std::os::windows::process::ExitStatusExt;
154        ExitStatusExt::from_raw(code as u32)
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use super::TestEnvGuard;
161    use std::env;
162
163    const SET_RESTORE_ABSENT: &str = "OXDOCK_SYS_TEST_UTILS_SET_RESTORE_ABSENT";
164    const SET_RESTORE_PREVIOUS: &str = "OXDOCK_SYS_TEST_UTILS_SET_RESTORE_PREVIOUS";
165    const REMOVE_RESTORE: &str = "OXDOCK_SYS_TEST_UTILS_REMOVE_RESTORE";
166    const KEY_A: &str = "OXDOCK_SYS_TEST_UTILS_KEY_A";
167    const KEY_B: &str = "OXDOCK_SYS_TEST_UTILS_KEY_B";
168    const NESTED: &str = "OXDOCK_SYS_TEST_UTILS_NESTED";
169
170    #[test]
171    fn set_guard_restores_absent_state_on_drop() {
172        drop(TestEnvGuard::remove(SET_RESTORE_ABSENT));
173
174        let guard = TestEnvGuard::set(SET_RESTORE_ABSENT, "value");
175        assert_eq!(env::var(SET_RESTORE_ABSENT).as_deref(), Ok("value"));
176        drop(guard);
177
178        assert!(env::var(SET_RESTORE_ABSENT).is_err());
179    }
180
181    #[test]
182    fn set_guard_restores_previous_value_on_drop() {
183        // Arrange a pre-existing value directly; unique key => no other test
184        // touches it and no guard is held for it here.
185        unsafe { env::set_var(SET_RESTORE_PREVIOUS, "original") };
186
187        let guard = TestEnvGuard::set(SET_RESTORE_PREVIOUS, "temporary");
188        assert_eq!(env::var(SET_RESTORE_PREVIOUS).as_deref(), Ok("temporary"));
189        drop(guard);
190
191        assert_eq!(env::var(SET_RESTORE_PREVIOUS).as_deref(), Ok("original"));
192        unsafe { env::remove_var(SET_RESTORE_PREVIOUS) };
193    }
194
195    #[test]
196    fn remove_guard_restores_previous_value_on_drop() {
197        unsafe { env::set_var(REMOVE_RESTORE, "keep-me") };
198
199        let guard = TestEnvGuard::remove(REMOVE_RESTORE);
200        assert!(env::var(REMOVE_RESTORE).is_err());
201        drop(guard);
202
203        assert_eq!(env::var(REMOVE_RESTORE).as_deref(), Ok("keep-me"));
204        unsafe { env::remove_var(REMOVE_RESTORE) };
205    }
206
207    #[test]
208    fn guards_for_different_keys_coexist() {
209        let a = TestEnvGuard::set(KEY_A, "1");
210        let b = TestEnvGuard::set(KEY_B, "2");
211
212        assert_eq!(env::var(KEY_A).as_deref(), Ok("1"));
213        assert_eq!(env::var(KEY_B).as_deref(), Ok("2"));
214
215        drop(b);
216        drop(a);
217    }
218
219    #[test]
220    #[should_panic(expected = "already guarded")]
221    fn same_key_nesting_panics_rather_than_deadlocking() {
222        let _outer = TestEnvGuard::set(NESTED, "outer");
223        let _inner = TestEnvGuard::set(NESTED, "inner");
224    }
225}