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