Skip to main content

vtcode_commons/fs/
bound_file.rs

1//! Read-only files opened beneath a trusted root without following links.
2#![allow(
3    unsafe_code,
4    reason = "The no-follow openat primitive binds reads to a validated directory descriptor."
5)]
6
7use std::fs::File;
8use std::io;
9use std::path::Path;
10
11#[cfg(unix)]
12use std::io::Write;
13#[cfg(unix)]
14use std::os::fd::{AsRawFd, FromRawFd};
15
16/// Open an absolute directory as a live handle for operations that must keep
17/// using the same directory even if an attacker renames a path component.
18#[cfg(unix)]
19pub fn open_directory_handle(path: &Path) -> io::Result<File> {
20    open_directory_beneath(path)
21}
22
23#[cfg(not(unix))]
24pub fn open_directory_handle(_path: &Path) -> io::Result<File> {
25    Err(io::Error::new(
26        io::ErrorKind::Unsupported,
27        "handle-bound directory operations are unavailable on this platform",
28    ))
29}
30
31/// Make a child process start with its working directory bound to `directory`.
32/// The descriptor remains open through `exec`, so the `fchdir` runs before the
33/// close-on-exec flag can take effect and avoids a path-based cwd race.
34#[cfg(unix)]
35pub fn set_command_working_directory(command: &mut std::process::Command, directory: &File) -> io::Result<()> {
36    use std::os::unix::process::CommandExt;
37
38    let descriptor = directory.as_raw_fd();
39    let change_directory = move || {
40        // SAFETY: `descriptor` is an open directory descriptor inherited by
41        // the child, and `fchdir` does not access Rust-managed memory.
42        let result = unsafe { libc::fchdir(descriptor) };
43        if result == 0 {
44            Ok(())
45        } else {
46            Err(io::Error::last_os_error())
47        }
48    };
49    // SAFETY: `pre_exec` is used only to change the child cwd to a descriptor
50    // owned by the parent. The closure performs no allocation or locking.
51    unsafe {
52        command.pre_exec(change_directory);
53    }
54    Ok(())
55}
56
57#[cfg(not(unix))]
58pub fn set_command_working_directory(_command: &mut std::process::Command, _directory: &File) -> io::Result<()> {
59    Err(io::Error::new(
60        io::ErrorKind::Unsupported,
61        "handle-bound process directories are unavailable on this platform",
62    ))
63}
64
65/// Open a regular, single-link file through bound directory handles.
66/// `root` must be an absolute, previously resolved trusted root.
67#[cfg(unix)]
68pub fn open_file_beneath(root: &Path, relative: &Path) -> io::Result<File> {
69    use std::ffi::CString;
70    use std::os::fd::{AsRawFd, FromRawFd};
71    use std::os::unix::ffi::OsStrExt;
72    use std::os::unix::fs::{MetadataExt, OpenOptionsExt};
73    use std::path::Component;
74
75    let invalid =
76        || io::Error::new(io::ErrorKind::InvalidInput, "expected an absolute root and a normal relative file path");
77    if !root.is_absolute() || relative.as_os_str().is_empty() || relative.is_absolute() {
78        return Err(invalid());
79    }
80    let mut directory = std::fs::OpenOptions::new()
81        .read(true)
82        .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC)
83        .open("/")?;
84    let mut names = Vec::new();
85    for component in root.components() {
86        match component {
87            Component::RootDir => {}
88            Component::Normal(name) => names.push(name),
89            _ => return Err(invalid()),
90        }
91    }
92    for component in relative.components() {
93        match component {
94            Component::Normal(name) => names.push(name),
95            _ => return Err(invalid()),
96        }
97    }
98    let count = names.len();
99    for (index, name) in names.into_iter().enumerate() {
100        let name = CString::new(name.as_bytes()).map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?;
101        let last = index + 1 == count;
102        let flags = libc::O_RDONLY
103            | libc::O_NOFOLLOW
104            | libc::O_CLOEXEC
105            | if last { libc::O_NONBLOCK } else { libc::O_DIRECTORY };
106        // SAFETY: directory owns a live descriptor; name is NUL-terminated and
107        // flags never create a file, so openat requires no mode argument.
108        let descriptor = unsafe { libc::openat(directory.as_raw_fd(), name.as_ptr(), flags) };
109        if descriptor < 0 {
110            return Err(io::Error::last_os_error());
111        }
112        // SAFETY: openat returned a new, uniquely owned descriptor above.
113        directory = unsafe { File::from_raw_fd(descriptor) };
114    }
115    let metadata = directory.metadata()?;
116    if !metadata.is_file() || metadata.nlink() != 1 {
117        return Err(io::Error::new(io::ErrorKind::PermissionDenied, "expected a regular single-link file"));
118    }
119    Ok(directory)
120}
121
122/// Ensure a directory exists beneath a trusted root without following
123/// symlinks in any component.
124///
125/// Missing components are created with private Unix permissions. The root
126/// must already exist and be an absolute path; `relative` must contain only
127/// normal, relative components.
128#[cfg(unix)]
129pub fn ensure_directory_beneath(root: &Path, relative: &Path) -> io::Result<()> {
130    let mut directory = open_directory_beneath(root)?;
131    for component in normal_components(relative, false)? {
132        directory = open_or_create_directory_at(&directory, &component)?;
133    }
134    Ok(())
135}
136
137/// Validate an existing directory beneath a trusted root without following
138/// symlinks in any component.
139#[cfg(unix)]
140pub fn validate_directory_beneath(root: &Path, relative: &Path) -> io::Result<()> {
141    let mut directory = open_directory_beneath(root)?;
142    for component in normal_components(relative, false)? {
143        directory = open_directory_at(&directory, &component)?;
144    }
145    Ok(())
146}
147
148/// Write a new file beneath a trusted root while directory and file handles
149/// remain bound to that root. Existing files (including symlinks) are never
150/// replaced.
151#[cfg(unix)]
152pub fn write_file_beneath(root: &Path, relative: &Path, contents: &[u8]) -> io::Result<()> {
153    let mut file = create_file_beneath(root, relative)?;
154    file.write_all(contents)?;
155    file.sync_all()
156}
157
158/// Create a new private file beneath a trusted root and return its bound
159/// handle. Existing paths are never opened or replaced.
160#[cfg(unix)]
161pub fn create_file_beneath(root: &Path, relative: &Path) -> io::Result<File> {
162    open_new_file_beneath(root, relative)
163}
164
165/// Copy a regular file between two trusted roots without resolving a path
166/// after its parent has been validated. The destination must not already exist.
167#[cfg(unix)]
168pub fn copy_file_beneath(
169    source_root: &Path,
170    source_relative: &Path,
171    destination_root: &Path,
172    destination_relative: &Path,
173) -> io::Result<()> {
174    let mut source = open_file_beneath(source_root, source_relative)?;
175    let mut destination = open_new_file_beneath(destination_root, destination_relative)?;
176    io::copy(&mut source, &mut destination)?;
177    destination.sync_all()
178}
179
180/// Create a symlink below a trusted root without following or replacing any
181/// parent component. The target is stored verbatim and is never resolved.
182#[cfg(unix)]
183pub fn create_symlink_beneath(root: &Path, relative: &Path, target: &Path) -> io::Result<()> {
184    use std::os::unix::ffi::OsStrExt;
185
186    let components = normal_components(relative, false)?;
187    let (file_name, parent_components) = components
188        .split_last()
189        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "expected a relative symlink path"))?;
190    let mut directory = open_directory_beneath(root)?;
191    for component in parent_components {
192        directory = open_or_create_directory_at(&directory, component)?;
193    }
194
195    let name = c_string(file_name)?;
196    let target = std::ffi::CString::new(target.as_os_str().as_bytes())
197        .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?;
198    // SAFETY: `directory` owns a live directory descriptor, both strings are
199    // NUL-terminated, and symlinkat creates only the named child entry.
200    let result = unsafe { libc::symlinkat(target.as_ptr(), directory.as_raw_fd(), name.as_ptr()) };
201    if result < 0 {
202        return Err(io::Error::last_os_error());
203    }
204    Ok(())
205}
206
207/// Open an advisory lock file beneath a trusted root without following
208/// symlinks. The caller owns the returned file and can hold an exclusive lock
209/// with `fs2::FileExt` for the duration of a compound filesystem operation.
210#[cfg(unix)]
211pub fn open_lock_file_beneath(root: &Path, relative: &Path) -> io::Result<File> {
212    use std::os::unix::fs::MetadataExt;
213
214    let components = normal_components(relative, false)?;
215    let (file_name, parent_components) = components
216        .split_last()
217        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "expected a relative lock file path"))?;
218    let mut directory = open_directory_beneath(root)?;
219    for component in parent_components {
220        directory = open_directory_at(&directory, component)?;
221    }
222
223    let name = c_string(file_name)?;
224    // SAFETY: `directory` owns a live directory descriptor, `name` is
225    // NUL-terminated, and O_NOFOLLOW prevents replacing the lock with a
226    // symlink while it is opened.
227    let descriptor = unsafe {
228        libc::openat(
229            directory.as_raw_fd(),
230            name.as_ptr(),
231            libc::O_RDWR | libc::O_CREAT | libc::O_NOFOLLOW | libc::O_CLOEXEC,
232            0o600,
233        )
234    };
235    if descriptor < 0 {
236        return Err(io::Error::last_os_error());
237    }
238    // SAFETY: openat returned a new, uniquely owned descriptor above.
239    let file = unsafe { File::from_raw_fd(descriptor) };
240    let metadata = file.metadata()?;
241    if !metadata.is_file() || metadata.nlink() != 1 {
242        return Err(io::Error::new(io::ErrorKind::PermissionDenied, "expected a regular single-link lock file"));
243    }
244    Ok(file)
245}
246
247#[cfg(not(unix))]
248pub fn ensure_directory_beneath(root: &Path, relative: &Path) -> io::Result<()> {
249    let components = normal_components(relative, false)?;
250    let mut current = root.to_path_buf();
251    let root_metadata = std::fs::symlink_metadata(&current)?;
252    if root_metadata.file_type().is_symlink() || !root_metadata.is_dir() {
253        return Err(io::Error::other("trusted root must be a regular directory"));
254    }
255    for component in components {
256        current.push(component);
257        match std::fs::symlink_metadata(&current) {
258            Ok(metadata) if metadata.file_type().is_symlink() => {
259                return Err(io::Error::other(format!("refusing symlink directory {}", current.display())));
260            }
261            Ok(metadata) if !metadata.is_dir() => {
262                return Err(io::Error::other(format!("{} is not a directory", current.display())));
263            }
264            Ok(_) => {}
265            Err(error) if error.kind() == io::ErrorKind::NotFound => {
266                std::fs::create_dir(&current)?;
267            }
268            Err(error) => return Err(error),
269        }
270    }
271    Ok(())
272}
273
274#[cfg(not(unix))]
275pub fn validate_directory_beneath(root: &Path, relative: &Path) -> io::Result<()> {
276    let components = normal_components(relative, false)?;
277    let mut current = root.to_path_buf();
278    let root_metadata = std::fs::symlink_metadata(&current)?;
279    if root_metadata.file_type().is_symlink() || !root_metadata.is_dir() {
280        return Err(io::Error::other("trusted root must be a regular directory"));
281    }
282    for component in components {
283        current.push(component);
284        let metadata = std::fs::symlink_metadata(&current)?;
285        if metadata.file_type().is_symlink() || !metadata.is_dir() {
286            return Err(io::Error::other(format!("{} is not a regular directory", current.display())));
287        }
288    }
289    Ok(())
290}
291
292#[cfg(not(unix))]
293pub fn write_file_beneath(root: &Path, relative: &Path, contents: &[u8]) -> io::Result<()> {
294    let _ = (root, relative, contents);
295    Err(io::Error::new(
296        io::ErrorKind::Unsupported,
297        "handle-bound file writes are unavailable on this platform",
298    ))
299}
300
301#[cfg(not(unix))]
302pub fn copy_file_beneath(
303    _source_root: &Path,
304    _source_relative: &Path,
305    _destination_root: &Path,
306    _destination_relative: &Path,
307) -> io::Result<()> {
308    Err(io::Error::new(
309        io::ErrorKind::Unsupported,
310        "handle-bound file copies are unavailable on this platform",
311    ))
312}
313
314#[cfg(not(unix))]
315pub fn create_symlink_beneath(_root: &Path, _relative: &Path, _target: &Path) -> io::Result<()> {
316    Err(io::Error::new(
317        io::ErrorKind::Unsupported,
318        "handle-bound symlink creation is unavailable on this platform",
319    ))
320}
321
322/// Advisory lock files are only used for local coordination. The path checks
323/// below still reject symlinked parents before opening the handle.
324#[cfg(not(unix))]
325pub fn open_lock_file_beneath(root: &Path, relative: &Path) -> io::Result<File> {
326    use std::fs::OpenOptions;
327
328    let components = normal_components(relative, false)?;
329    let (file_name, parent_components) = components
330        .split_last()
331        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "expected a relative lock file path"))?;
332    let parent = parent_components.iter().fold(root.to_path_buf(), |mut path, component| {
333        path.push(component);
334        path
335    });
336    let relative_parent = parent
337        .strip_prefix(root)
338        .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "bound path escaped trusted root"))?;
339    ensure_directory_beneath(root, relative_parent)?;
340    let path = parent.join(file_name);
341    if let Ok(metadata) = std::fs::symlink_metadata(&path)
342        && (metadata.file_type().is_symlink() || !metadata.is_file())
343    {
344        return Err(io::Error::new(io::ErrorKind::PermissionDenied, "expected a regular lock file"));
345    }
346    let file = OpenOptions::new().read(true).write(true).create(true).open(&path)?;
347    let metadata = std::fs::symlink_metadata(&path)?;
348    if metadata.file_type().is_symlink() || !metadata.is_file() {
349        return Err(io::Error::new(io::ErrorKind::PermissionDenied, "expected a regular lock file"));
350    }
351    Ok(file)
352}
353
354#[cfg(unix)]
355fn open_directory_beneath(root: &Path) -> io::Result<File> {
356    let mut directory = open_directory_at_path(Path::new("/"))?;
357    for component in normal_components(root, true)? {
358        directory = open_directory_at(&directory, &component)?;
359    }
360    Ok(directory)
361}
362
363#[cfg(unix)]
364fn open_new_file_beneath(root: &Path, relative: &Path) -> io::Result<File> {
365    let components = normal_components(relative, false)?;
366    let (file_name, parent_components) = components
367        .split_last()
368        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "expected a relative file path"))?;
369    let mut directory = open_directory_beneath(root)?;
370    for component in parent_components {
371        directory = open_or_create_directory_at(&directory, component)?;
372    }
373
374    let name = c_string(file_name)?;
375    let flags = libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_NOFOLLOW | libc::O_CLOEXEC;
376    // SAFETY: `directory` owns a live directory descriptor, `name` is
377    // NUL-terminated, and the mode is supplied because O_CREAT is set.
378    let descriptor = unsafe { libc::openat(directory.as_raw_fd(), name.as_ptr(), flags, 0o600) };
379    if descriptor < 0 {
380        return Err(io::Error::last_os_error());
381    }
382    // SAFETY: openat returned a new, uniquely owned descriptor above.
383    Ok(unsafe { File::from_raw_fd(descriptor) })
384}
385
386#[cfg(unix)]
387fn open_directory_at_path(path: &Path) -> io::Result<File> {
388    use std::os::unix::fs::OpenOptionsExt;
389
390    std::fs::OpenOptions::new()
391        .read(true)
392        .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC)
393        .open(path)
394}
395
396#[cfg(unix)]
397fn open_directory_at(parent: &File, component: &std::ffi::OsString) -> io::Result<File> {
398    let name = c_string(component)?;
399    // SAFETY: `parent` owns a live directory descriptor and `name` is
400    // NUL-terminated. No symlink is followed while resolving the component.
401    let descriptor = unsafe {
402        libc::openat(
403            parent.as_raw_fd(),
404            name.as_ptr(),
405            libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC,
406        )
407    };
408    if descriptor < 0 {
409        return Err(io::Error::last_os_error());
410    }
411    // SAFETY: openat returned a new, uniquely owned descriptor above.
412    Ok(unsafe { File::from_raw_fd(descriptor) })
413}
414
415#[cfg(unix)]
416fn open_or_create_directory_at(parent: &File, component: &std::ffi::OsString) -> io::Result<File> {
417    match open_directory_at(parent, component) {
418        Ok(directory) => Ok(directory),
419        Err(error) if error.kind() == io::ErrorKind::NotFound => {
420            let name = c_string(component)?;
421            // SAFETY: `parent` owns a live directory descriptor and `name` is
422            // NUL-terminated. mkdirat creates only this child entry.
423            let result = unsafe { libc::mkdirat(parent.as_raw_fd(), name.as_ptr(), 0o700) };
424            if result < 0 {
425                let mkdir_error = io::Error::last_os_error();
426                if mkdir_error.kind() != io::ErrorKind::AlreadyExists {
427                    return Err(mkdir_error);
428                }
429            }
430            open_directory_at(parent, component)
431        }
432        Err(error) => Err(error),
433    }
434}
435
436fn normal_components(path: &Path, absolute: bool) -> io::Result<Vec<std::ffi::OsString>> {
437    use std::path::Component;
438
439    if absolute != path.is_absolute() || (!absolute && path.as_os_str().is_empty()) {
440        return Err(io::Error::new(io::ErrorKind::InvalidInput, "invalid bound path"));
441    }
442    path.components()
443        .filter_map(|component| match component {
444            Component::Prefix(_) if absolute => None,
445            Component::RootDir if absolute => None,
446            Component::Normal(name) => Some(Ok(name.to_os_string())),
447            _ => Some(Err(io::Error::new(io::ErrorKind::InvalidInput, "bound path contains traversal"))),
448        })
449        .collect()
450}
451
452#[cfg(unix)]
453fn c_string(name: &std::ffi::OsString) -> io::Result<std::ffi::CString> {
454    use std::os::unix::ffi::OsStrExt;
455
456    std::ffi::CString::new(name.as_os_str().as_bytes())
457        .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))
458}
459
460#[cfg(not(unix))]
461pub fn open_file_beneath(_root: &Path, _relative: &Path) -> io::Result<File> {
462    Err(io::Error::new(io::ErrorKind::Unsupported, "bound no-follow reads are unavailable on this platform"))
463}
464
465#[cfg(not(unix))]
466pub fn create_file_beneath(_root: &Path, _relative: &Path) -> io::Result<File> {
467    Err(io::Error::new(
468        io::ErrorKind::Unsupported,
469        "bound no-follow file creation is unavailable on this platform",
470    ))
471}
472
473#[cfg(all(test, unix))]
474mod tests {
475    use super::*;
476    use std::io::Read;
477    use std::os::unix::fs::symlink;
478
479    #[test]
480    fn bound_read_rejects_parent_final_links_and_traversal() {
481        let temp = tempfile::tempdir().unwrap();
482        let root = crate::canonicalize(temp.path()).unwrap();
483        std::fs::create_dir(root.join("real")).unwrap();
484        std::fs::write(root.join("real/output"), "retained output").unwrap();
485        symlink(root.join("real"), root.join("alias")).unwrap();
486        symlink(root.join("real/output"), root.join("link")).unwrap();
487        assert!(open_file_beneath(&root, Path::new("alias/output")).is_err());
488        assert!(open_file_beneath(&root, Path::new("link")).is_err());
489        assert!(open_file_beneath(&root, Path::new("../outside")).is_err());
490        assert!(open_file_beneath(&root, Path::new("real")).is_err());
491        let mut opened = open_file_beneath(&root, Path::new("real/output")).unwrap();
492        std::fs::rename(root.join("real"), root.join("retained")).unwrap();
493        symlink("/", root.join("real")).unwrap();
494        let mut text = String::new();
495        opened.read_to_string(&mut text).unwrap();
496        assert_eq!(text, "retained output");
497    }
498}