diff --git a/src/uu/touch/src/touch.rs b/src/uu/touch/src/touch.rs index bcb688ee7..4d1c9772c 100644 --- a/src/uu/touch/src/touch.rs +++ b/src/uu/touch/src/touch.rs @@ -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 { + 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""); + } } diff --git a/tests/by-util/test_touch.rs b/tests/by-util/test_touch.rs index 7fd736be5..fbabdb75c 100644 --- a/tests/by-util/test_touch.rs +++ b/tests/by-util/test_touch.rs @@ -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"), ""); +} diff --git a/util/check-safe-traversal.sh b/util/check-safe-traversal.sh index 871abb0a9..d64e6ea4c 100755 --- a/util/check-safe-traversal.sh +++ b/util/check-safe-traversal.sh @@ -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 ""