Merge pull request #12171 from sylvestre/toctou-touch

touch: drop O_TRUNC on create to close TOCTOU race (#10019)
This commit is contained in:
Sylvestre Ledru
2026-06-04 17:02:57 +02:00
committed by GitHub
3 changed files with 105 additions and 5 deletions
+47 -4
View File
@@ -4,7 +4,7 @@
// file that was distributed with this source code.
// spell-checker:ignore (ToDO) datelike datetime filetime lpszfilepath mktime strtime timelike utime DATETIME UTIME futimens
// spell-checker:ignore (FORMATS) MMDDhhmm YYYYMMDDHHMM YYMMDDHHMM YYYYMMDDHHMMS
// spell-checker:ignore (FORMATS) MMDDhhmm YYYYMMDDHHMM YYMMDDHHMM YYYYMMDDHHMMS CREAT
pub mod error;
@@ -23,9 +23,8 @@ use rustix::fs::Timestamps;
use rustix::fs::futimens;
use std::borrow::Cow;
use std::ffi::{OsStr, OsString};
#[cfg(unix)]
use std::fs;
use std::fs::OpenOptions;
use std::fs::{self, File};
use std::io::{Error, ErrorKind};
#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;
@@ -436,6 +435,20 @@ pub fn touch(files: &[InputFile], opts: &Options) -> Result<(), TouchError> {
Ok(())
}
/// Create `path` if it does not exist, without ever truncating it.
///
/// Uses `O_CREAT` but deliberately not `O_TRUNC`: if an attacker plants a
/// symlink at `path` in the window between the metadata check in
/// [`touch_file`] and this open, the open follows it but must not zero the
/// symlink's target. Matches GNU touch (issue #10019).
fn create_without_truncate(path: &Path) -> std::io::Result<fs::File> {
OpenOptions::new()
.write(true)
.create(true)
.truncate(false)
.open(path)
}
/// Create or update the timestamp for a single file.
///
/// # Arguments
@@ -486,7 +499,7 @@ fn touch_file(
return Ok(());
}
if let Err(e) = File::create(path) {
if let Err(e) = create_without_truncate(path) {
// we need to check if the path is the path to a directory (ends with a separator)
// we can't use File::create to create a directory
// we cannot use path.is_dir() because it calls fs::metadata which we already called
@@ -981,4 +994,34 @@ mod tests {
assert_eq!(actual_atime, atime);
assert_eq!(actual_mtime, mtime);
}
// The #10019 fix: the create-open must use O_CREAT without O_TRUNC. During
// the TOCTOU race the open lands on an *existing* file (the symlink's
// target), so opening an existing file must leave its contents intact. This
// deterministically distinguishes the fix from the old File::create, which
// used O_TRUNC and would zero the file here.
#[cfg(unix)]
#[test]
fn create_without_truncate_does_not_truncate_existing_file() {
let dir = tempdir().unwrap();
let path = dir.path().join("victim");
std::fs::write(&path, b"do not truncate me").unwrap();
super::create_without_truncate(&path).unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"do not truncate me");
}
// The other half of the contract: when the path is missing it must be
// created (as an empty file), matching the old File::create behavior.
#[cfg(unix)]
#[test]
fn create_without_truncate_creates_missing_file() {
let dir = tempdir().unwrap();
let path = dir.path().join("new");
super::create_without_truncate(&path).unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"");
}
}
+35
View File
@@ -1074,3 +1074,38 @@ fn test_touch_device_files() {
.succeeds()
.no_output();
}
// Touching a symlink to an existing file must not truncate the target, like
// GNU touch. The target exists, so this exercises the update_times path, not
// the create path changed for #10019 — it guards the general "touch never
// truncates" contract. The create-path fix itself is covered by the
// create_without_truncate unit tests in src/touch.rs and the syscall-flag
// check in util/check-safe-traversal.sh.
#[test]
#[cfg(unix)]
fn test_touch_does_not_truncate_symlink_target() {
use std::os::unix::fs::symlink;
let (at, mut ucmd) = at_and_ucmd!();
at.write("victim", "do not truncate me");
symlink(at.plus("victim"), at.plus("link")).unwrap();
ucmd.arg("link").succeeds();
assert_eq!(at.read("victim"), "do not truncate me");
}
// Touching a dangling symlink creates its target as an empty file, like GNU.
#[test]
#[cfg(unix)]
fn test_touch_through_dangling_symlink_creates_target() {
use std::os::unix::fs::symlink;
let (at, mut ucmd) = at_and_ucmd!();
symlink(at.plus("missing"), at.plus("link")).unwrap();
ucmd.arg("link").succeeds();
assert!(at.file_exists("missing"));
assert_eq!(at.read("missing"), "");
}
+23 -1
View File
@@ -191,7 +191,7 @@ if [ "$USE_MULTICALL" -eq 1 ]; then
AVAILABLE_UTILS=$($COREUTILS_BIN --list)
else
AVAILABLE_UTILS=""
for util in rm chmod chown chgrp du mv cp; do
for util in rm chmod chown chgrp du mv cp touch; do
if [ -f "$PROJECT_ROOT/target/${PROFILE}/$util" ]; then
AVAILABLE_UTILS="$AVAILABLE_UTILS $util"
fi
@@ -387,6 +387,28 @@ if echo "$AVAILABLE_UTILS" | grep -q "mv" && [ -d /dev/shm ]; then
fi
fi
# Test touch - creating a file must use O_CREAT but never O_TRUNC, so that a
# symlink planted in the metadata-check/open race window (#10019) is not
# truncated. This observes the flags directly, which integration tests cannot.
if echo "$AVAILABLE_UTILS" | grep -q "touch"; then
echo ""
echo "Testing touch (create_no_truncate)..."
if [ "$USE_MULTICALL" -eq 1 ]; then
touch_cmd="$COREUTILS_BIN touch"
else
touch_cmd="$PROJECT_ROOT/target/${PROFILE}/touch"
fi
strace -f -e trace=openat -o strace_touch_create.log $touch_cmd test_touch_new 2>/dev/null || true
cat strace_touch_create.log
if ! grep -q 'openat(.*test_touch_new.*O_CREAT' strace_touch_create.log; then
fail_immediately "touch did not create test_touch_new via openat(O_CREAT)"
fi
if grep 'test_touch_new' strace_touch_create.log | grep -q 'O_TRUNC'; then
fail_immediately "touch opened the target with O_TRUNC - vulnerable to truncating a symlink target (#10019)"
fi
echo "✓ touch creates with O_CREAT and without O_TRUNC"
fi
echo ""
echo "✓ Basic safe traversal verification completed"
echo ""