fs_mistrust/file_access.rs
1//! Functionality for opening files while verifying their permissions.
2
3use std::{
4 borrow::Cow,
5 fs::{File, OpenOptions},
6 io::{Read as _, Write},
7 path::{Path, PathBuf},
8};
9
10#[cfg(unix)]
11use std::os::unix::fs::OpenOptionsExt as _;
12
13use crate::{CheckedDir, Error, Result, Verifier, dir::FullPathCheck, walk::PathType};
14
15/// Helper object for accessing a file on disk while checking the necessary permissions.
16///
17/// You can use a `FileAccess` when you want to read or write a file,
18/// while making sure that the file obeys the permissions rules of
19/// an associated [`CheckedDir`] or [`Verifier`].
20///
21/// `FileAccess` is a separate type from `CheckedDir` and `Verifier`
22/// so that you can set options to control the behavior of how files are opened.
23///
24/// Note: When we refer to a path _"obeying the constraints"_ of this `FileAccess`,
25/// we mean:
26/// * If the `FileAccess` wraps a `CheckedDir`, the requirement that it is a relative path
27/// containing no ".." elements,
28/// or other elements that would take it outside the `CheckedDir`.
29/// * If the `FileAccess` wraps a `Verifier`, there are no requirements.
30pub struct FileAccess<'a> {
31 /// Validator object that we use for checking file permissions.
32 pub(crate) inner: Inner<'a>,
33 /// If set, we create files with this mode.
34 #[cfg(unix)]
35 create_with_mode: Option<u32>,
36 /// If set, we follow final-position symlinks in provided paths.
37 follow_final_links: bool,
38}
39
40/// Inner object for checking file permissions.
41pub(crate) enum Inner<'a> {
42 /// A CheckedDir backing this FileAccess.
43 CheckedDir(&'a CheckedDir),
44 /// A Verifier backing this FileAccess.
45 Verifier(Verifier<'a>),
46}
47
48impl<'a> FileAccess<'a> {
49 /// Create a new `FileAccess` to access files within CheckedDir,
50 /// using default options.
51 pub(crate) fn from_checked_dir(checked_dir: &'a CheckedDir) -> Self {
52 Self::from_inner(Inner::CheckedDir(checked_dir))
53 }
54 /// Create a new `FileAccess` to access files anywhere on the filesystem,
55 /// using default options.
56 pub(crate) fn from_verifier(verifier: Verifier<'a>) -> Self {
57 Self::from_inner(Inner::Verifier(verifier))
58 }
59 /// Create a new `FileAccess` from `inner`,
60 /// using default options.
61 fn from_inner(inner: Inner<'a>) -> Self {
62 Self {
63 inner,
64 #[cfg(unix)]
65 create_with_mode: None,
66 follow_final_links: false,
67 }
68 }
69 /// Check path constraints on `path` and verify its permissions
70 /// (or the permissions of its parent) according to `check_type`
71 fn verified_full_path(&self, path: &Path, check_type: FullPathCheck) -> Result<PathBuf> {
72 match &self.inner {
73 Inner::CheckedDir(cd) => cd.verified_full_path(path, check_type),
74 Inner::Verifier(v) => {
75 let to_verify = match check_type {
76 FullPathCheck::CheckPath => path,
77 FullPathCheck::CheckParent => path.parent().unwrap_or(path),
78 };
79 v.check(to_verify)?;
80 Ok(path.into())
81 }
82 }
83 }
84 /// Return a `Verifier` to use for checking permissions.
85 fn verifier(&self) -> crate::Verifier {
86 match &self.inner {
87 Inner::CheckedDir(cd) => cd.verifier(),
88 Inner::Verifier(v) => v.clone(),
89 }
90 }
91 /// Return the location of `path` relative to this verifier.
92 ///
93 /// Fails if `path` does not [obey the constraints](FileAccess) of this `FileAccess`,
94 /// but does not do any permissions checking.
95 fn location_unverified<'b>(&self, path: &'b Path) -> Result<Cow<'b, Path>> {
96 Ok(match self.inner {
97 Inner::CheckedDir(cd) => cd.join(path)?.into(),
98 Inner::Verifier(_) => path.into(),
99 })
100 }
101
102 /// Configure this FileAccess: when used to create a file,
103 /// that file will be created with the provided unix permissions.
104 ///
105 /// If this option is not set, newly created files have mode 0600.
106 #[cfg_attr(not(unix), expect(unused_mut))]
107 #[cfg_attr(not(unix), expect(unused_variables))]
108 pub fn create_with_mode(mut self, mode: u32) -> Self {
109 #[cfg(unix)]
110 {
111 self.create_with_mode = Some(mode);
112 }
113 self
114 }
115
116 /// Configure this FileAccess: if the file to be accessed is a symlink,
117 /// and this is set to true, we will follow that symlink when creating or reading the file.
118 ///
119 /// By default, this option is false.
120 ///
121 /// Note that if you use this option with a `CheckedDir`,
122 /// it can read or write a file outside of the `CheckedDir`,
123 /// which might not be what you wanted.
124 ///
125 /// This option does not affect the handling of links that are _not_
126 /// in the final position of the path.
127 ///
128 /// This option does not disable the regular `fs-mistrust` checks:
129 /// we still ensure that the link's target, and its location, are not
130 /// modifiable by an untrusted user.
131 pub fn follow_final_links(mut self, follow: bool) -> Self {
132 self.follow_final_links = follow;
133 self
134 }
135
136 /// Open a file relative to this `FileAccess`, using a set of [`OpenOptions`].
137 ///
138 /// `path` must be a path to the new file, [obeying the constraints](FileAccess) of this `FileAccess`.
139 /// We check, but do not create, the file's parent directories.
140 /// We check the file's permissions after opening it.
141 ///
142 /// If the file is created (and this is a unix-like operating system), we
143 /// always create it with a mode based on [`create_with_mode()`](Self::create_with_mode),
144 /// regardless of any mode set in `options`.
145 /// If `create_with_mode()` wasn't called, we create the file with mode 600.
146 //
147 // Note: This function, and others, take ownership of `self`, to prevent people from storing and
148 // reusing FileAccess objects. We're avoiding that because we don't want people confusing
149 // FileAccess objects created with CheckedDir and Verifier.
150 pub fn open<P: AsRef<Path>>(self, path: P, options: &OpenOptions) -> Result<File> {
151 self.open_internal(path.as_ref(), options)
152 }
153
154 /// As `open`, but take `self` by reference. For internal use.
155 fn open_internal(&self, path: &Path, options: &OpenOptions) -> Result<File> {
156 let follow_links = self.follow_final_links;
157
158 // If we're following links, then we want to look at the whole path,
159 // since the final element might be a link. If so, we need to look at
160 // where it is linking to, and validate that as well.
161 let check_type = if follow_links {
162 FullPathCheck::CheckPath
163 } else {
164 FullPathCheck::CheckParent
165 };
166
167 let path = match self.verified_full_path(path.as_ref(), check_type) {
168 Ok(path) => path.into(),
169 // We tolerate a not-found error if we're following links:
170 // - If the final element of the path is what wasn't found, then we might create it
171 // ourselves when we open it.
172 // - If an earlier element of the path wasn't found, we will get a second NotFound error
173 // when we try to open the file, which is okay.
174 Err(Error::NotFound(_)) if follow_links => self.location_unverified(path.as_ref())?,
175 Err(e) => return Err(e),
176 };
177
178 #[allow(unused_mut)]
179 let mut options = options.clone();
180
181 #[cfg(unix)]
182 {
183 let create_mode = self.create_with_mode.unwrap_or(0o600);
184 options.mode(create_mode);
185 // Don't follow symlinks out of a secure directory.
186 if !follow_links {
187 options.custom_flags(libc::O_NOFOLLOW);
188 }
189 }
190
191 let file = options
192 .open(&path)
193 .map_err(|e| Error::io(e, path.as_ref(), "open file"))?;
194 let meta = file
195 .metadata()
196 .map_err(|e| Error::inspecting(e, path.as_ref()))?;
197
198 if let Some(error) = self
199 .verifier()
200 .check_one(path.as_ref(), PathType::Content, &meta)
201 .into_iter()
202 .next()
203 {
204 Err(error)
205 } else {
206 Ok(file)
207 }
208 }
209
210 /// Read the contents of the file at `path` relative to this `FileAccess`, as a
211 /// String, if possible.
212 ///
213 /// Return an error if `path` is absent, if its permissions are incorrect,
214 /// if it does not [obey the constraints](FileAccess) of this `FileAccess`,
215 /// or if its contents are not UTF-8.
216 pub fn read_to_string<P: AsRef<Path>>(self, path: P) -> Result<String> {
217 let path = path.as_ref();
218 let mut file = self.open(path, OpenOptions::new().read(true))?;
219 let mut result = String::new();
220 file.read_to_string(&mut result)
221 .map_err(|e| Error::io(e, path, "read file"))?;
222 Ok(result)
223 }
224
225 /// Read the contents of the file at `path` relative to this `FileAccess`, as a
226 /// vector of bytes, if possible.
227 ///
228 /// Return an error if `path` is absent, if its permissions are incorrect,
229 /// or if it does not [obey the constraints](FileAccess) of this `FileAccess`.
230 pub fn read<P: AsRef<Path>>(self, path: P) -> Result<Vec<u8>> {
231 let path = path.as_ref();
232 let mut file = self.open(path, OpenOptions::new().read(true))?;
233 let mut result = Vec::new();
234 file.read_to_end(&mut result)
235 .map_err(|e| Error::io(e, path, "read file"))?;
236 Ok(result)
237 }
238
239 /// Store `contents` into the file located at `path` relative to this `FileAccess`.
240 ///
241 /// We won't write to `path` directly: instead, we'll write to a temporary
242 /// file in the same directory as `path`, and then replace `path` with that
243 /// temporary file if we were successful. (This isn't truly atomic on all
244 /// file systems, but it's closer than many alternatives.)
245 ///
246 /// # Limitations
247 ///
248 /// This function will clobber any existing files with the same name as
249 /// `path` but with the extension `tmp`. (That is, if you are writing to
250 /// "foo.txt", it will replace "foo.tmp" in the same directory.)
251 ///
252 /// This function may give incorrect behavior if multiple threads or
253 /// processes are writing to the same file at the same time: it is the
254 /// programmer's responsibility to use appropriate locking to avoid this.
255 pub fn write_and_replace<P: AsRef<Path>, C: AsRef<[u8]>>(
256 self,
257 path: P,
258 contents: C,
259 ) -> Result<()> {
260 let path = path.as_ref();
261 let final_path = self.verified_full_path(path, FullPathCheck::CheckParent)?;
262
263 let tmp_name = path.with_extension("tmp");
264 // We remove the temporary file before opening it, if it's present: otherwise it _might_ be
265 // a symlink to somewhere silly.
266 let _ignore = std::fs::remove_file(&tmp_name);
267
268 // TODO: The parent directory verification performed by "open" here is redundant with that done in
269 // `verified_full_path` above.
270 let mut tmp_file = self.open_internal(
271 &tmp_name,
272 OpenOptions::new().create(true).truncate(true).write(true),
273 )?;
274
275 // Write the data.
276 tmp_file
277 .write_all(contents.as_ref())
278 .map_err(|e| Error::io(e, &tmp_name, "write to file"))?;
279 // Flush and close.
280 drop(tmp_file);
281
282 // Replace the old file.
283 std::fs::rename(
284 // It's okay to use location_unverified here, since we already verified it when we
285 // called `open`.
286 self.location_unverified(tmp_name.as_path())?,
287 final_path,
288 )
289 .map_err(|e| Error::io(e, path, "replace file"))?;
290 Ok(())
291 }
292}
293
294#[cfg(test)]
295mod test {
296 // @@ begin test lint list maintained by maint/add_warning @@
297 #![allow(clippy::bool_assert_comparison)]
298 #![allow(clippy::clone_on_copy)]
299 #![allow(clippy::dbg_macro)]
300 #![allow(clippy::mixed_attributes_style)]
301 #![allow(clippy::print_stderr)]
302 #![allow(clippy::print_stdout)]
303 #![allow(clippy::single_char_pattern)]
304 #![allow(clippy::unwrap_used)]
305 #![allow(clippy::unchecked_duration_subtraction)]
306 #![allow(clippy::useless_vec)]
307 #![allow(clippy::needless_pass_by_value)]
308 //! <!-- @@ end test lint list maintained by maint/add_warning @@ -->
309
310 #[cfg(unix)]
311 use std::fs;
312
313 use super::*;
314 use crate::{Mistrust, testing::Dir};
315
316 #[test]
317 fn create_public_in_checked_dir() {
318 let d = Dir::new();
319 d.dir("a");
320
321 d.chmod("a", 0o700);
322
323 let m = Mistrust::builder()
324 .ignore_prefix(d.canonical_root())
325 .build()
326 .unwrap();
327 let checked = m.verifier().secure_dir(d.path("a")).unwrap();
328
329 {
330 let mut f = checked
331 .file_access()
332 .open(
333 "private-1.txt",
334 OpenOptions::new().write(true).create_new(true),
335 )
336 .unwrap();
337 f.write_all(b"Hello world\n").unwrap();
338
339 checked
340 .file_access()
341 .write_and_replace("private-2.txt", b"Hello world 2\n")
342 .unwrap();
343 }
344 {
345 let mut f = checked
346 .file_access()
347 .create_with_mode(0o640)
348 .open(
349 "public-1.txt",
350 OpenOptions::new().write(true).create_new(true),
351 )
352 .unwrap();
353 f.write_all(b"Hello wider world\n").unwrap();
354
355 checked
356 .file_access()
357 .create_with_mode(0o644)
358 .write_and_replace("public-2.txt", b"Hello wider world 2")
359 .unwrap();
360 }
361
362 #[cfg(target_family = "unix")]
363 {
364 use std::os::unix::fs::MetadataExt;
365 assert_eq!(
366 fs::metadata(d.path("a/private-1.txt")).unwrap().mode() & 0o7777,
367 0o600
368 );
369 assert_eq!(
370 fs::metadata(d.path("a/private-2.txt")).unwrap().mode() & 0o7777,
371 0o600
372 );
373 assert_eq!(
374 fs::metadata(d.path("a/public-1.txt")).unwrap().mode() & 0o7777,
375 0o640
376 );
377 assert_eq!(
378 fs::metadata(d.path("a/public-2.txt")).unwrap().mode() & 0o7777,
379 0o644
380 );
381 }
382 }
383
384 #[test]
385 #[cfg(unix)]
386 fn open_symlinks() {
387 use crate::testing::LinkType;
388 let d = Dir::new();
389 d.dir("a");
390 d.dir("a/b");
391 d.dir("a/c");
392 d.file("a/c/file1.txt");
393 d.link_rel(LinkType::File, "../c/file1.txt", "a/b/present");
394 d.link_rel(LinkType::File, "../c/file2.txt", "a/b/absent");
395 d.chmod("a", 0o700);
396 d.chmod("a/b", 0o700);
397 d.chmod("a/c", 0o700);
398 d.chmod("a/c/file1.txt", 0o600);
399
400 let m = Mistrust::builder()
401 .ignore_prefix(d.canonical_root())
402 .build()
403 .unwrap();
404
405 // Try reading
406 let contents = m
407 .file_access()
408 .follow_final_links(true)
409 .read(d.path("a/b/present"))
410 .unwrap();
411 assert_eq!(
412 &contents[..],
413 &b"This space is intentionally left blank"[..]
414 );
415 let error = m
416 .file_access()
417 .follow_final_links(true)
418 .read(d.path("a/b/absent"))
419 .unwrap_err();
420 assert!(matches!(error, Error::NotFound(_)));
421
422 // Try writing.
423 {
424 let mut f = m
425 .file_access()
426 .follow_final_links(true)
427 .open(
428 d.path("a/b/present"),
429 OpenOptions::new().write(true).truncate(true),
430 )
431 .unwrap();
432 f.write_all(b"This is extremely serious!").unwrap();
433 }
434 let contents = m
435 .file_access()
436 .follow_final_links(true)
437 .read(d.path("a/b/present"))
438 .unwrap();
439 assert_eq!(&contents[..], &b"This is extremely serious!"[..]);
440
441 let contents = m.file_access().read(d.path("a/c/file1.txt")).unwrap();
442 assert_eq!(&contents[..], &b"This is extremely serious!"[..]);
443 {
444 let mut f = m
445 .file_access()
446 .follow_final_links(true)
447 .open(
448 d.path("a/b/absent"),
449 OpenOptions::new().create(true).write(true),
450 )
451 .unwrap();
452 f.write_all(b"This is extremely silly!").unwrap();
453 }
454 let contents = m.file_access().read(d.path("a/c/file2.txt")).unwrap();
455 assert_eq!(&contents[..], &b"This is extremely silly!"[..]);
456 }
457}