Skip to main content

tanzim_testing/
environment.rs

1//! A sandboxed temporary [`Environment`] for tests and examples.
2//!
3//! [`Environment::run`] executes a closure inside a freshly created temporary directory. On entry it
4//! changes the working directory into that sandbox and snapshots the whole process environment; on
5//! exit (whether the closure returns or panics) it restores the environment and working directory and
6//! deletes the sandbox. Every run is serialized behind a process-global lock, so parallel tests do not
7//! race on the shared working directory / environment — with the caveat that `Environment`-based tests
8//! effectively run one at a time.
9//!
10//! ```
11//! use tanzim_testing::environment::run;
12//!
13//! let read_back = run(|env| {
14//!     env.write_file("hello.txt", b"world")?;
15//!     Ok(std::fs::read_to_string("hello.txt")?)
16//! })
17//! .unwrap();
18//! assert_eq!(read_back, "world");
19//! ```
20
21use cfg_if::cfg_if;
22use std::error::Error as StdError;
23use std::ffi::{OsStr, OsString};
24use std::fmt::{self, Display, Formatter};
25use std::path::{Component, Path, PathBuf};
26use std::sync::{Mutex, MutexGuard};
27use std::time::Instant;
28
29/// Process-global lock. Serializes every [`Environment::run`] so concurrent test threads cannot stomp
30/// on the shared working directory / environment. A `std` `Mutex` with a `const` initializer keeps the
31/// crate dependency-free.
32static ENV_LOCK: Mutex<()> = Mutex::new(());
33
34/// Errors returned by [`Environment`] operations.
35///
36/// [`Display`] is one line by default; the alternate form (`{error:#}`) appends the underlying cause
37/// chain, so wrapped [`std::io::Error`]s surface their real reason.
38#[derive(Debug)]
39pub enum Error {
40    /// A method that requires an active sandbox was called outside of [`Environment::run`].
41    Inactive,
42    /// A filesystem or environment operation failed. `action` describes what was attempted and `path`
43    /// names the target when there is one.
44    Io {
45        /// What was being attempted, e.g. `"create the file"`.
46        action: String,
47        /// The target path, when the failing operation had one.
48        path: Option<PathBuf>,
49        /// The underlying cause.
50        source: std::io::Error,
51    },
52    /// A `create_*` / `write_file` path was absolute; sandbox paths must be relative.
53    NotRelative {
54        /// The offending path.
55        path: PathBuf,
56    },
57    /// A path resolved outside the sandbox directory (e.g. it contained a `..` component).
58    Escapes {
59        /// The offending path.
60        path: PathBuf,
61    },
62    /// A user error `?`-converted from inside a `run` closure.
63    Other(Box<dyn StdError + Send + Sync>),
64}
65
66impl Display for Error {
67    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
68        match self {
69            Error::Inactive => write!(
70                f,
71                "the sandbox environment is not active; call this inside `Environment::run`"
72            )?,
73            Error::Io {
74                action,
75                path,
76                source: _,
77            } => match path {
78                Some(path) => write!(f, "could not {action} `{}`", path.display())?,
79                None => write!(f, "could not {action}")?,
80            },
81            Error::NotRelative { path } => write!(
82                f,
83                "path `{}` must be relative to the sandbox",
84                path.display()
85            )?,
86            Error::Escapes { path } => {
87                write!(f, "path `{}` escapes the sandbox directory", path.display())?
88            }
89            Error::Other(source) => write!(f, "{source}")?,
90        }
91        if f.alternate() {
92            let mut cause = StdError::source(self);
93            while let Some(error) = cause {
94                write!(f, ": {error}")?;
95                cause = error.source();
96            }
97        }
98        Ok(())
99    }
100}
101
102impl StdError for Error {
103    fn source(&self) -> Option<&(dyn StdError + 'static)> {
104        match self {
105            Error::Io { source, .. } => Some(source),
106            Error::Other(source) => source.source(),
107            Error::Inactive | Error::NotRelative { .. } | Error::Escapes { .. } => None,
108        }
109    }
110}
111
112impl From<Box<dyn StdError + Send + Sync>> for Error {
113    fn from(source: Box<dyn StdError + Send + Sync>) -> Self {
114        Error::Other(source)
115    }
116}
117
118impl From<std::io::Error> for Error {
119    fn from(source: std::io::Error) -> Self {
120        Error::Other(Box::new(source))
121    }
122}
123
124/// A sandboxed temporary environment. Build one with [`Environment::temporary`] and enter it with
125/// [`Environment::run`], or use the free [`run`] function to do both at once.
126pub struct Environment {
127    entered: Option<Entered>,
128}
129
130/// State captured while a sandbox is active. Restored and torn down in [`Environment`]'s `Drop`.
131struct Entered {
132    directory: PathBuf,
133    saved_cwd: PathBuf,
134    saved_env: Vec<(OsString, OsString)>,
135    started: Instant,
136    // Held for the whole run; released only after `Drop` finishes cleaning up. Declared last so it is
137    // dropped last.
138    _guard: MutexGuard<'static, ()>,
139}
140
141/// Run `f` inside a fresh sandbox. Shorthand for [`Environment::temporary`] followed by
142/// [`Environment::run`].
143pub fn run<T>(f: impl FnOnce(&mut Environment) -> Result<T, Error>) -> Result<T, Error> {
144    Environment::temporary().run(f)
145}
146
147impl Environment {
148    /// Create a fresh, not-yet-entered environment. The temporary directory is created later, inside
149    /// [`run`](Environment::run), while the global lock is held.
150    pub fn temporary() -> Self {
151        Self { entered: None }
152    }
153
154    /// The active sandbox directory (the canonicalized temporary directory), or `None` before
155    /// [`run`](Environment::run) has entered.
156    pub fn directory(&self) -> Option<&Path> {
157        match &self.entered {
158            Some(entered) => Some(&entered.directory),
159            None => None,
160        }
161    }
162
163    /// Enter the sandbox and run `f` inside it.
164    ///
165    /// Acquires the global lock, creates a temporary directory (falling back to the current directory
166    /// if the system temporary directory is not writable), snapshots the environment and working
167    /// directory, and `chdir`s into the sandbox. When `f` returns — or panics — the environment and
168    /// working directory are restored and the sandbox is deleted before the lock is released.
169    ///
170    /// User errors convert into [`Error::Other`], so `?` works inside the closure for any
171    /// `Box<dyn Error + Send + Sync>` or [`std::io::Error`].
172    pub fn run<T>(
173        mut self,
174        f: impl FnOnce(&mut Environment) -> Result<T, Error>,
175    ) -> Result<T, Error> {
176        let guard = match ENV_LOCK.lock() {
177            Ok(guard) => guard,
178            Err(poisoned) => poisoned.into_inner(),
179        };
180        let started = Instant::now();
181
182        cfg_if! {
183            if #[cfg(feature = "tracing")] {
184                tracing::debug!(msg = "Entering sandbox environment");
185            } else if #[cfg(feature = "logging")] {
186                log::debug!("msg=\"Entering sandbox environment\"");
187            }
188        }
189
190        let saved_cwd = match std::env::current_dir() {
191            Ok(cwd) => cwd,
192            Err(source) => {
193                return Err(Error::Io {
194                    action: String::from("read the current directory"),
195                    path: None,
196                    source,
197                });
198            }
199        };
200        let saved_env = std::env::vars_os().collect();
201
202        let nanos = match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
203            Ok(elapsed) => elapsed.as_nanos(),
204            Err(_) => 0,
205        };
206        let name = format!("tanzim-testing-{}-{}", std::process::id(), nanos);
207
208        let target = std::env::temp_dir().join(&name);
209        let created = match std::fs::create_dir(&target) {
210            Ok(()) => target,
211            Err(source) => {
212                if source.kind() != std::io::ErrorKind::PermissionDenied {
213                    return Err(Error::Io {
214                        action: String::from("create the sandbox directory"),
215                        path: Some(target),
216                        source,
217                    });
218                }
219                let fallback = saved_cwd.join(&name);
220                match std::fs::create_dir(&fallback) {
221                    Ok(()) => fallback,
222                    Err(source) => {
223                        return Err(Error::Io {
224                            action: String::from("create the sandbox directory"),
225                            path: Some(fallback),
226                            source,
227                        });
228                    }
229                }
230            }
231        };
232        let directory = match std::fs::canonicalize(&created) {
233            Ok(directory) => directory,
234            Err(source) => {
235                let _ = std::fs::remove_dir_all(&created);
236                return Err(Error::Io {
237                    action: String::from("resolve the sandbox directory"),
238                    path: Some(created),
239                    source,
240                });
241            }
242        };
243
244        cfg_if! {
245            if #[cfg(feature = "tracing")] {
246                tracing::info!(msg = "Created sandbox directory", path = ?directory);
247            } else if #[cfg(feature = "logging")] {
248                log::info!("msg=\"Created sandbox directory\" path={directory:?}");
249            }
250        }
251
252        let cwd_target = directory.clone();
253        self.entered = Some(Entered {
254            directory,
255            saved_cwd,
256            saved_env,
257            started,
258            _guard: guard,
259        });
260
261        match std::env::set_current_dir(&cwd_target) {
262            Ok(()) => {}
263            Err(source) => {
264                return Err(Error::Io {
265                    action: String::from("enter the sandbox directory"),
266                    path: Some(cwd_target),
267                    source,
268                });
269            }
270        }
271
272        cfg_if! {
273            if #[cfg(feature = "tracing")] {
274                tracing::trace!(msg = "Changed working directory into sandbox", path = ?cwd_target);
275            } else if #[cfg(feature = "logging")] {
276                log::trace!("msg=\"Changed working directory into sandbox\" path={cwd_target:?}");
277            }
278        }
279
280        f(&mut self)
281    }
282
283    /// Remove every environment variable from the process. The full environment was snapshotted on
284    /// entry, so it is restored when the sandbox is dropped. A no-op if called outside of
285    /// [`run`](Environment::run).
286    pub fn clear_env(&mut self) {
287        if self.entered.is_none() {
288            return;
289        }
290
291        cfg_if! {
292            if #[cfg(feature = "tracing")] {
293                tracing::debug!(msg = "Clearing environment variables");
294            } else if #[cfg(feature = "logging")] {
295                log::debug!("msg=\"Clearing environment variables\"");
296            }
297        }
298
299        for (key, _) in std::env::vars_os() {
300            // SAFETY: guarded by ENV_LOCK; single-threaded within the sandbox.
301            unsafe { std::env::remove_var(&key) };
302        }
303    }
304
305    /// Set the environment variable `key` to `value` for the duration of the sandbox. The full
306    /// environment was snapshotted on entry, so this is undone when the sandbox is dropped — use it
307    /// instead of a hand-rolled `unsafe { std::env::set_var(..) }` so tests stay self-contained.
308    /// Returns [`Error::Inactive`] when called outside of [`run`](Environment::run).
309    pub fn set_env(
310        &mut self,
311        key: impl AsRef<OsStr>,
312        value: impl AsRef<OsStr>,
313    ) -> Result<(), Error> {
314        if self.entered.is_none() {
315            return Err(Error::Inactive);
316        }
317        let key = key.as_ref();
318        let value = value.as_ref();
319
320        cfg_if! {
321            if #[cfg(feature = "tracing")] {
322                tracing::debug!(msg = "Setting environment variable", key = ?key);
323            } else if #[cfg(feature = "logging")] {
324                log::debug!("msg=\"Setting environment variable\" key={key:?}");
325            }
326        }
327
328        // SAFETY: guarded by ENV_LOCK; single-threaded within the sandbox.
329        unsafe { std::env::set_var(key, value) };
330
331        cfg_if! {
332            if #[cfg(feature = "tracing")] {
333                tracing::trace!(msg = "Set environment variable value", key = ?key, value = ?value);
334            } else if #[cfg(feature = "logging")] {
335                log::trace!("msg=\"Set environment variable value\" key={key:?} value={value:?}");
336            }
337        }
338        cfg_if! {
339            if #[cfg(feature = "tracing")] {
340                tracing::info!(msg = "Set environment variable", key = ?key);
341            } else if #[cfg(feature = "logging")] {
342                log::info!("msg=\"Set environment variable\" key={key:?}");
343            }
344        }
345        Ok(())
346    }
347
348    /// Create an empty file at `path` (relative to the sandbox), truncating any existing file. The
349    /// sandbox is the current directory during [`run`](Environment::run), so read it back with the
350    /// same relative path.
351    pub fn create_file(&mut self, path: impl AsRef<Path>) -> Result<(), Error> {
352        let directory = match &self.entered {
353            Some(entered) => entered.directory.clone(),
354            None => return Err(Error::Inactive),
355        };
356        let full = resolve(&directory, path.as_ref())?;
357        let _existed = full.exists();
358
359        cfg_if! {
360            if #[cfg(feature = "tracing")] {
361                tracing::debug!(msg = "Creating file", path = ?full);
362            } else if #[cfg(feature = "logging")] {
363                log::debug!("msg=\"Creating file\" path={full:?}");
364            }
365        }
366
367        create_parents(&full)?;
368        match std::fs::File::create(&full) {
369            Ok(_) => {}
370            Err(source) => {
371                return Err(Error::Io {
372                    action: String::from("create the file"),
373                    path: Some(full),
374                    source,
375                });
376            }
377        }
378        confirm_within(&directory, &full)?;
379
380        cfg_if! {
381            if #[cfg(feature = "tracing")] {
382                tracing::info!(msg = "Created file", path = ?full, recreated = _existed);
383            } else if #[cfg(feature = "logging")] {
384                log::info!("msg=\"Created file\" path={full:?} recreated={_existed}");
385            }
386        }
387        Ok(())
388    }
389
390    /// Create a fresh file at `path` (relative to the sandbox), truncating any existing file, and write
391    /// `contents` to it.
392    pub fn write_file(
393        &mut self,
394        path: impl AsRef<Path>,
395        contents: impl AsRef<[u8]>,
396    ) -> Result<(), Error> {
397        let directory = match &self.entered {
398            Some(entered) => entered.directory.clone(),
399            None => return Err(Error::Inactive),
400        };
401        let full = resolve(&directory, path.as_ref())?;
402        let bytes = contents.as_ref();
403        let _existed = full.exists();
404
405        cfg_if! {
406            if #[cfg(feature = "tracing")] {
407                tracing::debug!(msg = "Writing file", path = ?full, bytes = bytes.len());
408            } else if #[cfg(feature = "logging")] {
409                log::debug!("msg=\"Writing file\" path={full:?} bytes={}", bytes.len());
410            }
411        }
412
413        create_parents(&full)?;
414        match std::fs::write(&full, bytes) {
415            Ok(()) => {}
416            Err(source) => {
417                return Err(Error::Io {
418                    action: String::from("write the file"),
419                    path: Some(full),
420                    source,
421                });
422            }
423        }
424        confirm_within(&directory, &full)?;
425
426        cfg_if! {
427            if #[cfg(feature = "tracing")] {
428                tracing::trace!(
429                    msg = "Wrote file contents",
430                    path = ?full,
431                    contents = %String::from_utf8_lossy(bytes),
432                );
433            } else if #[cfg(feature = "logging")] {
434                log::trace!(
435                    "msg=\"Wrote file contents\" path={full:?} contents={}",
436                    String::from_utf8_lossy(bytes),
437                );
438            }
439        }
440        cfg_if! {
441            if #[cfg(feature = "tracing")] {
442                tracing::info!(
443                    msg = "Wrote file",
444                    path = ?full,
445                    bytes = bytes.len(),
446                    recreated = _existed,
447                );
448            } else if #[cfg(feature = "logging")] {
449                log::info!(
450                    "msg=\"Wrote file\" path={full:?} bytes={} recreated={_existed}",
451                    bytes.len(),
452                );
453            }
454        }
455        Ok(())
456    }
457
458    /// Create a directory (and any missing parents) at `path`, relative to the sandbox.
459    pub fn create_directory(&mut self, path: impl AsRef<Path>) -> Result<(), Error> {
460        let directory = match &self.entered {
461            Some(entered) => entered.directory.clone(),
462            None => return Err(Error::Inactive),
463        };
464        let full = resolve(&directory, path.as_ref())?;
465        let _existed = full.exists();
466
467        cfg_if! {
468            if #[cfg(feature = "tracing")] {
469                tracing::debug!(msg = "Creating directory", path = ?full);
470            } else if #[cfg(feature = "logging")] {
471                log::debug!("msg=\"Creating directory\" path={full:?}");
472            }
473        }
474
475        match std::fs::create_dir_all(&full) {
476            Ok(()) => {}
477            Err(source) => {
478                return Err(Error::Io {
479                    action: String::from("create the directory"),
480                    path: Some(full),
481                    source,
482                });
483            }
484        }
485        confirm_within(&directory, &full)?;
486
487        cfg_if! {
488            if #[cfg(feature = "tracing")] {
489                tracing::info!(msg = "Created directory", path = ?full, recreated = _existed);
490            } else if #[cfg(feature = "logging")] {
491                log::info!("msg=\"Created directory\" path={full:?} recreated={_existed}");
492            }
493        }
494        Ok(())
495    }
496}
497
498impl Drop for Environment {
499    fn drop(&mut self) {
500        let entered = match self.entered.take() {
501            Some(entered) => entered,
502            None => return,
503        };
504
505        cfg_if! {
506            if #[cfg(feature = "tracing")] {
507                tracing::trace!(msg = "Restoring environment and removing sandbox");
508            } else if #[cfg(feature = "logging")] {
509                log::trace!("msg=\"Restoring environment and removing sandbox\"");
510            }
511        }
512
513        for (key, _) in std::env::vars_os() {
514            // SAFETY: guarded by ENV_LOCK; single-threaded within the sandbox.
515            unsafe { std::env::remove_var(&key) };
516        }
517        for (key, value) in &entered.saved_env {
518            // SAFETY: guarded by ENV_LOCK; single-threaded within the sandbox.
519            unsafe { std::env::set_var(key, value) };
520        }
521
522        match std::env::set_current_dir(&entered.saved_cwd) {
523            Ok(()) => {}
524            Err(_source) => {
525                cfg_if! {
526                    if #[cfg(feature = "tracing")] {
527                        tracing::warn!(
528                            msg = "Could not restore working directory",
529                            path = ?entered.saved_cwd,
530                            error = ?_source,
531                        );
532                    } else if #[cfg(feature = "logging")] {
533                        log::warn!(
534                            "msg=\"Could not restore working directory\" path={:?} error={_source:?}",
535                            entered.saved_cwd,
536                        );
537                    }
538                }
539            }
540        }
541
542        match std::fs::remove_dir_all(&entered.directory) {
543            Ok(()) => {
544                cfg_if! {
545                    if #[cfg(feature = "tracing")] {
546                        tracing::info!(msg = "Removed sandbox directory", path = ?entered.directory);
547                    } else if #[cfg(feature = "logging")] {
548                        log::info!(
549                            "msg=\"Removed sandbox directory\" path={:?}",
550                            entered.directory,
551                        );
552                    }
553                }
554            }
555            Err(_source) => {
556                cfg_if! {
557                    if #[cfg(feature = "tracing")] {
558                        tracing::warn!(
559                            msg = "Could not remove sandbox directory",
560                            path = ?entered.directory,
561                            error = ?_source,
562                        );
563                    } else if #[cfg(feature = "logging")] {
564                        log::warn!(
565                            "msg=\"Could not remove sandbox directory\" path={:?} error={_source:?}",
566                            entered.directory,
567                        );
568                    }
569                }
570            }
571        }
572
573        let _held = entered.started.elapsed();
574        cfg_if! {
575            if #[cfg(feature = "tracing")] {
576                tracing::info!(msg = "Released sandbox lock", held_seconds = _held.as_secs_f64());
577            } else if #[cfg(feature = "logging")] {
578                log::info!("msg=\"Released sandbox lock\" held_seconds={}", _held.as_secs_f64());
579            }
580        }
581    }
582}
583
584/// Join `relative` onto the sandbox `directory`, rejecting absolute paths and any `..` component that
585/// could escape the sandbox.
586fn resolve(directory: &Path, relative: &Path) -> Result<PathBuf, Error> {
587    if relative.is_absolute() {
588        return Err(Error::NotRelative {
589            path: relative.to_path_buf(),
590        });
591    }
592    for component in relative.components() {
593        if matches!(component, Component::ParentDir) {
594            return Err(Error::Escapes {
595                path: relative.to_path_buf(),
596            });
597        }
598    }
599    Ok(directory.join(relative))
600}
601
602/// Create any missing parent directories for `full`.
603fn create_parents(full: &Path) -> Result<(), Error> {
604    match full.parent() {
605        Some(parent) => match std::fs::create_dir_all(parent) {
606            Ok(()) => Ok(()),
607            Err(source) => Err(Error::Io {
608                action: String::from("create parent directories"),
609                path: Some(parent.to_path_buf()),
610                source,
611            }),
612        },
613        None => Ok(()),
614    }
615}
616
617/// Defense in depth: confirm the just-created `full` canonicalizes to somewhere inside `directory`.
618fn confirm_within(directory: &Path, full: &Path) -> Result<(), Error> {
619    let canonical = match std::fs::canonicalize(full) {
620        Ok(canonical) => canonical,
621        Err(source) => {
622            return Err(Error::Io {
623                action: String::from("resolve the created path"),
624                path: Some(full.to_path_buf()),
625                source,
626            });
627        }
628    };
629    if canonical.starts_with(directory) {
630        Ok(())
631    } else {
632        Err(Error::Escapes {
633            path: full.to_path_buf(),
634        })
635    }
636}