diff --git a/.github/workflows/CICD.yml b/.github/workflows/CICD.yml index fac09b36a..7f667076a 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: @@ -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: | @@ -637,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 }} @@ -692,7 +687,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) @@ -861,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 }} @@ -897,6 +890,20 @@ 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) 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 + ${_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 if: steps.vars.outputs.DEPLOY && matrix.job.skip-publish != true @@ -904,18 +911,19 @@ 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 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: | ${{ steps.vars.outputs.STAGING }}/${{ steps.vars.outputs.PKG_NAME }} + docs.tar.zst env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -1264,13 +1272,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/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 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 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 diff --git a/.vscode/cspell.dictionaries/jargon.wordlist.txt b/.vscode/cspell.dictionaries/jargon.wordlist.txt index 33f495948..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 @@ -202,6 +203,7 @@ nofield # * clippy uninlined nonminimal +rposition # * CPU/hardware features ASIMD diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a7a006221..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. @@ -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 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/README.md b/README.md index 59a2e2c6b..450dae317 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.
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/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/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/src/uu/dirname/src/dirname.rs b/src/uu/dirname/src/dirname.rs index 3399b4a03..6bd91d910 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::Borrowed(&bytes[..slash_start]); } } + + // 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::Borrowed(result); + } + + // 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/src/uu/du/benches/du_bench.rs b/src/uu/du/benches/du_bench.rs index 8a2d29246..0b63ce9a2 100644 --- a/src/uu/du/benches/du_bench.rs +++ b/src/uu/du/benches/du_bench.rs @@ -61,25 +61,49 @@ 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 #[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/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/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/src/uu/numfmt/benches/numfmt_bench.rs b/src/uu/numfmt/benches/numfmt_bench.rs index d75bf4ad1..aed3fb035 100644 --- a/src/uu/numfmt/benches/numfmt_bench.rs +++ b/src/uu/numfmt/benches/numfmt_bench.rs @@ -10,96 +10,120 @@ 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 #[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(); - 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 #[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() { 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/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/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/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/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/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/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/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/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() = 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_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"); +} 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] 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"); +} diff --git a/tests/by-util/test_dd.rs b/tests/by-util/test_dd.rs index 3e53f9a59..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(); @@ -1814,6 +1818,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() { diff --git a/tests/by-util/test_dirname.rs b/tests/by-util/test_dirname.rs index c7cdf3a46..92350261d 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_")); @@ -105,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() @@ -156,7 +155,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!() @@ -182,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"); @@ -216,3 +208,56 @@ 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() { + 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() { + 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"); +} 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" 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"); +} 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!()); 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")); +} 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 */ 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(); +} 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() { 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!(); 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"); } } diff --git a/util/build-gnu.sh b/util/build-gnu.sh index 48beaeee5..febabc984 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}")" ### @@ -90,15 +91,15 @@ cd - export CARGOFLAGS # tell to make 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 install 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 install 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=more + make MULTICALL=y SKIP_UTILS=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 fi -[ -e "${UU_BUILD_DIR}/ginstall" ] || ln -vf "${UU_BUILD_DIR}/install" "${UU_BUILD_DIR}/ginstall" # The GNU tests use renamed install to ginstall +[ -e "${UU_BUILD_DIR}/ginstall" ] || ln -vf "${UU_BUILD_DIR}/install" "${UU_BUILD_DIR}/ginstall" # The GNU tests use ginstall ## cd "${path_GNU}" && echo "[ pwd:'${PWD}' ]" @@ -112,7 +113,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" @@ -120,7 +121,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 @@ -128,15 +129,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 @@ -151,12 +152,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 @@ -167,36 +168,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' @@ -207,101 +208,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 @@ -309,29 +310,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 @@ -340,19 +341,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/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" 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