Bind final-message output before running sandboxed tools (#58974)

## Why

`codex exec --output-last-message` wrote through a pathname after tools finished, allowing sandbox activity to redirect the final write by replacing that path with a symlink. The related risk is described in https://github.com/openai/codex/pull/25956.

## What changed

- Open the destination before starting the agent and retain its handle until the final write.
- Reject symlinks, Windows reparse points, and multiply linked files; verify that the pathname still identifies the bound file before truncating and after writing.
- Propagate output errors so the command fails when the destination cannot be opened or written safely.
- Preserve inherited Unix output streams, FIFOs, devices, and Windows `NUL` support, while avoiding duplicate final answers when human output targets stdout.

## Testing

Add regression coverage for sandbox-created symlinks across consecutive invocations, redirected stdout, delayed truncation, hard links, replaced paths, and Windows stream binding and filesystem identity checks.

GitOrigin-RevId: 4a31bd28a5eb2d0a9e200cb658eb7d19dc93e933
This commit is contained in:
viyatb-oai
2026-11-02 20:36:39 +00:00
committed by copyberry
parent 9c23d93d5b
commit 636cdcb7ec
17 changed files with 788 additions and 54 deletions
+1
View File
@@ -140,6 +140,7 @@ pub use fs_helper::CODEX_FS_HELPER_ARG1;
pub use fs_helper_main::main as run_fs_helper_main;
pub use local_file_system::LOCAL_FS;
pub use local_file_system::LocalFileSystem;
pub use no_follow::BoundOutput;
pub use noise_channel::NoiseChannelError;
pub use noise_channel::NoiseChannelIdentity;
pub use noise_channel::NoiseChannelPublicKey;
+307
View File
@@ -16,6 +16,79 @@ use unix as imp;
#[cfg(windows)]
use windows as imp;
fn invalid_output(reason: &'static str) -> io::Error {
io::Error::new(io::ErrorKind::InvalidInput, reason)
}
/// A destination bound before sandboxed tools run. Only ordinary path-based files are truncated.
pub struct BoundOutput {
file: std::fs::File,
destination: Destination,
}
enum Destination {
Path(std::path::PathBuf),
Stream { is_stdout: bool },
}
impl BoundOutput {
pub fn open(path: &Path) -> io::Result<Self> {
// Check raw syntax before absolute() or components() discards directory markers.
let bytes = path.as_os_str().as_encoded_bytes();
let leaf = bytes
.rsplit(|b| *b == b'/' || (cfg!(windows) && *b == b'\\'))
.next();
if matches!(leaf, None | Some(b"" | b"." | b"..")) {
return Err(invalid_output("output must name a file"));
}
#[cfg(windows)]
if bytes.eq_ignore_ascii_case(b"NUL") || bytes.eq_ignore_ascii_case(br"\\.\NUL") {
return Ok(Self {
file: windows::open_null_output()?,
destination: Destination::Stream { is_stdout: false },
});
}
#[cfg(unix)]
if let Some(output) = unix::inherited_output(path)? {
return Ok(output);
}
#[cfg(unix)]
let path = unix::output_path(path)?;
#[cfg(windows)]
let path = std::path::absolute(path)?;
let file = imp::open_output(&path)?;
if file.metadata()?.is_file() {
imp::reject_multiple_links(&file)?;
}
Ok(Self {
file,
destination: Destination::Path(path),
})
}
/// Whether the caller explicitly selected the inherited standard output stream.
pub fn is_stdout(&self) -> bool {
matches!(self.destination, Destination::Stream { is_stdout: true })
}
pub fn write(mut self, contents: &[u8]) -> io::Result<()> {
use std::io::Write;
if let Destination::Path(path) = &self.destination {
imp::verify_output_path(&self.file, path)?;
if self.file.metadata()?.is_file() {
imp::reject_multiple_links(&self.file)?;
self.file.set_len(0)?;
}
}
self.file.write_all(contents)?;
// A caller may reopen the pathname after a successful exit. Detect a visible swap.
if let Destination::Path(path) = &self.destination {
imp::verify_output_path(&self.file, path)?;
}
Ok(())
}
}
pub(crate) fn open_file(path: &Path) -> io::Result<std::fs::File> {
imp::open_file_sync(path)
}
@@ -58,3 +131,237 @@ pub(crate) async fn create_directory(path: &Path, recursive: bool) -> io::Result
pub(crate) async fn remove(path: &Path, recursive: bool, force: bool) -> io::Result<()> {
imp::remove(path.to_path_buf(), recursive, force).await
}
#[cfg(test)]
mod output_tests {
//! Exercise the destination as a caller of the final-message writer.
use super::BoundOutput;
use pretty_assertions::assert_eq;
use std::fs;
#[test]
fn regular_output_is_truncated_only_when_written() -> std::io::Result<()> {
let dir = tempfile::tempdir()?;
let path = dir.path().join("message");
fs::write(&path, "old message")?;
let output = BoundOutput::open(&path)?;
assert_eq!(fs::read_to_string(&path)?, "old message");
output.write(b"new")?;
assert_eq!(fs::read_to_string(&path)?, "new");
Ok(())
}
#[cfg(unix)]
#[test]
fn rejects_added_links_and_replaced_paths_before_writing() -> std::io::Result<()> {
let dir = tempfile::tempdir()?;
let path = dir.path().join("message");
fs::write(&path, "new")?;
let output = BoundOutput::open(&path)?;
let alias = dir.path().join("alias");
fs::hard_link(&path, &alias)?;
assert!(BoundOutput::open(&alias).is_err());
assert!(output.write(b"overwritten").is_err());
assert_eq!(fs::read(&alias)?, b"new");
fs::remove_file(&alias)?;
let output = BoundOutput::open(&path)?;
fs::rename(&path, &alias)?;
fs::write(&path, "replacement")?;
assert!(output.write(b"overwritten").is_err());
assert_eq!(fs::read(&path)?, b"replacement");
assert_eq!(fs::read(&alias)?, b"new");
Ok(())
}
#[cfg(windows)]
#[test]
fn windows_output_binds_the_chosen_stream_until_write() -> std::io::Result<()> {
use std::os::windows::fs::OpenOptionsExt;
use windows_sys::Win32::Foundation::ERROR_SHARING_VIOLATION;
use windows_sys::Win32::Storage::FileSystem::DELETE;
let dir = tempfile::tempdir()?;
let base = dir.path().join("message");
fs::write(&base, "base")?;
let fs_name = super::windows::filesystem_name(&fs::File::open(&base)?)?;
let ntfs = fs_name.eq_ignore_ascii_case("NTFS");
let sibling = dir.path().join("message:sibling");
if ntfs {
fs::write(&sibling, "keep")?;
let alias = dir.path().join("alias");
fs::hard_link(&base, &alias)?;
assert!(BoundOutput::open(&base).is_err());
fs::remove_file(alias)?;
}
let names: &[&str] = if ntfs {
&[
"message",
"message::$DATA",
"message:answer",
"message:answer:$DATA",
]
} else {
&["message"]
};
for name in names {
let path = dir.path().join(name);
fs::write(&base, "base")?;
fs::write(&path, "previous contents")?;
let open_for_rename = || fs::OpenOptions::new().access_mode(DELETE).open(&path);
let rename_handle = open_for_rename()?;
assert!(
matches!(BoundOutput::open(&path), Err(e) if e.raw_os_error() == Some(ERROR_SHARING_VIOLATION as i32))
);
drop(rename_handle);
let output = BoundOutput::open(&path)?;
assert!(
matches!(open_for_rename(), Err(e) if e.raw_os_error() == Some(ERROR_SHARING_VIOLATION as i32))
);
output.write(b"done")?;
assert_eq!(fs::read(&path)?, b"done");
drop(open_for_rename()?);
if name.starts_with("message:answer") {
assert_eq!(fs::read(&base)?, b"base");
}
if ntfs {
assert_eq!(fs::read(&sibling)?, b"keep");
}
}
Ok(())
}
#[cfg(windows)]
#[test]
fn nul_discards_output_without_accepting_other_devices() -> std::io::Result<()> {
for name in ["NUL", "nul", r"\\.\NUL"] {
BoundOutput::open(name.as_ref())?.write(b"discarded")?;
}
assert!(BoundOutput::open(r"\\.\CON".as_ref()).is_err());
Ok(())
}
#[cfg(windows)]
#[test]
fn unavailable_full_ids_use_directory_entries_only_on_fat_volumes() -> std::io::Result<()> {
use windows_sys::Win32::Foundation::ERROR_INVALID_PARAMETER;
let dir = tempfile::tempdir()?;
let path = dir.path().join("message");
let output = BoundOutput::open(&path)?;
let other = dir.path().join("other");
fs::write(&other, "other")?;
// Legacy IDs on a ReFS temp volume are not a valid stand-in for FAT directory entries.
let host = super::windows::filesystem_name(&output.file)?;
let legacy_fixture = ["NTFS", "FAT", "FAT32", "exFAT"]
.iter()
.any(|name| host.eq_ignore_ascii_case(name));
let verify = |candidate: &std::path::Path, volume: &str, named: Option<&str>| {
super::windows::verify_output_path_with(
&output.file,
candidate,
|_| {
Err(std::io::Error::from_raw_os_error(
ERROR_INVALID_PARAMETER as i32,
))
},
|file| {
if std::ptr::eq(file, &output.file) {
return Ok(volume.to_owned());
}
named
.map(str::to_owned)
.ok_or(std::io::ErrorKind::Unsupported.into())
},
)
};
for volume in ["FAT", "FAT32", "exFAT"] {
// Reaching the second volume query proves the first one selected the fallback.
assert!(
matches!(verify(&path, volume, None), Err(e) if e.kind() == std::io::ErrorKind::Unsupported)
);
if legacy_fixture {
verify(&path, volume, Some(volume))?;
assert!(verify(&other, volume, Some(volume)).is_err());
}
}
for (bound, named) in [("ReFS", "exFAT"), ("exFAT", "ReFS")] {
assert!(
matches!(verify(&path, bound, Some(named)), Err(e) if e.raw_os_error() == Some(ERROR_INVALID_PARAMETER as i32))
);
}
Ok(())
}
#[cfg(windows)]
#[test]
#[ignore = "requires CODEX_TEST_EXFAT_DIR on a writable exFAT volume"]
fn writes_on_exfat() -> anyhow::Result<()> {
let dir = tempfile::tempdir_in(std::env::var("CODEX_TEST_EXFAT_DIR")?)?;
let path = dir.path().join("message");
fs::write(&path, "previous message")?;
assert!(
super::windows::filesystem_name(&fs::File::open(&path)?)?.eq_ignore_ascii_case("exFAT")
);
BoundOutput::open(&path)?.write(b"done")?;
assert_eq!(fs::read(&path)?, b"done");
Ok(())
}
#[cfg(unix)]
#[test]
fn output_only_accepts_leading_parent_components() -> std::io::Result<()> {
let dir = tempfile::tempdir()?;
fs::create_dir_all(dir.path().join("a/b"))?;
let path = dir.path().join("a/b/../../message");
assert!(
matches!(BoundOutput::open(&path), Err(e) if e.kind() == std::io::ErrorKind::InvalidInput)
);
assert!(!dir.path().join("message").exists());
let mut expected = std::env::current_dir()?;
expected.pop();
assert_eq!(
super::unix::output_path("./../message".as_ref())?,
expected.join("message")
);
Ok(())
}
#[cfg(unix)]
#[test]
fn writes_fifo_device_and_inherited_pipe() -> anyhow::Result<()> {
use std::io::Read;
use std::os::fd::AsRawFd;
use std::os::unix::fs::OpenOptionsExt;
let dir = tempfile::tempdir()?;
let fifo = dir.path().join("pipe");
assert!(
std::process::Command::new("mkfifo")
.arg(&fifo)
.status()?
.success()
);
let mut reader = fs::OpenOptions::new()
.read(true)
.custom_flags(libc::O_NONBLOCK)
.open(&fifo)?;
BoundOutput::open(&fifo)?.write(b"fifo")?;
let mut buf = [0; 4];
reader.read_exact(&mut buf)?;
assert_eq!(buf, *b"fifo");
BoundOutput::open(std::path::Path::new("/dev/null"))?.write(b"device")?;
let stdout = BoundOutput::open(std::path::Path::new("/dev/stdout"))?;
assert!(stdout.is_stdout());
let read_only = rustix::io::dup(&reader)?;
let alias = format!("/dev/fd/{}", read_only.as_raw_fd());
assert!(
matches!(BoundOutput::open(alias.as_ref()), Err(e) if e.kind() == std::io::ErrorKind::InvalidInput)
);
let writer = fs::OpenOptions::new().write(true).open(&fifo)?;
// Match the inheritable pipe descriptor passed by shell process substitution.
let inherited = rustix::io::dup(&writer)?;
let alias = format!("/dev/fd/{}", inherited.as_raw_fd());
BoundOutput::open(alias.as_ref())?.write(b"pipe")?;
reader.read_exact(&mut buf)?;
assert_eq!(buf, *b"pipe");
Ok(())
}
}
+121 -12
View File
@@ -68,7 +68,7 @@ fn root() -> io::Result<OwnedFd> {
.map_err(io::Error::from)
}
fn open_directory(parent: &OwnedFd, name: &OsString) -> io::Result<OwnedFd> {
fn open_directory(parent: &OwnedFd, name: &std::ffi::OsStr) -> io::Result<OwnedFd> {
openat(
parent,
name,
@@ -90,6 +90,124 @@ fn parent(path: &Path) -> io::Result<(OwnedFd, OsString)> {
Ok((directory, leaf))
}
pub(super) fn inherited_output(path: &Path) -> io::Result<Option<super::BoundOutput>> {
use std::os::fd::FromRawFd;
let name = path.to_str().unwrap_or_default();
let fd = match name {
"/dev/stdout" => libc::STDOUT_FILENO,
"/dev/stderr" => libc::STDERR_FILENO,
_ => {
let number = name
.strip_prefix("/dev/fd/")
.or_else(|| name.strip_prefix("/proc/self/fd/"));
let Some(fd) = number
.filter(|s| s.bytes().all(|b| b.is_ascii_digit()))
.and_then(|s| s.parse().ok())
else {
return Ok(None);
};
fd
}
};
// Only caller-supplied descriptors may be selected, including shell process substitution.
// SAFETY: fcntl validates the integer descriptor; F_DUPFD_CLOEXEC returns a new owned fd.
let flags = unsafe { libc::fcntl(fd, libc::F_GETFD) };
if flags < 0 {
return Err(io::Error::last_os_error());
}
if flags & libc::FD_CLOEXEC != 0 {
return Err(super::invalid_output("output descriptor is not inherited"));
}
let copy = unsafe { libc::fcntl(fd, libc::F_DUPFD_CLOEXEC, 3) };
if copy < 0 {
return Err(io::Error::last_os_error());
}
let file = unsafe { std::fs::File::from_raw_fd(copy) };
let access = rustix::fs::fcntl_getfl(&file)? & OFlags::ACCMODE;
if !matches!(access, OFlags::WRONLY | OFlags::RDWR) {
return Err(super::invalid_output("output descriptor is not writable"));
}
Ok(Some(super::BoundOutput {
file,
destination: super::Destination::Stream {
is_stdout: fd == libc::STDOUT_FILENO,
},
}))
}
pub(super) fn reject_multiple_links(file: &std::fs::File) -> io::Result<()> {
use std::os::unix::fs::MetadataExt;
if file.metadata()?.nlink() > 1 {
return Err(super::invalid_output("output has multiple hard links"));
}
Ok(())
}
pub(super) fn output_path(path: &Path) -> io::Result<PathBuf> {
// Resolve leading ".." against the caller's cwd, never against a movable directory fd.
let mut suffix = path.strip_prefix(".").unwrap_or(path);
let mut base = if suffix.is_absolute() {
PathBuf::new()
} else {
std::env::current_dir()?
};
while let Ok(rest) = suffix.strip_prefix("..") {
base.pop();
suffix = rest;
}
let path = base.join(suffix);
#[cfg(target_os = "macos")]
{
// Expand one known root link only. Never canonicalize its target or the caller's suffix.
if let Some(physical) = ["tmp", "var", "etc"].into_iter().find_map(|name| {
let root = Path::new("/").join(name);
let suffix = path.strip_prefix(&root).ok()?;
let expected = Path::new("private").join(name);
(std::fs::read_link(root).ok()? == expected)
.then(|| Path::new("/").join(expected).join(suffix))
}) {
return Ok(physical);
}
}
Ok(path)
}
pub(super) fn verify_output_path(file: &std::fs::File, path: &Path) -> io::Result<()> {
let (dir, leaf) = parent(path)?;
let named = statat(dir, leaf, AtFlags::SYMLINK_NOFOLLOW)?;
let bound = fstat(file)?;
if (named.st_dev, named.st_ino) != (bound.st_dev, bound.st_ino) {
return Err(super::invalid_output("output path was replaced"));
}
Ok(())
}
pub(super) fn open_output(path: &Path) -> io::Result<std::fs::File> {
let (directory, leaf) = parent(path)?;
let file = open_writable(&directory, &leaf)?;
if !file.metadata()?.is_file() {
let flags = rustix::fs::fcntl_getfl(&file)?;
rustix::fs::fcntl_setfl(&file, flags & !OFlags::NONBLOCK)?;
}
Ok(file)
}
// Opening the leaf must not truncate it or hang on a FIFO without a reader.
fn open_writable(directory: &OwnedFd, leaf: &std::ffi::OsStr) -> io::Result<std::fs::File> {
let file = openat(
directory,
leaf,
OFlags::WRONLY
| OFlags::CREATE
| OFlags::NOFOLLOW
| OFlags::CLOEXEC
| OFlags::NOCTTY
| OFlags::NONBLOCK,
Mode::from_raw_mode(0o666),
)?;
Ok(std::fs::File::from(file))
}
fn open_entry_sync(path: &Path) -> io::Result<std::fs::File> {
let (parent, leaf) = parent(path)?;
let file = openat(
@@ -115,17 +233,8 @@ pub(super) fn open_file_sync(path: &Path) -> io::Result<std::fs::File> {
pub(super) async fn write_file(path: PathBuf, contents: Vec<u8>) -> io::Result<()> {
tokio::task::spawn_blocking(move || {
let (parent, leaf) = parent(&path)?;
// Prevent FIFOs and devices from blocking during open before the
// descriptor can be validated as a regular file below.
let file = openat(
&parent,
leaf,
OFlags::WRONLY | OFlags::CREATE | OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::CLOEXEC,
Mode::from_raw_mode(0o666),
)
.map_err(io::Error::from)?;
let mut file = std::fs::File::from(file);
let (directory, leaf) = parent(&path)?;
let mut file = open_writable(&directory, &leaf)?;
if !file.metadata()?.is_file() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
+183 -9
View File
@@ -23,16 +23,22 @@ use windows_sys::Win32::Foundation::RtlNtStatusToDosError;
use windows_sys::Win32::Foundation::UNICODE_STRING;
use windows_sys::Win32::Security::SECURITY_QUALITY_OF_SERVICE;
use windows_sys::Win32::Security::SecurityIdentification;
use windows_sys::Win32::Storage::FileSystem::BY_HANDLE_FILE_INFORMATION;
use windows_sys::Win32::Storage::FileSystem::DELETE;
use windows_sys::Win32::Storage::FileSystem::FILE_ATTRIBUTE_NORMAL;
use windows_sys::Win32::Storage::FileSystem::FILE_DISPOSITION_INFO;
use windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_READ;
use windows_sys::Win32::Storage::FileSystem::FILE_ID_INFO;
use windows_sys::Win32::Storage::FileSystem::FILE_READ_ATTRIBUTES;
use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_DELETE;
use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_READ;
use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_WRITE;
use windows_sys::Win32::Storage::FileSystem::FILE_WRITE_DATA;
use windows_sys::Win32::Storage::FileSystem::FileDispositionInfo;
use windows_sys::Win32::Storage::FileSystem::FileIdInfo;
use windows_sys::Win32::Storage::FileSystem::GetFileInformationByHandle;
use windows_sys::Win32::Storage::FileSystem::GetFileInformationByHandleEx;
use windows_sys::Win32::Storage::FileSystem::GetVolumeInformationByHandleW;
use windows_sys::Win32::Storage::FileSystem::SetFileInformationByHandle;
use windows_sys::Win32::System::IO::IO_STATUS_BLOCK;
use windows_sys::Win32::System::IO::IO_STATUS_BLOCK_0;
@@ -40,6 +46,7 @@ use windows_sys::Win32::System::IO::IO_STATUS_BLOCK_0;
#[path = "windows_volume_fallback.rs"]
mod volume_fallback;
const SHARE_ALL: u32 = FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE;
const FILE_DIRECTORY_FILE: u32 = 0x0000_0001;
const FILE_SYNCHRONOUS_IO_NONALERT: u32 = 0x0000_0020;
const FILE_NON_DIRECTORY_FILE: u32 = 0x0000_0040;
@@ -128,6 +135,22 @@ fn open_handle(
desired_access: u32,
create_disposition: u32,
create_options: u32,
) -> io::Result<OwnedHandle> {
open_handle_with_sharing(
path,
desired_access,
create_disposition,
create_options,
SHARE_ALL,
)
}
fn open_handle_with_sharing(
path: &Path,
desired_access: u32,
create_disposition: u32,
create_options: u32,
share_access: u32,
) -> io::Result<OwnedHandle> {
let mut name = nt_path(path)?;
if let Some(handle) = open_nt_handle(
@@ -137,6 +160,7 @@ fn open_handle(
create_disposition,
create_options,
OBJ_DONT_REPARSE,
share_access,
)? {
return Ok(handle);
}
@@ -151,6 +175,7 @@ fn open_handle(
desired_access,
create_disposition,
create_options,
share_access,
);
}
Err(reparse_error())
@@ -168,6 +193,7 @@ fn open_nt_handle(
create_disposition: u32,
create_options: u32,
attributes: u32,
share_access: u32,
) -> io::Result<Option<OwnedHandle>> {
let maximum_length = u16::try_from(size_of_val(path))
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "filesystem path is too long"))?;
@@ -203,7 +229,7 @@ fn open_nt_handle(
&mut io_status_block,
ptr::null(),
FILE_ATTRIBUTE_NORMAL,
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
share_access,
create_disposition,
create_options | FILE_SYNCHRONOUS_IO_NONALERT,
ptr::null(),
@@ -253,16 +279,164 @@ fn validate_regular_file(file: &std::fs::File, path: &Path) -> io::Result<()> {
Ok(())
}
fn file_information(file: &std::fs::File) -> io::Result<BY_HANDLE_FILE_INFORMATION> {
let mut info = std::mem::MaybeUninit::uninit();
// SAFETY: the file owns the handle and a successful call initializes info.
if unsafe { GetFileInformationByHandle(file.as_raw_handle(), info.as_mut_ptr()) } == 0 {
return Err(io::Error::last_os_error());
}
Ok(unsafe { info.assume_init() })
}
pub(super) fn reject_multiple_links(file: &std::fs::File) -> io::Result<()> {
if file_information(file)?.nNumberOfLinks > 1 {
return Err(super::invalid_output("output has multiple hard links"));
}
Ok(())
}
fn file_identity(file: &std::fs::File) -> io::Result<(u64, [u8; 16])> {
// ReFS requires the full 128-bit identifier to distinguish files on the same volume.
let mut info = std::mem::MaybeUninit::<FILE_ID_INFO>::uninit();
// SAFETY: the open handle is valid and FileIdInfo fills this exact structure on success.
if unsafe {
GetFileInformationByHandleEx(
file.as_raw_handle(),
FileIdInfo,
info.as_mut_ptr().cast(),
size_of::<FILE_ID_INFO>() as u32,
)
} == 0
{
return Err(io::Error::last_os_error());
}
let info = unsafe { info.assume_init() };
// A filesystem may report success with an all-zero ID when it cannot identify the file.
if info.FileId.Identifier == [0; 16] {
return Err(super::invalid_output(
"filesystem did not provide an output file ID",
));
}
Ok((info.VolumeSerialNumber, info.FileId.Identifier))
}
pub(super) fn verify_output_path(file: &std::fs::File, path: &Path) -> io::Result<()> {
verify_output_path_with(file, path, file_identity, filesystem_name)
}
pub(super) fn verify_output_path_with(
file: &std::fs::File,
path: &Path,
file_id: impl Fn(&std::fs::File) -> io::Result<(u64, [u8; 16])>,
volume_name: impl Fn(&std::fs::File) -> io::Result<String>,
) -> io::Result<()> {
let current = open_entry(path)?;
let same = match file_id(file) {
Ok(id) => id == file_id(&current)?,
Err(error) => {
let is_fat = |name: String| {
["FAT", "FAT32", "exFAT"]
.iter()
.any(|fs| name.eq_ignore_ascii_case(fs))
};
if !is_fat(volume_name(file)?) || !is_fat(volume_name(&current)?) {
return Err(error);
}
same_fat_file(file, &current)?
}
};
if !same {
return Err(super::invalid_output("output path was replaced"));
}
Ok(())
}
pub(super) fn filesystem_name(file: &std::fs::File) -> io::Result<String> {
let mut name = [0u16; 32];
// SAFETY: only the filesystem name is requested, in the supplied writable buffer.
if unsafe {
GetVolumeInformationByHandleW(
file.as_raw_handle(),
ptr::null_mut(),
/*nvolumenamesize*/ 0,
ptr::null_mut(),
ptr::null_mut(),
ptr::null_mut(),
name.as_mut_ptr(),
name.len() as u32,
)
} == 0
{
return Err(io::Error::last_os_error());
}
let name = name.split(|unit| *unit == 0).next().unwrap_or_default();
Ok(String::from_utf16_lossy(name))
}
fn same_fat_file(file: &std::fs::File, current: &std::fs::File) -> io::Result<bool> {
// The bound output denies delete sharing, so its FAT directory slot cannot be recycled.
let id = |file| -> io::Result<_> {
let info = file_information(file)?;
if info.nFileIndexHigh == 0 && info.nFileIndexLow == 0 {
return Err(super::invalid_output(
"filesystem did not provide an output file ID",
));
}
Ok((
info.dwVolumeSerialNumber,
info.nFileIndexHigh,
info.nFileIndexLow,
))
};
Ok(id(file)? == id(current)?)
}
pub(super) fn open_null_output() -> io::Result<std::fs::File> {
// Use the kernel null device directly so no user-controlled DOS alias is followed.
let mut name: Vec<_> = r"\Device\Null".encode_utf16().chain([0]).collect();
let handle = open_nt_handle(
&mut name,
/*root_directory*/ None,
FILE_WRITE_DATA,
FILE_OPEN,
/*create_options*/ 0,
OBJ_DONT_REPARSE,
SHARE_ALL,
)?
.ok_or_else(reparse_error)?;
Ok(handle.into())
}
fn open_writable_regular_file(path: &Path, share_access: u32) -> io::Result<std::fs::File> {
let handle = open_handle_with_sharing(
path,
FILE_READ_ATTRIBUTES | FILE_WRITE_DATA,
FILE_OPEN_IF,
FILE_NON_DIRECTORY_FILE,
share_access,
)?;
let file = std::fs::File::from(handle);
validate_regular_file(&file, path)?;
Ok(file)
}
pub(super) fn open_output(path: &Path) -> io::Result<std::fs::File> {
if matches!(path.components().next(), Some(Component::Prefix(p)) if p.kind().is_verbatim())
&& path.as_os_str().as_encoded_bytes().contains(&b'/')
{
return Err(super::invalid_output("invalid verbatim output path"));
}
// Full file IDs identify the base file, not a particular data stream. Keep the
// selected stream bound from the first open, including the unnamed default stream.
let file = open_writable_regular_file(path, FILE_SHARE_READ | FILE_SHARE_WRITE)?;
// Fail before starting a turn if this filesystem cannot verify the output.
verify_output_path(&file, path)?;
Ok(file)
}
pub(super) async fn write_file(path: PathBuf, contents: Vec<u8>) -> io::Result<()> {
tokio::task::spawn_blocking(move || {
let handle = open_handle(
&path,
FILE_READ_ATTRIBUTES | FILE_WRITE_DATA,
FILE_OPEN_IF,
FILE_NON_DIRECTORY_FILE,
)?;
let mut file = std::fs::File::from(handle);
validate_regular_file(&file, &path)?;
let mut file = open_writable_regular_file(&path, SHARE_ALL)?;
file.set_len(0)?;
file.write_all(&contents)
})
@@ -15,7 +15,13 @@ fn relative_volume_opens_files_and_rejects_junctions() -> io::Result<()> {
let temp = tempfile::tempdir()?;
let real = std::fs::canonicalize(temp.path())?;
let open = |path: &Path, access, disposition, options| {
volume_fallback::open_relative_to_volume(&mut nt_path(path)?, access, disposition, options)
volume_fallback::open_relative_to_volume(
&mut nt_path(path)?,
access,
disposition,
options,
SHARE_ALL,
)
};
let existing = real.join("existing.txt");
std::fs::File::from(open(
@@ -16,6 +16,7 @@ pub(super) fn open_relative_to_volume(
desired_access: u32,
create_disposition: u32,
create_options: u32,
share_access: u32,
) -> io::Result<OwnedHandle> {
let mut root_name = name[..DOS_DRIVE_ROOT_LEN].to_vec();
root_name.push(0);
@@ -27,6 +28,7 @@ pub(super) fn open_relative_to_volume(
create_disposition,
create_options,
OBJ_DONT_REPARSE,
share_access,
)?
.ok_or_else(reparse_error)
}
@@ -39,6 +41,7 @@ pub(super) fn open_volume_root(name: &mut [u16]) -> io::Result<OwnedHandle> {
FILE_OPEN,
FILE_DIRECTORY_FILE | FILE_OPEN_REPARSE_POINT,
/*attributes*/ 0,
SHARE_ALL,
)?
.ok_or_else(reparse_error)?;
let root = std::fs::File::from(handle);