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 = open_new_file_beneath(root, relative)?;
154    file.write_all(contents)?;
155    file.sync_all()
156}
157
158/// Copy a regular file between two trusted roots without resolving a path
159/// after its parent has been validated. The destination must not already exist.
160#[cfg(unix)]
161pub fn copy_file_beneath(
162    source_root: &Path,
163    source_relative: &Path,
164    destination_root: &Path,
165    destination_relative: &Path,
166) -> io::Result<()> {
167    let mut source = open_file_beneath(source_root, source_relative)?;
168    let mut destination = open_new_file_beneath(destination_root, destination_relative)?;
169    io::copy(&mut source, &mut destination)?;
170    destination.sync_all()
171}
172
173/// Create a symlink below a trusted root without following or replacing any
174/// parent component. The target is stored verbatim and is never resolved.
175#[cfg(unix)]
176pub fn create_symlink_beneath(root: &Path, relative: &Path, target: &Path) -> io::Result<()> {
177    use std::os::unix::ffi::OsStrExt;
178
179    let components = normal_components(relative, false)?;
180    let (file_name, parent_components) = components
181        .split_last()
182        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "expected a relative symlink path"))?;
183    let mut directory = open_directory_beneath(root)?;
184    for component in parent_components {
185        directory = open_or_create_directory_at(&directory, component)?;
186    }
187
188    let name = c_string(file_name)?;
189    let target = std::ffi::CString::new(target.as_os_str().as_bytes())
190        .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?;
191    // SAFETY: `directory` owns a live directory descriptor, both strings are
192    // NUL-terminated, and symlinkat creates only the named child entry.
193    let result = unsafe { libc::symlinkat(target.as_ptr(), directory.as_raw_fd(), name.as_ptr()) };
194    if result < 0 {
195        return Err(io::Error::last_os_error());
196    }
197    Ok(())
198}
199
200/// Open an advisory lock file beneath a trusted root without following
201/// symlinks. The caller owns the returned file and can hold an exclusive lock
202/// with `fs2::FileExt` for the duration of a compound filesystem operation.
203#[cfg(unix)]
204pub fn open_lock_file_beneath(root: &Path, relative: &Path) -> io::Result<File> {
205    use std::os::unix::fs::MetadataExt;
206
207    let components = normal_components(relative, false)?;
208    let (file_name, parent_components) = components
209        .split_last()
210        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "expected a relative lock file path"))?;
211    let mut directory = open_directory_beneath(root)?;
212    for component in parent_components {
213        directory = open_directory_at(&directory, component)?;
214    }
215
216    let name = c_string(file_name)?;
217    // SAFETY: `directory` owns a live directory descriptor, `name` is
218    // NUL-terminated, and O_NOFOLLOW prevents replacing the lock with a
219    // symlink while it is opened.
220    let descriptor = unsafe {
221        libc::openat(
222            directory.as_raw_fd(),
223            name.as_ptr(),
224            libc::O_RDWR | libc::O_CREAT | libc::O_NOFOLLOW | libc::O_CLOEXEC,
225            0o600,
226        )
227    };
228    if descriptor < 0 {
229        return Err(io::Error::last_os_error());
230    }
231    // SAFETY: openat returned a new, uniquely owned descriptor above.
232    let file = unsafe { File::from_raw_fd(descriptor) };
233    let metadata = file.metadata()?;
234    if !metadata.is_file() || metadata.nlink() != 1 {
235        return Err(io::Error::new(io::ErrorKind::PermissionDenied, "expected a regular single-link lock file"));
236    }
237    Ok(file)
238}
239
240#[cfg(not(unix))]
241pub fn ensure_directory_beneath(root: &Path, relative: &Path) -> io::Result<()> {
242    let components = normal_components(relative, false)?;
243    let mut current = root.to_path_buf();
244    let root_metadata = std::fs::symlink_metadata(&current)?;
245    if root_metadata.file_type().is_symlink() || !root_metadata.is_dir() {
246        return Err(io::Error::other("trusted root must be a regular directory"));
247    }
248    for component in components {
249        current.push(component);
250        match std::fs::symlink_metadata(&current) {
251            Ok(metadata) if metadata.file_type().is_symlink() => {
252                return Err(io::Error::other(format!("refusing symlink directory {}", current.display())));
253            }
254            Ok(metadata) if !metadata.is_dir() => {
255                return Err(io::Error::other(format!("{} is not a directory", current.display())));
256            }
257            Ok(_) => {}
258            Err(error) if error.kind() == io::ErrorKind::NotFound => {
259                std::fs::create_dir(&current)?;
260            }
261            Err(error) => return Err(error),
262        }
263    }
264    Ok(())
265}
266
267#[cfg(not(unix))]
268pub fn validate_directory_beneath(root: &Path, relative: &Path) -> io::Result<()> {
269    let components = normal_components(relative, false)?;
270    let mut current = root.to_path_buf();
271    let root_metadata = std::fs::symlink_metadata(&current)?;
272    if root_metadata.file_type().is_symlink() || !root_metadata.is_dir() {
273        return Err(io::Error::other("trusted root must be a regular directory"));
274    }
275    for component in components {
276        current.push(component);
277        let metadata = std::fs::symlink_metadata(&current)?;
278        if metadata.file_type().is_symlink() || !metadata.is_dir() {
279            return Err(io::Error::other(format!("{} is not a regular directory", current.display())));
280        }
281    }
282    Ok(())
283}
284
285#[cfg(not(unix))]
286pub fn write_file_beneath(root: &Path, relative: &Path, contents: &[u8]) -> io::Result<()> {
287    let _ = (root, relative, contents);
288    Err(io::Error::new(
289        io::ErrorKind::Unsupported,
290        "handle-bound file writes are unavailable on this platform",
291    ))
292}
293
294#[cfg(not(unix))]
295pub fn copy_file_beneath(
296    _source_root: &Path,
297    _source_relative: &Path,
298    _destination_root: &Path,
299    _destination_relative: &Path,
300) -> io::Result<()> {
301    Err(io::Error::new(
302        io::ErrorKind::Unsupported,
303        "handle-bound file copies are unavailable on this platform",
304    ))
305}
306
307#[cfg(not(unix))]
308pub fn create_symlink_beneath(_root: &Path, _relative: &Path, _target: &Path) -> io::Result<()> {
309    Err(io::Error::new(
310        io::ErrorKind::Unsupported,
311        "handle-bound symlink creation is unavailable on this platform",
312    ))
313}
314
315/// Advisory lock files are only used for local coordination. The path checks
316/// below still reject symlinked parents before opening the handle.
317#[cfg(not(unix))]
318pub fn open_lock_file_beneath(root: &Path, relative: &Path) -> io::Result<File> {
319    use std::fs::OpenOptions;
320
321    let components = normal_components(relative, false)?;
322    let (file_name, parent_components) = components
323        .split_last()
324        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "expected a relative lock file path"))?;
325    let parent = parent_components.iter().fold(root.to_path_buf(), |mut path, component| {
326        path.push(component);
327        path
328    });
329    let relative_parent = parent
330        .strip_prefix(root)
331        .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "bound path escaped trusted root"))?;
332    ensure_directory_beneath(root, relative_parent)?;
333    let path = parent.join(file_name);
334    if let Ok(metadata) = std::fs::symlink_metadata(&path)
335        && (metadata.file_type().is_symlink() || !metadata.is_file())
336    {
337        return Err(io::Error::new(io::ErrorKind::PermissionDenied, "expected a regular lock file"));
338    }
339    let file = OpenOptions::new().read(true).write(true).create(true).open(&path)?;
340    let metadata = std::fs::symlink_metadata(&path)?;
341    if metadata.file_type().is_symlink() || !metadata.is_file() {
342        return Err(io::Error::new(io::ErrorKind::PermissionDenied, "expected a regular lock file"));
343    }
344    Ok(file)
345}
346
347#[cfg(unix)]
348fn open_directory_beneath(root: &Path) -> io::Result<File> {
349    let mut directory = open_directory_at_path(Path::new("/"))?;
350    for component in normal_components(root, true)? {
351        directory = open_directory_at(&directory, &component)?;
352    }
353    Ok(directory)
354}
355
356#[cfg(unix)]
357fn open_new_file_beneath(root: &Path, relative: &Path) -> io::Result<File> {
358    let components = normal_components(relative, false)?;
359    let (file_name, parent_components) = components
360        .split_last()
361        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "expected a relative file path"))?;
362    let mut directory = open_directory_beneath(root)?;
363    for component in parent_components {
364        directory = open_or_create_directory_at(&directory, component)?;
365    }
366
367    let name = c_string(file_name)?;
368    let flags = libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_NOFOLLOW | libc::O_CLOEXEC;
369    // SAFETY: `directory` owns a live directory descriptor, `name` is
370    // NUL-terminated, and the mode is supplied because O_CREAT is set.
371    let descriptor = unsafe { libc::openat(directory.as_raw_fd(), name.as_ptr(), flags, 0o600) };
372    if descriptor < 0 {
373        return Err(io::Error::last_os_error());
374    }
375    // SAFETY: openat returned a new, uniquely owned descriptor above.
376    Ok(unsafe { File::from_raw_fd(descriptor) })
377}
378
379#[cfg(unix)]
380fn open_directory_at_path(path: &Path) -> io::Result<File> {
381    use std::os::unix::fs::OpenOptionsExt;
382
383    std::fs::OpenOptions::new()
384        .read(true)
385        .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC)
386        .open(path)
387}
388
389#[cfg(unix)]
390fn open_directory_at(parent: &File, component: &std::ffi::OsString) -> io::Result<File> {
391    let name = c_string(component)?;
392    // SAFETY: `parent` owns a live directory descriptor and `name` is
393    // NUL-terminated. No symlink is followed while resolving the component.
394    let descriptor = unsafe {
395        libc::openat(
396            parent.as_raw_fd(),
397            name.as_ptr(),
398            libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC,
399        )
400    };
401    if descriptor < 0 {
402        return Err(io::Error::last_os_error());
403    }
404    // SAFETY: openat returned a new, uniquely owned descriptor above.
405    Ok(unsafe { File::from_raw_fd(descriptor) })
406}
407
408#[cfg(unix)]
409fn open_or_create_directory_at(parent: &File, component: &std::ffi::OsString) -> io::Result<File> {
410    match open_directory_at(parent, component) {
411        Ok(directory) => Ok(directory),
412        Err(error) if error.kind() == io::ErrorKind::NotFound => {
413            let name = c_string(component)?;
414            // SAFETY: `parent` owns a live directory descriptor and `name` is
415            // NUL-terminated. mkdirat creates only this child entry.
416            let result = unsafe { libc::mkdirat(parent.as_raw_fd(), name.as_ptr(), 0o700) };
417            if result < 0 {
418                let mkdir_error = io::Error::last_os_error();
419                if mkdir_error.kind() != io::ErrorKind::AlreadyExists {
420                    return Err(mkdir_error);
421                }
422            }
423            open_directory_at(parent, component)
424        }
425        Err(error) => Err(error),
426    }
427}
428
429fn normal_components(path: &Path, absolute: bool) -> io::Result<Vec<std::ffi::OsString>> {
430    use std::path::Component;
431
432    if absolute != path.is_absolute() || (!absolute && path.as_os_str().is_empty()) {
433        return Err(io::Error::new(io::ErrorKind::InvalidInput, "invalid bound path"));
434    }
435    path.components()
436        .filter_map(|component| match component {
437            Component::Prefix(_) if absolute => None,
438            Component::RootDir if absolute => None,
439            Component::Normal(name) => Some(Ok(name.to_os_string())),
440            _ => Some(Err(io::Error::new(io::ErrorKind::InvalidInput, "bound path contains traversal"))),
441        })
442        .collect()
443}
444
445#[cfg(unix)]
446fn c_string(name: &std::ffi::OsString) -> io::Result<std::ffi::CString> {
447    use std::os::unix::ffi::OsStrExt;
448
449    std::ffi::CString::new(name.as_os_str().as_bytes())
450        .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))
451}
452
453#[cfg(not(unix))]
454pub fn open_file_beneath(_root: &Path, _relative: &Path) -> io::Result<File> {
455    Err(io::Error::new(io::ErrorKind::Unsupported, "bound no-follow reads are unavailable on this platform"))
456}
457
458#[cfg(all(test, unix))]
459mod tests {
460    use super::*;
461    use std::io::Read;
462    use std::os::unix::fs::symlink;
463
464    #[test]
465    fn bound_read_rejects_parent_final_links_and_traversal() {
466        let temp = tempfile::tempdir().unwrap();
467        let root = crate::canonicalize(temp.path()).unwrap();
468        std::fs::create_dir(root.join("real")).unwrap();
469        std::fs::write(root.join("real/output"), "retained output").unwrap();
470        symlink(root.join("real"), root.join("alias")).unwrap();
471        symlink(root.join("real/output"), root.join("link")).unwrap();
472        assert!(open_file_beneath(&root, Path::new("alias/output")).is_err());
473        assert!(open_file_beneath(&root, Path::new("link")).is_err());
474        assert!(open_file_beneath(&root, Path::new("../outside")).is_err());
475        assert!(open_file_beneath(&root, Path::new("real")).is_err());
476        let mut opened = open_file_beneath(&root, Path::new("real/output")).unwrap();
477        std::fs::rename(root.join("real"), root.join("retained")).unwrap();
478        symlink("/", root.join("real")).unwrap();
479        let mut text = String::new();
480        opened.read_to_string(&mut text).unwrap();
481        assert_eq!(text, "retained output");
482    }
483}