envseal 0.3.12

Write-only secret vault with process-level access control — post-agent secret management
Documentation
//! TOCTOU-free open that refuses to traverse symlinks (Unix) or
//! NTFS reparse points (Windows).
//!
//! # Audit C1 / H7
//!
//! `verify_not_symlink(path)` followed by `File::open(path)` is a
//! classic TOCTOU race: between the metadata check and the open,
//! an attacker can replace `path` with a junction / symlink and
//! redirect the read to attacker-controlled content. The previous
//! Windows code paths leaned on `verify_not_symlink` for the check
//! and then called `std::fs::read_to_string` / `File::open`, which
//! happily follows reparse points. The Unix paths used
//! `O_NOFOLLOW` and were already safe.
//!
//! The helpers below close that gap by performing the open and
//! the integrity check in one syscall (Unix `O_NOFOLLOW`) or by
//! opening with `FILE_FLAG_OPEN_REPARSE_POINT` and inspecting the
//! resulting handle's attributes (Windows). The handle is never
//! reachable for the caller without the check having succeeded.

use std::fs::File;
use std::io;
use std::path::Path;

use crate::error::Error;

/// Open `path` for read, refusing to traverse a symlink or reparse
/// point. The returned [`File`] is bound to the actual on-disk node
/// at the path — not to whatever a symlink might have pointed at.
///
/// # Errors
/// - [`Error::PolicyTampered`] if the path is a symlink or NTFS
///   reparse point.
/// - [`Error::StorageIo`] for any other I/O failure (file not
///   found, permission denied, …).
pub fn open_read_no_traverse(path: &Path) -> Result<File, Error> {
    open_read_no_traverse_inner(path).map_err(|e| match e {
        OpenError::Refused(msg) => Error::PolicyTampered(msg),
        OpenError::Io(io_err) => Error::StorageIo(io_err),
    })
}

enum OpenError {
    Refused(String),
    Io(io::Error),
}

#[cfg(unix)]
fn open_read_no_traverse_inner(path: &Path) -> Result<File, OpenError> {
    use std::os::unix::fs::OpenOptionsExt;
    let file = std::fs::OpenOptions::new()
        .read(true)
        .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC)
        .open(path)
        .map_err(|e| {
            // ELOOP from O_NOFOLLOW maps to "is a symlink" — surface
            // that as a refusal, not as a generic I/O error.
            if e.raw_os_error() == Some(libc::ELOOP) {
                OpenError::Refused(format!(
                    "refusing to open '{}': path is a symlink (TOCTOU-free check)",
                    path.display()
                ))
            } else {
                OpenError::Io(e)
            }
        })?;
    Ok(file)
}

#[cfg(windows)]
fn open_read_no_traverse_inner(path: &Path) -> Result<File, OpenError> {
    use std::os::windows::fs::OpenOptionsExt;
    use std::os::windows::io::AsRawHandle;
    use windows_sys::Win32::Storage::FileSystem::{
        GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION, FILE_ATTRIBUTE_REPARSE_POINT,
        FILE_FLAG_OPEN_REPARSE_POINT,
    };

    // FILE_FLAG_OPEN_REPARSE_POINT instructs the kernel NOT to follow
    // the reparse point — instead, the open returns a handle to the
    // reparse-point file itself. We then inspect its attributes; if
    // it IS a reparse point we refuse.
    let file = std::fs::OpenOptions::new()
        .read(true)
        .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT)
        .open(path)
        .map_err(OpenError::Io)?;

    // Inspect the handle's attributes — the open only confirmed the
    // open, not the type. GetFileInformationByHandle reads the
    // canonical attribute bits at the time of the call on the same
    // handle, eliminating the TOCTOU window.
    // SAFETY: `file` is a valid std::fs::File holding an OS file
    // handle; AsRawHandle returns it for the duration of the borrow.
    let mut info: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() };
    let ok = unsafe {
        GetFileInformationByHandle(file.as_raw_handle().cast::<core::ffi::c_void>(), &mut info)
    };
    if ok == 0 {
        return Err(OpenError::Io(io::Error::last_os_error()));
    }
    if (info.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0 {
        return Err(OpenError::Refused(format!(
            "refusing to open '{}': path is an NTFS reparse point (junction, mount, \
             symlink, cloud-file). Reparse points silently redirect path resolution \
             and are not allowed in vault / .envseal paths.",
            path.display()
        )));
    }
    Ok(file)
}

#[cfg(not(any(unix, windows)))]
fn open_read_no_traverse_inner(path: &Path) -> Result<File, OpenError> {
    // Best-effort fallback: stat with symlink_metadata then open.
    // This DOES have a TOCTOU window — but on platforms where
    // neither O_NOFOLLOW nor FILE_FLAG_OPEN_REPARSE_POINT exists,
    // the OS itself doesn't support fully race-free no-follow opens.
    let meta = std::fs::symlink_metadata(path).map_err(OpenError::Io)?;
    if meta.file_type().is_symlink() {
        return Err(OpenError::Refused(format!(
            "refusing to open '{}': path is a symlink",
            path.display()
        )));
    }
    std::fs::File::open(path).map_err(OpenError::Io)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn opens_a_regular_file() {
        let tmp = tempfile::tempdir().unwrap();
        let path = tmp.path().join("plain.txt");
        std::fs::write(&path, b"hello").unwrap();
        let f = open_read_no_traverse(&path);
        assert!(f.is_ok(), "regular file open must succeed: {:?}", f.err());
    }

    #[cfg(unix)]
    #[test]
    fn refuses_a_symlink() {
        let tmp = tempfile::tempdir().unwrap();
        let target = tmp.path().join("real.txt");
        std::fs::write(&target, b"x").unwrap();
        let link = tmp.path().join("link.txt");
        std::os::unix::fs::symlink(&target, &link).unwrap();
        let err = open_read_no_traverse(&link).expect_err("must refuse symlink");
        match err {
            Error::PolicyTampered(_) => {}
            other => panic!("expected PolicyTampered, got {other:?}"),
        }
    }
}