diff --git a/Cargo.lock b/Cargo.lock index dabc2e4fa20..849b56965fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4198,7 +4198,9 @@ dependencies = [ "filetime", "fluent", "jiff", + "nix", "parse_datetime", + "tempfile", "thiserror 2.0.18", "uucore", "windows-sys 0.61.2", diff --git a/src/uu/touch/Cargo.toml b/src/uu/touch/Cargo.toml index 9f66c3795c7..cbcda5ae4a2 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/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 e50686417e2..cac2b6a31f9 100644 --- a/src/uu/touch/src/touch.rs +++ b/src/uu/touch/src/touch.rs @@ -3,22 +3,30 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore (ToDO) datelike datetime filetime lpszfilepath mktime strtime timelike utime +// spell-checker:ignore (ToDO) datelike datetime filetime lpszfilepath mktime strtime timelike utime DATETIME UTIME futimens // spell-checker:ignore (FORMATS) MMDDhhmm YYYYMMDDHHMM YYMMDDHHMM YYYYMMDDHHMMS 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; use jiff::{Timestamp, ToSpan, Zoned}; +#[cfg(unix)] +use nix::sys::stat::futimens; +#[cfg(unix)] +use nix::sys::time::TimeSpec; use std::borrow::Cow; use std::ffi::{OsStr, OsString}; +#[cfg(unix)] +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; @@ -577,11 +585,82 @@ 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) - } else { - set_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 { + 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()) + }); + } + } + } + } + + set_file_times(path, atime, mtime) + .map_err_context(|| translate!("touch-error-setting-times-of-path", "path" => path.quote())) +} + +#[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 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"))); } - .map_err_context(|| translate!("touch-error-setting-times-of-path", "path" => path.quote())) + + 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 @@ -774,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; @@ -833,14 +912,21 @@ 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::io::ErrorKind; + #[cfg(unix)] + use tempfile::tempdir; + #[cfg(windows)] use std::env; #[cfg(windows)] use uucore::locale; + #[cfg(unix)] + use uucore::translate; #[cfg(windows)] #[test] @@ -851,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] @@ -908,4 +992,63 @@ 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"); + 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 = std::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(&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:")); + } }