From 06866afa96ced8e493449d1dd22fa81ffc8f1590 Mon Sep 17 00:00:00 2001 From: mattsu Date: Wed, 24 Dec 2025 18:45:35 +0900 Subject: [PATCH 1/4] feat(touch): use futimens via write fd to trigger IN_CLOSE_WRITE on Linux - Add `try_futimens_via_write_fd` function for Unix systems to open files write-only and set times using `futimens`, ensuring inotify watchers detect file closure after touch. - Modify `update_times` to attempt this method before falling back to `set_file_times`, improving compatibility with file monitoring tools on Linux. - Include necessary imports for Unix-specific operations. --- src/uu/touch/src/touch.rs | 49 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 46 insertions(+), 3 deletions(-) diff --git a/src/uu/touch/src/touch.rs b/src/uu/touch/src/touch.rs index 90676d21f0d..b1af4f5a2ae 100644 --- a/src/uu/touch/src/touch.rs +++ b/src/uu/touch/src/touch.rs @@ -18,11 +18,17 @@ use filetime::{FileTime, set_file_times, set_symlink_file_times}; use jiff::{Timestamp, Zoned}; use std::borrow::Cow; use std::ffi::OsString; +#[cfg(unix)] +use std::fs::OpenOptions; use std::fs::{self, File}; use std::io::{Error, ErrorKind}; +#[cfg(unix)] +use std::os::unix::io::AsRawFd; use std::path::{Path, PathBuf}; use uucore::display::Quotable; use uucore::error::{FromIo, UResult, USimpleError}; +#[cfg(unix)] +use uucore::libc; use uucore::parser::shortcut_value_parser::ShortcutValueParser; use uucore::translate; use uucore::{format_usage, show}; @@ -566,11 +572,48 @@ fn update_times( // The filename, access time (atime), and modification time (mtime) are provided as inputs. if opts.no_deref && !is_stdout { - set_symlink_file_times(path, atime, mtime) + return set_symlink_file_times(path, atime, mtime).map_err_context( + || translate!("touch-error-setting-times-of-path", "path" => path.quote()), + ); + } + + #[cfg(unix)] + { + // Open write-only and use futimens to trigger IN_CLOSE_WRITE on Linux. + if !is_stdout && try_futimens_via_write_fd(path, atime, mtime).is_ok() { + return Ok(()); + } + } + + set_file_times(path, atime, mtime) + .map_err_context(|| translate!("touch-error-setting-times-of-path", "path" => path.quote())) +} + +#[cfg(unix)] +fn try_futimens_via_write_fd(path: &Path, atime: FileTime, mtime: FileTime) -> std::io::Result<()> { + let metadata = fs::metadata(path)?; + if !metadata.is_file() { + return Err(Error::other("not a regular file")); + } + + let file = OpenOptions::new().write(true).open(path)?; + let times = [ + libc::timespec { + tv_sec: atime.unix_seconds() as libc::time_t, + tv_nsec: atime.nanoseconds() as libc::c_long, + }, + libc::timespec { + tv_sec: mtime.unix_seconds() as libc::time_t, + tv_nsec: mtime.nanoseconds() as libc::c_long, + }, + ]; + + let rc = unsafe { libc::futimens(file.as_raw_fd(), times.as_ptr()) }; + if rc == 0 { + Ok(()) } else { - set_file_times(path, atime, mtime) + Err(std::io::Error::last_os_error()) } - .map_err_context(|| translate!("touch-error-setting-times-of-path", "path" => path.quote())) } /// Get metadata of the provided path From 27dfb9a649fa5d6f12943de3222e925beb1118cd Mon Sep 17 00:00:00 2001 From: mattsu Date: Wed, 24 Dec 2025 18:53:18 +0900 Subject: [PATCH 2/4] chore: add 'futimens' to spell-checker ignore list in touch.rs - Updated the spell-checker comment to include 'futimens', likely a system call or function name, to avoid false positives in code spell-checking. --- src/uu/touch/src/touch.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/uu/touch/src/touch.rs b/src/uu/touch/src/touch.rs index b1af4f5a2ae..67a195124db 100644 --- a/src/uu/touch/src/touch.rs +++ b/src/uu/touch/src/touch.rs @@ -3,7 +3,7 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore (ToDO) filetime datetime lpszfilepath mktime DATETIME datelike timelike +// spell-checker:ignore (ToDO) filetime datetime lpszfilepath mktime DATETIME datelike timelike futimens // spell-checker:ignore (FORMATS) MMDDhhmm YYYYMMDDHHMM YYMMDDHHMM YYYYMMDDHHMMS pub mod error; From 028aa01108c0e7dcad413e9710886e30f5ed2050 Mon Sep 17 00:00:00 2001 From: mattsu Date: Wed, 24 Dec 2025 19:48:13 +0900 Subject: [PATCH 3/4] refactor(touch): replace libc futimens with nix crate for safer file time setting - Add nix and tempfile dependencies to Cargo.toml and Cargo.lock - Replace unsafe libc::futimens calls with nix::sys::stat::futimens for better safety and portability - Introduce try_futimens_via_write_fd function using nix abstractions - Add unit tests to verify futimens functionality on Unix systems This change reduces unsafe code usage and leverages a Rust-friendly library for Unix-specific operations. --- Cargo.lock | 2 ++ src/uu/touch/Cargo.toml | 6 ++++ src/uu/touch/src/touch.rs | 75 ++++++++++++++++++++++++++++----------- 3 files changed, 62 insertions(+), 21 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5781d4e32fd..3a43d1ff425 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3989,7 +3989,9 @@ dependencies = [ "filetime", "fluent", "jiff", + "nix", "parse_datetime", + "tempfile", "thiserror 2.0.17", "uucore", "windows-sys 0.61.2", diff --git a/src/uu/touch/Cargo.toml b/src/uu/touch/Cargo.toml index f5409ec7a5a..e180e076278 100644 --- a/src/uu/touch/Cargo.toml +++ b/src/uu/touch/Cargo.toml @@ -28,6 +28,12 @@ thiserror = { workspace = true } uucore = { workspace = true, features = ["libc", "parser"] } fluent = { workspace = true } +[target.'cfg(unix)'.dependencies] +nix = { workspace = true, features = ["fs"] } + +[dev-dependencies] +tempfile = { workspace = true } + [target.'cfg(target_os = "windows")'.dependencies] windows-sys = { workspace = true, features = [ "Win32_Storage_FileSystem", diff --git a/src/uu/touch/src/touch.rs b/src/uu/touch/src/touch.rs index 67a195124db..d24ae4c2f14 100644 --- a/src/uu/touch/src/touch.rs +++ b/src/uu/touch/src/touch.rs @@ -16,19 +16,19 @@ use clap::builder::{PossibleValue, ValueParser}; use clap::{Arg, ArgAction, ArgGroup, ArgMatches, Command}; use filetime::{FileTime, set_file_times, set_symlink_file_times}; use jiff::{Timestamp, Zoned}; +#[cfg(unix)] +use nix::sys::stat::futimens; +#[cfg(unix)] +use nix::sys::time::TimeSpec; use std::borrow::Cow; use std::ffi::OsString; #[cfg(unix)] use std::fs::OpenOptions; use std::fs::{self, File}; use std::io::{Error, ErrorKind}; -#[cfg(unix)] -use std::os::unix::io::AsRawFd; use std::path::{Path, PathBuf}; use uucore::display::Quotable; use uucore::error::{FromIo, UResult, USimpleError}; -#[cfg(unix)] -use uucore::libc; use uucore::parser::shortcut_value_parser::ShortcutValueParser; use uucore::translate; use uucore::{format_usage, show}; @@ -590,6 +590,11 @@ fn update_times( } #[cfg(unix)] +/// Set file times via file descriptor using `futimens`. +/// +/// This opens the file write-only and uses the POSIX `futimens` call to set +/// access and modification times on the open FD (not by path), which also +/// triggers `IN_CLOSE_WRITE` on Linux when the FD is closed. fn try_futimens_via_write_fd(path: &Path, atime: FileTime, mtime: FileTime) -> std::io::Result<()> { let metadata = fs::metadata(path)?; if !metadata.is_file() { @@ -597,23 +602,11 @@ fn try_futimens_via_write_fd(path: &Path, atime: FileTime, mtime: FileTime) -> s } let file = OpenOptions::new().write(true).open(path)?; - let times = [ - libc::timespec { - tv_sec: atime.unix_seconds() as libc::time_t, - tv_nsec: atime.nanoseconds() as libc::c_long, - }, - libc::timespec { - tv_sec: mtime.unix_seconds() as libc::time_t, - tv_nsec: mtime.nanoseconds() as libc::c_long, - }, - ]; - - let rc = unsafe { libc::futimens(file.as_raw_fd(), times.as_ptr()) }; - if rc == 0 { - Ok(()) - } else { - Err(std::io::Error::last_os_error()) - } + let atime_spec = TimeSpec::new(atime.unix_seconds() as _, atime.nanoseconds() as _); + let mtime_spec = TimeSpec::new(mtime.unix_seconds() as _, mtime.nanoseconds() as _); + + futimens(&file, &atime_spec, &mtime_spec) + .map_err(|err| std::io::Error::from_raw_os_error(err as i32)) } /// Get metadata of the provided path @@ -895,6 +888,13 @@ mod tests { uu_app, }; + #[cfg(unix)] + use std::fs; + #[cfg(unix)] + use std::io::ErrorKind; + #[cfg(unix)] + use tempfile::tempdir; + #[cfg(windows)] use std::env; #[cfg(windows)] @@ -966,4 +966,37 @@ mod tests { Ok(_) => panic!("Expected to error with TouchError::InvalidFiletime but succeeded"), } } + + #[cfg(unix)] + #[test] + fn test_try_futimens_via_write_fd_sets_times() { + let dir = tempdir().unwrap(); + let path = dir.path().join("futimens-file"); + fs::write(&path, b"data").unwrap(); + + let atime = FileTime::from_unix_time(1_600_000_000, 123_456_789); + let mtime = FileTime::from_unix_time(1_600_000_100, 987_654_321); + + super::try_futimens_via_write_fd(&path, atime, mtime).unwrap(); + + let metadata = fs::metadata(&path).unwrap(); + let actual_atime = FileTime::from_last_access_time(&metadata); + let actual_mtime = FileTime::from_last_modification_time(&metadata); + + assert_eq!(actual_atime, atime); + assert_eq!(actual_mtime, mtime); + } + + #[cfg(unix)] + #[test] + fn test_try_futimens_via_write_fd_rejects_non_file() { + let dir = tempdir().unwrap(); + let atime = FileTime::from_unix_time(1_600_000_000, 0); + let mtime = FileTime::from_unix_time(1_600_000_001, 0); + + let err = super::try_futimens_via_write_fd(dir.path(), atime, mtime) + .expect_err("expected error for non-regular file"); + assert_eq!(err.kind(), ErrorKind::Other); + assert!(err.to_string().contains("not a regular file")); + } } From deef7817072dbd95ebe42d06d77c238a64f6ad19 Mon Sep 17 00:00:00 2001 From: mattsu Date: Wed, 11 Feb 2026 14:30:50 +0900 Subject: [PATCH 4/4] fix(touch): improve error handling for non-regular files and futimens fallback Enhanced error handling in the touch utility by: - Adding proper error message translation for non-regular files - Implementing fallback mechanism when futimens fails - Improving error context and messaging for better user experience - Adding proper handling for special files like FIFOs and directories - Ensuring consistent error reporting across different platforms --- src/uu/touch/locales/en-US.ftl | 1 + src/uu/touch/locales/fr-FR.ftl | 1 + src/uu/touch/src/touch.rs | 121 +++++++++++++++++++++++++-------- 3 files changed, 96 insertions(+), 27 deletions(-) diff --git a/src/uu/touch/locales/en-US.ftl b/src/uu/touch/locales/en-US.ftl index 985f7cca193..39260d77c36 100644 --- a/src/uu/touch/locales/en-US.ftl +++ b/src/uu/touch/locales/en-US.ftl @@ -19,6 +19,7 @@ touch-error-setting-times-of = setting times of { $filename } touch-error-setting-times-no-such-file = setting times of { $filename }: No such file or directory touch-error-cannot-touch = cannot touch { $filename } touch-error-no-such-file-or-directory = No such file or directory +touch-error-not-a-regular-file = not a regular file touch-error-failed-to-get-attributes = failed to get attributes of { $path } touch-error-setting-times-of-path = setting times of { $path } touch-error-invalid-date-ts-format = invalid date ts format { $date } diff --git a/src/uu/touch/locales/fr-FR.ftl b/src/uu/touch/locales/fr-FR.ftl index 7ca2f995bfb..09844fa58d7 100644 --- a/src/uu/touch/locales/fr-FR.ftl +++ b/src/uu/touch/locales/fr-FR.ftl @@ -20,6 +20,7 @@ touch-error-setting-times-of = définition des temps de { $filename } touch-error-setting-times-no-such-file = définition des temps de { $filename } : Aucun fichier ou répertoire de ce type touch-error-cannot-touch = impossible de toucher { $filename } touch-error-no-such-file-or-directory = Aucun fichier ou répertoire de ce type +touch-error-not-a-regular-file = pas un fichier ordinaire touch-error-failed-to-get-attributes = échec d'obtention des attributs de { $path } touch-error-setting-times-of-path = définition des temps de { $path } touch-error-invalid-date-ts-format = format de date ts invalide { $date } diff --git a/src/uu/touch/src/touch.rs b/src/uu/touch/src/touch.rs index 1961bf16775..cac2b6a31f9 100644 --- a/src/uu/touch/src/touch.rs +++ b/src/uu/touch/src/touch.rs @@ -10,7 +10,7 @@ pub mod error; use clap::builder::{PossibleValue, ValueParser}; use clap::{Arg, ArgAction, ArgGroup, ArgMatches, Command}; -use filetime::{FileTime, set_file_times, set_symlink_file_times}; +use filetime::{set_file_times, set_symlink_file_times, FileTime}; use jiff::civil::Time; use jiff::fmt::strtime; use jiff::tz::TimeZone; @@ -25,6 +25,8 @@ use std::ffi::{OsStr, OsString}; use std::fs::OpenOptions; use std::fs::{self, File}; use std::io::{Error, ErrorKind}; +#[cfg(unix)] +use std::os::unix::fs::OpenOptionsExt; use std::path::{Path, PathBuf}; use std::time::SystemTime; use uucore::display::Quotable; @@ -591,8 +593,23 @@ fn update_times( #[cfg(unix)] { // Open write-only and use futimens to trigger IN_CLOSE_WRITE on Linux. - if !is_stdout && try_futimens_via_write_fd(path, atime, mtime).is_ok() { - return Ok(()); + if !is_stdout { + match try_futimens_via_write_fd(path, atime, mtime) { + Ok(()) => return Ok(()), + Err(futimens_err) => { + return set_file_times(path, atime, mtime) + .map_err(|fallback_err| { + Error::other(format!( + "futimens failed: {}; set_file_times fallback failed: {}", + uucore::error::strip_errno(&futimens_err), + uucore::error::strip_errno(&fallback_err), + )) + }) + .map_err_context(|| { + translate!("touch-error-setting-times-of-path", "path" => path.quote()) + }); + } + } } } @@ -607,17 +624,43 @@ fn update_times( /// access and modification times on the open FD (not by path), which also /// triggers `IN_CLOSE_WRITE` on Linux when the FD is closed. fn try_futimens_via_write_fd(path: &Path, atime: FileTime, mtime: FileTime) -> std::io::Result<()> { - let metadata = fs::metadata(path)?; - if !metadata.is_file() { - return Err(Error::other("not a regular file")); + let file = OpenOptions::new() + .write(true) + // Avoid blocking on special files (e.g. FIFOs) before we can inspect metadata. + .custom_flags(nix::libc::O_NONBLOCK) + .open(path) + .map_err(|err| { + if err.raw_os_error() == Some(nix::libc::EISDIR) { + Error::other(translate!("touch-error-not-a-regular-file")) + } else { + err + } + })?; + if !file.metadata()?.is_file() { + return Err(Error::other(translate!("touch-error-not-a-regular-file"))); } - let file = OpenOptions::new().write(true).open(path)?; - let atime_spec = TimeSpec::new(atime.unix_seconds() as _, atime.nanoseconds() as _); - let mtime_spec = TimeSpec::new(mtime.unix_seconds() as _, mtime.nanoseconds() as _); - - futimens(&file, &atime_spec, &mtime_spec) - .map_err(|err| std::io::Error::from_raw_os_error(err as i32)) + let atime_sec = atime + .unix_seconds() + .try_into() + .map_err(|_| Error::from(ErrorKind::InvalidInput))?; + let atime_nsec = atime + .nanoseconds() + .try_into() + .map_err(|_| Error::from(ErrorKind::InvalidInput))?; + let mtime_sec = mtime + .unix_seconds() + .try_into() + .map_err(|_| Error::from(ErrorKind::InvalidInput))?; + let mtime_nsec = mtime + .nanoseconds() + .try_into() + .map_err(|_| Error::from(ErrorKind::InvalidInput))?; + + let atime_spec = TimeSpec::new(atime_sec, atime_nsec); + let mtime_spec = TimeSpec::new(mtime_sec, mtime_nsec); + + futimens(&file, &atime_spec, &mtime_spec).map_err(Error::from) } /// Get metadata of the provided path @@ -810,11 +853,11 @@ fn pathbuf_from_stdout() -> Result { { use std::os::windows::prelude::AsRawHandle; use windows_sys::Win32::Foundation::{ - ERROR_INVALID_PARAMETER, ERROR_NOT_ENOUGH_MEMORY, ERROR_PATH_NOT_FOUND, GetLastError, + GetLastError, ERROR_INVALID_PARAMETER, ERROR_NOT_ENOUGH_MEMORY, ERROR_PATH_NOT_FOUND, HANDLE, MAX_PATH, }; use windows_sys::Win32::Storage::FileSystem::{ - FILE_NAME_OPENED, GetFinalPathNameByHandleW, + GetFinalPathNameByHandleW, FILE_NAME_OPENED, }; let handle = std::io::stdout().lock().as_raw_handle() as HANDLE; @@ -869,12 +912,10 @@ mod tests { use filetime::FileTime; use crate::{ - ChangeTimes, Options, Source, determine_atime_mtime_change, error::TouchError, touch, - uu_app, + determine_atime_mtime_change, error::TouchError, touch, uu_app, ChangeTimes, Options, + Source, }; - #[cfg(unix)] - use std::fs; #[cfg(unix)] use std::io::ErrorKind; #[cfg(unix)] @@ -884,6 +925,8 @@ mod tests { use std::env; #[cfg(windows)] use uucore::locale; + #[cfg(unix)] + use uucore::translate; #[cfg(windows)] #[test] @@ -894,12 +937,10 @@ mod tests { let _ = locale::setup_localization("touch"); // We can trigger an error by not setting stdout to anything (will // fail with code 1) - assert!( - super::pathbuf_from_stdout() - .expect_err("pathbuf_from_stdout should have failed") - .to_string() - .contains("GetFinalPathNameByHandleW failed with code 1") - ); + assert!(super::pathbuf_from_stdout() + .expect_err("pathbuf_from_stdout should have failed") + .to_string() + .contains("GetFinalPathNameByHandleW failed with code 1")); } #[test] @@ -957,14 +998,14 @@ mod tests { fn test_try_futimens_via_write_fd_sets_times() { let dir = tempdir().unwrap(); let path = dir.path().join("futimens-file"); - fs::write(&path, b"data").unwrap(); + std::fs::write(&path, b"data").unwrap(); let atime = FileTime::from_unix_time(1_600_000_000, 123_456_789); let mtime = FileTime::from_unix_time(1_600_000_100, 987_654_321); super::try_futimens_via_write_fd(&path, atime, mtime).unwrap(); - let metadata = fs::metadata(&path).unwrap(); + let metadata = std::fs::metadata(&path).unwrap(); let actual_atime = FileTime::from_last_access_time(&metadata); let actual_mtime = FileTime::from_last_modification_time(&metadata); @@ -982,6 +1023,32 @@ mod tests { let err = super::try_futimens_via_write_fd(dir.path(), atime, mtime) .expect_err("expected error for non-regular file"); assert_eq!(err.kind(), ErrorKind::Other); - assert!(err.to_string().contains("not a regular file")); + assert!(err + .to_string() + .contains(&translate!("touch-error-not-a-regular-file"))); + } + + #[cfg(unix)] + #[test] + fn test_update_times_keeps_futimens_error_when_fallback_fails() { + let dir = tempdir().unwrap(); + let path = dir.path().join("missing"); + let atime = FileTime::from_unix_time(1_600_000_000, 0); + let mtime = FileTime::from_unix_time(1_600_000_001, 0); + let opts = Options { + no_create: false, + no_deref: false, + source: Source::Now, + date: None, + change_times: ChangeTimes::Both, + strict: false, + }; + + let err = super::update_times(&path, false, &opts, atime, mtime) + .expect_err("expected both futimens and fallback to fail"); + let message = err.to_string(); + + assert!(message.contains("futimens failed:")); + assert!(message.contains("set_file_times fallback failed:")); } }