From f9053aed06636c3ea3e4df3cf85c76fe09591834 Mon Sep 17 00:00:00 2001 From: tasodoufu Date: Sat, 3 Oct 2026 17:30:05 +0900 Subject: [PATCH 1/7] sort: stop reading --random-source after the 128-bit salt salt_from_random_source read up to 1 MiB from the random source to hash into the salt. GNU reads exactly the 128 bits it needs and keeps the file open. On a named pipe, the over-read blocks forever on a writer that only supplied the salt, e.g. mkfifo fifo printf XXXXXXXXXXXXXXXX > fifo & # stays open sort -R --random-source=fifo input # hang Matching GNU, read exactly SALT_LEN bytes and close the source before shuffling. Sources shorter than 16 bytes keep reporting end of file. --- src/uu/sort/src/sort.rs | 46 +++++++++++++++------------- tests/by-util/test_sort.rs | 63 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 21 deletions(-) diff --git a/src/uu/sort/src/sort.rs b/src/uu/sort/src/sort.rs index 0bdc87dc61b..f12d5433ee6 100644 --- a/src/uu/sort/src/sort.rs +++ b/src/uu/sort/src/sort.rs @@ -3179,8 +3179,6 @@ fn get_rand_string() -> [u8; SALT_LEN] { } const SALT_LEN: usize = 16; // 128-bit salt -const MAX_BYTES: usize = 1024 * 1024; // Read cap: 1 MiB -const BUF_LEN: usize = 8192; // 8 KiB read buffer const U64_LEN: usize = 8; const RANDOM_SOURCE_TAG: &[u8] = b"uutils-sort-random-source"; // Domain separation tag @@ -3190,35 +3188,41 @@ const RANDOM_SOURCE_TAG: &[u8] = b"uutils-sort-random-source"; // Domain separat /// bits and reports `end of file` when the source cannot supply them, rather /// than shuffling with whatever it managed to read. fn salt_from_random_source(path: &Path) -> UResult<[u8; SALT_LEN]> { + // GNU reads exactly SALT_LEN bytes from the source and keeps the file open + // (a named pipe keeps its writer blocked until the reader closes it), so an + // over-read here would hang writers like + // `printf %016d > fifo & sort -R --random-source=fifo`. Match GNU and stop + // after the salt bytes are collected. let mut reader = open_with_open_failed_error(path)?; - let mut buf = [0u8; BUF_LEN]; - let mut total = 0usize; - // freeze seed for --random-source - let mut hasher = FoldHasher::with_seed(1, SharedSeed::global_fixed()); - - while let n @ 1.. = reader - .read(&mut buf) - .map_err(|error| SortError::ReadFailed { - path: path.to_owned(), - error, - })? - && let remaining @ 1.. = MAX_BYTES.saturating_sub(total) - { - let take = n.min(remaining); - hasher.write(&buf[..take]); - total = total.saturating_add(take); - if take < n { - break; + let mut salt = [0u8; SALT_LEN]; + let mut filled = 0usize; + while filled < SALT_LEN { + match reader.read(&mut salt[filled..]) { + Ok(0) => break, + Ok(n) => filled = filled.saturating_add(n), + // Retry on interrupted reads (EINTR) instead of surfacing them. + Err(error) if error.kind() == std::io::ErrorKind::Interrupted => (), + Err(error) => { + return Err(SortError::ReadFailed { + path: path.to_owned(), + error, + } + .into()); + } } } - if total < SALT_LEN { + if filled < SALT_LEN { return Err(SortError::RandomSourceEndOfFile { path: path.to_owned(), } .into()); } + // freeze seed for --random-source + let mut hasher = FoldHasher::with_seed(1, SharedSeed::global_fixed()); + hasher.write(&salt[..filled]); + let first = hasher.finish(); // freeze seed for --random-source let mut second_hasher = FoldHasher::with_seed(2, SharedSeed::global_fixed()); diff --git a/tests/by-util/test_sort.rs b/tests/by-util/test_sort.rs index 6aeccf0e70b..cd891ef072b 100644 --- a/tests/by-util/test_sort.rs +++ b/tests/by-util/test_sort.rs @@ -517,6 +517,69 @@ fn test_random_source_is_not_read_without_random_sort() { .stdout_is("a\nb\nc\n"); } +#[test] +fn test_random_source_larger_than_the_salt() { + use uutests::util_name; + + // GNU stops reading after the 128-bit salt, so sources larger than + // 16 bytes work and yield a deterministic order. + let ts = TestScenario::new(util_name!()); + ts.fixtures.write_bytes("source", &vec![b'x'; 8192]); + ts.fixtures.write("input", "b\na\nc\n"); + + let first = ts + .ucmd() + .args(&["-R", "--random-source=source", "input"]) + .succeeds() + .stdout_move_str(); + let second = ts + .ucmd() + .args(&["-R", "--random-source=source", "input"]) + .succeeds() + .stdout_move_str(); + + assert_eq!(first.lines().count(), 3); + assert_eq!(first, second); +} + +#[test] +fn test_random_source_named_pipe_does_not_overread() { + use uutests::util_name; + + // GNU reads exactly the 128-bit salt from the source and keeps the file + // open while shuffling. An over-read on a named pipe would block forever + // on a writer that only supplied the salt. + use std::io::Write; + + let ts = TestScenario::new(util_name!()); + ts.fixtures.write("input", "b\na\nc\n"); + ts.fixtures.mkdir("d"); + let fifo = ts.fixtures.plus("d/source"); + + nix::unistd::mkfifo(&fifo, nix::sys::stat::Mode::S_IRWXU).unwrap(); + + let fifo_w = fifo.clone(); + let writer = std::thread::spawn(move || { + let mut file = std::fs::OpenOptions::new() + .write(true) + .open(&fifo_w) + .unwrap(); + file.write_all(&[b'x'; SALT_TEST_LEN]).unwrap(); + // Keep the descriptor open like a writer would before closing it + // (e.g. `printf %016d > fifo` still running). + std::thread::sleep(Duration::from_secs(5)); + drop(file); + }); + + ts.ucmd() + .args(&["-R", "--random-source=d/source", "input"]) + .succeeds() + .stdout_matches(®ex::Regex::new("[abc]\n[abc]\n[abc]\n").unwrap()); + writer.join().unwrap(); +} + +const SALT_TEST_LEN: usize = 16; + #[test] fn test_random_ignore_case() { let input = "ABC\nABc\nAbC\nAbc\naBC\naBc\nabC\nabc\n"; From ebaad2d71162737c350d22898d042463785a2fec Mon Sep 17 00:00:00 2001 From: tasodoufu Date: Sat, 3 Oct 2026 22:59:05 +0900 Subject: [PATCH 2/7] test(sort): gate random-source fifo test to unix targets Windows builds do not have the unix-only nix dev-dependency configured, so the unconditionally-compiled fifo test failed the whole test suite on all feat_os_windows CI jobs. Keep the over-read regression coverage on unix targets, matching existing fifo tests in this file. --- tests/by-util/test_sort.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/by-util/test_sort.rs b/tests/by-util/test_sort.rs index cd891ef072b..d489e16eae2 100644 --- a/tests/by-util/test_sort.rs +++ b/tests/by-util/test_sort.rs @@ -543,6 +543,7 @@ fn test_random_source_larger_than_the_salt() { } #[test] +#[cfg(unix)] fn test_random_source_named_pipe_does_not_overread() { use uutests::util_name; From b63d7c2e5644404661949318add5fd87ddcfdcc1 Mon Sep 17 00:00:00 2001 From: tasodoufu Date: Sun, 4 Oct 2026 17:06:15 +0900 Subject: [PATCH 3/7] test(sort): gate SALT_TEST_LEN to unix to fix Windows clippy SALT_TEST_LEN is only referenced by the fifo over-read test, which is #[cfg(unix)]-gated. On non-unix targets the constant is dead code and cargo clippy -- -D warnings fails the Style/lint (windows) job. Reproduce locally by temporarily removing the unix gate on the constant: 'warning: constant `SALT_TEST_LEN` is never used' appears; with the gate restored, cargo check and clippy -D warnings are clean on x86_64-unknown-linux-gnu. --- tests/by-util/test_sort.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/by-util/test_sort.rs b/tests/by-util/test_sort.rs index d489e16eae2..28075b8b915 100644 --- a/tests/by-util/test_sort.rs +++ b/tests/by-util/test_sort.rs @@ -579,6 +579,7 @@ fn test_random_source_named_pipe_does_not_overread() { writer.join().unwrap(); } +#[cfg(unix)] const SALT_TEST_LEN: usize = 16; #[test] From 0981005ddb36029fc5e187311508ec579ed583a6 Mon Sep 17 00:00:00 2001 From: tasodoufu Date: Sun, 4 Oct 2026 22:54:51 +0900 Subject: [PATCH 4/7] sort: write full salt buffer into hasher --- src/uu/sort/src/sort.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/uu/sort/src/sort.rs b/src/uu/sort/src/sort.rs index f12d5433ee6..084ae67e1e6 100644 --- a/src/uu/sort/src/sort.rs +++ b/src/uu/sort/src/sort.rs @@ -3221,7 +3221,7 @@ fn salt_from_random_source(path: &Path) -> UResult<[u8; SALT_LEN]> { // freeze seed for --random-source let mut hasher = FoldHasher::with_seed(1, SharedSeed::global_fixed()); - hasher.write(&salt[..filled]); + hasher.write(&salt); let first = hasher.finish(); // freeze seed for --random-source From 1f3a52ff2dcc508ff75b67992a5d8fcc3b8d5e4a Mon Sep 17 00:00:00 2001 From: tasodoufu Date: Mon, 5 Oct 2026 17:07:42 +0900 Subject: [PATCH 5/7] sort: address random source review feedback --- src/uu/sort/src/sort.rs | 36 ++++++++++++----------------------- tests/by-util/test_sort.rs | 39 +++++++++++++++++++------------------- 2 files changed, 31 insertions(+), 44 deletions(-) diff --git a/src/uu/sort/src/sort.rs b/src/uu/sort/src/sort.rs index 084ae67e1e6..a1b07e5aef2 100644 --- a/src/uu/sort/src/sort.rs +++ b/src/uu/sort/src/sort.rs @@ -3182,11 +3182,10 @@ const SALT_LEN: usize = 16; // 128-bit salt const U64_LEN: usize = 8; const RANDOM_SOURCE_TAG: &[u8] = b"uutils-sort-random-source"; // Domain separation tag -/// Create a 128-bit salt by hashing up to 1 MiB from the given file. +/// Create a 128-bit salt by hashing the first [`SALT_LEN`] bytes from the file. /// /// The file has to hold at least [`SALT_LEN`] bytes. GNU asks for the same 128 -/// bits and reports `end of file` when the source cannot supply them, rather -/// than shuffling with whatever it managed to read. +/// bits and reports `end of file` when the source cannot supply them. fn salt_from_random_source(path: &Path) -> UResult<[u8; SALT_LEN]> { // GNU reads exactly SALT_LEN bytes from the source and keeps the file open // (a named pipe keeps its writer blocked until the reader closes it), so an @@ -3195,29 +3194,18 @@ fn salt_from_random_source(path: &Path) -> UResult<[u8; SALT_LEN]> { // after the salt bytes are collected. let mut reader = open_with_open_failed_error(path)?; let mut salt = [0u8; SALT_LEN]; - let mut filled = 0usize; - while filled < SALT_LEN { - match reader.read(&mut salt[filled..]) { - Ok(0) => break, - Ok(n) => filled = filled.saturating_add(n), - // Retry on interrupted reads (EINTR) instead of surfacing them. - Err(error) if error.kind() == std::io::ErrorKind::Interrupted => (), - Err(error) => { - return Err(SortError::ReadFailed { - path: path.to_owned(), - error, - } - .into()); + reader.read_exact(&mut salt).map_err(|error| { + if error.kind() == std::io::ErrorKind::UnexpectedEof { + SortError::RandomSourceEndOfFile { + path: path.to_owned(), + } + } else { + SortError::ReadFailed { + path: path.to_owned(), + error, } } - } - - if filled < SALT_LEN { - return Err(SortError::RandomSourceEndOfFile { - path: path.to_owned(), - } - .into()); - } + })?; // freeze seed for --random-source let mut hasher = FoldHasher::with_seed(1, SharedSeed::global_fixed()); diff --git a/tests/by-util/test_sort.rs b/tests/by-util/test_sort.rs index 28075b8b915..60b3920b6b7 100644 --- a/tests/by-util/test_sort.rs +++ b/tests/by-util/test_sort.rs @@ -519,56 +519,54 @@ fn test_random_source_is_not_read_without_random_sort() { #[test] fn test_random_source_larger_than_the_salt() { - use uutests::util_name; - - // GNU stops reading after the 128-bit salt, so sources larger than - // 16 bytes work and yield a deterministic order. - let ts = TestScenario::new(util_name!()); - ts.fixtures.write_bytes("source", &vec![b'x'; 8192]); - ts.fixtures.write("input", "b\na\nc\n"); + let ts = TestScenario::new(uutests::util_name!()); + let at = &ts.fixtures; + at.write("input", "b\na\nc\n"); + let mut source_a = vec![b'a'; 32]; + let mut source_b = source_a.clone(); + source_a[16] = b'b'; + source_b[16] = b'c'; + at.write_bytes("source-a", &source_a); + at.write_bytes("source-b", &source_b); let first = ts .ucmd() - .args(&["-R", "--random-source=source", "input"]) + .args(&["-R", "--random-source=source-a", "input"]) .succeeds() .stdout_move_str(); let second = ts .ucmd() - .args(&["-R", "--random-source=source", "input"]) + .args(&["-R", "--random-source=source-b", "input"]) .succeeds() .stdout_move_str(); - assert_eq!(first.lines().count(), 3); assert_eq!(first, second); } #[test] #[cfg(unix)] fn test_random_source_named_pipe_does_not_overread() { - use uutests::util_name; - // GNU reads exactly the 128-bit salt from the source and keeps the file // open while shuffling. An over-read on a named pipe would block forever // on a writer that only supplied the salt. use std::io::Write; - let ts = TestScenario::new(util_name!()); + let ts = TestScenario::new(uutests::util_name!()); ts.fixtures.write("input", "b\na\nc\n"); ts.fixtures.mkdir("d"); - let fifo = ts.fixtures.plus("d/source"); - - nix::unistd::mkfifo(&fifo, nix::sys::stat::Mode::S_IRWXU).unwrap(); + ts.fixtures.mkfifo("d/source"); - let fifo_w = fifo.clone(); + let fifo_w = ts.fixtures.plus("d/source"); + let (done_tx, done_rx) = std::sync::mpsc::channel(); let writer = std::thread::spawn(move || { let mut file = std::fs::OpenOptions::new() .write(true) .open(&fifo_w) .unwrap(); file.write_all(&[b'x'; SALT_TEST_LEN]).unwrap(); - // Keep the descriptor open like a writer would before closing it - // (e.g. `printf %016d > fifo` still running). - std::thread::sleep(Duration::from_secs(5)); + // Keep the descriptor open until sort has finished. An over-read + // would block here waiting for bytes beyond the salt. + done_rx.recv().unwrap(); drop(file); }); @@ -576,6 +574,7 @@ fn test_random_source_named_pipe_does_not_overread() { .args(&["-R", "--random-source=d/source", "input"]) .succeeds() .stdout_matches(®ex::Regex::new("[abc]\n[abc]\n[abc]\n").unwrap()); + done_tx.send(()).unwrap(); writer.join().unwrap(); } From cbe15dadb93df881cfe65ecfb53d5e290e147689 Mon Sep 17 00:00:00 2001 From: tasodoufu Date: Wed, 14 Oct 2026 23:36:22 +0900 Subject: [PATCH 6/7] touch: interpret -d dates without a timezone in the local zone GNU touch treats a -d date/time without an explicit timezone as local time; uutils resolved it as UTC, shifting timestamps by the local UTC offset (e.g. 2026-08-01 12:00:00 in Europe/Rome became 14:00+02:00). Fix the %Y-%m-%d %H:%M:%S path in touch's parser to resolve the civil datetime in the system timezone (ambiguous instants fall back to the earlier offset, like GNU mktime). Formats that carry an explicit %z offset keep the existing resolution. Dates parsed by parse_datetime already resolve through the reference zoned datetime, which keeps a locally meaningful base. Add a regression test that runs touch under TZ=Asia/Tokyo and asserts the mtime/atime instant for 2026-08-01 12:00:00, which differs from the UTC misinterpretation by 9 hours. Fixes #15291 --- src/uu/touch/src/touch.rs | 16 +++++++++++++++- tests/by-util/test_touch.rs | 28 ++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/uu/touch/src/touch.rs b/src/uu/touch/src/touch.rs index a9da259ab63..62566d28f55 100644 --- a/src/uu/touch/src/touch.rs +++ b/src/uu/touch/src/touch.rs @@ -822,8 +822,22 @@ fn parse_date(ref_zoned: Zoned, s: &str) -> Result { // Also support other formats found in the GNU tests like // in tests/misc/stat-nanoseconds.sh // or tests/touch/no-rights.sh + // A date and time without an explicit timezone is interpreted in the + // local zone (GNU behavior), not as UTC (issue #15291). Formats that + // carry a trailing %z offset are still resolved via that offset, so they + // keep an explicit UTC branch. + if let Ok(filetime) = strtime::parse(format::YYYYMMDDHHMMS, s) + .and_then(|tm| tm.to_datetime()) + .and_then(|dt| { + TimeZone::system() + .to_ambiguous_zoned(dt) + .unambiguous() + }) + .map(|zdt| timestamp_to_filetime(zdt.timestamp())) + { + return Ok(filetime); + } for fmt in [ - format::YYYYMMDDHHMMS, format::YYYYMMDDHHMMSS, format::YYYYMMDDHHMM_OFFSET, ] { diff --git a/tests/by-util/test_touch.rs b/tests/by-util/test_touch.rs index bac66196b31..a7165dc2784 100644 --- a/tests/by-util/test_touch.rs +++ b/tests/by-util/test_touch.rs @@ -1390,3 +1390,31 @@ fn test_touch_set_time_on_unreadable_unwritable_file() { assert_eq!(atime, expected); assert_eq!(mtime, expected); } + +// -d with a date and time lacking an explicit timezone must be interpreted in +// the local zone (GNU compatibility), not as UTC. Regression test for +// https://github.com/uutils/coreutils/issues/15291. +#[test] +fn test_touch_date_without_offset_is_local_time() { + // Force TZ=Asia/Tokyo: the harness default TZ=UTC would make the buggy + // "interpret as UTC" behavior indistinguishable from the correct one. + let (at, mut ucmd) = at_and_ucmd!(); + let file = "touch_local_no_offset"; + at.touch(file); + ucmd.env("TZ", "Asia/Tokyo") + .args(&["-d", "2026-08-01 12:00:00", file]) + .succeeds() + .no_output(); + let (atime, mtime) = get_file_times(&at, file); + // 1_785_585_600 = 2026-08-01 12:00 UTC; Asia/Tokyo is UTC+9, so civil + // 12:00 local is 03:00 UTC = 1_785_553_200. + let expected = FileTime::from_unix_time(1_785_585_600 - 9 * 3600, 0); + assert_eq!( + mtime, expected, + "mtime should be 2026-08-01 12:00 Asia/Tokyo, not UTC" + ); + assert_eq!( + atime, expected, + "atime should be 2026-08-01 12:00 Asia/Tokyo, not UTC" + ); +} From e630e6c02ded9a34356c7608c7e496c387556ae7 Mon Sep 17 00:00:00 2001 From: tasodoufu Date: Thu, 15 Oct 2026 09:05:20 +0900 Subject: [PATCH 7/7] touch: fix formatting per review (cargo fmt) --- src/uu/touch/src/touch.rs | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/src/uu/touch/src/touch.rs b/src/uu/touch/src/touch.rs index 62566d28f55..32c0703b9f1 100644 --- a/src/uu/touch/src/touch.rs +++ b/src/uu/touch/src/touch.rs @@ -828,19 +828,12 @@ fn parse_date(ref_zoned: Zoned, s: &str) -> Result { // keep an explicit UTC branch. if let Ok(filetime) = strtime::parse(format::YYYYMMDDHHMMS, s) .and_then(|tm| tm.to_datetime()) - .and_then(|dt| { - TimeZone::system() - .to_ambiguous_zoned(dt) - .unambiguous() - }) + .and_then(|dt| TimeZone::system().to_ambiguous_zoned(dt).unambiguous()) .map(|zdt| timestamp_to_filetime(zdt.timestamp())) { return Ok(filetime); } - for fmt in [ - format::YYYYMMDDHHMMSS, - format::YYYYMMDDHHMM_OFFSET, - ] { + for fmt in [format::YYYYMMDDHHMMSS, format::YYYYMMDDHHMM_OFFSET] { if let Ok(parsed) = strtime::parse(fmt, s) .and_then(|tm| tm.to_datetime()) .and_then(|dt| TimeZone::UTC.to_zoned(dt))