Skip to main content

talon/
safe_fs.rs

1//! Descriptor-relative, symlink-safe access to agent-writable directories
2//! (MYTH-10).
3//!
4//! Everything below `groups/<folder>/` can be changed by the lane's
5//! (potentially compromised) container at any time, so path-based reads
6//! there are racy: `reports/` can be swapped for a symlink to a host
7//! directory between a check and the open, and a file checked with
8//! `lstat` can be replaced by a FIFO that blocks the reading thread
9//! forever. The helpers here therefore
10//!
11//! - open the operator-controlled root (`groups/`) normally and every
12//!   directory below it with `O_DIRECTORY | O_NOFOLLOW`, relative to the
13//!   already-open parent (`openat`), so later path swaps cannot redirect
14//!   the host;
15//! - open files with `O_NOFOLLOW | O_NONBLOCK | O_NOCTTY | O_CLOEXEC`
16//!   (symlinks fail, FIFOs cannot block) and decide on the *opened* inode
17//!   via `fstat`: regular file, single hard link, size within the limit;
18//! - read at most `limit + 1` bytes;
19//! - create files only with `O_CREAT | O_EXCL | O_NOFOLLOW`, so an existing
20//!   entry (including a dangling symlink) is never written through.
21//!
22//! Reference pattern: `crates/talon-ipc/src/fsio.rs` (not a dependency).
23
24use std::fs::{File, Metadata};
25use std::io::{self, Read, Write};
26use std::os::fd::{BorrowedFd, OwnedFd};
27use std::os::unix::fs::{FileTypeExt, MetadataExt};
28use std::path::Path;
29
30use rustix::fs::{self as rfs, AtFlags, Mode, OFlags};
31use rustix::io::Errno;
32
33fn dir_flags() -> OFlags {
34    OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC
35}
36
37fn file_read_flags() -> OFlags {
38    OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::NOCTTY | OFlags::CLOEXEC
39}
40
41fn is_symlink_errno(e: Errno) -> bool {
42    // Linux/macOS report O_NOFOLLOW on a symlink as ELOOP, FreeBSD as EMLINK.
43    e == Errno::LOOP || e == Errno::MLINK
44}
45
46fn unsafe_entry(name: &str, what: &str) -> io::Error {
47    io::Error::new(
48        io::ErrorKind::InvalidData,
49        format!("{name:?} is {what}; refusing to follow it"),
50    )
51}
52
53/// Open an operator-controlled directory (e.g. `groups/`). It may
54/// legitimately be a symlink (data on another volume); everything *below*
55/// it is opened with [`open_dir_at`].
56///
57/// # Errors
58/// The directory cannot be opened.
59pub fn open_root(path: &Path) -> io::Result<OwnedFd> {
60    Ok(rfs::open(
61        path,
62        OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
63        Mode::empty(),
64    )?)
65}
66
67/// Open the sub-directory `name` of `parent` without following symlinks.
68///
69/// # Errors
70/// `NotFound` when missing; `InvalidData` when it is a symlink or not a
71/// directory; other I/O errors as reported.
72pub fn open_dir_at(parent: BorrowedFd<'_>, name: &str) -> io::Result<OwnedFd> {
73    match rfs::openat(parent, name, dir_flags(), Mode::empty()) {
74        Ok(fd) => Ok(fd),
75        Err(e) if is_symlink_errno(e) || e == Errno::NOTDIR => {
76            Err(unsafe_entry(name, "a symlink or not a directory"))
77        }
78        Err(e) => Err(e.into()),
79    }
80}
81
82/// Create the sub-directory `name` of `parent` (mode `0o755` before umask)
83/// unless it exists, then open it without following symlinks.
84///
85/// # Errors
86/// See [`open_dir_at`]; `mkdirat` failures other than "exists".
87pub fn ensure_dir_at(parent: BorrowedFd<'_>, name: &str) -> io::Result<OwnedFd> {
88    match rfs::mkdirat(parent, name, Mode::from_raw_mode(0o755)) {
89        Ok(()) => {}
90        Err(e) if e == Errno::EXIST => {}
91        Err(e) => return Err(e.into()),
92    }
93    open_dir_at(parent, name)
94}
95
96/// Whether `name` exists in `dir` (any type; a symlink is not followed).
97///
98/// # Errors
99/// `fstatat` failures other than "not found".
100pub fn entry_exists_at(dir: BorrowedFd<'_>, name: &str) -> io::Result<bool> {
101    match rfs::statat(dir, name, AtFlags::SYMLINK_NOFOLLOW) {
102        Ok(_) => Ok(true),
103        Err(e) if e == Errno::NOENT => Ok(false),
104        Err(e) => Err(e.into()),
105    }
106}
107
108/// Why [`open_regular_at`] refused an entry.
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub enum OpenRefusal {
111    /// The entry does not exist.
112    Missing,
113    /// The entry exists but must not be read (symlink, FIFO, directory,
114    /// hard-linked, too large, unreadable). The text says why.
115    Rejected(String),
116}
117
118fn describe(ft: std::fs::FileType) -> &'static str {
119    if ft.is_dir() {
120        "a directory"
121    } else if ft.is_symlink() {
122        "a symlink"
123    } else if ft.is_fifo() {
124        "a fifo"
125    } else if ft.is_socket() {
126        "a socket"
127    } else if ft.is_block_device() || ft.is_char_device() {
128        "a device"
129    } else {
130        "a special file"
131    }
132}
133
134/// Open `name` in `dir` for reading: no symlinks, non-blocking (a FIFO can
135/// neither block the open nor the read), and — checked on the opened inode
136/// — a regular file with a single link of at most `max_bytes`.
137///
138/// # Errors
139/// [`OpenRefusal`] describing why the entry was not opened.
140pub fn open_regular_at(
141    dir: BorrowedFd<'_>,
142    name: &str,
143    max_bytes: u64,
144) -> Result<(File, Metadata), OpenRefusal> {
145    let fd = match rfs::openat(dir, name, file_read_flags(), Mode::empty()) {
146        Ok(fd) => fd,
147        Err(e) if e == Errno::NOENT => return Err(OpenRefusal::Missing),
148        Err(e) if is_symlink_errno(e) => {
149            return Err(OpenRefusal::Rejected("symbolic link".into()));
150        }
151        Err(e) => return Err(OpenRefusal::Rejected(format!("cannot open: {e}"))),
152    };
153    let file = File::from(fd);
154    let meta = file
155        .metadata()
156        .map_err(|e| OpenRefusal::Rejected(format!("cannot stat: {e}")))?;
157    let ft = meta.file_type();
158    if !ft.is_file() {
159        return Err(OpenRefusal::Rejected(format!(
160            "not a regular file ({})",
161            describe(ft)
162        )));
163    }
164    if meta.nlink() > 1 {
165        return Err(OpenRefusal::Rejected(format!(
166            "regular file with {} hard links",
167            meta.nlink()
168        )));
169    }
170    if meta.len() > max_bytes {
171        return Err(OpenRefusal::Rejected(format!(
172            "file too large ({} bytes > {max_bytes} byte limit)",
173            meta.len()
174        )));
175    }
176    Ok((file, meta))
177}
178
179/// Read at most `max_bytes` from `file`.
180///
181/// # Errors
182/// Read failures, or `InvalidData` when the file holds more than
183/// `max_bytes` (it grew after the size check).
184pub fn read_capped(file: File, max_bytes: u64) -> io::Result<Vec<u8>> {
185    let mut buf = Vec::new();
186    file.take(max_bytes.saturating_add(1))
187        .read_to_end(&mut buf)?;
188    if u64::try_from(buf.len()).unwrap_or(u64::MAX) > max_bytes {
189        return Err(io::Error::new(
190            io::ErrorKind::InvalidData,
191            format!("file grew beyond the {max_bytes} byte limit while reading"),
192        ));
193    }
194    Ok(buf)
195}
196
197/// Create the new file `name` in `dir` (mode `0o644` before umask) and
198/// write `content`. Never opens an existing entry: a file, directory or
199/// (even dangling) symlink at `name` makes this fail with `AlreadyExists`.
200///
201/// # Errors
202/// `AlreadyExists`, or any create/write failure.
203pub fn create_new_at(dir: BorrowedFd<'_>, name: &str, content: &[u8]) -> io::Result<()> {
204    let fd = rfs::openat(
205        dir,
206        name,
207        OFlags::WRONLY
208            | OFlags::CREATE
209            | OFlags::EXCL
210            | OFlags::NOFOLLOW
211            | OFlags::NOCTTY
212            | OFlags::CLOEXEC,
213        Mode::from_raw_mode(0o644),
214    )?;
215    let mut file = File::from(fd);
216    file.write_all(content)?;
217    file.flush()
218}
219
220#[cfg(test)]
221mod tests {
222    use std::os::fd::AsFd;
223    use std::os::unix::fs::symlink;
224
225    use super::*;
226
227    #[test]
228    fn dirs_below_the_root_are_never_followed() {
229        let tmp = tempfile::tempdir().unwrap();
230        let root = tmp.path().join("groups");
231        std::fs::create_dir_all(root.join("real")).unwrap();
232        let elsewhere = tmp.path().join("elsewhere");
233        std::fs::create_dir_all(&elsewhere).unwrap();
234        symlink(&elsewhere, root.join("link")).unwrap();
235        std::fs::write(root.join("file"), "x").unwrap();
236
237        let fd = open_root(&root).unwrap();
238        assert!(open_dir_at(fd.as_fd(), "real").is_ok());
239        let err = open_dir_at(fd.as_fd(), "link").unwrap_err();
240        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
241        let err = open_dir_at(fd.as_fd(), "file").unwrap_err();
242        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
243        let err = open_dir_at(fd.as_fd(), "missing").unwrap_err();
244        assert_eq!(err.kind(), io::ErrorKind::NotFound);
245
246        // ensure_dir_at creates, re-opens, and refuses a symlink.
247        assert!(ensure_dir_at(fd.as_fd(), "new").is_ok());
248        assert!(root.join("new").is_dir());
249        assert!(ensure_dir_at(fd.as_fd(), "new").is_ok());
250        assert!(ensure_dir_at(fd.as_fd(), "link").is_err());
251    }
252
253    #[test]
254    fn root_may_be_an_operator_symlink() {
255        let tmp = tempfile::tempdir().unwrap();
256        std::fs::create_dir_all(tmp.path().join("real")).unwrap();
257        symlink(tmp.path().join("real"), tmp.path().join("alias")).unwrap();
258        assert!(open_root(&tmp.path().join("alias")).is_ok());
259    }
260
261    #[test]
262    fn regular_files_only() {
263        let tmp = tempfile::tempdir().unwrap();
264        let dir = tmp.path();
265        std::fs::write(dir.join("ok"), "hello").unwrap();
266        std::fs::write(dir.join("target"), "secret").unwrap();
267        symlink(dir.join("target"), dir.join("sym")).unwrap();
268        std::fs::hard_link(dir.join("target"), dir.join("hard")).unwrap();
269        std::fs::create_dir(dir.join("sub")).unwrap();
270        std::fs::write(dir.join("big"), vec![b'a'; 64]).unwrap();
271        let fifo = dir.join("fifo");
272        rfs::mkfifoat(rfs::CWD, &fifo, Mode::from_raw_mode(0o600)).unwrap();
273
274        let fd = open_root(dir).unwrap();
275        let (file, meta) = open_regular_at(fd.as_fd(), "ok", 1024).unwrap();
276        assert_eq!(meta.len(), 5);
277        assert_eq!(read_capped(file, 1024).unwrap(), b"hello");
278
279        assert_eq!(
280            open_regular_at(fd.as_fd(), "missing", 1024).unwrap_err(),
281            OpenRefusal::Missing
282        );
283        for name in ["sym", "hard", "sub", "big", "fifo"] {
284            assert!(
285                matches!(
286                    open_regular_at(fd.as_fd(), name, 32),
287                    Err(OpenRefusal::Rejected(_))
288                ),
289                "{name} must be rejected"
290            );
291        }
292    }
293
294    #[test]
295    fn read_capped_detects_growth() {
296        let tmp = tempfile::tempdir().unwrap();
297        std::fs::write(tmp.path().join("f"), "0123456789").unwrap();
298        let file = File::open(tmp.path().join("f")).unwrap();
299        assert!(read_capped(file, 4).is_err());
300    }
301
302    #[test]
303    fn create_new_never_writes_through_existing_entries() {
304        let tmp = tempfile::tempdir().unwrap();
305        let dir = tmp.path().join("d");
306        std::fs::create_dir(&dir).unwrap();
307        let victim = tmp.path().join("victim");
308        std::fs::write(&victim, "original").unwrap();
309        symlink(&victim, dir.join("live-link")).unwrap();
310        symlink(tmp.path().join("nowhere"), dir.join("dangling")).unwrap();
311
312        let fd = open_root(&dir).unwrap();
313        assert!(entry_exists_at(fd.as_fd(), "dangling").unwrap());
314        assert!(!entry_exists_at(fd.as_fd(), "fresh").unwrap());
315        create_new_at(fd.as_fd(), "fresh", b"content").unwrap();
316        assert_eq!(
317            std::fs::read_to_string(dir.join("fresh")).unwrap(),
318            "content"
319        );
320        assert_eq!(
321            create_new_at(fd.as_fd(), "fresh", b"again")
322                .unwrap_err()
323                .kind(),
324            io::ErrorKind::AlreadyExists
325        );
326        assert!(create_new_at(fd.as_fd(), "live-link", b"pwned").is_err());
327        assert!(create_new_at(fd.as_fd(), "dangling", b"pwned").is_err());
328        assert_eq!(std::fs::read_to_string(&victim).unwrap(), "original");
329        assert!(!tmp.path().join("nowhere").exists());
330    }
331}