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.
110/// On Unix always returns true without probing; on Windows probes symlink
111/// creation under the provided target directory. Accepts a `&Path` to avoid
112/// depending on `oxdock-fs` and creating a circular crate dependency.
113#[allow(clippy::disallowed_methods, clippy::disallowed_types)]
114pub fn can_create_symlinks(target: &Path) -> bool {
115    #[cfg(unix)]
116    {
117        let _ = target;
118        true
119    }
120
121    #[cfg(windows)]
122    {
123        use std::fs;
124        use std::os::windows::fs::symlink_dir;
125        let test_src = target.join("__oxdock_test_symlink_src");
126        let test_dst = target.join("__oxdock_test_symlink_dst");
127        // Localized allowance: sys test helper may create/remove test dirs.
128        let _ = fs::create_dir_all(&test_src);
129        let ok = symlink_dir(&test_src, &test_dst).is_ok();
130        let _ = fs::remove_dir_all(&test_dst);
131        let _ = fs::remove_dir_all(&test_src);
132        ok
133    }
134
135    #[cfg(not(any(unix, windows)))]
136    {
137        let _ = target;
138        false
139    }
140}
141
142/// Build a process [`std::process::ExitStatus`] from a raw exit code.
143///
144/// Canonical shared definition used by the mock manager, the Miri synthetic
145/// backend, and most executor tests (all need to fabricate statuses without
146/// spawning). Note: `ChildHandle` and `exec::steps` retain small local
147/// duplicates.
148#[allow(clippy::disallowed_methods, clippy::disallowed_types)]
149pub fn exit_status_from_code(code: i32) -> std::process::ExitStatus {
150    #[cfg(unix)]
151    {
152        use std::os::unix::process::ExitStatusExt;
153        ExitStatusExt::from_raw(code << 8)
154    }
155    #[cfg(windows)]
156    {
157        use std::os::windows::process::ExitStatusExt;
158        ExitStatusExt::from_raw(code as u32)
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::TestEnvGuard;
165    use std::env;
166
167    const SET_RESTORE_ABSENT: &str = "OXDOCK_SYS_TEST_UTILS_SET_RESTORE_ABSENT";
168    const SET_RESTORE_PREVIOUS: &str = "OXDOCK_SYS_TEST_UTILS_SET_RESTORE_PREVIOUS";
169    const REMOVE_RESTORE: &str = "OXDOCK_SYS_TEST_UTILS_REMOVE_RESTORE";
170    const KEY_A: &str = "OXDOCK_SYS_TEST_UTILS_KEY_A";
171    const KEY_B: &str = "OXDOCK_SYS_TEST_UTILS_KEY_B";
172    const NESTED: &str = "OXDOCK_SYS_TEST_UTILS_NESTED";
173
174    #[test]
175    fn set_guard_restores_absent_state_on_drop() {
176        drop(TestEnvGuard::remove(SET_RESTORE_ABSENT));
177
178        let guard = TestEnvGuard::set(SET_RESTORE_ABSENT, "value");
179        assert_eq!(env::var(SET_RESTORE_ABSENT).as_deref(), Ok("value"));
180        drop(guard);
181
182        assert!(env::var(SET_RESTORE_ABSENT).is_err());
183    }
184
185    #[test]
186    fn set_guard_restores_previous_value_on_drop() {
187        // Arrange a pre-existing value directly; unique key => no other test
188        // touches it and no guard is held for it here.
189        unsafe { env::set_var(SET_RESTORE_PREVIOUS, "original") };
190
191        let guard = TestEnvGuard::set(SET_RESTORE_PREVIOUS, "temporary");
192        assert_eq!(env::var(SET_RESTORE_PREVIOUS).as_deref(), Ok("temporary"));
193        drop(guard);
194
195        assert_eq!(env::var(SET_RESTORE_PREVIOUS).as_deref(), Ok("original"));
196        unsafe { env::remove_var(SET_RESTORE_PREVIOUS) };
197    }
198
199    #[test]
200    fn remove_guard_restores_previous_value_on_drop() {
201        unsafe { env::set_var(REMOVE_RESTORE, "keep-me") };
202
203        let guard = TestEnvGuard::remove(REMOVE_RESTORE);
204        assert!(env::var(REMOVE_RESTORE).is_err());
205        drop(guard);
206
207        assert_eq!(env::var(REMOVE_RESTORE).as_deref(), Ok("keep-me"));
208        unsafe { env::remove_var(REMOVE_RESTORE) };
209    }
210
211    #[test]
212    fn guards_for_different_keys_coexist() {
213        let a = TestEnvGuard::set(KEY_A, "1");
214        let b = TestEnvGuard::set(KEY_B, "2");
215
216        assert_eq!(env::var(KEY_A).as_deref(), Ok("1"));
217        assert_eq!(env::var(KEY_B).as_deref(), Ok("2"));
218
219        drop(b);
220        drop(a);
221    }
222
223    #[test]
224    #[should_panic(expected = "already guarded")]
225    fn same_key_nesting_panics_rather_than_deadlocking() {
226        let _outer = TestEnvGuard::set(NESTED, "outer");
227        let _inner = TestEnvGuard::set(NESTED, "inner");
228    }
229}