From df1e043ec08f81a9d0d7191bf883c21a253bc4cc Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Thu, 15 Jan 2026 20:09:45 +0900 Subject: [PATCH 01/32] CICD.yml: Drop checks for hashsum --- .github/workflows/CICD.yml | 23 ++++++++--------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/.github/workflows/CICD.yml b/.github/workflows/CICD.yml index fac09b36a..fc8b265b9 100644 --- a/.github/workflows/CICD.yml +++ b/.github/workflows/CICD.yml @@ -297,7 +297,6 @@ jobs: mv -T target target.cache fi # Check that we don't cross-build uudoc - # also do not try to generate manpages for part of hashsum env CARGO_BUILD_TARGET=aarch64-unknown-linux-gnu make install-manpages PREFIX=/tmp/usr UTILS=true # build (host) make build @@ -371,25 +370,19 @@ jobs: run: | set -x DESTDIR=/tmp/ make PROFILE=release MULTICALL=n install - # Check that the utils are present - test -f /tmp/usr/local/bin/hashsum - # Check that hashsum symlinks are present - test -h /tmp/usr/local/bin/b2sum - test -h /tmp/usr/local/bin/md5sum - test -h /tmp/usr/local/bin/sha1sum - test -h /tmp/usr/local/bin/sha224sum - test -h /tmp/usr/local/bin/sha256sum - test -h /tmp/usr/local/bin/sha384sum - test -h /tmp/usr/local/bin/sha512sum + # Check that *sum are present + for s in {md5,b2,sha1,sha224,sha256,sha384,sha512}sum + do test -e /tmp/usr/local/bin/${s} + done - name: "`make install MULTICALL=y LN=ln -svf`" shell: bash run: | set -x DESTDIR=/tmp/ make PROFILE=release MULTICALL=y LN="ln -svf" install - # Check that relative symlinks of hashsum are present - [ $(readlink /tmp/usr/local/bin/b2sum) = coreutils ] - [ $(readlink /tmp/usr/local/bin/md5sum) = coreutils ] - [ $(readlink /tmp/usr/local/bin/sha512sum) = coreutils ] + # Check that symlinks of *sum are present + for s in {md5,b2,sha1,sha224,sha256,sha384,sha512}sum + do test $(readlink /tmp/usr/local/bin/${s}) = coreutils + done - name: "`make UTILS=XXX`" shell: bash run: | From a1545ef2a96db060292a12415c8631c05b7d8b21 Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Fri, 16 Jan 2026 21:19:01 +0100 Subject: [PATCH 02/32] try to decrease the variance in the memory usage for benchmark (#10273) --- src/uu/du/benches/du_bench.rs | 14 +++++++++++--- src/uu/numfmt/benches/numfmt_bench.rs | 8 ++++++-- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/uu/du/benches/du_bench.rs b/src/uu/du/benches/du_bench.rs index 8a2d29246..5ea45ed9c 100644 --- a/src/uu/du/benches/du_bench.rs +++ b/src/uu/du/benches/du_bench.rs @@ -77,9 +77,17 @@ fn du_all_wide_tree(bencher: Bencher, (total_files, total_dirs): (usize, usize)) /// Benchmark du on deep directory structures #[divan::bench(args = [(100, 3)])] fn du_deep_tree(bencher: Bencher, (depth, files_per_level): (usize, usize)) { - let temp_dir = TempDir::new().unwrap(); - fs_tree::create_deep_tree(temp_dir.path(), depth, files_per_level); - bench_du_with_args(bencher, &temp_dir, &[]); + bencher + .with_inputs(|| { + let temp_dir = TempDir::new().unwrap(); + fs_tree::create_deep_tree(temp_dir.path(), depth, files_per_level); + temp_dir + }) + .bench_values(|temp_dir| { + let temp_path_str = temp_dir.path().to_str().unwrap(); + let args = vec![temp_path_str]; + black_box(run_util_function(uumain, &args)); + }); } /// Benchmark du -s (summarize) on balanced tree diff --git a/src/uu/numfmt/benches/numfmt_bench.rs b/src/uu/numfmt/benches/numfmt_bench.rs index d75bf4ad1..561b65093 100644 --- a/src/uu/numfmt/benches/numfmt_bench.rs +++ b/src/uu/numfmt/benches/numfmt_bench.rs @@ -63,8 +63,12 @@ fn numfmt_from_si(bencher: Bencher, count: usize) { /// Benchmark large numbers with SI formatting #[divan::bench(args = [10_000])] fn numfmt_large_numbers_si(bencher: Bencher, count: usize) { - // Generate larger numbers (millions to billions range) - let numbers: Vec = (1..=count).map(|n| (n * 1_000_000).to_string()).collect(); + // Generate numbers that all produce uniform SI output lengths (all in 1-9M range) + // This avoids variance from variable output string lengths + let numbers: Vec = (1..=count) + .map(|n| ((n % 9) + 1) * 1_000_000) + .map(|n| n.to_string()) + .collect(); let mut args = vec!["--to=si"]; let number_refs: Vec<&str> = numbers.iter().map(|s| s.as_str()).collect(); args.extend(number_refs); From 3efdb50543004ca2f39b0527590520f0a72f3abf Mon Sep 17 00:00:00 2001 From: Christopher Dryden Date: Fri, 16 Jan 2026 23:18:22 +0000 Subject: [PATCH 03/32] ls: fix symlink target coloring for chains and extensions --- src/uu/ls/src/ls.rs | 13 +++++++++---- tests/by-util/test_ls.rs | 30 ++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/src/uu/ls/src/ls.rs b/src/uu/ls/src/ls.rs index 3e98c1116..6694d7bca 100644 --- a/src/uu/ls/src/ls.rs +++ b/src/uu/ls/src/ls.rs @@ -3398,10 +3398,15 @@ fn display_item_name( } } - match fs::metadata(&absolute_target) { - Ok(_) => { - let target_data = - PathData::new(absolute_target, None, None, config, false); + match fs::canonicalize(&absolute_target) { + Ok(resolved_target) => { + let target_data = PathData::new( + resolved_target, + None, + target_path.file_name().map(|s| s.to_os_string()), + config, + false, + ); name.push(color_name( escaped_target, &target_data, diff --git a/tests/by-util/test_ls.rs b/tests/by-util/test_ls.rs index 571540d11..41b72af6b 100644 --- a/tests/by-util/test_ls.rs +++ b/tests/by-util/test_ls.rs @@ -4951,6 +4951,36 @@ fn test_dereference_symlink_file_color() { .stdout_is(out_exp); } +/// Symlink chain target should be colored by final target type, not as symlink (#8934). +#[test] +fn test_symlink_chain_target_color() { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("file"); + at.relative_symlink_file("file", "link1"); + at.relative_symlink_file("link1", "link2"); + let out = ucmd + .args(&["-l", "--color=always", "link2"]) + .succeeds() + .stdout_move_str(); + let target = out.split("->").nth(1).unwrap(); + assert!(!target.contains("36m")); // 36m = cyan (symlink color) +} + +/// Symlink target should be colored by extension (e.g., .tar.gz shows as archive color). +#[test] +fn test_symlink_target_extension_color() { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("archive.tar.gz"); + at.relative_symlink_file("archive.tar.gz", "link"); + let out = ucmd + .env("LS_COLORS", "*.tar.gz=31") + .args(&["-l", "--color=always", "link"]) + .succeeds() + .stdout_move_str(); + let target = out.split("->").nth(1).unwrap(); + assert!(target.contains("31m")); // 31 = red (our configured archive color) +} + #[test] fn test_tabsize_option() { let scene = TestScenario::new(util_name!()); From c66be8cd60565a42e6436cd5895aebd62b1aa718 Mon Sep 17 00:00:00 2001 From: Etienne Cordonnier Date: Sat, 17 Jan 2026 09:32:44 +0100 Subject: [PATCH 04/32] stdbuf: use exec instead of forking (#9495) --- src/uu/stdbuf/src/stdbuf.rs | 69 ++++++++++------------------------- tests/by-util/test_stdbuf.rs | 71 +++++++++++++++++++++++++++++++++++- 2 files changed, 89 insertions(+), 51 deletions(-) diff --git a/src/uu/stdbuf/src/stdbuf.rs b/src/uu/stdbuf/src/stdbuf.rs index f45dd2b97..b18e73d5d 100644 --- a/src/uu/stdbuf/src/stdbuf.rs +++ b/src/uu/stdbuf/src/stdbuf.rs @@ -7,12 +7,14 @@ use clap::{Arg, ArgAction, ArgMatches, Command}; use std::ffi::OsString; +#[cfg(unix)] +use std::os::unix::process::CommandExt; use std::path::PathBuf; use std::process; use tempfile::TempDir; use tempfile::tempdir; use thiserror::Error; -use uucore::error::{FromIo, UResult, USimpleError, UUsageError}; +use uucore::error::{UResult, USimpleError, UUsageError}; use uucore::format_usage; use uucore::parser::parse_size::parse_size_u64; use uucore::translate; @@ -208,55 +210,22 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { set_command_env(&mut command, "_STDBUF_E", &options.stderr); command.args(command_params); - let mut process = match command.spawn() { - Ok(p) => p, - Err(e) => { - return match e.kind() { - std::io::ErrorKind::PermissionDenied => Err(USimpleError::new( - 126, - translate!("stdbuf-error-permission-denied"), - )), - std::io::ErrorKind::NotFound => Err(USimpleError::new( - 127, - translate!("stdbuf-error-no-such-file"), - )), - _ => Err(USimpleError::new( - 1, - translate!("stdbuf-error-failed-to-execute", "error" => e), - )), - }; - } - }; - - let status = process.wait().map_err_context(String::new)?; - match status.code() { - Some(i) => { - if i == 0 { - Ok(()) - } else { - Err(i.into()) - } - } - None => { - #[cfg(unix)] - { - use std::os::unix::process::ExitStatusExt; - let signal_msg = status - .signal() - .map_or_else(|| "unknown".to_string(), |s| s.to_string()); - Err(USimpleError::new( - 1, - translate!("stdbuf-error-killed-by-signal", "signal" => signal_msg), - )) - } - #[cfg(not(unix))] - { - Err(USimpleError::new( - 1, - "process terminated abnormally".to_string(), - )) - } - } + // Replace the current process with the target program (no fork) using exec. + let e = command.exec(); + // exec() only returns if there was an error + match e.kind() { + std::io::ErrorKind::PermissionDenied => Err(USimpleError::new( + 126, + translate!("stdbuf-error-permission-denied"), + )), + std::io::ErrorKind::NotFound => Err(USimpleError::new( + 127, + translate!("stdbuf-error-no-such-file"), + )), + _ => Err(USimpleError::new( + 1, + translate!("stdbuf-error-failed-to-execute", "error" => e), + )), } } diff --git a/tests/by-util/test_stdbuf.rs b/tests/by-util/test_stdbuf.rs index c74ad54ec..00e117f47 100644 --- a/tests/by-util/test_stdbuf.rs +++ b/tests/by-util/test_stdbuf.rs @@ -2,7 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore dyld dylib setvbuf +// spell-checker:ignore cmdline dyld dylib PDEATHSIG setvbuf #[cfg(target_os = "linux")] use uutests::at_and_ucmd; use uutests::new_ucmd; @@ -276,3 +276,72 @@ fn test_stdbuf_non_utf8_paths() { .succeeds() .stdout_is("test content for stdbuf\n"); } + +#[test] +#[cfg(target_os = "linux")] +fn test_stdbuf_no_fork_regression() { + // Regression test for issue #9066: https://github.com/uutils/coreutils/issues/9066 + // The original stdbuf implementation used fork+spawn which broke signal handling + // and PR_SET_PDEATHSIG. This test verifies that stdbuf uses exec() instead. + // With fork: stdbuf process would remain visible in process list + // With exec: stdbuf process is replaced by target command (GNU compatible) + + use std::process::{Command, Stdio}; + use std::thread; + use std::time::Duration; + + let scene = TestScenario::new(util_name!()); + + // Start stdbuf with a long-running command + let mut child = Command::new(&scene.bin_path) + .args(["stdbuf", "-o0", "sleep", "3"]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("Failed to start stdbuf"); + + let child_pid = child.id(); + + // Poll until exec happens or timeout + let cmdline_path = format!("/proc/{child_pid}/cmdline"); + let timeout = Duration::from_secs(2); + let poll_interval = Duration::from_millis(10); + let start_time = std::time::Instant::now(); + + let command_name = loop { + if start_time.elapsed() > timeout { + child.kill().ok(); + panic!("TIMEOUT: Process {child_pid} did not respond within {timeout:?}"); + } + + if let Ok(cmdline) = std::fs::read_to_string(&cmdline_path) { + let cmd_parts: Vec<&str> = cmdline.split('\0').collect(); + let name = cmd_parts.first().map_or("", |v| v); + + // Wait for exec to complete (process name changes from original binary to target) + // Handle both multicall binary (coreutils) and individual utilities (stdbuf) + if !name.contains("coreutils") && !name.contains("stdbuf") && !name.is_empty() { + break name.to_string(); + } + } + + thread::sleep(poll_interval); + }; + + // The loop already waited for exec (no longer original binary), so this should always pass + // But keep the assertion as a safety check and clear documentation + assert!( + !command_name.contains("coreutils") && !command_name.contains("stdbuf"), + "REGRESSION: Process {child_pid} is still original binary (coreutils or stdbuf) - fork() used instead of exec()" + ); + + // Ensure we're running the expected target command + assert!( + command_name.contains("sleep"), + "Expected 'sleep' command at PID {child_pid}, got: {command_name}" + ); + + // Cleanup + child.kill().ok(); + child.wait().ok(); +} From 0b5826086c339dc3d7f59b7f47a214d9cff5371c Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Sat, 17 Jan 2026 17:56:13 +0900 Subject: [PATCH 05/32] CICD.yml: Upload manpages and completions (#10257) --- .github/workflows/CICD.yml | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/.github/workflows/CICD.yml b/.github/workflows/CICD.yml index fc8b265b9..87813c798 100644 --- a/.github/workflows/CICD.yml +++ b/.github/workflows/CICD.yml @@ -4,7 +4,7 @@ name: CICD # spell-checker:ignore (env/flags) Awarnings Ccodegen Coverflow Cpanic Dwarnings RUSTDOCFLAGS RUSTFLAGS Zpanic CARGOFLAGS # spell-checker:ignore (jargon) SHAs deps dequote softprops subshell toolchain fuzzers dedupe devel profdata # spell-checker:ignore (people) Peltoche rivy dtolnay Anson dawidd -# spell-checker:ignore (shell/tools) binutils choco clippy dmake esac fakeroot fdesc fdescfs gmake grcov halium lcov libclang libfuse libssl limactl mkdir nextest nocross pacman popd printf pushd redoxer rsync rustc rustfmt rustup shopt sccache utmpdump xargs +# spell-checker:ignore (shell/tools) binutils choco clippy dmake esac fakeroot fdesc fdescfs gmake grcov halium lcov libclang libfuse libssl limactl mkdir nextest nocross pacman popd printf pushd redoxer rsync rustc rustfmt rustup shopt sccache utmpdump xargs zstd # spell-checker:ignore (misc) aarch alnum armhf bindir busytest coreutils defconfig DESTDIR gecos getenforce gnueabihf issuecomment maint manpages msys multisize noconfirm nofeatures nullglob onexitbegin onexitend pell runtest Swatinem tempfile testsuite toybox uutils libsystemd codspeed env: @@ -890,6 +890,19 @@ jobs: *) tar czf '${{ steps.vars.outputs.PKG_NAME }}' '${{ steps.vars.outputs.PKG_BASENAME }}'/* ;; esac popd >/dev/null + - name: Package manpages and completions + if: matrix.job.target == 'x86_64-unknown-linux-gnu' && matrix.job.features == 'feat_os_unix,uudoc' + run: | + mkdir -p share/{man/man1,bash-completion/completions,fish/vendor_completions.d,zsh/site-functions,elvish/lib} + _uudoc=target/${{ matrix.job.target }}/release/uudoc + for bin in $('target/${{ matrix.job.target }}/release/coreutils' --list);do + ${_uudoc} manpage ${bin} > share/man/man1/${bin}.1 + ${_uudoc} completion ${bin} bash > share/bash-completion/completions/${bin}.bash + ${_uudoc} completion ${bin} fish > share/fish/vendor_completions.d/${bin}.fish + ${_uudoc} completion ${bin} zsh > share/zsh/site-functions/_${bin} + ${_uudoc} completion ${bin} elvish > share/elvish/lib/${bin}.elv + done + tar --zstd -cf docs.tar.zst share - name: Publish uses: softprops/action-gh-release@v2 if: steps.vars.outputs.DEPLOY && matrix.job.skip-publish != true @@ -897,6 +910,7 @@ jobs: draft: true files: | ${{ steps.vars.outputs.STAGING }}/${{ steps.vars.outputs.PKG_NAME }} + docs.tar.zst env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Publish latest commit @@ -909,6 +923,7 @@ jobs: prerelease: true files: | ${{ steps.vars.outputs.STAGING }}/${{ steps.vars.outputs.PKG_NAME }} + docs.tar.zst env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 1b3a66aef8f291f23a761541b498fa5879b35ea1 Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Sat, 17 Jan 2026 18:44:23 +0900 Subject: [PATCH 06/32] CICD.yml: upload binaries without version string (#10217) --- .github/workflows/CICD.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/CICD.yml b/.github/workflows/CICD.yml index 87813c798..788f4ed5e 100644 --- a/.github/workflows/CICD.yml +++ b/.github/workflows/CICD.yml @@ -685,7 +685,7 @@ jobs: outputs TARGET_ARCH TARGET_OS # package name PKG_suffix=".tar.gz" ; case '${{ matrix.job.target }}' in *-pc-windows-*) PKG_suffix=".zip" ;; esac; - PKG_BASENAME=${PROJECT_NAME}-${REF_TAG:-$REF_SHAS}-${{ matrix.job.target }} + PKG_BASENAME=${PROJECT_NAME}-${{ matrix.job.target }} PKG_NAME=${PKG_BASENAME}${PKG_suffix} outputs PKG_suffix PKG_BASENAME PKG_NAME # deployable tag? (ie, leading "vM" or "M"; M == version number) @@ -915,10 +915,9 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Publish latest commit uses: softprops/action-gh-release@v2 - if: steps.vars.outputs.DEPLOY && matrix.job.skip-publish != true + if: github.event_name == 'push' && github.ref == 'refs/heads/main' && matrix.job.skip-publish != true with: tag_name: latest-commit - force_update: true draft: false prerelease: true files: | From 8728685e56a5fc5a26a84537a98395aba5cbf3c6 Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Sat, 17 Jan 2026 18:48:36 +0900 Subject: [PATCH 07/32] CI: Purge disk space at background to avoid delaying (#10276) --- .github/workflows/CICD.yml | 8 ++++---- .github/workflows/freebsd.yml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/CICD.yml b/.github/workflows/CICD.yml index 788f4ed5e..bc42a6bc5 100644 --- a/.github/workflows/CICD.yml +++ b/.github/workflows/CICD.yml @@ -630,6 +630,8 @@ jobs: - uses: actions/checkout@v6 with: persist-credentials: false + - name: Avoid no space left on device + run: sudo rm -rf /usr/share/dotnet /usr/local/lib/android & - uses: dtolnay/rust-toolchain@master with: toolchain: ${{ env.RUST_MIN_SRV }} @@ -854,8 +856,6 @@ jobs: if: matrix.job.skip-tests != true shell: bash run: | - command -v sudo && sudo rm -rf /usr/local/lib/android /usr/share/dotnet # avoid no space left - df -h ||: ## Test individual utilities ${{ steps.vars.outputs.CARGO_CMD }} ${{ steps.vars.outputs.CARGO_CMD_OPTIONS }} test --target=${{ matrix.job.target }} \ ${{ matrix.job.cargo-options }} ${{ steps.dep_vars.outputs.CARGO_UTILITY_LIST_OPTIONS }} @@ -1271,13 +1271,13 @@ jobs: - uses: actions/checkout@v6 with: persist-credentials: false + - name: Avoid no space left on device + run: sudo rm -rf /usr/share/dotnet /usr/local/lib/android & - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - name: build and test all features individually shell: bash run: | - command -v sudo && sudo rm -rf /usr/local/lib/android /usr/share/dotnet # avoid no space left - df -h ||: CARGO_FEATURES_OPTION='--features=${{ matrix.job.features }}' ; for f in $(util/show-utils.sh ${CARGO_FEATURES_OPTION}) do diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index ccbf7be9b..549f2ba85 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -134,7 +134,7 @@ jobs: with: persist-credentials: false - name: Avoid no space left on device (Ubuntu runner) - run: sudo rm -rf /usr/share/dotnet /usr/local/lib/android + run: sudo rm -rf /usr/share/dotnet /usr/local/lib/android & - uses: Swatinem/rust-cache@v2 - name: Run sccache-cache uses: mozilla-actions/sccache-action@v0.0.9 From 1876f51568dacc92af81a13a285916e4655839fd Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Sat, 17 Jan 2026 18:49:14 +0900 Subject: [PATCH 08/32] GnuTests.yml: Dedup setenforce 1 (#10266) --- .github/workflows/GnuTests.yml | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/.github/workflows/GnuTests.yml b/.github/workflows/GnuTests.yml index 39251c584..0f8ed7fd1 100644 --- a/.github/workflows/GnuTests.yml +++ b/.github/workflows/GnuTests.yml @@ -233,15 +233,6 @@ jobs: lima ls -laZ /etc/selinux lima sudo sestatus - # Ensure we're running in enforcing mode - lima sudo setenforce 1 - lima getenforce - - # Create test files with SELinux contexts for testing - lima sudo mkdir -p /var/test_selinux - lima sudo touch /var/test_selinux/test_file - lima sudo chcon -t etc_t /var/test_selinux/test_file - lima ls -Z /var/test_selinux/test_file # Verify context - name: Install dependencies in VM run: | lima sudo dnf -y update @@ -267,8 +258,16 @@ jobs: lima bash -c "cd ~/work/uutils/ && echo 'Found SELinux tests:'; wc -l selinux-tests.txt" - name: Run GNU SELinux tests run: | + # Ensure we're running in enforcing mode lima sudo setenforce 1 lima getenforce + + # Create test files with SELinux contexts for testing + lima sudo mkdir -p /var/test_selinux + lima sudo touch /var/test_selinux/test_file + lima sudo chcon -t etc_t /var/test_selinux/test_file + lima ls -Z /var/test_selinux/test_file # Verify context + lima cat /proc/filesystems lima bash -c "cd ~/work/uutils/ && bash util/run-gnu-test.sh \$(cat selinux-tests.txt)" - name: Extract testing info from individual logs into JSON From 43eeaf93227c1d1bd1760381660c3c0ab90a97e9 Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Sat, 17 Jan 2026 18:57:25 +0900 Subject: [PATCH 09/32] build-gnu.sh: Replace ${SED} with gsed wrapper (#10201) --- util/build-gnu.sh | 139 +++++++++++++++++++++++----------------------- util/fetch-gnu.sh | 3 +- 2 files changed, 72 insertions(+), 70 deletions(-) diff --git a/util/build-gnu.sh b/util/build-gnu.sh index dfefa6d26..13b055ae0 100755 --- a/util/build-gnu.sh +++ b/util/build-gnu.sh @@ -9,15 +9,16 @@ set -e # Use GNU make, readlink and sed on *BSD and macOS -MAKE=$(command -v gmake||command -v make) -READLINK=$(command -v greadlink||command -v readlink) # Use our readlink to remove a dependency -SED=$(command -v gsed||command -v sed) +command -v gmake && make(){ gmake "$@";} +command -v greadlink && readlink(){ greadlink "$@";} # todo: use our readlink for less deps +command -v gsed && sed(){ gsed "$@";} +SED=$(command -v gsed||command -v sed) # for find...exec... SYSTEM_TIMEOUT=$(command -v timeout) SYSTEM_YES=$(command -v yes) ME="${0}" -ME_dir="$(dirname -- "$("${READLINK}" -fm -- "${ME}")")" +ME_dir="$(dirname -- "$(readlink -fm -- "${ME}")")" REPO_main_dir="$(dirname -- "${ME_dir}")" @@ -28,7 +29,7 @@ unset CARGOFLAGS ### * config (from environment with fallback defaults); note: GNU is expected to be a sibling repo directory path_UUTILS=${path_UUTILS:-${REPO_main_dir}} -path_GNU="$("${READLINK}" -fm -- "${path_GNU:-${path_UUTILS}/../gnu}")" +path_GNU="$(readlink -fm -- "${path_GNU:-${path_UUTILS}/../gnu}")" ### @@ -88,14 +89,14 @@ fi cd - export CARGOFLAGS # tell to make - "${MAKE}" UTILS=install + make UTILS=install [ -e "${UU_BUILD_DIR}/ginstall" ] || ln -vf "${UU_BUILD_DIR}/install" "${UU_BUILD_DIR}/ginstall" # The GNU tests use renamed install to ginstall if [ "${SELINUX_ENABLED}" = 1 ];then # Build few utils for SELinux for faster build. MULTICALL=y fails... - "${MAKE}" UTILS="cat chcon chmod cp cut dd echo env groups id ln ls mkdir mkfifo mknod mktemp mv printf rm rmdir runcon seq stat test touch tr true uname wc whoami" + make UTILS="cat chcon chmod cp cut dd echo env groups id ln ls mkdir mkfifo mknod mktemp mv printf rm rmdir runcon seq stat test touch tr true uname wc whoami" else # Use MULTICALL=y for faster build - "${MAKE}" MULTICALL=y SKIP_UTILS="install more" + make MULTICALL=y SKIP_UTILS="install more" for binary in $("${UU_BUILD_DIR}"/coreutils --list) do [ -e "${UU_BUILD_DIR}/${binary}" ] || ln -vf "${UU_BUILD_DIR}/coreutils" "${UU_BUILD_DIR}/${binary}" done @@ -114,7 +115,7 @@ done # Always update the PATH to test the uutils coreutils instead of the GNU coreutils # This ensures the correct path is used even if the repository was moved or rebuilt in a different location -"${SED}" -i "s/^[[:blank:]]*PATH=.*/ PATH='${UU_BUILD_DIR//\//\\/}\$(PATH_SEPARATOR)'\"\$\$PATH\" \\\/" tests/local.mk +sed -i "s/^[[:blank:]]*PATH=.*/ PATH='${UU_BUILD_DIR//\//\\/}\$(PATH_SEPARATOR)'\"\$\$PATH\" \\\/" tests/local.mk if test -f gnu-built; then echo "GNU build already found. Skip" @@ -122,7 +123,7 @@ if test -f gnu-built; then echo "Note: the customization of the tests will still happen" else # Disable useless checks - "${SED}" -i 's|check-texinfo: $(syntax_checks)|check-texinfo:|' doc/local.mk + sed -i 's|check-texinfo: $(syntax_checks)|check-texinfo:|' doc/local.mk # Stop manpage generation for cleaner log : > man/local.mk # Use CFLAGS for best build time since we discard GNU coreutils @@ -130,15 +131,15 @@ else --enable-single-binary=symlinks --enable-install-program="arch,kill,uptime,hostname" \ "$([ "${SELINUX_ENABLED}" = 1 ] && echo --with-selinux || echo --without-selinux)" #Add timeout to to protect against hangs - "${SED}" -i 's|^"\$@|'"${SYSTEM_TIMEOUT}"' 600 "\$@|' build-aux/test-driver + sed -i 's|^"\$@|'"${SYSTEM_TIMEOUT}"' 600 "\$@|' build-aux/test-driver # Use a better diff - "${SED}" -i 's|diff -c|diff -u|g' tests/Coreutils.pm + sed -i 's|diff -c|diff -u|g' tests/Coreutils.pm # Skip make if possible # Use GNU nproc for *BSD and macOS NPROC="$(command -v nproc||command -v gnproc)" test "${SELINUX_ENABLED}" = 1 && touch src/getlimits # SELinux tests does not use it - test -f src/getlimits || "${MAKE}" -j "$("${NPROC}")" + test -f src/getlimits || make -j "$("${NPROC}")" cp -f src/getlimits "${UU_BUILD_DIR}" # Handle generated factor tests @@ -153,12 +154,12 @@ else ) for i in ${seq}; do echo "strip t${i}.sh from Makefile" - "${SED}" -i -e "s/\$(tf)\/t${i}.sh//g" Makefile + sed -i -e "s/\$(tf)\/t${i}.sh//g" Makefile done # Remove tests checking for --version & --help # Not really interesting for us and logs are too big - "${SED}" -i -e '/tests\/help\/help-version.sh/ D' \ + sed -i -e '/tests\/help\/help-version.sh/ D' \ -e '/tests\/help\/help-version-getopt.sh/ D' \ Makefile touch gnu-built @@ -169,36 +170,36 @@ grep -rl 'path_prepend_' tests/* | xargs -r "${SED}" -i 's| path_prepend_ ./src| grep -rl '\$abs_path_dir_' tests/*/*.sh | xargs -r "${SED}" -i "s|\$abs_path_dir_|${UU_BUILD_DIR//\//\\/}|g" # We can't build runcon and chcon without libselinux. But GNU no longer builds dummies of them. So consider they are SELinux specific. -"${SED}" -i 's/^print_ver_.*/require_selinux_/' tests/runcon/runcon-compute.sh -"${SED}" -i 's/^print_ver_.*/require_selinux_/' tests/runcon/runcon-no-reorder.sh -"${SED}" -i 's/^print_ver_.*/require_selinux_/' tests/chcon/chcon-fail.sh +sed -i 's/^print_ver_.*/require_selinux_/' tests/runcon/runcon-compute.sh +sed -i 's/^print_ver_.*/require_selinux_/' tests/runcon/runcon-no-reorder.sh +sed -i 's/^print_ver_.*/require_selinux_/' tests/chcon/chcon-fail.sh # Mask mtab by unshare instead of LD_PRELOAD (able to merge this to GNU?) -"${SED}" -i -e 's|^export LD_PRELOAD=.*||' -e "s|.*maybe LD_PRELOAD.*|df() { unshare -rm bash -c \"mount -t tmpfs tmpfs /proc \&\& command df \\\\\"\\\\\$@\\\\\"\" -- \"\$@\"; }|" tests/df/no-mtab-status.sh +sed -i -e 's|^export LD_PRELOAD=.*||' -e "s|.*maybe LD_PRELOAD.*|df() { unshare -rm bash -c \"mount -t tmpfs tmpfs /proc \&\& command df \\\\\"\\\\\$@\\\\\"\" -- \"\$@\"; }|" tests/df/no-mtab-status.sh # We use coreutils yes -"${SED}" -i "s|--coreutils-prog=||g" tests/misc/coreutils.sh +sed -i "s|--coreutils-prog=||g" tests/misc/coreutils.sh # Different message -"${SED}" -i "s|coreutils: unknown program 'blah'|blah: function/utility not found|" tests/misc/coreutils.sh +sed -i "s|coreutils: unknown program 'blah'|blah: function/utility not found|" tests/misc/coreutils.sh # Use the system coreutils where the test fails due to error in a util that is not the one being tested -"${SED}" -i "s|grep '^#define HAVE_CAP 1' \$CONFIG_HEADER > /dev/null|true|" tests/ls/capability.sh +sed -i "s|grep '^#define HAVE_CAP 1' \$CONFIG_HEADER > /dev/null|true|" tests/ls/capability.sh # our messages are better -"${SED}" -i "s|cannot stat 'symlink': Permission denied|not writing through dangling symlink 'symlink'|" tests/cp/fail-perm.sh -"${SED}" -i "s|cp: target directory 'symlink': Permission denied|cp: 'symlink' is not a directory|" tests/cp/fail-perm.sh +sed -i "s|cannot stat 'symlink': Permission denied|not writing through dangling symlink 'symlink'|" tests/cp/fail-perm.sh +sed -i "s|cp: target directory 'symlink': Permission denied|cp: 'symlink' is not a directory|" tests/cp/fail-perm.sh # Our message is a bit better -"${SED}" -i "s|cannot create regular file 'no-such/': Not a directory|'no-such/' is not a directory|" tests/mv/trailing-slash.sh +sed -i "s|cannot create regular file 'no-such/': Not a directory|'no-such/' is not a directory|" tests/mv/trailing-slash.sh # Our message is better -"${SED}" -i "s|warning: unrecognized escape|warning: incomplete hex escape|" tests/stat/stat-printf.pl +sed -i "s|warning: unrecognized escape|warning: incomplete hex escape|" tests/stat/stat-printf.pl -"${SED}" -i 's|timeout |'"${SYSTEM_TIMEOUT}"' |' tests/tail/follow-stdin.sh +sed -i 's|timeout |'"${SYSTEM_TIMEOUT}"' |' tests/tail/follow-stdin.sh # trap_sigpipe_or_skip_ fails with uutils tools because of a bug in # timeout/yes (https://github.com/uutils/coreutils/issues/7252), so we use # system's yes/timeout to make sure the tests run (instead of being skipped). -"${SED}" -i 's|\(trap .* \)timeout\( .* \)yes|'"\1${SYSTEM_TIMEOUT}\2${SYSTEM_YES}"'|' init.cfg +sed -i 's|\(trap .* \)timeout\( .* \)yes|'"\1${SYSTEM_TIMEOUT}\2${SYSTEM_YES}"'|' init.cfg # Remove dup of /usr/bin/ and /usr/local/bin/ when executed several times grep -rlE '/usr/bin/\s?/usr/bin' init.cfg tests/* | xargs -r "${SED}" -Ei 's|/usr/bin/\s?/usr/bin/|/usr/bin/|g' @@ -209,101 +210,101 @@ grep -rlE '/usr/local/bin/\s?/usr/local/bin' init.cfg tests/* | xargs -r "${SED} # we should not regress our project just to match what GNU is going. # So, do some changes on the fly -"${SED}" -i -e "s|removed directory 'a/'|removed directory 'a'|g" tests/rm/v-slash.sh +sed -i -e "s|removed directory 'a/'|removed directory 'a'|g" tests/rm/v-slash.sh # 'rel' doesn't exist. Our implementation is giving a better message. -"${SED}" -i -e "s|rm: cannot remove 'rel': Permission denied|rm: cannot remove 'rel': No such file or directory|g" tests/rm/inaccessible.sh +sed -i -e "s|rm: cannot remove 'rel': Permission denied|rm: cannot remove 'rel': No such file or directory|g" tests/rm/inaccessible.sh # Our implementation shows "Directory not empty" for directories that can't be accessed due to lack of execute permissions # This is actually more accurate than "Permission denied" since the real issue is that we can't empty the directory -"${SED}" -i -e "s|rm: cannot remove 'a/1': Permission denied|rm: cannot remove 'a/1/2': Permission denied|g" -e "s|rm: cannot remove 'b': Permission denied|rm: cannot remove 'a': Directory not empty\nrm: cannot remove 'b/3': Permission denied|g" tests/rm/rm2.sh +sed -i -e "s|rm: cannot remove 'a/1': Permission denied|rm: cannot remove 'a/1/2': Permission denied|g" -e "s|rm: cannot remove 'b': Permission denied|rm: cannot remove 'a': Directory not empty\nrm: cannot remove 'b/3': Permission denied|g" tests/rm/rm2.sh # overlay-headers.sh test intends to check for inotify events, # however there's a bug because `---dis` is an alias for: `---disable-inotify` sed -i -e "s|---dis ||g" tests/tail/overlay-headers.sh # Do not FAIL, just do a regular ERROR -"${SED}" -i -e "s|framework_failure_ 'no inotify_add_watch';|fail=1;|" tests/tail/inotify-rotate-resources.sh +sed -i -e "s|framework_failure_ 'no inotify_add_watch';|fail=1;|" tests/tail/inotify-rotate-resources.sh # pr-tests.pl: Override the comparison function to suppress diff output # This prevents the test from overwhelming logs while still reporting failures -"${SED}" -i '/^my $fail = run_tests/i no warnings "redefine"; *Coreutils::_compare_files = sub { my ($p, $t, $io, $a, $e) = @_; my $d = File::Compare::compare($a, $e); warn "$p: test $t: mismatch\\n" if $d; return $d; };' tests/pr/pr-tests.pl +sed -i '/^my $fail = run_tests/i no warnings "redefine"; *Coreutils::_compare_files = sub { my ($p, $t, $io, $a, $e) = @_; my $d = File::Compare::compare($a, $e); warn "$p: test $t: mismatch\\n" if $d; return $d; };' tests/pr/pr-tests.pl # We don't have the same error message and no need to be that specific -"${SED}" -i -e "s|invalid suffix in --pages argument|invalid --pages argument|" \ +sed -i -e "s|invalid suffix in --pages argument|invalid --pages argument|" \ -e "s|--pages argument '\$too_big' too large|invalid --pages argument '\$too_big'|" \ -e "s|invalid page range|invalid --pages argument|" tests/misc/xstrtol.pl # When decoding an invalid base32/64 string, gnu writes everything it was able to decode until # it hit the decode error, while we don't write anything if the input is invalid. -"${SED}" -i "s/\(baddecode.*OUT=>\"\).*\"/\1\"/g" tests/basenc/base64.pl -"${SED}" -i "s/\(\(b2[ml]_[69]\|z85_8\|z85_35\).*OUT=>\)[^}]*\(.*\)/\1\"\"\3/g" tests/basenc/basenc.pl +sed -i "s/\(baddecode.*OUT=>\"\).*\"/\1\"/g" tests/basenc/base64.pl +sed -i "s/\(\(b2[ml]_[69]\|z85_8\|z85_35\).*OUT=>\)[^}]*\(.*\)/\1\"\"\3/g" tests/basenc/basenc.pl # add "error: " to the expected error message -"${SED}" -i "s/\$prog: invalid input/\$prog: error: invalid input/g" tests/basenc/basenc.pl +sed -i "s/\$prog: invalid input/\$prog: error: invalid input/g" tests/basenc/basenc.pl # basenc: swap out error message for unexpected arg -"${SED}" -i "s/ {ERR=>\"\$prog: foobar\\\\n\" \. \$try_help }/ {ERR=>\"error: unexpected argument '--foobar' found\n\n tip: to pass '--foobar' as a value, use '-- --foobar'\n\nUsage: basenc [OPTION]... [FILE]\n\nFor more information, try '--help'.\n\"}]/" tests/basenc/basenc.pl -"${SED}" -i "s/ {ERR_SUBST=>\"s\/(unrecognized|unknown) option \[-' \]\*foobar\[' \]\*\/foobar\/\"}],//" tests/basenc/basenc.pl +sed -i "s/ {ERR=>\"\$prog: foobar\\\\n\" \. \$try_help }/ {ERR=>\"error: unexpected argument '--foobar' found\n\n tip: to pass '--foobar' as a value, use '-- --foobar'\n\nUsage: basenc [OPTION]... [FILE]\n\nFor more information, try '--help'.\n\"}]/" tests/basenc/basenc.pl +sed -i "s/ {ERR_SUBST=>\"s\/(unrecognized|unknown) option \[-' \]\*foobar\[' \]\*\/foobar\/\"}],//" tests/basenc/basenc.pl # exit early for the selinux check. The first is enough for us. -"${SED}" -i "s|# Independent of whether SELinux|return 0\n #|g" init.cfg +sed -i "s|# Independent of whether SELinux|return 0\n #|g" init.cfg # Some tests are executed with the "nobody" user. # The check to verify if it works is based on the GNU coreutils version # making it too restrictive for us -"${SED}" -i "s|\$PACKAGE_VERSION|[0-9]*|g" tests/rm/fail-2eperm.sh tests/mv/sticky-to-xpart.sh init.cfg +sed -i "s|\$PACKAGE_VERSION|[0-9]*|g" tests/rm/fail-2eperm.sh tests/mv/sticky-to-xpart.sh init.cfg # usage_vs_getopt.sh is heavily modified as it runs all the binaries # with the option -/ is used, clap is returning a better error than GNU's. Adjust the GNU test -"${SED}" -i -e "s~ grep \" '\*/'\*\" err || framework_failure_~ grep \" '*-/'*\" err || framework_failure_~" tests/misc/usage_vs_getopt.sh -"${SED}" -i -e "s~ sed -n \"1s/'\\\/'/'OPT'/p\" < err >> pat || framework_failure_~ sed -n \"1s/'-\\\/'/'OPT'/p\" < err >> pat || framework_failure_~" tests/misc/usage_vs_getopt.sh +sed -i -e "s~ grep \" '\*/'\*\" err || framework_failure_~ grep \" '*-/'*\" err || framework_failure_~" tests/misc/usage_vs_getopt.sh +sed -i -e "s~ sed -n \"1s/'\\\/'/'OPT'/p\" < err >> pat || framework_failure_~ sed -n \"1s/'-\\\/'/'OPT'/p\" < err >> pat || framework_failure_~" tests/misc/usage_vs_getopt.sh # Ignore runcon, it needs some extra attention # For all other tools, we want drop-in compatibility, and that includes the exit code. -"${SED}" -i -e "s/rcexp=1$/rcexp=1\n case \"\$prg\" in runcon|stdbuf) return;; esac/" tests/misc/usage_vs_getopt.sh +sed -i -e "s/rcexp=1$/rcexp=1\n case \"\$prg\" in runcon|stdbuf) return;; esac/" tests/misc/usage_vs_getopt.sh # GNU has option=[SUFFIX], clap is -"${SED}" -i -e "s/cat opts/sed -i -e \"s| <.\*$||g\" opts/" tests/misc/usage_vs_getopt.sh +sed -i -e "s/cat opts/sed -i -e \"s| <.\*$||g\" opts/" tests/misc/usage_vs_getopt.sh # for some reasons, some stuff are duplicated, strip that -"${SED}" -i -e "s/provoked error./provoked error\ncat pat |sort -u > pat/" tests/misc/usage_vs_getopt.sh +sed -i -e "s/provoked error./provoked error\ncat pat |sort -u > pat/" tests/misc/usage_vs_getopt.sh # install verbose messages shows ginstall as command -"${SED}" -i -e "s/ginstall: creating directory/install: creating directory/g" tests/install/basic-1.sh +sed -i -e "s/ginstall: creating directory/install: creating directory/g" tests/install/basic-1.sh # GNU doesn't support padding < -LONG_MAX # disable this test case -"${SED}" -i -Ez "s/\n([^\n#]*pad-3\.2[^\n]*)\n([^\n]*)\n([^\n]*)/\n# uutils\/numfmt supports padding = LONG_MIN\n#\1\n#\2\n#\3/" tests/numfmt/numfmt.pl +sed -i -Ez "s/\n([^\n#]*pad-3\.2[^\n]*)\n([^\n]*)\n([^\n]*)/\n# uutils\/numfmt supports padding = LONG_MIN\n#\1\n#\2\n#\3/" tests/numfmt/numfmt.pl # Update the GNU error message to match the one generated by clap -"${SED}" -i -e "s/\$prog: multiple field specifications/error: the argument '--field ' cannot be used multiple times\n\nUsage: numfmt [OPTION]... [NUMBER]...\n\nFor more information, try '--help'./g" tests/numfmt/numfmt.pl -"${SED}" -i -e "s/Try 'mv --help' for more information/For more information, try '--help'/g" -e "s/mv: missing file operand/error: the following required arguments were not provided:\n ...\n\nUsage: mv [OPTION]... [-T] SOURCE DEST\n mv [OPTION]... SOURCE... DIRECTORY\n mv [OPTION]... -t DIRECTORY SOURCE...\n/g" -e "s/mv: missing destination file operand after 'no-file'/error: The argument '...' requires at least 2 values, but only 1 was provided\n\nUsage: mv [OPTION]... [-T] SOURCE DEST\n mv [OPTION]... SOURCE... DIRECTORY\n mv [OPTION]... -t DIRECTORY SOURCE...\n/g" tests/mv/diag.sh +sed -i -e "s/\$prog: multiple field specifications/error: the argument '--field ' cannot be used multiple times\n\nUsage: numfmt [OPTION]... [NUMBER]...\n\nFor more information, try '--help'./g" tests/numfmt/numfmt.pl +sed -i -e "s/Try 'mv --help' for more information/For more information, try '--help'/g" -e "s/mv: missing file operand/error: the following required arguments were not provided:\n ...\n\nUsage: mv [OPTION]... [-T] SOURCE DEST\n mv [OPTION]... SOURCE... DIRECTORY\n mv [OPTION]... -t DIRECTORY SOURCE...\n/g" -e "s/mv: missing destination file operand after 'no-file'/error: The argument '...' requires at least 2 values, but only 1 was provided\n\nUsage: mv [OPTION]... [-T] SOURCE DEST\n mv [OPTION]... SOURCE... DIRECTORY\n mv [OPTION]... -t DIRECTORY SOURCE...\n/g" tests/mv/diag.sh # our error message is better -"${SED}" -i -e "s|mv: cannot overwrite 'a/t': Directory not empty|mv: cannot move 'b/t' to 'a/t': Directory not empty|" tests/mv/dir2dir.sh +sed -i -e "s|mv: cannot overwrite 'a/t': Directory not empty|mv: cannot move 'b/t' to 'a/t': Directory not empty|" tests/mv/dir2dir.sh # GNU doesn't support width > INT_MAX # disable these test cases -"${SED}" -i -E "s|^([^#]*2_31.*)$|#\1|g" tests/printf/printf-cov.pl +sed -i -E "s|^([^#]*2_31.*)$|#\1|g" tests/printf/printf-cov.pl -"${SED}" -i -e "s/du: invalid -t argument/du: invalid --threshold argument/" -e "s/du: option requires an argument/error: a value is required for '--threshold ' but none was supplied/" -e "s/Try 'du --help' for more information./\nFor more information, try '--help'./" tests/du/threshold.sh +sed -i -e "s/du: invalid -t argument/du: invalid --threshold argument/" -e "s/du: option requires an argument/error: a value is required for '--threshold ' but none was supplied/" -e "s/Try 'du --help' for more information./\nFor more information, try '--help'./" tests/du/threshold.sh # Remove the extra output check -"${SED}" -i -e "s|Try '\$prog --help' for more information.\\\n||" tests/du/files0-from.pl -"${SED}" -i -e "s|-: No such file or directory|cannot access '-': No such file or directory|g" tests/du/files0-from.pl +sed -i -e "s|Try '\$prog --help' for more information.\\\n||" tests/du/files0-from.pl +sed -i -e "s|-: No such file or directory|cannot access '-': No such file or directory|g" tests/du/files0-from.pl # Skip the move-dir-while-traversing test - our implementation uses safe traversal with openat() # which avoids the TOCTOU race condition that this test tries to trigger. The test uses inotify # to detect when du opens a directory path and moves it to cause an error, but our openat-based # implementation doesn't trigger inotify events on the full path, preventing the race condition. # This is actually better behavior - we're immune to this class of filesystem race attacks. -"${SED}" -i '1s/^/exit 0 # Skip test - uutils du uses safe traversal that prevents this race condition\n/' tests/du/move-dir-while-traversing.sh +sed -i '1s/^/exit 0 # Skip test - uutils du uses safe traversal that prevents this race condition\n/' tests/du/move-dir-while-traversing.sh awk 'BEGIN {count=0} /compare exp out2/ && count < 6 {sub(/compare exp out2/, "grep -q \"cannot be used with\" out2"); count++} 1' tests/df/df-output.sh > tests/df/df-output.sh.tmp && mv tests/df/df-output.sh.tmp tests/df/df-output.sh # with ls --dired, in case of error, we have a slightly different error position -"${SED}" -i -e "s|44 45|48 49|" tests/ls/stat-failed.sh +sed -i -e "s|44 45|48 49|" tests/ls/stat-failed.sh # small difference in the error message -"${SED}" -i -e "s/ls: invalid argument 'XX' for 'time style'/ls: invalid --time-style argument 'XX'/" \ +sed -i -e "s/ls: invalid argument 'XX' for 'time style'/ls: invalid --time-style argument 'XX'/" \ -e "s/Valid arguments are:/Possible values are:/" \ -e "s/Try 'ls --help' for more information./\nFor more information try --help/" \ tests/ls/time-style-diag.sh @@ -311,29 +312,29 @@ awk 'BEGIN {count=0} /compare exp out2/ && count < 6 {sub(/compare exp out2/, "g # disable two kind of tests: # "hostid BEFORE --help" doesn't fail for GNU. we fail. we are probably doing better # "hostid BEFORE --help AFTER " same for this -"${SED}" -i -e "s/env \$prog \$BEFORE \$opt > out2/env \$prog \$BEFORE \$opt > out2 #/" -e "s/env \$prog \$BEFORE \$opt AFTER > out3/env \$prog \$BEFORE \$opt AFTER > out3 #/" -e "s/compare exp out2/compare exp out2 #/" -e "s/compare exp out3/compare exp out3 #/" tests/help/help-version-getopt.sh +sed -i -e "s/env \$prog \$BEFORE \$opt > out2/env \$prog \$BEFORE \$opt > out2 #/" -e "s/env \$prog \$BEFORE \$opt AFTER > out3/env \$prog \$BEFORE \$opt AFTER > out3 #/" -e "s/compare exp out2/compare exp out2 #/" -e "s/compare exp out3/compare exp out3 #/" tests/help/help-version-getopt.sh # Add debug info + we have less syscall then GNU's. Adjust our check. -"${SED}" -i -e '/test \$n_stat1 = \$n_stat2 \\/c\ +sed -i -e '/test \$n_stat1 = \$n_stat2 \\/c\ echo "n_stat1 = \$n_stat1"\n\ echo "n_stat2 = \$n_stat2"\n\ test \$n_stat1 -ge \$n_stat2 \\' tests/ls/stat-free-color.sh # no need to replicate this output with hashsum -"${SED}" -i -e "s|Try 'md5sum --help' for more information.\\\n||" tests/cksum/md5sum.pl +sed -i -e "s|Try 'md5sum --help' for more information.\\\n||" tests/cksum/md5sum.pl # Our ls command always outputs ANSI color codes prepended with a zero. However, # in the case of GNU, it seems inconsistent. Nevertheless, it looks like it # doesn't matter whether we prepend a zero or not. -"${SED}" -i -E 's/\^\[\[([1-9]m)/^[[0\1/g; s/\^\[\[m/^[[0m/g' tests/ls/color-norm.sh +sed -i -E 's/\^\[\[([1-9]m)/^[[0\1/g; s/\^\[\[m/^[[0m/g' tests/ls/color-norm.sh # It says in the test itself that having more than one reset is a bug, so we # don't need to replicate that behavior. -"${SED}" -i -E 's/(\^\[\[0m)+/\^\[\[0m/g' tests/ls/color-norm.sh +sed -i -E 's/(\^\[\[0m)+/\^\[\[0m/g' tests/ls/color-norm.sh # GNU's ls seems to output color codes in the order given in the environment # variable, but our ls seems to output them in a predefined order. Nevertheless, # the order doesn't matter, so it's okay. -"${SED}" -i 's/44;37/37;44/' tests/ls/multihardlink.sh +sed -i 's/44;37/37;44/' tests/ls/multihardlink.sh # Just like mentioned in the previous patch, GNU's ls output color codes in the # same way it is specified in the environment variable, but our ls emits them @@ -342,19 +343,19 @@ test \$n_stat1 -ge \$n_stat2 \\' tests/ls/stat-free-color.sh # individually, for example, ^[[31^[[42 instead of ^[[31;42, but we don't do # that anywhere in our implementation, and it looks like GNU's ls also doesn't # do that. So, it's okay to ignore the zero. -"${SED}" -i "s/color_code='0;31;42'/color_code='31;42'/" tests/ls/color-clear-to-eol.sh +sed -i "s/color_code='0;31;42'/color_code='31;42'/" tests/ls/color-clear-to-eol.sh # patching this because of the same reason as the last one. -"${SED}" -i "s/color_code='0;31;42'/color_code='31;42'/" tests/ls/quote-align.sh +sed -i "s/color_code='0;31;42'/color_code='31;42'/" tests/ls/quote-align.sh # Slightly different error message -"${SED}" -i 's/not supported/unexpected argument/' tests/mv/mv-exchange.sh +sed -i 's/not supported/unexpected argument/' tests/mv/mv-exchange.sh # upstream doesn't having the program name in the error message # but we do. We should keep it that way. -"${SED}" -i 's/echo "changing security context/echo "chcon: changing security context/' tests/chcon/chcon.sh +sed -i 's/echo "changing security context/echo "chcon: changing security context/' tests/chcon/chcon.sh # Disable this test, it is not relevant for us: # * the selinux crate is handling errors # * the test says "maybe we should not fail when no context available" -"${SED}" -i -e "s|returns_ 1||g" tests/cp/no-ctx.sh +sed -i -e "s|returns_ 1||g" tests/cp/no-ctx.sh diff --git a/util/fetch-gnu.sh b/util/fetch-gnu.sh index 54a893df5..8b2cebe6c 100755 --- a/util/fetch-gnu.sh +++ b/util/fetch-gnu.sh @@ -16,4 +16,5 @@ curl -L ${repo}/raw/refs/heads/master/tests/stty/bad-speed.sh > tests/stty/bad-s curl -L ${repo}/raw/refs/heads/master/tests/runcon/runcon-compute.sh > tests/runcon/runcon-compute.sh curl -L ${repo}/raw/refs/heads/master/tests/tac/tac-continue.sh > tests/tac/tac-continue.sh # Add tac-continue.sh to root tests (it requires root to mount tmpfs) -sed -i 's|tests/split/l-chunk-root.sh.*|tests/split/l-chunk-root.sh\t\t\t\\\n tests/tac/tac-continue.sh\t\t\t\\|' tests/local.mk +# Use sed -i.bak for macOS +sed -i.bak 's|tests/split/l-chunk-root.sh.*|tests/split/l-chunk-root.sh\t\t\t\\\n tests/tac/tac-continue.sh\t\t\t\\|' tests/local.mk From 6dac4d407483202a80e142d1e08d9bf1d4fc128b Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Sat, 17 Jan 2026 11:33:24 +0100 Subject: [PATCH 10/32] bench: try to remove more variances (#10277) --- src/uu/du/benches/du_bench.rs | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/src/uu/du/benches/du_bench.rs b/src/uu/du/benches/du_bench.rs index 5ea45ed9c..0b63ce9a2 100644 --- a/src/uu/du/benches/du_bench.rs +++ b/src/uu/du/benches/du_bench.rs @@ -61,17 +61,33 @@ fn du_human_balanced_tree( /// Benchmark du on wide directory structures (many files/dirs, shallow) #[divan::bench(args = [(5000, 500)])] fn du_wide_tree(bencher: Bencher, (total_files, total_dirs): (usize, usize)) { - let temp_dir = TempDir::new().unwrap(); - fs_tree::create_wide_tree(temp_dir.path(), total_files, total_dirs); - bench_du_with_args(bencher, &temp_dir, &[]); + bencher + .with_inputs(|| { + let temp_dir = TempDir::new().unwrap(); + fs_tree::create_wide_tree(temp_dir.path(), total_files, total_dirs); + temp_dir + }) + .bench_values(|temp_dir| { + let temp_path_str = temp_dir.path().to_str().unwrap(); + let args = vec![temp_path_str]; + black_box(run_util_function(uumain, &args)); + }); } /// Benchmark du -a on wide directory structures #[divan::bench(args = [(5000, 500)])] fn du_all_wide_tree(bencher: Bencher, (total_files, total_dirs): (usize, usize)) { - let temp_dir = TempDir::new().unwrap(); - fs_tree::create_wide_tree(temp_dir.path(), total_files, total_dirs); - bench_du_with_args(bencher, &temp_dir, &["-a"]); + bencher + .with_inputs(|| { + let temp_dir = TempDir::new().unwrap(); + fs_tree::create_wide_tree(temp_dir.path(), total_files, total_dirs); + temp_dir + }) + .bench_values(|temp_dir| { + let temp_path_str = temp_dir.path().to_str().unwrap(); + let args = vec![temp_path_str, "-a"]; + black_box(run_util_function(uumain, &args)); + }); } /// Benchmark du on deep directory structures From 038a08bff27ea5eddbf97a452e8f522e2ca157a7 Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Sat, 17 Jan 2026 12:13:51 +0100 Subject: [PATCH 11/32] contrib: add info about expectations and ping (#10275) --- CONTRIBUTING.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a7a006221..fcd0f1971 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -250,8 +250,8 @@ gitignore: add temporary files - It's up to you whether you want to use `git merge main` or `git rebase main`. - Feel free to ask for help with merge conflicts. -- You do not need to ping maintainers to request a review, but it's fine to do - so if you don't get a response within a few days. +- You do not need to ping maintainers to request a review immediately after submission. If you do not get a response to your patch within a few days, it is fine to request a review. + - If after a week your patch has still not been reviewed, we recommend that you ping the maintainers on our Discord channel in `#coreutils-chat`. ## Platforms From 2a044db3a2f02acbb5324a675f9a0bc6ccf84930 Mon Sep 17 00:00:00 2001 From: Max Ambaum Date: Sat, 17 Jan 2026 12:41:29 +0000 Subject: [PATCH 12/32] cp: Fixed POSIXLY_CORRECT test to actually check if file exists and its contents (#10247) cp: Added check of file contents in POSIXLY_CORRECT test cp: Fixed windows test by including path cp: Updated test --- tests/by-util/test_cp.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/by-util/test_cp.rs b/tests/by-util/test_cp.rs index 2d252c560..8cb844ce8 100644 --- a/tests/by-util/test_cp.rs +++ b/tests/by-util/test_cp.rs @@ -2991,11 +2991,15 @@ fn test_copy_through_dangling_symlink() { fn test_copy_through_dangling_symlink_posixly_correct() { let (at, mut ucmd) = at_and_ucmd!(); at.touch("file"); + at.write("file", "content"); at.symlink_file("nonexistent", "target"); ucmd.arg("file") .arg("target") .env("POSIXLY_CORRECT", "1") .succeeds(); + assert!(at.file_exists("nonexistent")); + let contents = at.read("nonexistent"); + assert_eq!(contents, "content"); } #[test] From 2cb20e02b2e4ed291808cc68d835093557d01b71 Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Sat, 17 Jan 2026 13:53:22 +0100 Subject: [PATCH 13/32] bench: reduce memory variance in cp and numfmt benchmarks --- src/uu/cp/benches/cp_bench.rs | 31 ++++++++++++++++----------- src/uu/numfmt/benches/numfmt_bench.rs | 29 ++++++++++++++----------- 2 files changed, 34 insertions(+), 26 deletions(-) diff --git a/src/uu/cp/benches/cp_bench.rs b/src/uu/cp/benches/cp_bench.rs index d673c14e4..84954f0bb 100644 --- a/src/uu/cp/benches/cp_bench.rs +++ b/src/uu/cp/benches/cp_bench.rs @@ -82,20 +82,25 @@ fn cp_preserve_metadata( #[divan::bench(args = [16])] fn cp_large_file(bencher: Bencher, size_mb: usize) { - let temp_dir = TempDir::new().unwrap(); - let source = temp_dir.path().join("source.bin"); - let dest = temp_dir.path().join("dest.bin"); + bencher + .with_inputs(|| { + let temp_dir = TempDir::new().unwrap(); + let source = temp_dir.path().join("source.bin"); + binary_data::create_file(&source, size_mb, b'x'); + (temp_dir, source) + }) + .counter(divan::counter::BytesCount::new(size_mb * 1024 * 1024)) + .bench_values(|(temp_dir, source)| { + // Use unique destination name to avoid filesystem allocation variance + let dest = temp_dir.path().join(format!( + "dest_{}.bin", + std::ptr::addr_of!(temp_dir) as usize + )); + let source_str = source.to_str().unwrap(); + let dest_str = dest.to_str().unwrap(); - binary_data::create_file(&source, size_mb, b'x'); - - let source_str = source.to_str().unwrap(); - let dest_str = dest.to_str().unwrap(); - - bencher.bench(|| { - fs_utils::remove_path(&dest); - - black_box(run_util_function(uumain, &[source_str, dest_str])); - }); + black_box(run_util_function(uumain, &[source_str, dest_str])); + }); } fn main() { diff --git a/src/uu/numfmt/benches/numfmt_bench.rs b/src/uu/numfmt/benches/numfmt_bench.rs index 561b65093..b3f86cce5 100644 --- a/src/uu/numfmt/benches/numfmt_bench.rs +++ b/src/uu/numfmt/benches/numfmt_bench.rs @@ -63,19 +63,22 @@ fn numfmt_from_si(bencher: Bencher, count: usize) { /// Benchmark large numbers with SI formatting #[divan::bench(args = [10_000])] fn numfmt_large_numbers_si(bencher: Bencher, count: usize) { - // Generate numbers that all produce uniform SI output lengths (all in 1-9M range) - // This avoids variance from variable output string lengths - let numbers: Vec = (1..=count) - .map(|n| ((n % 9) + 1) * 1_000_000) - .map(|n| n.to_string()) - .collect(); - let mut args = vec!["--to=si"]; - let number_refs: Vec<&str> = numbers.iter().map(|s| s.as_str()).collect(); - args.extend(number_refs); - - bencher.bench(|| { - black_box(run_util_function(uumain, &args)); - }); + bencher + .with_inputs(|| { + // Generate numbers that all produce uniform SI output lengths (all in 1-9M range) + // This avoids variance from variable output string lengths + let numbers: Vec = (1..=count) + .map(|n| ((n % 9) + 1) * 1_000_000) + .map(|n| n.to_string()) + .collect(); + let mut args: Vec = vec!["--to=si".to_string()]; + args.extend(numbers); + args + }) + .bench_values(|args| { + let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); + black_box(run_util_function(uumain, &arg_refs)); + }); } /// Benchmark different padding widths From 08c78f2e2df992b084190b28ae1ae34ad5474197 Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Sat, 17 Jan 2026 22:17:59 +0900 Subject: [PATCH 14/32] {README,CONTRIBUTIONS}.md: Add link to binaries from latest commit (#10280) * {README,CONTRIBUTIONS}.md: Add link to binaries from latest commit * Add the word "from main branch" at link to tag/latest-commit --- CONTRIBUTING.md | 4 ++-- README.md | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fcd0f1971..a8e463707 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -78,11 +78,11 @@ issues and writing documentation are just as important as writing code. We can't fix bugs we don't know about, so good issues are super helpful! Here are some tips for writing good issues: -- If you find a bug, make sure it's still a problem on the `main` branch. +- If you find a bug, make sure it's still a problem on the [`main` branch](https://github.com/uutils/coreutils/releases/tag/latest-commit). - Search through the existing issues to see whether it has already been reported. - Make sure to include all relevant information, such as: - - Which version of uutils did you check? + - Which version or commit hash of uutils did you check? - Which version of GNU coreutils are you comparing with? - What platform are you on? - Provide a way to reliably reproduce the issue. diff --git a/README.md b/README.md index e770bd543..f05bf77ea 100644 --- a/README.md +++ b/README.md @@ -29,8 +29,9 @@ options might be missing or different behavior might be experienced.
-We provide prebuilt binaries at https://github.com/uutils/coreutils/releases/latest . -It is recommended to install from main branch if you install from source. +We provide prebuilt binaries, manpages, and shell completions from main branch at https://github.com/uutils/coreutils/releases/tag/latest-commit . +The latest stable tag https://github.com/uutils/coreutils/releases/latest exists only for reproducible products and packagers. +You should use binary from latest commit generally.
From 7eb78ab8602748c8bd14554c0ac7797444973eb8 Mon Sep 17 00:00:00 2001 From: Andrus Suvalau Date: Sat, 17 Jan 2026 14:18:24 +0100 Subject: [PATCH 15/32] dd: get rid of line buffered stdout (#10235) * dd: get rid of line-buffered stdout Line-buffered stdout causes partial write and read operations in dd, which is an issue when writing binary data to stdout. Partial writes can lead to data loss and require passing iflag=fullblock to ensure that the exact number of bytes is read. * dd: Add test to check for dropped writes (cherry picked from commit 0f7c53111df8b945bdb06217396b332f24a42100) * Fix build on Windows * dd: use OwnedFileDescriptorOrHandle OwnedFileDescriptorOrHandle can be used to bypass the LineWriter that is used by default for Stdout. * Run test_no_dropped_writes only on unix --------- Co-authored-by: Adrian Kretz --- src/uu/dd/src/dd.rs | 7 ++++--- tests/by-util/test_dd.rs | 23 +++++++++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/uu/dd/src/dd.rs b/src/uu/dd/src/dd.rs index f5f4f9365..ebcc737fd 100644 --- a/src/uu/dd/src/dd.rs +++ b/src/uu/dd/src/dd.rs @@ -30,7 +30,7 @@ use std::cmp; use std::env; use std::ffi::OsString; use std::fs::{File, OpenOptions}; -use std::io::{self, Read, Seek, SeekFrom, Stdout, Write}; +use std::io::{self, Read, Seek, SeekFrom, Write}; #[cfg(any(target_os = "linux", target_os = "android"))] use std::os::fd::AsFd; #[cfg(any(target_os = "linux", target_os = "android"))] @@ -601,7 +601,7 @@ enum Density { /// Data destinations. enum Dest { /// Output to stdout. - Stdout(Stdout), + Stdout(File), /// Output to a file. /// @@ -829,7 +829,8 @@ struct Output<'a> { impl<'a> Output<'a> { /// Instantiate this struct with stdout as a destination. fn new_stdout(settings: &'a Settings) -> UResult { - let mut dst = Dest::Stdout(io::stdout()); + let fx = OwnedFileDescriptorOrHandle::from(io::stdout())?; + let mut dst = Dest::Stdout(fx.into_file()); dst.seek(settings.seek, settings.obs) .map_err_context(|| translate!("dd-error-write-error"))?; Ok(Self { dst, settings }) diff --git a/tests/by-util/test_dd.rs b/tests/by-util/test_dd.rs index 3e53f9a59..08ffa83d0 100644 --- a/tests/by-util/test_dd.rs +++ b/tests/by-util/test_dd.rs @@ -1814,6 +1814,29 @@ fn test_wrong_number_err_msg() { .stderr_contains("dd: invalid number: '1kBb555'\n"); } +#[test] +#[cfg(unix)] +fn test_no_dropped_writes() { + use std::process::Stdio; + + const BLK_SIZE: usize = 0x4000; + const COUNT: usize = 1000; + const NUM_BYTES: usize = BLK_SIZE * COUNT; + + let result = new_ucmd!() + .args(&[ + "if=/dev/urandom", + &format!("bs={BLK_SIZE}"), + &format!("count={COUNT}"), + ]) + .set_stdout(Stdio::piped()) + .set_stderr(Stdio::piped()) + .succeeds(); + + assert_eq!(result.stdout().len(), NUM_BYTES); + assert!(result.stderr_str().contains(&format!("{NUM_BYTES} bytes"))); +} + #[test] #[cfg(any(target_os = "linux", target_os = "android"))] fn test_oflag_direct_partial_block() { From 7707b72870b19c25cd3c076128f106ef4e47d704 Mon Sep 17 00:00:00 2001 From: Dhruv <62135445+dhr412@users.noreply.github.com> Date: Sat, 17 Jan 2026 18:56:15 +0530 Subject: [PATCH 16/32] Fix tail -c panics when requested bytes exceed file size (#10268) * Fix tail -c panics when requested bytes exceed file size * Add tail bytes exceed file size test * Update test to hit block size condition --- src/uu/tail/src/tail.rs | 4 +++- tests/by-util/test_tail.rs | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/uu/tail/src/tail.rs b/src/uu/tail/src/tail.rs index 8782db36f..2ffb537cf 100644 --- a/src/uu/tail/src/tail.rs +++ b/src/uu/tail/src/tail.rs @@ -473,7 +473,9 @@ fn bounded_tail(file: &mut File, settings: &Settings) { return; } FilterMode::Bytes(Signum::Negative(count)) => { - file.seek(SeekFrom::End(-(*count as i64))).unwrap(); + if file.seek(SeekFrom::End(-(*count as i64))).is_err() { + file.seek(SeekFrom::Start(0)).unwrap(); + } limit = Some(*count); } FilterMode::Bytes(Signum::Positive(count)) if count > &1 => { diff --git a/tests/by-util/test_tail.rs b/tests/by-util/test_tail.rs index 390633704..134cc78eb 100644 --- a/tests/by-util/test_tail.rs +++ b/tests/by-util/test_tail.rs @@ -5040,6 +5040,22 @@ fn tail_n_lines_with_emoji() { .stdout_only("💐\n"); } +#[test] +fn test_tail_bytes_exceeds_file_size() { + let ts = TestScenario::new(util_name!()); + let at = &ts.fixtures; + + // Should be > 4096 bytes (block size can vary): + at.write("test_file.txt", &"x".repeat(5000)); + + ts.ucmd() + .arg("-c") + .arg("1048576") + .arg("test_file.txt") + .succeeds() + .stdout_only("x".repeat(5000)); +} + #[test] #[cfg(target_os = "linux")] fn test_follow_pipe_f() { From 75f45e87e52ed95840494963ab9a28651165d56e Mon Sep 17 00:00:00 2001 From: Martin Kunkel <41590858+martinkunkel2@users.noreply.github.com> Date: Sat, 17 Jan 2026 14:28:04 +0100 Subject: [PATCH 17/32] comm: fix comparison when reading from pipes (#9545) * comm: fix comparison when reading from pipes Use case is that two files are piped into comm, i.e. in bash comm <(cat file1) <(cat file2) Before the fix, comm reads from the pipes twice. Once in "fn comm" and once in "fn are_files_identical". As such, part of the data is skipped in comparison which leads to wrong output. This is fixed by skipping the file comparison in case one of the files is not a regular file. * comm: add test for reading from pipes --- Cargo.toml | 1 + src/uu/comm/src/comm.rs | 5 +++++ tests/by-util/test_comm.rs | 39 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 45 insertions(+) diff --git a/Cargo.toml b/Cargo.toml index 77738518f..2a3625625 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -549,6 +549,7 @@ uutests.workspace = true uucore = { workspace = true, features = [ "mode", "entries", + "pipes", "process", "signals", "utmpx", diff --git a/src/uu/comm/src/comm.rs b/src/uu/comm/src/comm.rs index 80b20b53f..37ac4de1c 100644 --- a/src/uu/comm/src/comm.rs +++ b/src/uu/comm/src/comm.rs @@ -136,6 +136,11 @@ pub fn are_files_identical(path1: &Path, path2: &Path) -> io::Result { return Ok(false); } + // only proceed if both are regular files + if !metadata1.is_file() || !metadata2.is_file() { + return Ok(false); + } + let file1 = File::open(path1)?; let file2 = File::open(path2)?; diff --git a/tests/by-util/test_comm.rs b/tests/by-util/test_comm.rs index bf719d7fb..3194d270e 100644 --- a/tests/by-util/test_comm.rs +++ b/tests/by-util/test_comm.rs @@ -648,3 +648,42 @@ fn test_comm_eintr_handling() { .stdout_contains("line2") .stdout_contains("line3"); } + +#[test] +#[cfg(any(target_os = "linux", target_os = "android"))] +fn test_comm_anonymous_pipes() { + use std::{io::Write, os::fd::AsRawFd, process}; + use uucore::pipes::pipe; + + let scene = TestScenario::new(util_name!()); + + // Open two anonymous pipes + let (comm1_reader, mut comm1_writer) = pipe().unwrap(); + let (comm2_reader, mut comm2_writer) = pipe().unwrap(); + + // comm reads the data in chunks + // make content large enough, so that at least two chunks are read + // default buffer size is 8192, so with 6 characters (5 digits + \n) per line we need to write at least 1366 lines + + // write 1500 lines into comm1: 00000\n00001\n...01500\n + let mut content = String::new(); + for i in 0..1500 { + content.push_str(&format!("{i:05}\n")); + } + assert!(comm1_writer.write_all(content.as_bytes()).is_ok()); + drop(comm1_writer); + + // write into comm2: 00000\n00001\n...01500\n99999\n + content.push_str("99999\n"); + assert!(comm2_writer.write_all(content.as_bytes()).is_ok()); + drop(comm2_writer); + + // run comm, showing unique lines in second input + let comm1_fd = format!("/proc/{}/fd/{}", process::id(), comm1_reader.as_raw_fd()); + let comm2_fd = format!("/proc/{}/fd/{}", process::id(), comm2_reader.as_raw_fd()); + scene + .ucmd() + .args(&["-13", &comm1_fd, &comm2_fd]) + .succeeds() + .stdout_is("99999\n"); +} From 2b203791b7baaaa26d94a871cb06a097d4f58d97 Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Sat, 17 Jan 2026 16:07:38 +0100 Subject: [PATCH 18/32] bench: fix variance in remaining numfmt benchmarks --- src/uu/numfmt/benches/numfmt_bench.rs | 119 +++++++++++++++----------- 1 file changed, 68 insertions(+), 51 deletions(-) diff --git a/src/uu/numfmt/benches/numfmt_bench.rs b/src/uu/numfmt/benches/numfmt_bench.rs index b3f86cce5..aed3fb035 100644 --- a/src/uu/numfmt/benches/numfmt_bench.rs +++ b/src/uu/numfmt/benches/numfmt_bench.rs @@ -10,54 +10,66 @@ use uucore::benchmark::run_util_function; /// Benchmark SI formatting by passing numbers as command-line arguments #[divan::bench(args = [10_000])] fn numfmt_to_si(bencher: Bencher, count: usize) { - let numbers: Vec = (1..=count).map(|n| n.to_string()).collect(); - let mut args = vec!["--to=si"]; - let number_refs: Vec<&str> = numbers.iter().map(|s| s.as_str()).collect(); - args.extend(number_refs); - - bencher.bench(|| { - black_box(run_util_function(uumain, &args)); - }); + bencher + .with_inputs(|| { + let numbers: Vec = (1..=count).map(|n| n.to_string()).collect(); + let mut args: Vec = vec!["--to=si".to_string()]; + args.extend(numbers); + args + }) + .bench_values(|args| { + let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); + black_box(run_util_function(uumain, &arg_refs)); + }); } /// Benchmark SI formatting with precision format #[divan::bench(args = [10_000])] fn numfmt_to_si_precision(bencher: Bencher, count: usize) { - let numbers: Vec = (1..=count).map(|n| n.to_string()).collect(); - let mut args = vec!["--to=si", "--format=%.6f"]; - let number_refs: Vec<&str> = numbers.iter().map(|s| s.as_str()).collect(); - args.extend(number_refs); - - bencher.bench(|| { - black_box(run_util_function(uumain, &args)); - }); + bencher + .with_inputs(|| { + let numbers: Vec = (1..=count).map(|n| n.to_string()).collect(); + let mut args: Vec = vec!["--to=si".to_string(), "--format=%.6f".to_string()]; + args.extend(numbers); + args + }) + .bench_values(|args| { + let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); + black_box(run_util_function(uumain, &arg_refs)); + }); } /// Benchmark IEC (binary) formatting #[divan::bench(args = [10_000])] fn numfmt_to_iec(bencher: Bencher, count: usize) { - let numbers: Vec = (1..=count).map(|n| n.to_string()).collect(); - let mut args = vec!["--to=iec"]; - let number_refs: Vec<&str> = numbers.iter().map(|s| s.as_str()).collect(); - args.extend(number_refs); - - bencher.bench(|| { - black_box(run_util_function(uumain, &args)); - }); + bencher + .with_inputs(|| { + let numbers: Vec = (1..=count).map(|n| n.to_string()).collect(); + let mut args: Vec = vec!["--to=iec".to_string()]; + args.extend(numbers); + args + }) + .bench_values(|args| { + let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); + black_box(run_util_function(uumain, &arg_refs)); + }); } /// Benchmark parsing from SI format back to raw numbers #[divan::bench(args = [10_000])] fn numfmt_from_si(bencher: Bencher, count: usize) { - // Generate SI formatted data (e.g., "1K", "2K", etc.) - let numbers: Vec = (1..=count).map(|n| format!("{n}K")).collect(); - let mut args = vec!["--from=si"]; - let number_refs: Vec<&str> = numbers.iter().map(|s| s.as_str()).collect(); - args.extend(number_refs); - - bencher.bench(|| { - black_box(run_util_function(uumain, &args)); - }); + bencher + .with_inputs(|| { + // Generate SI formatted data (e.g., "1K", "2K", etc.) + let numbers: Vec = (1..=count).map(|n| format!("{n}K")).collect(); + let mut args: Vec = vec!["--from=si".to_string()]; + args.extend(numbers); + args + }) + .bench_values(|args| { + let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); + black_box(run_util_function(uumain, &arg_refs)); + }); } /// Benchmark large numbers with SI formatting @@ -84,29 +96,34 @@ fn numfmt_large_numbers_si(bencher: Bencher, count: usize) { /// Benchmark different padding widths #[divan::bench(args = [(10_000, 50)])] fn numfmt_padding(bencher: Bencher, (count, padding): (usize, usize)) { - let numbers: Vec = (1..=count).map(|n| n.to_string()).collect(); - let padding_arg = format!("--padding={padding}"); - let mut args = vec!["--to=si", &padding_arg]; - let number_refs: Vec<&str> = numbers.iter().map(|s| s.as_str()).collect(); - args.extend(number_refs); - - bencher.bench(|| { - black_box(run_util_function(uumain, &args)); - }); + bencher + .with_inputs(|| { + let numbers: Vec = (1..=count).map(|n| n.to_string()).collect(); + let mut args: Vec = vec!["--to=si".to_string(), format!("--padding={padding}")]; + args.extend(numbers); + args + }) + .bench_values(|args| { + let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); + black_box(run_util_function(uumain, &arg_refs)); + }); } /// Benchmark round modes with SI formatting #[divan::bench(args = [("up", 10_000), ("down", 10_000), ("towards-zero", 10_000)])] fn numfmt_round_modes(bencher: Bencher, (round_mode, count): (&str, usize)) { - let numbers: Vec = (1..=count).map(|n| n.to_string()).collect(); - let round_arg = format!("--round={round_mode}"); - let mut args = vec!["--to=si", &round_arg]; - let number_refs: Vec<&str> = numbers.iter().map(|s| s.as_str()).collect(); - args.extend(number_refs); - - bencher.bench(|| { - black_box(run_util_function(uumain, &args)); - }); + bencher + .with_inputs(|| { + let numbers: Vec = (1..=count).map(|n| n.to_string()).collect(); + let mut args: Vec = + vec!["--to=si".to_string(), format!("--round={round_mode}")]; + args.extend(numbers); + args + }) + .bench_values(|args| { + let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); + black_box(run_util_function(uumain, &arg_refs)); + }); } fn main() { From cd288fc40141bbe46689ce73977779da43252dec Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Sat, 17 Jan 2026 16:20:39 +0100 Subject: [PATCH 19/32] ci: ensure test failures are caught in coverage script and fix them (#10286) --- src/uucore/src/lib/features/proc_info.rs | 15 ++++++++++----- src/uucore/src/lib/features/process.rs | 12 +++++------- tests/by-util/test_dd.rs | 4 ++++ util/build-run-test-coverage-linux.sh | 9 ++++++++- 4 files changed, 27 insertions(+), 13 deletions(-) diff --git a/src/uucore/src/lib/features/proc_info.rs b/src/uucore/src/lib/features/proc_info.rs index 8345e7e09..d36f5d010 100644 --- a/src/uucore/src/lib/features/proc_info.rs +++ b/src/uucore/src/lib/features/proc_info.rs @@ -465,11 +465,16 @@ mod tests { .flat_map(Teletype::try_from) .collect::>(); - assert_eq!(result.len(), 1); - assert_eq!( - pid_entry.tty(), - Vec::from_iter(result.into_iter()).first().unwrap().clone() - ); + // In CI environments or when running without a terminal, there may be no TTY + if result.is_empty() { + assert_eq!(pid_entry.tty(), Teletype::Unknown); + } else { + assert_eq!(result.len(), 1); + assert_eq!( + pid_entry.tty(), + Vec::from_iter(result.into_iter()).first().unwrap().clone() + ); + } } #[test] diff --git a/src/uucore/src/lib/features/process.rs b/src/uucore/src/lib/features/process.rs index 55e8c3648..043d4850d 100644 --- a/src/uucore/src/lib/features/process.rs +++ b/src/uucore/src/lib/features/process.rs @@ -67,13 +67,11 @@ pub fn getpid() -> pid_t { /// so some system such as redox doesn't supported. #[cfg(not(target_os = "redox"))] pub fn getsid(pid: i32) -> Result { - unsafe { - let result = libc::getsid(pid); - if Errno::last() == Errno::UnknownErrno { - Ok(result) - } else { - Err(Errno::last()) - } + let result = unsafe { libc::getsid(pid) }; + if result == -1 { + Err(Errno::last()) + } else { + Ok(result) } } diff --git a/tests/by-util/test_dd.rs b/tests/by-util/test_dd.rs index 08ffa83d0..ce0eec3d1 100644 --- a/tests/by-util/test_dd.rs +++ b/tests/by-util/test_dd.rs @@ -1655,6 +1655,8 @@ fn test_reading_partial_blocks_from_fifo() { .stdout(Stdio::piped()) .stderr(Stdio::piped()) .env("LC_ALL", "C") + .env("LANG", "C") + .env("LANGUAGE", "C") .spawn() .unwrap(); @@ -1700,6 +1702,8 @@ fn test_reading_partial_blocks_from_fifo_unbuffered() { .stdout(Stdio::piped()) .stderr(Stdio::piped()) .env("LC_ALL", "C") + .env("LANG", "C") + .env("LANGUAGE", "C") .spawn() .unwrap(); diff --git a/util/build-run-test-coverage-linux.sh b/util/build-run-test-coverage-linux.sh index 9dcfefed2..8aba21530 100755 --- a/util/build-run-test-coverage-linux.sh +++ b/util/build-run-test-coverage-linux.sh @@ -28,6 +28,8 @@ set -e # Treat unset variables as errors set -u +# Ensure pipeline failures are caught (not just the last command's exit code) +set -o pipefail # Print expanded commands to stdout before running them set -x @@ -39,7 +41,12 @@ REPO_main_dir="$(dirname -- "${ME_dir}")" FEATURES_OPTION=${FEATURES_OPTION:-"--features=feat_os_unix"} COVERAGE_DIR=${COVERAGE_DIR:-"${REPO_main_dir}/coverage"} -LLVM_PROFDATA="$(find "$(rustc --print sysroot)" -name llvm-profdata)" +# Find llvm-profdata in the nightly toolchain (which is used for coverage builds) +LLVM_PROFDATA="$(find "$(RUSTUP_TOOLCHAIN=nightly-gnu rustc --print sysroot)" -name llvm-profdata)" +if [ -z "${LLVM_PROFDATA}" ]; then + echo "Error: llvm-profdata not found. Install it with: rustup +nightly-gnu component add llvm-tools" + exit 1 +fi PROFRAW_DIR="${COVERAGE_DIR}/traces" PROFDATA_DIR="${COVERAGE_DIR}/data" From f2bec7b9fe285a61e1fe2143ce480112abba75a9 Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Sat, 17 Jan 2026 16:36:28 +0100 Subject: [PATCH 20/32] date/test: %x format specifier respects locale settings (#10285) --- tests/by-util/test_date.rs | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/by-util/test_date.rs b/tests/by-util/test_date.rs index 744cffecb..336d07a1c 100644 --- a/tests/by-util/test_date.rs +++ b/tests/by-util/test_date.rs @@ -1469,3 +1469,31 @@ fn test_date_posix_format_specifiers() { .stdout_is(format!("{expected}\n")); } } + +/// Test that %x format specifier respects locale settings +/// This is a regression test for locale-aware date formatting +#[test] +#[ignore = "https://bugs.launchpad.net/ubuntu/+source/rust-coreutils/+bug/2137410"] +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +fn test_date_format_x_locale_aware() { + // With C locale, %x should output MM/DD/YY (US format) + new_ucmd!() + .env("TZ", "UTC") + .env("LC_ALL", "C") + .arg("-d") + .arg("1997-01-19 08:17:48") + .arg("+%x") + .succeeds() + .stdout_is("01/19/97\n"); + + // With French locale, %x should output DD/MM/YYYY (European format) + // GNU date outputs: 19/01/1997 + new_ucmd!() + .env("TZ", "UTC") + .env("LC_ALL", "fr_FR.UTF-8") + .arg("-d") + .arg("1997-01-19 08:17:48") + .arg("+%x") + .succeeds() + .stdout_is("19/01/1997\n"); +} From b3ad96f8a004e0c70bb7b90ff5aaabea5eb5c19d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=E1=BA=A3=20th=E1=BA=BF=20gi=E1=BB=9Bi=20l=C3=A0=20Rust?= <90588855+naoNao89@users.noreply.github.com> Date: Sat, 17 Jan 2026 23:36:28 +0700 Subject: [PATCH 21/32] refactor(dirname): implement pure string manipulation per POSIX (#8936) --- .../cspell.dictionaries/jargon.wordlist.txt | 1 + src/uu/dirname/src/dirname.rs | 138 +++++++++++------- tests/by-util/test_dirname.rs | 70 ++++++++- 3 files changed, 151 insertions(+), 58 deletions(-) diff --git a/.vscode/cspell.dictionaries/jargon.wordlist.txt b/.vscode/cspell.dictionaries/jargon.wordlist.txt index 33f495948..2f21572f7 100644 --- a/.vscode/cspell.dictionaries/jargon.wordlist.txt +++ b/.vscode/cspell.dictionaries/jargon.wordlist.txt @@ -202,6 +202,7 @@ nofield # * clippy uninlined nonminimal +rposition # * CPU/hardware features ASIMD diff --git a/src/uu/dirname/src/dirname.rs b/src/uu/dirname/src/dirname.rs index 3399b4a03..8659465cd 100644 --- a/src/uu/dirname/src/dirname.rs +++ b/src/uu/dirname/src/dirname.rs @@ -4,8 +4,9 @@ // file that was distributed with this source code. use clap::{Arg, ArgAction, Command}; +use std::borrow::Cow; use std::ffi::OsString; -use std::path::Path; +#[cfg(unix)] use uucore::display::print_verbatim; use uucore::error::{UResult, UUsageError}; use uucore::format_usage; @@ -18,51 +19,84 @@ mod options { pub const DIR: &str = "dir"; } -/// Handle the special case where a path ends with "/." +/// Perform dirname as pure string manipulation per POSIX/GNU behavior. +/// +/// dirname should NOT normalize paths. It does simple string manipulation: +/// 1. Strip trailing slashes (unless path is all slashes) +/// 2. If ends with `/.` (possibly `//.` or `///.`), strip the `/+.` pattern +/// 3. Otherwise, remove everything after the last `/` +/// 4. If no `/` found, return `.` +/// 5. Strip trailing slashes from result (unless result would be empty) +/// +/// Examples: +/// - `foo/.` → `foo` +/// - `foo/./bar` → `foo/.` +/// - `foo/bar` → `foo` +/// - `a/b/c` → `a/b` /// -/// This matches GNU/POSIX behavior where `dirname("/home/dos/.")` returns "/home/dos" -/// rather than "/home" (which would be the result of `Path::parent()` due to normalization). /// Per POSIX.1-2017 dirname specification and GNU coreutils manual: /// - POSIX: /// - GNU: /// -/// dirname should do simple string manipulation without path normalization. /// See issue #8910 and similar fix in basename (#8373, commit c5268a897). -/// -/// Returns `Some(())` if the special case was handled (output already printed), -/// or `None` if normal `Path::parent()` logic should be used. -fn handle_trailing_dot(path_bytes: &[u8]) -> Option<()> { - if !path_bytes.ends_with(b"/.") { - return None; +fn dirname_string_manipulation(path_bytes: &[u8]) -> Cow<'_, [u8]> { + if path_bytes.is_empty() { + return Cow::Borrowed(b"."); } - // Strip the "/." suffix and print the result - if path_bytes.len() == 2 { - // Special case: "/." -> "/" - print!("/"); - Some(()) - } else { - // General case: "/home/dos/." -> "/home/dos" - let stripped = &path_bytes[..path_bytes.len() - 2]; - #[cfg(unix)] - { - use std::os::unix::ffi::OsStrExt; - let result = std::ffi::OsStr::from_bytes(stripped); - print_verbatim(result).unwrap(); - Some(()) - } - #[cfg(not(unix))] - { - // On non-Unix, fall back to lossy conversion - if let Ok(s) = std::str::from_utf8(stripped) { - print!("{s}"); - Some(()) - } else { - // Can't handle non-UTF-8 on non-Unix, fall through to normal logic - None + let mut bytes = path_bytes; + + // Step 1: Strip trailing slashes (but not if the entire path is slashes) + let all_slashes = bytes.iter().all(|&b| b == b'/'); + if all_slashes { + return Cow::Borrowed(b"/"); + } + + while bytes.len() > 1 && bytes.ends_with(b"/") { + bytes = &bytes[..bytes.len() - 1]; + } + + // Step 2: Check if it ends with `/.` and strip the `/+.` pattern + if bytes.ends_with(b".") && bytes.len() >= 2 { + let dot_pos = bytes.len() - 1; + if bytes[dot_pos - 1] == b'/' { + // Find where the slashes before the dot start + let mut slash_start = dot_pos - 1; + while slash_start > 0 && bytes[slash_start - 1] == b'/' { + slash_start -= 1; } + // Return the stripped result + if slash_start == 0 { + // Result would be empty + return if path_bytes.starts_with(b"/") { + Cow::Borrowed(b"/") + } else { + Cow::Borrowed(b".") + }; + } + return Cow::Owned(bytes[..slash_start].to_vec()); } } + + // Step 3: Normal dirname - find last / and remove everything after it + if let Some(last_slash_pos) = bytes.iter().rposition(|&b| b == b'/') { + // Found a slash, remove everything after it + let mut result = &bytes[..last_slash_pos]; + + // Strip trailing slashes from result (but keep at least one if at the start) + while result.len() > 1 && result.ends_with(b"/") { + result = &result[..result.len() - 1]; + } + + if result.is_empty() { + return Cow::Borrowed(b"/"); + } + + return Cow::Owned(result.to_vec()); + } + + // No slash found, return "." + Cow::Borrowed(b".") } #[uucore::main] @@ -83,27 +117,25 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { for path in &dirnames { let path_bytes = uucore::os_str_as_bytes(path.as_os_str()).unwrap_or(&[]); + let result = dirname_string_manipulation(path_bytes); - if handle_trailing_dot(path_bytes).is_none() { - // Normal path handling using Path::parent() - let p = Path::new(path); - match p.parent() { - Some(d) => { - if d.components().next().is_none() { - print!("."); - } else { - print_verbatim(d).unwrap(); - } - } - None => { - if p.is_absolute() || path.as_os_str() == "/" { - print!("/"); - } else { - print!("."); - } - } + #[cfg(unix)] + { + use std::os::unix::ffi::OsStrExt; + let result_os = std::ffi::OsStr::from_bytes(&result); + print_verbatim(result_os).unwrap(); + } + #[cfg(not(unix))] + { + // On non-Unix, fall back to lossy conversion + if let Ok(s) = std::str::from_utf8(&result) { + print!("{s}"); + } else { + // Fallback for non-UTF-8 paths on non-Unix systems + print!("."); } } + print!("{line_ending}"); } diff --git a/tests/by-util/test_dirname.rs b/tests/by-util/test_dirname.rs index c7cdf3a46..bd6994107 100644 --- a/tests/by-util/test_dirname.rs +++ b/tests/by-util/test_dirname.rs @@ -9,6 +9,11 @@ fn test_invalid_arg() { new_ucmd!().arg("--definitely-invalid").fails_with_code(1); } +#[test] +fn test_missing_operand() { + new_ucmd!().fails_with_code(1); +} + #[test] fn test_path_with_trailing_slashes() { new_ucmd!() @@ -71,15 +76,11 @@ fn test_dirname_non_utf8_paths() { use std::ffi::OsStr; use std::os::unix::ffi::OsStrExt; - // Create a test file with non-UTF-8 bytes in the name let non_utf8_bytes = b"test_\xFF\xFE/file.txt"; let non_utf8_name = OsStr::from_bytes(non_utf8_bytes); - // Test that dirname handles non-UTF-8 paths without crashing let result = new_ucmd!().arg(non_utf8_name).succeeds(); - // Just verify it didn't crash and produced some output - // The exact output format may vary due to lossy conversion let output = result.stdout_str_lossy(); assert!(!output.is_empty()); assert!(output.contains("test_")); @@ -156,7 +157,7 @@ fn test_trailing_dot_edge_cases() { new_ucmd!() .arg("/home/dos//.") .succeeds() - .stdout_is("/home/dos/\n"); + .stdout_is("/home/dos\n"); // Path with . in middle (should use normal logic) new_ucmd!() @@ -216,3 +217,62 @@ fn test_existing_behavior_preserved() { .succeeds() .stdout_is("/home/dos\n"); } + +#[test] +fn test_multiple_paths_comprehensive() { + // Comprehensive test for multiple paths in single invocation + new_ucmd!() + .args(&[ + "/home/dos/.", + "/var/log", + ".", + "/tmp/.", + "", + "/", + "relative/path", + ]) + .succeeds() + .stdout_is("/home/dos\n/var\n.\n/tmp\n.\n/\nrelative\n"); +} + +#[test] +fn test_all_dot_slash_variations() { + // Tests for all the cases mentioned in issue #8910 comment + // https://github.com/uutils/coreutils/issues/8910#issuecomment-3408735720 + + new_ucmd!().arg("foo//.").succeeds().stdout_is("foo\n"); + + new_ucmd!().arg("foo///.").succeeds().stdout_is("foo\n"); + + new_ucmd!().arg("foo/./").succeeds().stdout_is("foo\n"); + + new_ucmd!() + .arg("foo/bar/./") + .succeeds() + .stdout_is("foo/bar\n"); + + new_ucmd!().arg("foo/./bar").succeeds().stdout_is("foo/.\n"); +} + +#[test] +fn test_dot_slash_component_preservation() { + // Ensure that /. components in the middle are preserved + // These should NOT be normalized away + + new_ucmd!().arg("a/./b").succeeds().stdout_is("a/.\n"); + + new_ucmd!() + .arg("a/./b/./c") + .succeeds() + .stdout_is("a/./b/.\n"); + + new_ucmd!() + .arg("foo/./bar/baz") + .succeeds() + .stdout_is("foo/./bar\n"); + + new_ucmd!() + .arg("/path/./to/file") + .succeeds() + .stdout_is("/path/./to\n"); +} From b5bbabc18a1121908848d836f869a4e98eb63886 Mon Sep 17 00:00:00 2001 From: Dalton Caron Date: Sat, 17 Jan 2026 09:08:14 -0800 Subject: [PATCH 22/32] install: prevent TOCTOU race attack (#10067) * install: prevent TOCTOU race attack * cspell: add TOCTOU acronym to jargon word list --- .../cspell.dictionaries/jargon.wordlist.txt | 1 + src/uu/install/locales/en-US.ftl | 2 +- src/uu/install/locales/fr-FR.ftl | 2 +- src/uu/install/src/install.rs | 54 +++++-------------- tests/by-util/test_install.rs | 22 ++++++++ 5 files changed, 39 insertions(+), 42 deletions(-) diff --git a/.vscode/cspell.dictionaries/jargon.wordlist.txt b/.vscode/cspell.dictionaries/jargon.wordlist.txt index 2f21572f7..e4987609f 100644 --- a/.vscode/cspell.dictionaries/jargon.wordlist.txt +++ b/.vscode/cspell.dictionaries/jargon.wordlist.txt @@ -184,6 +184,7 @@ inacc maint proc procs +TOCTOU # * constants xffff diff --git a/src/uu/install/locales/en-US.ftl b/src/uu/install/locales/en-US.ftl index 0261f7320..76265a2b1 100644 --- a/src/uu/install/locales/en-US.ftl +++ b/src/uu/install/locales/en-US.ftl @@ -30,7 +30,7 @@ install-error-chown-failed = failed to chown { $path }: { $error } install-error-invalid-target = invalid target { $path }: No such file or directory install-error-target-not-dir = target { $path } is not a directory install-error-backup-failed = cannot backup { $from } to { $to } -install-error-install-failed = cannot install { $from } to { $to } +install-error-install-failed = cannot install { $from } to { $to }: { $error } install-error-strip-failed = strip program failed: { $error } install-error-strip-abnormal = strip process terminated abnormally - exit code: { $code } install-error-metadata-failed = metadata error diff --git a/src/uu/install/locales/fr-FR.ftl b/src/uu/install/locales/fr-FR.ftl index 208712c21..330ceb7b4 100644 --- a/src/uu/install/locales/fr-FR.ftl +++ b/src/uu/install/locales/fr-FR.ftl @@ -30,7 +30,7 @@ install-error-chown-failed = échec du chown { $path } : { $error } install-error-invalid-target = cible invalide { $path } : Aucun fichier ou répertoire de ce type install-error-target-not-dir = la cible { $path } n'est pas un répertoire install-error-backup-failed = impossible de sauvegarder { $from } vers { $to } -install-error-install-failed = impossible d'installer { $from } vers { $to } +install-error-install-failed = impossible d'installer { $from } vers { $to }: { $error } install-error-strip-failed = échec du programme strip : { $error } install-error-strip-abnormal = le processus strip s'est terminé anormalement - code de sortie : { $code } install-error-metadata-failed = erreur de métadonnées diff --git a/src/uu/install/src/install.rs b/src/uu/install/src/install.rs index d3e0b3e89..e128470fc 100644 --- a/src/uu/install/src/install.rs +++ b/src/uu/install/src/install.rs @@ -14,8 +14,8 @@ use filetime::{FileTime, set_file_times}; use selinux::SecurityContext; use std::ffi::OsString; use std::fmt::Debug; -use std::fs::File; use std::fs::{self, metadata}; +use std::fs::{File, OpenOptions}; use std::path::{MAIN_SEPARATOR, Path, PathBuf}; use std::process; use thiserror::Error; @@ -36,7 +36,7 @@ use uucore::translate; use uucore::{format_usage, show, show_error, show_if_err}; #[cfg(unix)] -use std::os::unix::fs::{FileTypeExt, MetadataExt}; +use std::os::unix::fs::MetadataExt; #[cfg(unix)] use std::os::unix::prelude::OsStrExt; @@ -88,8 +88,8 @@ enum InstallError { #[error("{}", translate!("install-error-backup-failed", "from" => .0.quote(), "to" => .1.quote()))] BackupFailed(PathBuf, PathBuf, #[source] std::io::Error), - #[error("{}", translate!("install-error-install-failed", "from" => .0.quote(), "to" => .1.quote()))] - InstallFailed(PathBuf, PathBuf, #[source] std::io::Error), + #[error("{}", translate!("install-error-install-failed", "from" => .0.quote(), "to" => .1.quote(), "error" => .2.clone()))] + InstallFailed(PathBuf, PathBuf, String), #[error("{}", translate!("install-error-strip-failed", "error" => .0.clone()))] StripProgramFailed(String), @@ -796,22 +796,6 @@ fn perform_backup(to: &Path, b: &Behavior) -> UResult> { } } -/// Copy a non-special file using [`fs::copy`]. -/// -/// # Parameters -/// * `from` - The source file path. -/// * `to` - The destination file path. -/// -/// # Returns -/// -/// Returns an empty Result or an error in case of failure. -fn copy_normal_file(from: &Path, to: &Path) -> UResult<()> { - if let Err(err) = fs::copy(from, to) { - return Err(InstallError::InstallFailed(from.to_path_buf(), to.to_path_buf(), err).into()); - } - Ok(()) -} - /// Copy a file from one path to another. Handles the certain cases of special /// files (e.g character specials). /// @@ -838,8 +822,10 @@ fn copy_file(from: &Path, to: &Path) -> UResult<()> { ) .into()); } - // fs::copy fails if destination is a invalid symlink. - // so lets just remove all existing files at destination before copy. + + // Remove existing file at destination to allow overwriting + // Note: create_new() below provides TOCTOU protection; if something + // appears at this path between the remove and create, it will fail safely if let Err(e) = fs::remove_file(to) { if e.kind() != std::io::ErrorKind::NotFound { show_error!( @@ -849,25 +835,13 @@ fn copy_file(from: &Path, to: &Path) -> UResult<()> { } } - let ft = match metadata(from) { - Ok(ft) => ft.file_type(), - Err(err) => { - return Err( - InstallError::InstallFailed(from.to_path_buf(), to.to_path_buf(), err).into(), - ); - } - }; + let mut handle = File::open(from)?; + // create_new provides TOCTOU protection + let mut dest = OpenOptions::new().write(true).create_new(true).open(to)?; - // Stream-based copying to get around the limitations of std::fs::copy - #[cfg(unix)] - if ft.is_char_device() || ft.is_block_device() || ft.is_fifo() { - let mut handle = File::open(from)?; - let mut dest = File::create(to)?; - copy_stream(&mut handle, &mut dest)?; - return Ok(()); - } - - copy_normal_file(from, to)?; + copy_stream(&mut handle, &mut dest).map_err(|err| { + InstallError::InstallFailed(from.to_path_buf(), to.to_path_buf(), err.to_string()) + })?; Ok(()) } diff --git a/tests/by-util/test_install.rs b/tests/by-util/test_install.rs index b6a998a02..7a2ccb875 100644 --- a/tests/by-util/test_install.rs +++ b/tests/by-util/test_install.rs @@ -2545,3 +2545,25 @@ fn test_install_unprivileged_option_u_skips_chown() { assert!(at.file_exists(dst_ok)); assert_eq!(at.metadata(dst_ok).uid(), geteuid()); } + +#[test] +fn test_install_normal_file_replaces_symlink() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + at.write("source", "new content"); + at.write("sensitive", "important data"); + + // Create symlink at destination + at.symlink_file("sensitive", "dest"); + + // Install should replace symlink with normal file (not follow it) + scene.ucmd().arg("source").arg("dest").succeeds(); + + // Verify dest is now a normal file, not a symlink + assert!(at.file_exists("dest")); + assert_eq!(at.read("dest"), "new content"); + + // Verify sensitive file was NOT modified + assert_eq!(at.read("sensitive"), "important data"); +} From 8ad8a500acfc18e037d0c26014627253dbbc71e2 Mon Sep 17 00:00:00 2001 From: Chris Dryden Date: Sat, 17 Jan 2026 14:15:34 -0500 Subject: [PATCH 23/32] Add 'tests/tail/follow-name' to ignore list (#10297) This one keeps popping up in PR's recently --- .github/workflows/ignore-intermittent.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ignore-intermittent.txt b/.github/workflows/ignore-intermittent.txt index 1e5086c10..e6cb5dc64 100644 --- a/.github/workflows/ignore-intermittent.txt +++ b/.github/workflows/ignore-intermittent.txt @@ -8,3 +8,4 @@ tests/tty/tty-eof tests/misc/stdbuf tests/misc/usage_vs_getopt tests/misc/tee +tests/tail/follow-name From 0333fb51f0f1886c77866fd37b0c40a632cce6b4 Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Sun, 18 Jan 2026 04:17:14 +0900 Subject: [PATCH 24/32] CICD.yml: Remove zsh completion for [ (#10278) --- .github/workflows/CICD.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/CICD.yml b/.github/workflows/CICD.yml index bc42a6bc5..1d77f67b0 100644 --- a/.github/workflows/CICD.yml +++ b/.github/workflows/CICD.yml @@ -902,6 +902,7 @@ jobs: ${_uudoc} completion ${bin} zsh > share/zsh/site-functions/_${bin} ${_uudoc} completion ${bin} elvish > share/elvish/lib/${bin}.elv done + rm share/zsh/site-functions/_[ # not supported tar --zstd -cf docs.tar.zst share - name: Publish uses: softprops/action-gh-release@v2 From d94332468d9640b3be35bd93a8e71e67b41ba62c Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Sat, 17 Jan 2026 20:24:20 +0100 Subject: [PATCH 25/32] tee: allow multiple -a flags (#10293) Fixes issue where tee rejected multiple append flags with error "the argument '--append' cannot be used multiple times". GNU tee accepts multiple -a flags, so this adds compatibility. --- src/uu/tee/src/tee.rs | 3 ++- tests/by-util/test_tee.rs | 24 ++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/uu/tee/src/tee.rs b/src/uu/tee/src/tee.rs index 1325b0465..cf3d89c0a 100644 --- a/src/uu/tee/src/tee.rs +++ b/src/uu/tee/src/tee.rs @@ -115,7 +115,8 @@ pub fn uu_app() -> Command { .long(options::APPEND) .short('a') .help(translate!("tee-help-append")) - .action(ArgAction::SetTrue), + .action(ArgAction::SetTrue) + .overrides_with(options::APPEND), ) .arg( Arg::new(options::IGNORE_INTERRUPTS) diff --git a/tests/by-util/test_tee.rs b/tests/by-util/test_tee.rs index ba6993371..4a3e16912 100644 --- a/tests/by-util/test_tee.rs +++ b/tests/by-util/test_tee.rs @@ -91,6 +91,30 @@ fn test_tee_append() { assert_eq!(at.read(file), content.repeat(2)); } +#[test] +fn test_tee_multiple_append_flags() { + // Test for bug: https://bugs.launchpad.net/ubuntu/+source/rust-coreutils/+bug/2134578 + // The command should accept multiple -a flags for different files + let (at, mut ucmd) = at_and_ucmd!(); + let content = "don't fail me now rust"; + let file1 = "log1"; + let file2 = "log2"; + + // Pre-populate files with some content to verify append behavior + at.write(file1, "existing1\n"); + at.write(file2, "existing2\n"); + + ucmd.args(&["-a", file1, "-a", file2]) + .pipe_in(content) + .succeeds() + .stdout_is(content); + + assert!(at.file_exists(file1)); + assert!(at.file_exists(file2)); + assert_eq!(at.read(file1), format!("existing1\n{content}")); + assert_eq!(at.read(file2), format!("existing2\n{content}")); +} + #[test] fn test_readonly() { let (at, mut ucmd) = at_and_ucmd!(); From f01d1e8422744b271cb7574fe5b1205c7423c550 Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Sat, 17 Jan 2026 20:35:09 +0100 Subject: [PATCH 26/32] dirname: use Cow::Borrowed to avoid unnecessary heap allocations (#10294) + remove useless comments --- src/uu/dirname/src/dirname.rs | 4 ++-- tests/by-util/test_dirname.rs | 15 --------------- 2 files changed, 2 insertions(+), 17 deletions(-) diff --git a/src/uu/dirname/src/dirname.rs b/src/uu/dirname/src/dirname.rs index 8659465cd..6bd91d910 100644 --- a/src/uu/dirname/src/dirname.rs +++ b/src/uu/dirname/src/dirname.rs @@ -74,7 +74,7 @@ fn dirname_string_manipulation(path_bytes: &[u8]) -> Cow<'_, [u8]> { Cow::Borrowed(b".") }; } - return Cow::Owned(bytes[..slash_start].to_vec()); + return Cow::Borrowed(&bytes[..slash_start]); } } @@ -92,7 +92,7 @@ fn dirname_string_manipulation(path_bytes: &[u8]) -> Cow<'_, [u8]> { return Cow::Borrowed(b"/"); } - return Cow::Owned(result.to_vec()); + return Cow::Borrowed(result); } // No slash found, return "." diff --git a/tests/by-util/test_dirname.rs b/tests/by-util/test_dirname.rs index bd6994107..92350261d 100644 --- a/tests/by-util/test_dirname.rs +++ b/tests/by-util/test_dirname.rs @@ -106,8 +106,6 @@ fn test_emoji_handling() { #[test] fn test_trailing_dot() { - // Basic case: path ending with /. should return parent without stripping last component - // This matches GNU coreutils behavior and fixes issue #8910 new_ucmd!() .arg("/home/dos/.") .succeeds() @@ -183,26 +181,19 @@ fn test_trailing_dot_non_utf8() { use std::ffi::OsStr; use std::os::unix::ffi::OsStrExt; - // Create a path with non-UTF-8 bytes ending in /. let non_utf8_bytes = b"/test_\xFF\xFE/."; let non_utf8_path = OsStr::from_bytes(non_utf8_bytes); - // Test that dirname handles non-UTF-8 paths with /. suffix let result = new_ucmd!().arg(non_utf8_path).succeeds(); - // The output should be the path without the /. suffix let output = result.stdout_str_lossy(); assert!(!output.is_empty()); assert!(output.contains("test_")); - // Should not contain the . at the end assert!(!output.trim().ends_with('.')); } #[test] fn test_existing_behavior_preserved() { - // Ensure we didn't break existing test cases - // These tests verify backward compatibility - // Normal paths without /. should work as before new_ucmd!().arg("/home/dos").succeeds().stdout_is("/home\n"); @@ -237,9 +228,6 @@ fn test_multiple_paths_comprehensive() { #[test] fn test_all_dot_slash_variations() { - // Tests for all the cases mentioned in issue #8910 comment - // https://github.com/uutils/coreutils/issues/8910#issuecomment-3408735720 - new_ucmd!().arg("foo//.").succeeds().stdout_is("foo\n"); new_ucmd!().arg("foo///.").succeeds().stdout_is("foo\n"); @@ -256,9 +244,6 @@ fn test_all_dot_slash_variations() { #[test] fn test_dot_slash_component_preservation() { - // Ensure that /. components in the middle are preserved - // These should NOT be normalized away - new_ucmd!().arg("a/./b").succeeds().stdout_is("a/.\n"); new_ucmd!() From c4985953f8b077416df65b620bf035b306cd4d55 Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Sat, 17 Jan 2026 21:12:10 +0100 Subject: [PATCH 27/32] uucore: simplify cfg checks in signals.rs (#10296) Remove redundant unix conditions since macOS is already covered by the unix target family --- src/uucore/src/lib/features/signals.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/uucore/src/lib/features/signals.rs b/src/uucore/src/lib/features/signals.rs index 1c4d684a7..8f2c822cb 100644 --- a/src/uucore/src/lib/features/signals.rs +++ b/src/uucore/src/lib/features/signals.rs @@ -457,13 +457,13 @@ pub unsafe extern "C" fn capture_sigpipe_state() { #[cfg(unix)] macro_rules! init_sigpipe_capture { () => { - #[cfg(all(unix, not(target_os = "macos")))] + #[cfg(not(target_os = "macos"))] #[used] #[unsafe(link_section = ".init_array")] static CAPTURE_SIGPIPE_STATE: unsafe extern "C" fn() = $crate::signals::capture_sigpipe_state; - #[cfg(all(unix, target_os = "macos"))] + #[cfg(target_os = "macos")] #[used] #[unsafe(link_section = "__DATA,__mod_init_func")] static CAPTURE_SIGPIPE_STATE: unsafe extern "C" fn() = From 2c3c68706ed3f7df0b40fe066636a2e2c235f678 Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Sun, 18 Jan 2026 06:15:46 +0900 Subject: [PATCH 28/32] CICD.yml: Add man and completion for coreutils(1) to docs.tar.zst --- .github/workflows/CICD.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/CICD.yml b/.github/workflows/CICD.yml index 1d77f67b0..7f667076a 100644 --- a/.github/workflows/CICD.yml +++ b/.github/workflows/CICD.yml @@ -895,7 +895,7 @@ jobs: run: | mkdir -p share/{man/man1,bash-completion/completions,fish/vendor_completions.d,zsh/site-functions,elvish/lib} _uudoc=target/${{ matrix.job.target }}/release/uudoc - for bin in $('target/${{ matrix.job.target }}/release/coreutils' --list);do + for bin in $('target/${{ matrix.job.target }}/release/coreutils' --list) coreutils;do ${_uudoc} manpage ${bin} > share/man/man1/${bin}.1 ${_uudoc} completion ${bin} bash > share/bash-completion/completions/${bin}.bash ${_uudoc} completion ${bin} fish > share/fish/vendor_completions.d/${bin}.fish From 9ea24d66b0a0c89a7d73e6a960e08df21c74249a Mon Sep 17 00:00:00 2001 From: Chris Dryden Date: Sat, 17 Jan 2026 17:13:54 -0500 Subject: [PATCH 29/32] timeout: display signal 0 as '0' instead of 'EXIT' in verbose mode (#10194) This is done to match a change in GNU coreutils after v9.9 --- src/uu/timeout/src/timeout.rs | 6 +++++- tests/by-util/test_timeout.rs | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/uu/timeout/src/timeout.rs b/src/uu/timeout/src/timeout.rs index de20bec83..84d946348 100644 --- a/src/uu/timeout/src/timeout.rs +++ b/src/uu/timeout/src/timeout.rs @@ -210,7 +210,11 @@ fn catch_sigterm() { /// Report that a signal is being sent if the verbose flag is set. fn report_if_verbose(signal: usize, cmd: &str, verbose: bool) { if verbose { - let s = signal_name_by_value(signal).unwrap(); + let s = if signal == 0 { + "0".to_string() + } else { + signal_name_by_value(signal).unwrap().to_string() + }; show_error!( "{}", translate!("timeout-verbose-sending-signal", "signal" => s, "command" => cmd.quote()) diff --git a/tests/by-util/test_timeout.rs b/tests/by-util/test_timeout.rs index 9c5c6c1a4..adce254d5 100644 --- a/tests/by-util/test_timeout.rs +++ b/tests/by-util/test_timeout.rs @@ -58,7 +58,7 @@ fn test_verbose() { new_ucmd!() .args(&[verbose_flag, "-s0", "-k.1", ".1", "sleep", "1"]) .fails() - .stderr_only("timeout: sending signal EXIT to command 'sleep'\ntimeout: sending signal KILL to command 'sleep'\n"); + .stderr_only("timeout: sending signal 0 to command 'sleep'\ntimeout: sending signal KILL to command 'sleep'\n"); } } From f4ed162cf3c355626c175bef56d5a99b6c1dac6d Mon Sep 17 00:00:00 2001 From: quantum-encoding Date: Sat, 17 Jan 2026 23:44:32 +0100 Subject: [PATCH 30/32] fix(sort): Enable locale-aware collation for UTF-8 locales (#9176) * fix(sort): Enable locale-aware collation for UTF-8 locales Fixes #9148 The sort implementation had locale support infrastructure (ICU collator) but it was never being used due to the fast_lexicographic optimization bypassing all locale-aware code. --- src/uu/sort/Cargo.toml | 4 ++ src/uu/sort/src/sort.rs | 61 ++++++++++++++++---- src/uucore/src/lib/features/i18n/collator.rs | 39 +++++++++++++ src/uucore/src/lib/features/i18n/mod.rs | 9 ++- tests/by-util/test_sort.rs | 54 +++++++++++++++++ 5 files changed, 156 insertions(+), 11 deletions(-) diff --git a/src/uu/sort/Cargo.toml b/src/uu/sort/Cargo.toml index 476375516..8b422898a 100644 --- a/src/uu/sort/Cargo.toml +++ b/src/uu/sort/Cargo.toml @@ -19,6 +19,9 @@ workspace = true [lib] path = "src/sort.rs" +[features] +i18n-collator = ["uucore/i18n-collator"] + [dependencies] bigdecimal = { workspace = true } binary-heap-plus = { workspace = true } @@ -39,6 +42,7 @@ uucore = { workspace = true, features = [ "parser-size", "version-cmp", "i18n-decimal", + "i18n-collator", ] } fluent = { workspace = true } diff --git a/src/uu/sort/src/sort.rs b/src/uu/sort/src/sort.rs index ddbf22576..efce29180 100644 --- a/src/uu/sort/src/sort.rs +++ b/src/uu/sort/src/sort.rs @@ -23,6 +23,7 @@ use chunks::LineData; use clap::builder::ValueParser; use clap::{Arg, ArgAction, ArgMatches, Command}; use custom_str_cmp::custom_str_cmp; + use ext_sort::ext_sort; use fnv::FnvHasher; use numeric_str_cmp::{NumInfo, NumInfoParseSettings, human_numeric_str_cmp, numeric_str_cmp}; @@ -47,6 +48,8 @@ use uucore::error::{FromIo, strip_errno}; use uucore::error::{UError, UResult, USimpleError, UUsageError}; use uucore::extendedbigdecimal::ExtendedBigDecimal; use uucore::format_usage; +#[cfg(feature = "i18n-collator")] +use uucore::i18n::collator::locale_cmp; use uucore::i18n::decimal::locale_decimal_separator; use uucore::line_ending::LineEnding; use uucore::parser::num_parser::{ExtendedParser, ExtendedParserError}; @@ -318,7 +321,10 @@ impl GlobalSettings { /// Precompute some data needed for sorting. /// This function **must** be called before starting to sort, and `GlobalSettings` may not be altered /// afterwards. - fn init_precomputed(&mut self) { + /// + /// When i18n-collator is enabled, `disable_fast_lexicographic` should be set to true if we're + /// in a UTF-8 locale (to force locale-aware collation instead of byte comparison). + fn init_precomputed(&mut self, disable_fast_lexicographic: bool) { self.precomputed.needs_tokens = self.selectors.iter().any(|s| s.needs_tokens); self.precomputed.selections_per_line = self.selectors.iter().filter(|s| s.needs_selection).count(); @@ -333,11 +339,15 @@ impl GlobalSettings { .filter(|s| matches!(s.settings.mode, SortMode::GeneralNumeric)) .count(); - self.precomputed.fast_lexicographic = self.can_use_fast_lexicographic(); + self.precomputed.fast_lexicographic = + !disable_fast_lexicographic && self.can_use_fast_lexicographic(); self.precomputed.fast_ascii_insensitive = self.can_use_fast_ascii_insensitive(); } /// Returns true when the fast lexicographic path can be used safely. + /// Note: When i18n-collator is enabled, the caller must have already determined + /// whether locale-aware collation is needed (via checking if we're in a UTF-8 locale). + /// This check is performed in uumain() before init_precomputed() is called. fn can_use_fast_lexicographic(&self) -> bool { self.mode == SortMode::Default && !self.ignore_case @@ -2065,7 +2075,15 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { emit_debug_warnings(&settings, &global_flags, &legacy_warnings); } - settings.init_precomputed(); + // Initialize locale collation if needed (UTF-8 locales) + // This MUST happen before init_precomputed() to avoid the performance regression + #[cfg(feature = "i18n-collator")] + let needs_locale_collation = uucore::i18n::collator::init_locale_collation(); + + #[cfg(not(feature = "i18n-collator"))] + let needs_locale_collation = false; + + settings.init_precomputed(needs_locale_collation); let result = exec(&mut files, &settings, output, &mut tmp_dir); // Wait here if `SIGINT` was received, @@ -2446,13 +2464,36 @@ fn compare_by<'a>( } SortMode::Month => month_compare(a_str, b_str), SortMode::Version => version_cmp(a_str, b_str), - SortMode::Default => custom_str_cmp( - a_str, - b_str, - settings.ignore_non_printing, - settings.dictionary_order, - settings.ignore_case, - ), + SortMode::Default => { + // Use locale-aware comparison if feature is enabled and no custom flags are set + #[cfg(feature = "i18n-collator")] + { + if settings.ignore_case + || settings.dictionary_order + || settings.ignore_non_printing + { + custom_str_cmp( + a_str, + b_str, + settings.ignore_non_printing, + settings.dictionary_order, + settings.ignore_case, + ) + } else { + locale_cmp(a_str, b_str) + } + } + #[cfg(not(feature = "i18n-collator"))] + { + custom_str_cmp( + a_str, + b_str, + settings.ignore_non_printing, + settings.dictionary_order, + settings.ignore_case, + ) + } + } }; if cmp != Ordering::Equal { return if settings.reverse { cmp.reverse() } else { cmp }; diff --git a/src/uucore/src/lib/features/i18n/collator.rs b/src/uucore/src/lib/features/i18n/collator.rs index fda8cd6e0..f0a9e6b35 100644 --- a/src/uucore/src/lib/features/i18n/collator.rs +++ b/src/uucore/src/lib/features/i18n/collator.rs @@ -30,6 +30,45 @@ pub fn init_collator(opts: CollatorOptions) { .expect("Collator already initialized"); } +/// Initialize the collator for locale-aware string comparison if needed. +/// +/// This function checks if the current locale requires locale-aware collation +/// (UTF-8 encoding) and initializes the ICU collator with appropriate settings +/// if necessary. For C/POSIX locales, no initialization is needed as byte +/// comparison is sufficient. +/// +/// # Returns +/// +/// `true` if the collator was initialized for a UTF-8 locale, `false` if +/// using C/POSIX locale (no initialization needed). +/// +/// # Example +/// +/// ``` +/// use uucore::i18n::collator::init_locale_collation; +/// +/// if init_locale_collation() { +/// // Using locale-aware collation +/// } else { +/// // Using byte comparison (C/POSIX locale) +/// } +/// ``` +pub fn init_locale_collation() -> bool { + use crate::i18n::{UEncoding, get_locale_encoding}; + + // Check if we need locale-aware collation + if get_locale_encoding() != UEncoding::Utf8 { + // C/POSIX locale - no collator needed + return false; + } + + // UTF-8 locale - initialize collator with Shifted mode to match GNU behavior + let mut opts = CollatorOptions::default(); + opts.alternate_handling = Some(AlternateHandling::Shifted); + + try_init_collator(opts) +} + /// Compare both strings with regard to the current locale. pub fn locale_cmp(left: &[u8], right: &[u8]) -> Ordering { // If the detected locale is 'C', just do byte-wise comparison diff --git a/src/uucore/src/lib/features/i18n/mod.rs b/src/uucore/src/lib/features/i18n/mod.rs index d47f2df98..79c804a03 100644 --- a/src/uucore/src/lib/features/i18n/mod.rs +++ b/src/uucore/src/lib/features/i18n/mod.rs @@ -20,7 +20,9 @@ pub enum UEncoding { Utf8, } -const DEFAULT_LOCALE: Locale = locale!("en-US-posix"); +// Use "und" (undefined) as the marker for C/POSIX locale +// This ensures real locales like "en-US" won't match +const DEFAULT_LOCALE: Locale = locale!("und"); /// Look at 3 environment variables in the following order /// @@ -38,6 +40,11 @@ fn get_locale_from_env(locale_name: &str) -> (Locale, UEncoding) { let mut split = locale_var_str.split(&['.', '@']); if let Some(simple) = split.next() { + // Handle explicit C and POSIX locales - these should always use byte comparison + if simple == "C" || simple == "POSIX" { + return (DEFAULT_LOCALE, UEncoding::Ascii); + } + // Naively convert the locale name to BCP47 tag format. // // See https://en.wikipedia.org/wiki/IETF_language_tag diff --git a/tests/by-util/test_sort.rs b/tests/by-util/test_sort.rs index b478912dd..0106d719f 100644 --- a/tests/by-util/test_sort.rs +++ b/tests/by-util/test_sort.rs @@ -2463,4 +2463,58 @@ fn test_start_buffer() { .stdout_only_bytes(&expected); } +#[test] +fn test_locale_collation_c_locale() { + // C locale uses byte order - this is deterministic and tests the fix for #9148 + // Accented characters (UTF-8 multibyte) sort after ASCII letters + let input = "é\ne\nE\na\nA\nz\n"; + // C locale byte order: A=0x41, E=0x45, a=0x61, e=0x65, z=0x7A, é=0xC3 0xA9 + let expected = "A\nE\na\ne\nz\né\n"; + + new_ucmd!() + .env("LC_ALL", "C") + .pipe_in(input) + .succeeds() + .stdout_is(expected); +} + +#[test] +fn test_locale_collation_utf8() { + // Test French UTF-8 locale handling - behavior depends on i18n-collator feature + // With feature: locale-aware collation (é sorts near e) + // Without feature: byte order (é after z, since 0xC3A9 > 0x7A) + let input = "z\né\ne\na\n"; + + let result = new_ucmd!() + .env("LC_ALL", "fr_FR.UTF-8") + .pipe_in(input) + .succeeds(); + + let output = result.stdout_str(); + let lines: Vec<&str> = output.lines().collect(); + + assert_eq!(lines.len(), 4, "Expected 4 sorted lines"); + assert_eq!(lines[0], "a", "'a' (0x61) should always sort first"); + + // Validate based on which collation mode is active + if lines[3] == "é" { + // Byte order mode: é (0xC3A9) > z (0x7A) + assert_eq!( + lines, + vec!["a", "e", "z", "é"], + "Byte order mode: expected a < e < z < é" + ); + } else { + // Locale collation mode: é sorts with base letter e + assert_eq!(lines[3], "z", "Locale mode: 'z' should sort last"); + let z_pos = lines.iter().position(|&x| x == "z").unwrap(); + let e_pos = lines.iter().position(|&x| x == "e").unwrap(); + let e_accent_pos = lines.iter().position(|&x| x == "é").unwrap(); + assert!( + e_pos < z_pos && e_accent_pos < z_pos, + "Locale mode: 'e' ({e_pos}) and 'é' ({e_accent_pos}) should sort before 'z' ({z_pos})" + ); + } +} + /* spell-checker: enable */ From fc17efe7beb08446b07fd208afec0bce45cb45be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gon=C3=A7alo=20Gomes?= Date: Wed, 14 Jan 2026 10:58:10 +0000 Subject: [PATCH 31/32] rm: don't treat symlinks as write-protected GNU rm does not check for write-protection on symbolic links; it instead prompts to "remove symbolic link" regardless of the link's permissions or its target's status. This change: - Ensures `prompt_file` checks for symlinks specifically using `symlink_metadata`, avoiding the incorrect "write-protected" prompt. - Refactors permission checks into `is_writable_metadata` to allow using the already-fetched metadata, which also optimizes performance by reducing redundant `stat` calls. - Updates `prompt_file_permission_readonly` to operate on metadata directly. --- src/uu/rm/src/rm.rs | 43 ++++++++++++++-------------------------- tests/by-util/test_rm.rs | 17 ++++++++++++++++ 2 files changed, 32 insertions(+), 28 deletions(-) diff --git a/src/uu/rm/src/rm.rs b/src/uu/rm/src/rm.rs index 55c5b932f..32bf94fd0 100644 --- a/src/uu/rm/src/rm.rs +++ b/src/uu/rm/src/rm.rs @@ -549,19 +549,8 @@ fn is_writable_metadata(metadata: &Metadata) -> bool { (mode & 0o200) > 0 } -/// Whether the given file or directory is writable. -#[cfg(unix)] -fn is_writable(path: &Path) -> bool { - match fs::metadata(path) { - Err(_) => false, - Ok(metadata) => is_writable_metadata(&metadata), - } -} - -/// Whether the given file or directory is writable. #[cfg(not(unix))] -fn is_writable(_path: &Path) -> bool { - // TODO Not yet implemented. +fn is_writable_metadata(_metadata: &Metadata) -> bool { true } @@ -799,35 +788,33 @@ fn prompt_file(path: &Path, options: &Options) -> bool { if options.interactive == InteractiveMode::Never { return true; } - // If interactive is Always we want to check if the file is symlink to prompt the right message - if options.interactive == InteractiveMode::Always { - if let Ok(metadata) = fs::symlink_metadata(path) { - if metadata.is_symlink() { - return prompt_yes!("remove symbolic link {}?", path.quote()); - } - } - } - let Ok(metadata) = fs::metadata(path) else { + let Ok(metadata) = fs::symlink_metadata(path) else { return true; }; - if options.interactive == InteractiveMode::Always && is_writable(path) { + if metadata.is_symlink() { + return options.interactive != InteractiveMode::Always + || prompt_yes!("remove symbolic link {}?", path.quote()); + } + + if options.interactive == InteractiveMode::Always && is_writable_metadata(&metadata) { return if metadata.len() == 0 { prompt_yes!("remove regular empty file {}?", path.quote()) } else { prompt_yes!("remove file {}?", path.quote()) }; } - prompt_file_permission_readonly(path, options) + + prompt_file_permission_readonly(path, options, &metadata) } -fn prompt_file_permission_readonly(path: &Path, options: &Options) -> bool { +fn prompt_file_permission_readonly(path: &Path, options: &Options, metadata: &Metadata) -> bool { let stdin_ok = options.__presume_input_tty.unwrap_or(false) || stdin().is_terminal(); - match (stdin_ok, fs::metadata(path), options.interactive) { - (false, _, InteractiveMode::PromptProtected) => true, - (_, Ok(_), _) if is_writable(path) => true, - (_, Ok(metadata), _) if metadata.len() == 0 => prompt_yes!( + match (stdin_ok, options.interactive) { + (false, InteractiveMode::PromptProtected) => true, + _ if is_writable_metadata(metadata) => true, + _ if metadata.len() == 0 => prompt_yes!( "remove write-protected regular empty file {}?", path.quote() ), diff --git a/tests/by-util/test_rm.rs b/tests/by-util/test_rm.rs index 38230f2ad..d0a8bca2e 100644 --- a/tests/by-util/test_rm.rs +++ b/tests/by-util/test_rm.rs @@ -1217,3 +1217,20 @@ fn test_progress_no_output_on_error() { .stderr_contains("cannot remove") .stderr_contains("No such file or directory"); } + +#[cfg(unix)] +#[test] +fn test_symlink_to_readonly_no_prompt() { + let (at, mut ucmd) = at_and_ucmd!(); + + at.touch("foo"); + at.set_mode("foo", 0o444); + at.symlink_file("foo", "bar"); + + ucmd.arg("---presume-input-tty") + .arg("bar") + .succeeds() + .no_stderr(); + + assert!(!at.symlink_exists("bar")); +} From 799de29ec1439401df47434a207d0180a686522b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=E1=BA=A3=20th=E1=BA=BF=20gi=E1=BB=9Bi=20l=C3=A0=20Rust?= <90588855+naoNao89@users.noreply.github.com> Date: Sun, 18 Jan 2026 06:02:57 +0700 Subject: [PATCH 32/32] fix: properly handle write errors for --version and --help output (#10223) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Modified ClapErrorWrapper to detect and report when printing --version/--help fails - Added test_version_help_dev_full to verify error handling - Fixes issue where cat --version > /dev/full silently succeeded instead of failing Addresses feedback from commit 9cc2e096d83a3fd419cbd8040b939d4b309f34a1 Co-authored-by: Cả thế giới là Rust --- src/uucore/src/lib/mods/error.rs | 37 ++++++++++++++++++++++------ tests/by-util/test_cat.rs | 16 ++++++++++++ tests/by-util/test_eintr_handling.rs | 6 ++--- 3 files changed, 48 insertions(+), 11 deletions(-) diff --git a/src/uucore/src/lib/mods/error.rs b/src/uucore/src/lib/mods/error.rs index ef270546c..d2239d128 100644 --- a/src/uucore/src/lib/mods/error.rs +++ b/src/uucore/src/lib/mods/error.rs @@ -55,8 +55,10 @@ // spell-checker:ignore uioerror rustdoc use std::{ + cell::Cell, error::Error, fmt::{Display, Formatter}, + io::Write, sync::atomic::{AtomicI32, Ordering}, }; @@ -700,6 +702,7 @@ impl From for Box { pub struct ClapErrorWrapper { code: i32, error: clap::Error, + print_failed: Cell, } /// Extension trait for `clap::Error` to adjust the exit code. @@ -710,13 +713,21 @@ pub trait UClapError { impl From for Box { fn from(e: clap::Error) -> Self { - Box::new(ClapErrorWrapper { code: 1, error: e }) + Box::new(ClapErrorWrapper { + code: 1, + error: e, + print_failed: Cell::new(false), + }) } } impl UClapError for clap::Error { fn with_exit_code(self, code: i32) -> ClapErrorWrapper { - ClapErrorWrapper { code, error: self } + ClapErrorWrapper { + code, + error: self, + print_failed: Cell::new(false), + } } } @@ -731,12 +742,11 @@ impl UClapError> impl UError for ClapErrorWrapper { fn code(&self) -> i32 { // If the error is a DisplayHelp or DisplayVersion variant, - // we don't want to apply the custom error code, but leave - // it 0. + // check if printing failed. If it did, return 1, otherwise 0. if let clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion = self.error.kind() { - 0 + i32::from(self.print_failed.get()) } else { self.code } @@ -748,9 +758,20 @@ impl Error for ClapErrorWrapper {} // This is abuse of the Display trait impl Display for ClapErrorWrapper { fn fmt(&self, _f: &mut Formatter<'_>) -> Result<(), std::fmt::Error> { - // Intentionally ignore the result - error.print() writes directly to stderr - // and we always return Ok(()) to satisfy Display's contract - let _ = self.error.print(); + // Check if printing succeeds. For DisplayHelp and DisplayVersion, + // error.print() writes to stdout, so we need to detect write failures + // (e.g., when stdout is /dev/full). + if let Err(print_fail) = self.error.print() { + // Mark that printing failed so code() can return the appropriate exit code + self.print_failed.set(true); + // Try to display this error to stderr, but ignore if that fails too + // since we're already in an error state. + let _ = writeln!(std::io::stderr(), "{}: {print_fail}", crate::util_name()); + // Mirror GNU behavior: when failing to print help or version, exit with error code. + // This avoids silent failures when stdout is full or closed. + set_exit_code(1); + } + // Always return Ok(()) to satisfy Display's contract and prevent panic Ok(()) } } diff --git a/tests/by-util/test_cat.rs b/tests/by-util/test_cat.rs index 2d35a2e25..7cb824f60 100644 --- a/tests/by-util/test_cat.rs +++ b/tests/by-util/test_cat.rs @@ -864,6 +864,22 @@ fn test_write_error_handling() { .stderr_contains("No space left on device"); } +#[test] +#[cfg(target_os = "linux")] +fn test_version_help_dev_full() { + use std::fs::OpenOptions; + + for option in ["--version", "--help"] { + let dev_full = OpenOptions::new().write(true).open("/dev/full").unwrap(); + + new_ucmd!() + .arg(option) + .set_stdout(dev_full) + .fails() + .stderr_contains("No space left on device"); + } +} + #[test] fn test_cat_eintr_handling() { // Test that cat properly handles EINTR (ErrorKind::Interrupted) during I/O operations diff --git a/tests/by-util/test_eintr_handling.rs b/tests/by-util/test_eintr_handling.rs index 313a69f63..f195ff582 100644 --- a/tests/by-util/test_eintr_handling.rs +++ b/tests/by-util/test_eintr_handling.rs @@ -11,9 +11,9 @@ //! # CI Integration //! EINTR handling tests are NOW visible in CI logs through integration tests: //! - `test_cat_eintr_handling` in `tests/by-util/test_cat.rs` -//! - `test_comm_eintr_handling` in `tests/by-util/test_comm.rs` +//! - `test_comm_eintr_handling` in `tests/by-util/test_comm.rs` //! - `test_od_eintr_handling` in `tests/by-util/test_od.rs` -//! +//! //! These integration tests use the mock utilities from this module to verify //! that each utility properly handles signal interruptions during I/O operations. //! Test results appear in CI logs under the "Test" steps when running `cargo nextest run`. @@ -171,7 +171,7 @@ mod tests { assert_eq!(n, 5); assert_eq!(&buf, b"hello"); - // Read rest of data without interruption + // Read rest of data without interruption let n = reader.read(&mut buf).unwrap(); assert_eq!(n, 5); assert_eq!(&buf, b" worl"); // Second chunk of "hello world"