figment2/
jail.rs

1use std::fs::{File, self};
2use std::io::{Write, BufWriter};
3use std::path::{Path, PathBuf};
4use std::fmt::Display;
5use std::ffi::{OsStr, OsString};
6use std::collections::HashMap;
7
8use tempfile::TempDir;
9use parking_lot::Mutex;
10
11use crate::error::Result;
12
13// TODO: Clear environment variables before entering this? Will they mess with
14// anything else?
15/// A "sandboxed" environment with isolated env and file system namespace.
16///
17/// `Jail` creates a pseudo-sandboxed (not _actually_ sandboxed) environment for
18/// testing configurations. Specifically, `Jail`:
19///
20///   * Synchronizes all calls to [`Jail::expect_with()`] and
21///     [`Jail::try_with()`] to prevent environment variables races.
22///   * Switches into a fresh temporary directory ([`Jail::directory()`]) where
23///     files can be created with [`Jail::create_file()`].
24///   * Keeps track of environment variables created with [`Jail::set_env()`]
25///     and clears them when the `Jail` exits.
26///   * Deletes the temporary directory and all of its contents when exiting.
27///
28/// Additionally, because `Jail` expects functions that return a [`Result`],
29/// the `?` operator can be used liberally in a jail:
30///
31/// ```rust
32/// use figment2::{Figment, Jail, providers::{Format, Toml, Env}};
33/// # #[derive(serde::Deserialize)]
34/// # struct Config {
35/// #     name: String,
36/// #     authors: Vec<String>,
37/// #     publish: bool
38/// # }
39///
40/// figment2::Jail::expect_with(|jail| {
41///     jail.create_file("Cargo.toml", r#"
42///       name = "test"
43///       authors = ["bob"]
44///       publish = false
45///     "#)?;
46///
47///     jail.set_env("CARGO_NAME", "env-test");
48///
49///     let config: Config = Figment::new()
50///         .merge(Toml::file("Cargo.toml"))
51///         .merge(Env::prefixed("CARGO_"))
52///         .extract()?;
53///
54///     Ok(())
55/// });
56/// ```
57#[cfg_attr(nightly, doc(cfg(feature = "test")))]
58pub struct Jail {
59    _directory: TempDir,
60    canonical_dir: PathBuf,
61    saved_env_vars: HashMap<OsString, Option<OsString>>,
62    saved_cwd: PathBuf,
63}
64
65/// Convert a `T: Display` to a `String`.
66fn as_string<S: Display>(s: S) -> String { s.to_string() }
67
68/// Remove any dots from the path by popping as needed.
69fn dedot(path: &Path) -> PathBuf {
70    use std::path::Component::*;
71
72    let mut comps = vec![];
73    for component in path.components() {
74        match component {
75            p@Prefix(_) => comps = vec![p],
76            r@RootDir if comps.iter().all(|c| matches!(c, Prefix(_))) => comps.push(r),
77            r@RootDir => comps = vec![r],
78            CurDir => { },
79            ParentDir if comps.iter().all(|c| matches!(c, Prefix(_) | RootDir)) => { },
80            ParentDir => { comps.pop(); },
81            c@Normal(_) => comps.push(c),
82        }
83    }
84
85    comps.iter().map(|c| c.as_os_str()).collect()
86}
87
88static LOCK: Mutex<()> = parking_lot::const_mutex(());
89
90impl Jail {
91    /// Creates a new jail that calls `f`, passing itself to `f`.
92    ///
93    /// # Panics
94    ///
95    /// Panics if `f` panics or if [`Jail::try_with(f)`](Jail::try_with) returns
96    /// an `Err`; prints the error message.
97    ///
98    /// # Example
99    ///
100    /// ```rust
101    /// figment2::Jail::expect_with(|jail| {
102    ///     /* in the jail */
103    ///
104    ///     Ok(())
105    /// });
106    /// ```
107    #[track_caller]
108    pub fn expect_with<F: FnOnce(&mut Jail) -> Result<()>>(f: F) {
109        if let Err(e) = Jail::try_with(f) {
110            panic!("jail failed: {}", e)
111        }
112    }
113
114    /// Creates a new jail that calls `f`, passing itself to `f`. Returns the
115    /// result from `f` if `f` does not panic.
116    ///
117    /// # Panics
118    ///
119    /// Panics if `f` panics.
120    ///
121    /// # Example
122    ///
123    /// ```rust
124    /// let result = figment2::Jail::try_with(|jail| {
125    ///     /* in the jail */
126    ///
127    ///     Ok(())
128    /// });
129    /// ```
130    #[track_caller]
131    pub fn try_with<F: FnOnce(&mut Jail) -> Result<()>>(f: F) -> Result<()> {
132        let _lock = LOCK.lock();
133        let directory = TempDir::new().map_err(as_string)?;
134        let mut jail = Jail {
135            canonical_dir: directory.path().canonicalize().map_err(as_string)?,
136            _directory: directory,
137            saved_cwd: std::env::current_dir().map_err(as_string)?,
138            saved_env_vars: HashMap::new(),
139        };
140
141        std::env::set_current_dir(jail.directory()).map_err(as_string)?;
142        f(&mut jail)
143    }
144
145    /// Returns the directory the jail has switched into. The contents of this
146    /// directory will be cleared when `Jail` is dropped.
147    ///
148    /// # Example
149    ///
150    /// ```rust
151    /// figment2::Jail::expect_with(|jail| {
152    ///     let tmp_directory = jail.directory();
153    ///
154    ///     Ok(())
155    /// });
156    /// ```
157    pub fn directory(&self) -> &Path {
158        &self.canonical_dir
159    }
160
161    fn safe_jailed_path(&self, path: &Path) -> Result<PathBuf> {
162        let path = dedot(path);
163        if path.is_absolute() && path.starts_with(self.directory()) {
164            return Ok(path);
165        }
166
167        if !path.is_relative() {
168            return Err("Jail: input path is outside of jail directory".to_string().into());
169        }
170
171        Ok(path)
172    }
173
174    /// Creates a file with contents `contents` within the jail's directory. The
175    /// file is deleted when the jail is dropped.
176    ///
177    /// # Errors
178    ///
179    /// An error is returned if `path` is not relative or is outside of the
180    /// jail's directory. I/O errors while creating the file are returned.
181    ///
182    /// # Example
183    ///
184    /// ```rust
185    /// figment2::Jail::expect_with(|jail| {
186    ///     jail.create_file("MyConfig.json", "contents...")?;
187    ///     Ok(())
188    /// });
189    /// ```
190    pub fn create_file<P: AsRef<Path>>(&self, path: P, contents: &str) -> Result<File> {
191        self.create_binary(path.as_ref(), contents.as_bytes())
192    }
193
194    /// Creates a file with binary contents `bytes` within the jail's directory.
195    /// The file is deleted when the jail is dropped.
196    ///
197    /// # Errors
198    ///
199    /// An error is returned if `path` is not relative or is outside of the
200    /// jail's directory. I/O errors while creating the file are returned.
201    ///
202    /// # Example
203    ///
204    /// ```rust
205    /// figment2::Jail::expect_with(|jail| {
206    ///     jail.create_binary("file.bin", &[0xFF, 0x4F, 0xFF, 0x51])?;
207    ///     Ok(())
208    /// });
209    /// ```
210    pub fn create_binary<P: AsRef<Path>>(&self, path: P, bytes: &[u8]) -> Result<File> {
211        let path = self.safe_jailed_path(path.as_ref())?;
212        let file = File::create(path).map_err(as_string)?;
213        let mut writer = BufWriter::new(file);
214        writer.write_all(bytes).map_err(as_string)?;
215        Ok(writer.into_inner().map_err(as_string)?)
216    }
217
218    /// Creates a directory at `path` within the jail's directory and returns
219    /// the relative path to the subdirectory in the jail. Recursively creates
220    /// directories for all of its parent components if they are missing.
221    ///
222    /// The directory and all of its contents are deleted when the jail is
223    /// dropped.
224    ///
225    /// # Errors
226    ///
227    /// An error is returned if `path` is not relative or is outside of the
228    /// jail's directory. Any I/O errors encountered while creating the
229    /// subdirectory are returned.
230    ///
231    /// # Example
232    ///
233    /// ```rust
234    /// use std::path::Path;
235    ///
236    /// figment2::Jail::expect_with(|jail| {
237    ///     let dir = jail.create_dir("subdir")?;
238    ///     jail.create_file(dir.join("config.json"), "{ foo: 123 }")?;
239    ///
240    ///     let dir = jail.create_dir("subdir/1/2")?;
241    ///     jail.create_file(dir.join("secret.toml"), "secret = 1337")?;
242    ///
243    ///     Ok(())
244    /// });
245    /// ```
246    pub fn create_dir<P: AsRef<Path>>(&self, path: P) -> Result<PathBuf> {
247        let path = self.safe_jailed_path(path.as_ref())?;
248        fs::create_dir_all(&path).map_err(as_string)?;
249        Ok(path)
250    }
251
252    /// Sets the jail's current working directory to `path` if `path` is within
253    /// [`Jail::directory()`]. Otherwise returns an error.
254    ///
255    /// # Errors
256    ///
257    /// An error is returned if `path` is not relative or is outside of the
258    /// jail's directory. Any I/O errors encountered while creating the
259    /// subdirectory are returned.
260    ///
261    /// # Example
262    ///
263    /// ```rust
264    /// use std::path::Path;
265    ///
266    /// figment2::Jail::expect_with(|jail| {
267    ///     assert_eq!(std::env::current_dir().unwrap(), jail.directory());
268    ///
269    ///     let subdir = jail.create_dir("subdir")?;
270    ///     jail.change_dir(&subdir)?;
271    ///     assert_eq!(std::env::current_dir().unwrap(), jail.directory().join(subdir));
272    ///
273    ///     let file = jail.create_file("foo.txt", "contents")?;
274    ///     assert!(!jail.directory().join("foo.txt").exists());
275    ///     assert!(jail.directory().join("subdir").join("foo.txt").exists());
276    ///
277    ///     jail.change_dir(jail.directory())?;
278    ///     assert_eq!(std::env::current_dir().unwrap(), jail.directory());
279    ///
280    ///     Ok(())
281    /// });
282    /// ```
283    pub fn change_dir<P: AsRef<Path>>(&self, path: P) -> Result<PathBuf> {
284        let path = self.safe_jailed_path(path.as_ref())?;
285        std::env::set_current_dir(&path).map_err(as_string)?;
286        Ok(path)
287    }
288
289    /// Remove all environment variables. All variables will be restored when
290    /// the jail is dropped.
291    ///
292    /// # Example
293    ///
294    /// ```rust
295    /// let init_count = std::env::vars_os().count();
296    ///
297    /// figment2::Jail::expect_with(|jail| {
298    ///     // We start with _something_ in the env vars.
299    ///     assert!(std::env::vars_os().count() != 0);
300    ///
301    ///     // Clear them all, and it's empty!
302    ///     jail.clear_env();
303    ///     assert!(std::env::vars_os().count() == 0);
304    ///
305    ///     // Set a value.
306    ///     jail.set_env("FIGMENT_SPECIAL_JAIL_VALUE", "value");
307    ///     assert!(std::env::vars_os().count() == 1);
308    ///
309    ///     // If we clear again, the new values are removed.
310    ///     jail.clear_env();
311    ///     assert!(std::env::vars_os().count() == 0);
312    ///
313    ///     Ok(())
314    /// });
315    ///
316    /// // After the drop, we have our original env vars.
317    /// assert!(std::env::vars_os().count() == init_count);
318    /// assert!(std::env::var("FIGMENT_SPECIAL_JAIL_VALUE").is_err());
319    /// ```
320    pub fn clear_env(&mut self) {
321        for (key, val) in std::env::vars_os() {
322            std::env::remove_var(&key);
323            self.saved_env_vars.entry(key).or_insert(Some(val));
324        }
325    }
326
327    /// Set the environment variable `k` to value `v`. The variable will be
328    /// removed when the jail is dropped.
329    ///
330    /// # Example
331    ///
332    /// ```rust
333    /// const VAR_NAME: &str = "my-very-special-figment-var";
334    ///
335    /// assert!(std::env::var(VAR_NAME).is_err());
336    ///
337    /// figment2::Jail::expect_with(|jail| {
338    ///     jail.set_env(VAR_NAME, "value");
339    ///     assert!(std::env::var(VAR_NAME).is_ok());
340    ///     Ok(())
341    /// });
342    ///
343    /// assert!(std::env::var(VAR_NAME).is_err());
344    /// ```
345    pub fn set_env<K: AsRef<str>, V: Display>(&mut self, k: K, v: V) {
346        let key = k.as_ref();
347        if !self.saved_env_vars.contains_key(OsStr::new(key)) {
348            self.saved_env_vars.insert(key.into(), std::env::var_os(key));
349        }
350
351        std::env::set_var(key, v.to_string());
352    }
353}
354
355impl Drop for Jail {
356    fn drop(&mut self) {
357        for (key, value) in &self.saved_env_vars {
358            match value {
359                Some(val) => std::env::set_var(key, val),
360                None => std::env::remove_var(key)
361            }
362        }
363
364        let _ = std::env::set_current_dir(&self.saved_cwd);
365    }
366}