Skip to main content

lean_ctx_fs/
confined_write.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Writes that stay below a trusted root and never follow a symlink on the way.
3//!
4//! A destination is a trusted `root` plus a relative path of plain components.
5//! Every component below the root — each directory and the file itself — must
6//! be a real directory or regular file (or not exist yet). A symlinked
7//! component, dangling or not, refuses the write: no byte is written and the
8//! error names the component.
9//!
10//! On Unix the write walks the path with directory handles: each directory is
11//! opened `O_DIRECTORY | O_NOFOLLOW` relative to its parent's handle, missing
12//! ones are created with `mkdirat`, the content is staged with
13//! `openat(O_CREAT | O_EXCL | O_NOFOLLOW)` and moved into place with
14//! `renameat`, which replaces a directory entry and never follows one. A
15//! symlink swapped in after the check therefore cannot redirect the write.
16//!
17//! On other platforms the same checks run on paths (`symlink_metadata` per
18//! component) immediately before a same-directory temp + rename. A component
19//! swapped for a link between that check and the rename remains a residual
20//! race there; Windows needs write access to the project directory for it.
21
22use std::ffi::OsString;
23use std::fmt;
24use std::path::{Component, Path, PathBuf};
25
26/// Why a confined write was refused or failed.
27#[derive(Debug)]
28pub enum ConfinedWriteError {
29    /// The relative path is empty, absolute, or holds `..`, `.` or a prefix.
30    InvalidPath(PathBuf),
31    /// A component below the root is a symlink (or reparse point).
32    Symlink(PathBuf),
33    /// A directory component exists but is not a directory.
34    NotADirectory(PathBuf),
35    /// The destination exists but is not a regular file.
36    NotAFile(PathBuf),
37    Io(PathBuf, std::io::Error),
38}
39
40impl fmt::Display for ConfinedWriteError {
41    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
42        match self {
43            Self::InvalidPath(p) => write!(
44                f,
45                "refusing write: {} is not a relative path of plain components",
46                p.display()
47            ),
48            Self::Symlink(p) => write!(
49                f,
50                "refusing write: {} is a symlink; data paths must not contain symlinked components",
51                p.display()
52            ),
53            Self::NotADirectory(p) => {
54                write!(f, "refusing write: {} is not a directory", p.display())
55            }
56            Self::NotAFile(p) => write!(f, "refusing write: {} is not a regular file", p.display()),
57            Self::Io(p, e) => write!(f, "write failed at {}: {e}", p.display()),
58        }
59    }
60}
61
62impl std::error::Error for ConfinedWriteError {}
63
64/// The plain components of `rel`, or why it is not a confined relative path.
65fn plain_components(rel: &Path) -> Result<Vec<OsString>, ConfinedWriteError> {
66    let mut out = Vec::new();
67    for component in rel.components() {
68        match component {
69            Component::Normal(name) => out.push(name.to_os_string()),
70            _ => return Err(ConfinedWriteError::InvalidPath(rel.to_path_buf())),
71        }
72    }
73    if out.is_empty() {
74        return Err(ConfinedWriteError::InvalidPath(rel.to_path_buf()));
75    }
76    Ok(out)
77}
78
79fn is_link(meta: &std::fs::Metadata) -> bool {
80    if meta.file_type().is_symlink() {
81        return true;
82    }
83    #[cfg(windows)]
84    {
85        use std::os::windows::fs::MetadataExt;
86        const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400;
87        if meta.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
88            return true;
89        }
90    }
91    false
92}
93
94/// Rebuild `root/rel` component by component, refusing every symlinked
95/// component below `root`. Missing components are fine (they would be created
96/// by [`write_below`]); nothing is created here. The last component may be a
97/// regular file or a directory (so a directory below the root can be checked
98/// too); [`write_below`] still refuses to replace a directory. `root` itself is
99/// trusted and may be reached through a link.
100pub fn rebuild_below(root: &Path, rel: &Path) -> Result<PathBuf, ConfinedWriteError> {
101    let names = plain_components(rel)?;
102    let last = names.len() - 1;
103    let mut current = root.to_path_buf();
104    for (index, name) in names.iter().enumerate() {
105        current.push(name);
106        match std::fs::symlink_metadata(&current) {
107            Ok(meta) if is_link(&meta) => return Err(ConfinedWriteError::Symlink(current)),
108            Ok(meta) if index < last && !meta.is_dir() => {
109                return Err(ConfinedWriteError::NotADirectory(current));
110            }
111            Ok(meta) if index == last && !meta.is_file() && !meta.is_dir() => {
112                return Err(ConfinedWriteError::NotAFile(current));
113            }
114            Ok(_) => {}
115            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
116                // Everything below a missing directory is missing too.
117                current.extend(&names[index + 1..]);
118                return Ok(current);
119            }
120            Err(e) => return Err(ConfinedWriteError::Io(current, e)),
121        }
122    }
123    Ok(current)
124}
125
126/// Write `bytes` to `root/rel` crash-atomically without following a symlink
127/// below `root`, creating missing directories (mode 0700 on Unix). The file
128/// gets `mode` on Unix (ignored elsewhere). Returns the written path.
129pub fn write_below(
130    root: &Path,
131    rel: &Path,
132    bytes: &[u8],
133    mode: u32,
134) -> Result<PathBuf, ConfinedWriteError> {
135    // Path-level pass first: a clear error naming the component, and the only
136    // check on platforms without directory handles.
137    let destination = rebuild_below(root, rel)?;
138    let names = plain_components(rel)?;
139    imp::write(root, &names, bytes, mode)?;
140    Ok(destination)
141}
142
143fn temp_name(name: &std::ffi::OsStr) -> OsString {
144    use std::sync::atomic::{AtomicU64, Ordering};
145    static COUNTER: AtomicU64 = AtomicU64::new(0);
146    let nanos = std::time::SystemTime::now()
147        .duration_since(std::time::UNIX_EPOCH)
148        .map_or(0, |d| d.subsec_nanos());
149    let mut out = OsString::from(".");
150    out.push(name);
151    out.push(format!(
152        ".{}.{nanos}.{}.tmp",
153        std::process::id(),
154        COUNTER.fetch_add(1, Ordering::Relaxed)
155    ));
156    out
157}
158
159#[cfg(unix)]
160mod imp {
161    use super::{ConfinedWriteError, temp_name};
162    use std::ffi::{CString, OsStr, OsString};
163    use std::io::Write;
164    use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
165    use std::os::unix::ffi::OsStrExt;
166    use std::path::{Path, PathBuf};
167
168    fn c_name(name: &OsStr, at: &Path) -> Result<CString, ConfinedWriteError> {
169        CString::new(name.as_bytes()).map_err(|_| ConfinedWriteError::InvalidPath(at.to_path_buf()))
170    }
171
172    fn last_error(at: &Path) -> ConfinedWriteError {
173        ConfinedWriteError::Io(at.to_path_buf(), std::io::Error::last_os_error())
174    }
175
176    /// `fstatat(AT_SYMLINK_NOFOLLOW)` of `name` in `dir`; `None` when missing.
177    fn lstat_at(
178        dir: &OwnedFd,
179        name: &CString,
180        at: &Path,
181    ) -> Result<Option<libc::stat>, ConfinedWriteError> {
182        let mut st = std::mem::MaybeUninit::<libc::stat>::uninit();
183        // SAFETY: `dir` is an open directory handle, `name` is NUL-terminated
184        // and `st` points to writable storage of the right size.
185        let rc = unsafe {
186            libc::fstatat(
187                dir.as_raw_fd(),
188                name.as_ptr(),
189                st.as_mut_ptr(),
190                libc::AT_SYMLINK_NOFOLLOW,
191            )
192        };
193        if rc == 0 {
194            // SAFETY: fstatat returned 0, so it initialised `st`.
195            return Ok(Some(unsafe { st.assume_init() }));
196        }
197        let error = std::io::Error::last_os_error();
198        if error.kind() == std::io::ErrorKind::NotFound {
199            return Ok(None);
200        }
201        Err(ConfinedWriteError::Io(at.to_path_buf(), error))
202    }
203
204    fn classify(st: &libc::stat, at: &Path, want_dir: bool) -> Result<(), ConfinedWriteError> {
205        let kind = st.st_mode & libc::S_IFMT;
206        if kind == libc::S_IFLNK {
207            return Err(ConfinedWriteError::Symlink(at.to_path_buf()));
208        }
209        if want_dir && kind != libc::S_IFDIR {
210            return Err(ConfinedWriteError::NotADirectory(at.to_path_buf()));
211        }
212        if !want_dir && kind != libc::S_IFREG {
213            return Err(ConfinedWriteError::NotAFile(at.to_path_buf()));
214        }
215        Ok(())
216    }
217
218    fn open_dir_at(dir: &OwnedFd, name: &CString) -> std::io::Result<OwnedFd> {
219        // SAFETY: `dir` is an open directory handle and `name` is
220        // NUL-terminated; the returned descriptor is owned below.
221        let fd = unsafe {
222            libc::openat(
223                dir.as_raw_fd(),
224                name.as_ptr(),
225                libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC,
226            )
227        };
228        if fd < 0 {
229            return Err(std::io::Error::last_os_error());
230        }
231        // SAFETY: `fd` is a fresh descriptor nothing else owns.
232        Ok(unsafe { OwnedFd::from_raw_fd(fd) })
233    }
234
235    /// Open (creating when missing) the directory `name` in `dir` without
236    /// following a symlink.
237    fn descend(dir: &OwnedFd, name: &OsStr, at: &Path) -> Result<OwnedFd, ConfinedWriteError> {
238        let c = c_name(name, at)?;
239        for _ in 0..2 {
240            match open_dir_at(dir, &c) {
241                Ok(fd) => return Ok(fd),
242                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
243                    // SAFETY: `dir` is an open directory handle and `c` is
244                    // NUL-terminated.
245                    let rc = unsafe { libc::mkdirat(dir.as_raw_fd(), c.as_ptr(), 0o700) };
246                    if rc != 0 {
247                        let error = std::io::Error::last_os_error();
248                        if error.kind() != std::io::ErrorKind::AlreadyExists {
249                            return Err(ConfinedWriteError::Io(at.to_path_buf(), error));
250                        }
251                    }
252                }
253                // ELOOP / ENOTDIR: a link or a non-directory sits here; say which.
254                Err(e) => {
255                    return match lstat_at(dir, &c, at)? {
256                        Some(st) => {
257                            classify(&st, at, true)?;
258                            Err(ConfinedWriteError::Io(at.to_path_buf(), e))
259                        }
260                        None => Err(ConfinedWriteError::Io(at.to_path_buf(), e)),
261                    };
262                }
263            }
264        }
265        Err(ConfinedWriteError::Io(
266            at.to_path_buf(),
267            std::io::Error::other("directory vanished while it was created"),
268        ))
269    }
270
271    pub(super) fn write(
272        root: &Path,
273        names: &[OsString],
274        bytes: &[u8],
275        mode: u32,
276    ) -> Result<(), ConfinedWriteError> {
277        let root_c = CString::new(root.as_os_str().as_bytes())
278            .map_err(|_| ConfinedWriteError::InvalidPath(root.to_path_buf()))?;
279        // The root is trusted: follow it, but it must be a directory.
280        // SAFETY: `root_c` is NUL-terminated; the descriptor is owned below.
281        let fd = unsafe {
282            libc::open(
283                root_c.as_ptr(),
284                libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC,
285            )
286        };
287        if fd < 0 {
288            return Err(last_error(root));
289        }
290        // SAFETY: `fd` is a fresh descriptor nothing else owns.
291        let mut dir = unsafe { OwnedFd::from_raw_fd(fd) };
292        let mut at = PathBuf::from(root);
293        let (file_name, parents) = names.split_last().expect("plain_components is never empty");
294        for name in parents {
295            at.push(name);
296            dir = descend(&dir, name, &at)?;
297        }
298        at.push(file_name);
299        let target = c_name(file_name, &at)?;
300        if let Some(st) = lstat_at(&dir, &target, &at)? {
301            classify(&st, &at, false)?;
302        }
303
304        let tmp = temp_name(file_name);
305        let tmp_c = c_name(&tmp, &at)?;
306        // SAFETY: `dir` is an open directory handle and `tmp_c` is
307        // NUL-terminated; O_EXCL|O_NOFOLLOW never opens an existing entry.
308        let fd = unsafe {
309            libc::openat(
310                dir.as_raw_fd(),
311                tmp_c.as_ptr(),
312                libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_NOFOLLOW | libc::O_CLOEXEC,
313                mode as libc::c_uint,
314            )
315        };
316        if fd < 0 {
317            return Err(last_error(&at));
318        }
319        // SAFETY: `fd` is a fresh descriptor nothing else owns.
320        let mut file = std::fs::File::from(unsafe { OwnedFd::from_raw_fd(fd) });
321        let staged = file
322            .write_all(bytes)
323            .and_then(|()| file.sync_all())
324            .map_err(|e| ConfinedWriteError::Io(at.clone(), e));
325        drop(file);
326        let unlink_tmp = || {
327            // SAFETY: as above; removing our own staged entry.
328            unsafe { libc::unlinkat(dir.as_raw_fd(), tmp_c.as_ptr(), 0) };
329        };
330        if let Err(e) = staged {
331            unlink_tmp();
332            return Err(e);
333        }
334        // renameat replaces the directory entry `target` itself: a symlink
335        // planted there after the check is replaced, never followed.
336        // SAFETY: both names are NUL-terminated and relative to `dir`.
337        let rc = unsafe {
338            libc::renameat(
339                dir.as_raw_fd(),
340                tmp_c.as_ptr(),
341                dir.as_raw_fd(),
342                target.as_ptr(),
343            )
344        };
345        if rc != 0 {
346            let error = last_error(&at);
347            unlink_tmp();
348            return Err(error);
349        }
350        // SAFETY: `dir` is an open directory handle.
351        unsafe { libc::fsync(dir.as_raw_fd()) };
352        Ok(())
353    }
354}
355
356#[cfg(not(unix))]
357mod imp {
358    use super::{ConfinedWriteError, rebuild_below, temp_name};
359    use std::ffi::OsString;
360    use std::path::{Path, PathBuf};
361
362    /// Residual race (documented in the module header): without directory
363    /// handles the checks run on paths immediately before the rename.
364    pub(super) fn write(
365        root: &Path,
366        names: &[OsString],
367        bytes: &[u8],
368        _mode: u32,
369    ) -> Result<(), ConfinedWriteError> {
370        let rel: PathBuf = names.iter().collect();
371        let (file_name, parents) = names.split_last().expect("plain_components is never empty");
372        let mut dir = root.to_path_buf();
373        for name in parents {
374            dir.push(name);
375            match std::fs::create_dir(&dir) {
376                Ok(()) => {}
377                Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
378                Err(e) => return Err(ConfinedWriteError::Io(dir, e)),
379            }
380            rebuild_below(root, &rel)?;
381        }
382        let target = dir.join(file_name);
383        let tmp = dir.join(temp_name(file_name));
384        let mut options = std::fs::OpenOptions::new();
385        options.write(true).create_new(true);
386        let staged = options.open(&tmp).and_then(|mut file| {
387            use std::io::Write;
388            file.write_all(bytes)?;
389            file.sync_all()
390        });
391        if let Err(e) = staged {
392            let _ = std::fs::remove_file(&tmp);
393            return Err(ConfinedWriteError::Io(target, e));
394        }
395        if let Err(e) = rebuild_below(root, &rel) {
396            let _ = std::fs::remove_file(&tmp);
397            return Err(e);
398        }
399        crate::atomic_fs::replace_file(&tmp, &target).map_err(|e| {
400            let _ = std::fs::remove_file(&tmp);
401            ConfinedWriteError::Io(target, e)
402        })
403    }
404}
405
406#[cfg(all(test, unix))]
407mod tests {
408    use super::*;
409    use std::os::unix::fs::symlink;
410
411    #[test]
412    fn writes_and_creates_missing_directories() {
413        let root = tempfile::tempdir().unwrap();
414        let path = write_below(root.path(), Path::new("a/b/c.json"), b"{}", 0o600).unwrap();
415        assert_eq!(path, root.path().join("a/b/c.json"));
416        assert_eq!(std::fs::read(&path).unwrap(), b"{}");
417        // Overwrite keeps working.
418        write_below(root.path(), Path::new("a/b/c.json"), b"[]", 0o600).unwrap();
419        assert_eq!(std::fs::read(&path).unwrap(), b"[]");
420        let leftovers: Vec<_> = std::fs::read_dir(root.path().join("a/b"))
421            .unwrap()
422            .flatten()
423            .map(|e| e.file_name())
424            .collect();
425        assert_eq!(leftovers.len(), 1, "no staged temp left: {leftovers:?}");
426    }
427
428    #[test]
429    fn a_trusted_root_may_be_a_link() {
430        let tmp = tempfile::tempdir().unwrap();
431        let real = tmp.path().join("real");
432        std::fs::create_dir(&real).unwrap();
433        let link = tmp.path().join("link");
434        symlink(&real, &link).unwrap();
435        write_below(&link, Path::new("x/y"), b"ok", 0o600).unwrap();
436        assert_eq!(std::fs::read(real.join("x/y")).unwrap(), b"ok");
437    }
438
439    #[test]
440    fn a_symlinked_parent_directory_is_refused() {
441        let tmp = tempfile::tempdir().unwrap();
442        let root = tmp.path().join("root");
443        let outside = tmp.path().join("outside");
444        std::fs::create_dir_all(&root).unwrap();
445        std::fs::create_dir_all(&outside).unwrap();
446        symlink(&outside, root.join("a")).unwrap();
447        let err = write_below(&root, Path::new("a/f"), b"x", 0o600).unwrap_err();
448        assert!(matches!(err, ConfinedWriteError::Symlink(ref p) if p == &root.join("a")));
449        assert!(!outside.join("f").exists());
450    }
451
452    #[test]
453    fn a_symlinked_target_file_is_refused() {
454        let tmp = tempfile::tempdir().unwrap();
455        let root = tmp.path().join("root");
456        std::fs::create_dir_all(root.join("d")).unwrap();
457        let victim = tmp.path().join("victim");
458        std::fs::write(&victim, b"keep").unwrap();
459        symlink(&victim, root.join("d/f")).unwrap();
460        let err = write_below(&root, Path::new("d/f"), b"x", 0o600).unwrap_err();
461        assert!(matches!(err, ConfinedWriteError::Symlink(_)), "{err}");
462        assert_eq!(std::fs::read(&victim).unwrap(), b"keep");
463        assert!(
464            std::fs::symlink_metadata(root.join("d/f"))
465                .unwrap()
466                .file_type()
467                .is_symlink()
468        );
469    }
470
471    #[test]
472    fn a_dangling_symlink_is_refused() {
473        let tmp = tempfile::tempdir().unwrap();
474        let root = tmp.path().join("root");
475        std::fs::create_dir_all(&root).unwrap();
476        let missing = tmp.path().join("missing");
477        symlink(&missing, root.join("d")).unwrap();
478        symlink(tmp.path().join("missing-file"), root.join("f")).unwrap();
479        assert!(matches!(
480            write_below(&root, Path::new("d/f"), b"x", 0o600),
481            Err(ConfinedWriteError::Symlink(_))
482        ));
483        assert!(matches!(
484            write_below(&root, Path::new("f"), b"x", 0o600),
485            Err(ConfinedWriteError::Symlink(_))
486        ));
487        assert!(!missing.exists());
488        assert!(!tmp.path().join("missing-file").exists());
489    }
490
491    #[test]
492    fn non_plain_relative_paths_are_refused() {
493        let root = tempfile::tempdir().unwrap();
494        for rel in ["", "../x", "a/../b", "/etc/passwd", "./a"] {
495            assert!(
496                matches!(
497                    write_below(root.path(), Path::new(rel), b"x", 0o600),
498                    Err(ConfinedWriteError::InvalidPath(_))
499                ),
500                "{rel}"
501            );
502        }
503    }
504
505    #[test]
506    fn a_directory_at_the_destination_is_refused() {
507        let root = tempfile::tempdir().unwrap();
508        std::fs::create_dir_all(root.path().join("d")).unwrap();
509        assert!(matches!(
510            write_below(root.path(), Path::new("d"), b"x", 0o600),
511            Err(ConfinedWriteError::NotAFile(_))
512        ));
513    }
514
515    /// The handle walk itself refuses a link that the path pass did not see
516    /// (the swap-after-check case).
517    #[test]
518    fn the_handle_walk_refuses_a_link_swapped_in_after_the_check() {
519        let tmp = tempfile::tempdir().unwrap();
520        let root = tmp.path().join("root");
521        let outside = tmp.path().join("outside");
522        std::fs::create_dir_all(&root).unwrap();
523        std::fs::create_dir_all(&outside).unwrap();
524        symlink(&outside, root.join("a")).unwrap();
525        let names = plain_components(Path::new("a/f")).unwrap();
526        let err = imp::write(&root, &names, b"x", 0o600).unwrap_err();
527        assert!(matches!(err, ConfinedWriteError::Symlink(_)), "{err}");
528        assert!(!outside.join("f").exists());
529    }
530}