diff --git a/src/uu/sort/src/sort.rs b/src/uu/sort/src/sort.rs index 0bdc87dc61b..a1b07e5aef2 100644 --- a/src/uu/sort/src/sort.rs +++ b/src/uu/sort/src/sort.rs @@ -3179,45 +3179,37 @@ 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 -/// 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 + // 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]; + 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 total < 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); let first = hasher.finish(); // freeze seed for --random-source diff --git a/src/uu/touch/src/touch.rs b/src/uu/touch/src/touch.rs index a9da259ab63..32c0703b9f1 100644 --- a/src/uu/touch/src/touch.rs +++ b/src/uu/touch/src/touch.rs @@ -822,11 +822,18 @@ 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 - for fmt in [ - format::YYYYMMDDHHMMS, - format::YYYYMMDDHHMMSS, - format::YYYYMMDDHHMM_OFFSET, - ] { + // 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::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)) diff --git a/tests/by-util/test_sort.rs b/tests/by-util/test_sort.rs index 6aeccf0e70b..60b3920b6b7 100644 --- a/tests/by-util/test_sort.rs +++ b/tests/by-util/test_sort.rs @@ -517,6 +517,70 @@ 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() { + 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-a", "input"]) + .succeeds() + .stdout_move_str(); + let second = ts + .ucmd() + .args(&["-R", "--random-source=source-b", "input"]) + .succeeds() + .stdout_move_str(); + + assert_eq!(first, second); +} + +#[test] +#[cfg(unix)] +fn test_random_source_named_pipe_does_not_overread() { + // 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(uutests::util_name!()); + ts.fixtures.write("input", "b\na\nc\n"); + ts.fixtures.mkdir("d"); + ts.fixtures.mkfifo("d/source"); + + 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 until sort has finished. An over-read + // would block here waiting for bytes beyond the salt. + done_rx.recv().unwrap(); + drop(file); + }); + + ts.ucmd() + .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(); +} + +#[cfg(unix)] +const SALT_TEST_LEN: usize = 16; + #[test] fn test_random_ignore_case() { let input = "ABC\nABc\nAbC\nAbc\naBC\naBc\nabC\nabc\n"; 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" + ); +}