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}