From e3dffb5b0ab6aa734085de88beb60d480822f839 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beat=20K=C3=BCng?= Date: Sat, 6 Sep 2025 14:01:49 +0200 Subject: [PATCH 001/425] fix test_ls: remove test/dir/ prefix in test_ls_capabilities The output of 'ls test/cap_pos test/dir' looks like this: test/cap_pos test/dir: cap_neg cap_pos --- tests/by-util/test_ls.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/by-util/test_ls.rs b/tests/by-util/test_ls.rs index a8ce2c32c..f24ad37ff 100644 --- a/tests/by-util/test_ls.rs +++ b/tests/by-util/test_ls.rs @@ -6070,7 +6070,7 @@ fn test_ls_capabilities() { .succeeds() .stdout_contains("\x1b[30;41mtest/cap_pos") // spell-checker:disable-line .stdout_contains("\x1b[30;41mcap_pos") // spell-checker:disable-line - .stdout_does_not_contain("0;41mtest/dir/cap_neg"); // spell-checker:disable-line + .stdout_does_not_contain("0;41mcap_neg"); // spell-checker:disable-line } #[cfg(feature = "test_risky_names")] From 20370d24a3bc13069547d299f431f3a3bd20aa0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beat=20K=C3=BCng?= Date: Wed, 10 Sep 2025 22:15:34 +0200 Subject: [PATCH 002/425] ls: fix color output in combination with capabilities This includes the following fixes: - style_for_indicator uses fallbacks in case ca= is not defined in LS_COLORS. This led to the normal file style being used, even if there was a more specific one. The fix is to use has_color_for instead. There is also a new test for that case (a specific style for .txt). - has_acl always returns true on a system with SELinux, as each file has an acl named "security.selinux". This adds a more specific method has_security_cap_acl that checks for an acl named "security.capability". This matches the behavior of GNU's ls. These problems already existed when capability coloring was added in 9a97c18877691f0f17b0fc1b3c0d9b21d2354b14. --- src/uu/ls/src/colors.rs | 14 +++++------ src/uucore/Cargo.toml | 2 +- src/uucore/src/lib/features/fsxattr.rs | 33 +++++++++++++++++++++++++- tests/by-util/test_ls.rs | 27 ++++++++++++++------- 4 files changed, 58 insertions(+), 18 deletions(-) diff --git a/src/uu/ls/src/colors.rs b/src/uu/ls/src/colors.rs index a7f58d0fd..18333f8b3 100644 --- a/src/uu/ls/src/colors.rs +++ b/src/uu/ls/src/colors.rs @@ -156,18 +156,16 @@ pub(crate) fn color_name( #[cfg(all(unix, not(any(target_os = "android", target_os = "macos"))))] { // Skip checking capabilities if LS_COLORS=ca=: - let capabilities = style_manager + let has_capabilities = style_manager .colors - .style_for_indicator(Indicator::Capabilities); - - let has_capabilities = if capabilities.is_none() { - false - } else { - uucore::fsxattr::has_acl(path.p_buf.as_path()) - }; + .has_explicit_style_for(Indicator::Capabilities) + && uucore::fsxattr::has_security_cap_acl(path.p_buf.as_path()); // If the file has capabilities, use a specific style for `ca` (capabilities) if has_capabilities { + let capabilities = style_manager + .colors + .style_for_indicator(Indicator::Capabilities); return style_manager.apply_style(capabilities, name, wrap); } } diff --git a/src/uucore/Cargo.toml b/src/uucore/Cargo.toml index 43ed7fa68..61025ad7d 100644 --- a/src/uucore/Cargo.toml +++ b/src/uucore/Cargo.toml @@ -130,7 +130,7 @@ extendedbigdecimal = ["bigdecimal", "num-traits"] fast-inc = [] fs = ["dunce", "libc", "winapi-util", "windows-sys"] fsext = ["libc", "windows-sys"] -fsxattr = ["xattr"] +fsxattr = ["xattr", "itertools"] lines = [] feat_systemd_logind = ["utmpx", "libc"] format = [ diff --git a/src/uucore/src/lib/features/fsxattr.rs b/src/uucore/src/lib/features/fsxattr.rs index 1f1356ee5..5e7861106 100644 --- a/src/uucore/src/lib/features/fsxattr.rs +++ b/src/uucore/src/lib/features/fsxattr.rs @@ -6,8 +6,11 @@ // spell-checker:ignore getxattr posix_acl_default //! Set of functions to manage xattr on files and dirs +use itertools::Itertools; use std::collections::HashMap; -use std::ffi::OsString; +use std::ffi::{OsStr, OsString}; +#[cfg(unix)] +use std::os::unix::ffi::OsStrExt; use std::path::Path; /// Copies extended attributes (xattrs) from one file or directory to another. @@ -85,6 +88,26 @@ pub fn has_acl>(file: P) -> bool { }) } +/// Checks if a file has an Access Control List (ACL) named "security.capability" based on its extended attributes. +/// +/// # Arguments +/// +/// * `file` - A reference to the path of the file. +/// +/// # Returns +/// +/// `true` if the file has an extended attribute named "security.capability", `false` otherwise. +pub fn has_security_cap_acl>(file: P) -> bool { + // don't use exacl here, it is doing more getxattr call then needed + xattr::list_deref(file).is_ok_and(|mut acl| { + #[cfg(unix)] + return acl.contains(OsStr::from_bytes(b"security.capability")); + + #[cfg(not(unix))] + return false; + }) +} + /// Returns the permissions bits of a file or directory which has Access Control List (ACL) entries based on its /// extended attributes (Only works for linux) /// @@ -240,6 +263,7 @@ mod tests { File::create(&file_path).unwrap(); + // FIXME: this fails on a system that uses SELinux assert!(!has_acl(&file_path)); let test_attr = "user.test_acl"; @@ -247,5 +271,12 @@ mod tests { xattr::set(&file_path, test_attr, test_value).unwrap(); assert!(has_acl(&file_path)); + assert!(!has_security_cap_acl(&file_path)); + + let test_attr = "security.capability"; + let test_value = b""; + xattr::set(&file_path, test_attr, test_value).unwrap(); + + assert!(has_security_cap_acl(&file_path)); } } diff --git a/tests/by-util/test_ls.rs b/tests/by-util/test_ls.rs index f24ad37ff..9c92e71d7 100644 --- a/tests/by-util/test_ls.rs +++ b/tests/by-util/test_ls.rs @@ -6041,11 +6041,11 @@ fn test_ls_capabilities() { } at.mkdir("test"); at.mkdir("test/dir"); - at.touch("test/cap_pos"); - at.touch("test/dir/cap_neg"); - at.touch("test/dir/cap_pos"); + at.touch("test/cap_pos.txt"); + at.touch("test/dir/cap_neg.txt"); + at.touch("test/dir/cap_pos.txt"); - let files = ["test/cap_pos", "test/dir/cap_pos"]; + let files = ["test/cap_pos.txt", "test/dir/cap_pos.txt"]; for file in &files { scene .cmd("sudo") @@ -6065,12 +6065,23 @@ fn test_ls_capabilities() { .ucmd() .env("LS_COLORS", ls_colors) .arg("--color=always") - .arg("test/cap_pos") + .arg("test/cap_pos.txt") .arg("test/dir") .succeeds() - .stdout_contains("\x1b[30;41mtest/cap_pos") // spell-checker:disable-line - .stdout_contains("\x1b[30;41mcap_pos") // spell-checker:disable-line - .stdout_does_not_contain("0;41mcap_neg"); // spell-checker:disable-line + .stdout_contains("\x1b[30;41mtest/cap_pos.txt") // spell-checker:disable-line + .stdout_contains("\x1b[30;41mcap_pos.txt") // spell-checker:disable-line + .stdout_does_not_contain("0;41mcap_neg.txt"); // spell-checker:disable-line + + // If ca= is not defined, ensure the specific style (.txt) for the file is used + let ls_colors = "di=:no=30;41:*.txt=31;41"; + + scene + .ucmd() + .env("LS_COLORS", ls_colors) + .arg("--color=always") + .arg("test/cap_pos.txt") + .succeeds() + .stdout_contains("\x1b[31;41mtest/cap_pos.txt"); // spell-checker:disable-line } #[cfg(feature = "test_risky_names")] From 8c7434cf3679367f9cf52a0d1b14c99761a50764 Mon Sep 17 00:00:00 2001 From: Alexandre Fresnais Date: Sun, 30 Nov 2025 00:35:53 +0100 Subject: [PATCH 003/425] install: add -U (unprivileged) option --- docs/src/extensions.md | 4 ++ src/uu/install/locales/en-US.ftl | 1 + src/uu/install/locales/fr-FR.ftl | 1 + src/uu/install/src/install.rs | 72 +++++++++++++++++++++----------- tests/by-util/test_install.rs | 32 ++++++++++++++ 5 files changed, 85 insertions(+), 25 deletions(-) diff --git a/docs/src/extensions.md b/docs/src/extensions.md index 9f82833cf..ca4a67edb 100644 --- a/docs/src/extensions.md +++ b/docs/src/extensions.md @@ -204,3 +204,7 @@ With `-U`/`--no-utf8`, you can interpret input files as 8-bit ASCII rather than ## `expand` `expand` also offers the `-U`/`--no-utf8` option to interpret input files as 8-bit ASCII instead of UTF-8. + +## `install` + +`install` offers FreeBSD's `-U` unprivileged option to not change the owner, the group, or the file flags of the destination. diff --git a/src/uu/install/locales/en-US.ftl b/src/uu/install/locales/en-US.ftl index 344301666..0b16dbb56 100644 --- a/src/uu/install/locales/en-US.ftl +++ b/src/uu/install/locales/en-US.ftl @@ -19,6 +19,7 @@ install-help-verbose = explain what is being done install-help-preserve-context = preserve security context install-help-context = set security context of files and directories install-help-default-context = set SELinux security context of destination file and each created directory to default type +install-help-unprivileged = do not require elevated privileges to change the owner, the group, or the file flags of the destination # Error messages install-error-dir-needs-arg = { $util_name } with -d requires at least one argument. diff --git a/src/uu/install/locales/fr-FR.ftl b/src/uu/install/locales/fr-FR.ftl index 0a28d9a6f..3a6e0a0c6 100644 --- a/src/uu/install/locales/fr-FR.ftl +++ b/src/uu/install/locales/fr-FR.ftl @@ -19,6 +19,7 @@ install-help-verbose = expliquer ce qui est fait install-help-preserve-context = préserver le contexte de sécurité install-help-context = définir le contexte de sécurité des fichiers et répertoires install-help-default-context = définir le contexte de sécurité SELinux du fichier de destination et de chaque répertoire créé au type par défaut +install-help-unprivileged = ne pas nécessiter de privilèges élevés pour changer le propriétaire, le groupe ou les attributs du fichier de destination # Messages d'erreur install-error-dir-needs-arg = { $util_name } avec -d nécessite au moins un argument. diff --git a/src/uu/install/src/install.rs b/src/uu/install/src/install.rs index 582eb91ac..94fdeb891 100644 --- a/src/uu/install/src/install.rs +++ b/src/uu/install/src/install.rs @@ -62,6 +62,7 @@ pub struct Behavior { preserve_context: bool, context: Option, default_context: bool, + unprivileged: bool, } #[derive(Error, Debug)] @@ -163,6 +164,7 @@ static OPT_VERBOSE: &str = "verbose"; static OPT_PRESERVE_CONTEXT: &str = "preserve-context"; static OPT_CONTEXT: &str = "context"; static OPT_DEFAULT_CONTEXT: &str = "default-context"; +static OPT_UNPRIVILEGED: &str = "unprivileged"; static ARG_FILES: &str = "files"; @@ -317,6 +319,13 @@ pub fn uu_app() -> Command { .value_hint(clap::ValueHint::AnyPath) .value_parser(clap::value_parser!(OsString)), ) + .arg( + Arg::new(OPT_UNPRIVILEGED) + .short('U') + .long(OPT_UNPRIVILEGED) + .help(translate!("install-help-unprivileged")) + .action(ArgAction::SetTrue), + ) } /// Determine behavior, given command line arguments. @@ -416,6 +425,7 @@ fn behavior(matches: &ArgMatches) -> UResult { let context = matches.get_one::(OPT_CONTEXT).cloned(); let default_context = matches.get_flag(OPT_DEFAULT_CONTEXT); + let unprivileged = matches.get_flag(OPT_UNPRIVILEGED); Ok(Behavior { main_function, @@ -439,6 +449,7 @@ fn behavior(matches: &ArgMatches) -> UResult { preserve_context: matches.get_flag(OPT_PRESERVE_CONTEXT), context, default_context, + unprivileged, }) } @@ -479,7 +490,7 @@ fn directory(paths: &[OsString], b: &Behavior) -> UResult<()> { // Set SELinux context for all created directories if needed #[cfg(feature = "selinux")] - if b.context.is_some() || b.default_context { + if should_set_selinux_context(b) { let context = get_context_for_selinux(b); set_selinux_context_for_directories_install(path_to_create.as_path(), context); } @@ -498,15 +509,17 @@ fn directory(paths: &[OsString], b: &Behavior) -> UResult<()> { continue; } - show_if_err!(chown_optional_user_group(path, b)); + if !b.unprivileged { + show_if_err!(chown_optional_user_group(path, b)); - // Set SELinux context for directory if needed - #[cfg(feature = "selinux")] - if b.default_context { - show_if_err!(set_selinux_default_context(path)); - } else if b.context.is_some() { - let context = get_context_for_selinux(b); - show_if_err!(set_selinux_security_context(path, context)); + // Set SELinux context for directory if needed + #[cfg(feature = "selinux")] + if b.default_context { + show_if_err!(set_selinux_default_context(path)); + } else if b.context.is_some() { + let context = get_context_for_selinux(b); + show_if_err!(set_selinux_security_context(path, context)); + } } } // If the exit code was set, or show! has been called at least once @@ -628,7 +641,7 @@ fn standard(mut paths: Vec, b: &Behavior) -> UResult<()> { // Set SELinux context for all created directories if needed #[cfg(feature = "selinux")] - if b.context.is_some() || b.default_context { + if should_set_selinux_context(b) { let context = get_context_for_selinux(b); set_selinux_context_for_directories_install(to_create, context); } @@ -918,7 +931,9 @@ fn set_ownership_and_permissions(to: &Path, b: &Behavior) -> UResult<()> { return Err(InstallError::ChmodFailed(to.to_path_buf()).into()); } - chown_optional_user_group(to, b)?; + if !b.unprivileged { + chown_optional_user_group(to, b)?; + } Ok(()) } @@ -984,16 +999,18 @@ fn copy(from: &Path, to: &Path, b: &Behavior) -> UResult<()> { } #[cfg(feature = "selinux")] - if b.preserve_context { - uucore::selinux::preserve_security_context(from, to) - .map_err(|e| InstallError::SelinuxContextFailed(e.to_string()))?; - } else if b.default_context { - set_selinux_default_context(to) - .map_err(|e| InstallError::SelinuxContextFailed(e.to_string()))?; - } else if b.context.is_some() { - let context = get_context_for_selinux(b); - set_selinux_security_context(to, context) - .map_err(|e| InstallError::SelinuxContextFailed(e.to_string()))?; + if !b.unprivileged { + if b.preserve_context { + uucore::selinux::preserve_security_context(from, to) + .map_err(|e| InstallError::SelinuxContextFailed(e.to_string()))?; + } else if b.default_context { + set_selinux_default_context(to) + .map_err(|e| InstallError::SelinuxContextFailed(e.to_string()))?; + } else if b.context.is_some() { + let context = get_context_for_selinux(b); + set_selinux_security_context(to, context) + .map_err(|e| InstallError::SelinuxContextFailed(e.to_string()))?; + } } if b.verbose { @@ -1022,6 +1039,11 @@ fn get_context_for_selinux(b: &Behavior) -> Option<&String> { } } +#[cfg(feature = "selinux")] +fn should_set_selinux_context(b: &Behavior) -> bool { + !b.unprivileged && (b.context.is_some() || b.default_context) +} + /// Check if a file needs to be copied due to ownership differences when no explicit group is specified. /// Returns true if the destination file's ownership would differ from what it should be after installation. fn needs_copy_for_ownership(to: &Path, to_meta: &fs::Metadata) -> bool { @@ -1113,7 +1135,7 @@ fn need_copy(from: &Path, to: &Path, b: &Behavior) -> bool { } #[cfg(feature = "selinux")] - if b.preserve_context && contexts_differ(from, to) { + if !b.unprivileged && b.preserve_context && contexts_differ(from, to) { return true; } @@ -1121,17 +1143,17 @@ fn need_copy(from: &Path, to: &Path, b: &Behavior) -> bool { // Check if the owner ID is specified and differs from the destination file's owner. if let Some(owner_id) = b.owner_id { - if owner_id != to_meta.uid() { + if !b.unprivileged && owner_id != to_meta.uid() { return true; } } // Check if the group ID is specified and differs from the destination file's group. if let Some(group_id) = b.group_id { - if group_id != to_meta.gid() { + if !b.unprivileged && group_id != to_meta.gid() { return true; } - } else if needs_copy_for_ownership(to, &to_meta) { + } else if !b.unprivileged && needs_copy_for_ownership(to, &to_meta) { return true; } diff --git a/tests/by-util/test_install.rs b/tests/by-util/test_install.rs index 2a2e7d670..cad4e425f 100644 --- a/tests/by-util/test_install.rs +++ b/tests/by-util/test_install.rs @@ -2489,3 +2489,35 @@ fn test_install_non_utf8_paths() { ucmd.arg("-D").arg(source_file).arg(&target_path).succeeds(); } + +#[test] +fn test_install_unprivileged_option_u_skips_chown() { + // This test only makes sense when not running as root. + if geteuid() == 0 { + return; + } + + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + let src = "source_file"; + let dst_fail = "target_fail"; + let dst_ok = "target_ok"; + at.touch(src); + + // Without -U, attempting to chown to root should fail for an unprivileged user. + let res = scene.ucmd().args(&["--owner=root", src, dst_fail]).run(); + + res.failure(); + + // With -U, install should not require elevated privileges for owner/group changes, + // meaning it should succeed and leave ownership as the current user. + scene + .ucmd() + .args(&["-U", "--owner=root", src, dst_ok]) + .succeeds() + .no_stderr(); + + assert!(at.file_exists(dst_ok)); + assert_eq!(at.metadata(dst_ok).uid(), geteuid()); +} From 650fe9fd81b3d0b7dc674bcd1e75ee5dac8231b1 Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Tue, 30 Dec 2025 00:47:33 +0900 Subject: [PATCH 004/425] Merge pull request #9567 from oech3/patch-3 build-gnu.sh: Use MULTICALL=y and skip not used utils for faster build --- util/build-gnu.sh | 35 ++++++++++++++++++----------------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/util/build-gnu.sh b/util/build-gnu.sh index c4bfac560..15c72720f 100755 --- a/util/build-gnu.sh +++ b/util/build-gnu.sh @@ -4,7 +4,7 @@ # spell-checker:ignore (paths) abmon deref discrim eacces getlimits getopt ginstall inacc infloop inotify reflink ; (misc) INT_OFLOW OFLOW # spell-checker:ignore baddecode submodules xstrtol distros ; (vars/env) SRCDIR vdir rcexp xpart dired OSTYPE ; (utils) greadlink gsed multihardlink texinfo CARGOFLAGS -# spell-checker:ignore openat TOCTOU CFLAGS tmpfs +# spell-checker:ignore openat TOCTOU CFLAGS tmpfs gnproc set -e @@ -87,18 +87,19 @@ else fi cd - -# Pass the feature flags to make, which will pass them to cargo -"${MAKE}" PROFILE="${PROFILE}" SKIP_UTILS=more CARGOFLAGS="${CARGO_FEATURE_FLAGS}" -# min test for SELinux -[ "${SELINUX_ENABLED}" = 1 ] && touch g && "${PROFILE}"/stat -c%C g && rm g - -cp "${UU_BUILD_DIR}/install" "${UU_BUILD_DIR}/ginstall" # The GNU tests rename this script before running, to avoid confusion with the make target -# Create *sum binaries -for sum in b2sum md5sum sha1sum sha224sum sha256sum sha384sum sha512sum; do - sum_path="${UU_BUILD_DIR}/${sum}" - test -f "${sum_path}" || (cd ${UU_BUILD_DIR} && ln -s "hashsum" "${sum}") -done -test -f "${UU_BUILD_DIR}/[" || (cd ${UU_BUILD_DIR} && ln -s "test" "[") +# bug: seq with MULTICALL=y breaks env-signal-handler.sh + "${MAKE}" UTILS="install seq" PROFILE="${PROFILE}" CARGOFLAGS="${CARGO_FEATURE_FLAGS}" +ln -vf "${UU_BUILD_DIR}/install" "${UU_BUILD_DIR}/ginstall" # The GNU tests use renamed install to ginstall +if [ "${SELINUX_ENABLED}" = 1 ];then + # Build few utils for SELinux for faster build. MULTICALL=y fails... + "${MAKE}" UTILS="cat chcon cp cut echo env groups id ln ls mkdir mkfifo mknod mktemp mv printf rm rmdir runcon stat test touch tr true uname wc whoami" PROFILE="${PROFILE}" CARGOFLAGS="${CARGO_FEATURE_FLAGS}" +else + # Use MULTICALL=y for faster build + "${MAKE}" MULTICALL=y SKIP_UTILS="install more seq" PROFILE="${PROFILE}" CARGOFLAGS="${CARGO_FEATURE_FLAGS}" + for binary in $("${UU_BUILD_DIR}"/coreutils --list) + do ln -vf "${UU_BUILD_DIR}/coreutils" "${UU_BUILD_DIR}/${binary}" + done +fi ## @@ -109,8 +110,7 @@ cd "${path_GNU}" && echo "[ pwd:'${PWD}' ]" for binary in $(./build-aux/gen-lists-of-programs.sh --list-progs); do bin_path="${UU_BUILD_DIR}/${binary}" test -f "${bin_path}" || { - echo "'${binary}' was not built with uutils, using the 'false' program" - cp "${UU_BUILD_DIR}/false" "${bin_path}" + cp -v /usr/bin/false "${bin_path}" } done @@ -135,8 +135,9 @@ else "${SED}" -i 's|diff -c|diff -u|g' tests/Coreutils.pm # Skip make if possible - # Use our nproc for *BSD and macOS - test -f src/getlimits || "${MAKE}" -j "$("${UU_BUILD_DIR}/nproc")" + # Use GNU nproc for *BSD and macOS + NPROC="$(command -v nproc||command -v gnproc)" + test -f src/getlimits || "${MAKE}" -j "$("${NPROC}")" cp -f src/getlimits "${UU_BUILD_DIR}" # Handle generated factor tests From b3d05b66f45ece8ab67c5986fa7bf95a3eeb7164 Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Tue, 30 Dec 2025 00:48:06 +0900 Subject: [PATCH 005/425] CICD.yml: Avoid no space left much more (#9907) * CICD.yml: Avoid no space left much more * Remove both of android and dotnet Co-authored-by: Sylvestre Ledru --------- Co-authored-by: Sylvestre Ledru --- .github/workflows/CICD.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/CICD.yml b/.github/workflows/CICD.yml index f2af93125..f0d2c8482 100644 --- a/.github/workflows/CICD.yml +++ b/.github/workflows/CICD.yml @@ -1194,7 +1194,8 @@ jobs: - name: build and test all features individually shell: bash run: | - command -v sudo && sudo rm -rf /usr/share/dotnet # avoid no space left + 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 From e12fe01117910a38548e12ac825458beb5ef0fb1 Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Tue, 30 Dec 2025 08:28:12 +0900 Subject: [PATCH 006/425] openbsd.yml: Replace cargo related cache deletion --- .github/workflows/openbsd.yml | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/.github/workflows/openbsd.yml b/.github/workflows/openbsd.yml index 8bb91566a..7a14240c8 100644 --- a/.github/workflows/openbsd.yml +++ b/.github/workflows/openbsd.yml @@ -47,7 +47,7 @@ jobs: prepare: | # Clean up disk space before installing packages df -h - rm -rf /usr/share/doc/* /usr/share/man/* /var/cache/* /tmp/* || true + rm -rf /usr/share/relink/* /usr/X11R6/* /usr/share/doc/* /usr/share/man/* || : pkg_add curl sudo-- jq coreutils bash rust rust-clippy rust-rustfmt llvm-- # Clean up package cache after installation pkg_delete -a || true @@ -115,8 +115,6 @@ jobs: fi # Clean to avoid to rsync back the files and free up disk space cargo clean - # Additional cleanup to free disk space - rm -rf ~/.cargo/registry/cache ~/.cargo/git/db || true if [ -n "\${FAIL_ON_FAULT}" ] && [ -n "\${FAULT}" ]; then exit 1 ; fi EOF @@ -144,10 +142,10 @@ jobs: prepare: | # Clean up disk space before installing packages df -h - rm -rf /usr/share/doc/* /usr/share/man/* /var/cache/* /tmp/* || true + rm -rf /usr/share/relink/* /usr/X11R6/* /usr/share/doc/* /usr/share/man/* || : pkg_add curl gmake sudo-- jq rust llvm-- # Clean up package cache after installation - pkg_delete -a || true + pkg_delete -a || : df -h run: | ## Prepare, build, and test @@ -197,8 +195,6 @@ jobs: cd "${WORKSPACE}" unset FAULT cargo build || FAULT=1 - # Clean build artifacts to save disk space before testing - rm -rf target/debug/build target/debug/incremental || true export PATH=~/.cargo/bin:${PATH} export RUST_BACKTRACE=1 export CARGO_TERM_COLOR=always @@ -216,6 +212,5 @@ jobs: # Clean to avoid to rsync back the files and free up disk space cargo clean # Additional cleanup to free disk space - rm -rf ~/.cargo/registry/cache ~/.cargo/git/db target/debug/deps target/release/deps || true if (test -n "\$FAULT"); then exit 1 ; fi EOF From f43602df2bd5cf401b2054998c6f12d422155a45 Mon Sep 17 00:00:00 2001 From: kdtie <59813592+nutthawit@users.noreply.github.com> Date: Tue, 30 Dec 2025 12:54:43 +0700 Subject: [PATCH 007/425] DEVELOPMENT.md: mention nightly requirement for code coverage (#9919) --- DEVELOPMENT.md | 1 + 1 file changed, 1 insertion(+) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 4f885e085..35291369c 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -262,6 +262,7 @@ To generate [gcov-based](https://github.com/mozilla/grcov#example-how-to-generat export CARGO_INCREMENTAL=0 export RUSTFLAGS="-Cinstrument-coverage -Ccodegen-units=1 -Copt-level=0 -Clink-dead-code -Coverflow-checks=off -Zpanic_abort_tests -Cpanic=abort" export RUSTDOCFLAGS="-Cpanic=abort" +export RUSTUP_TOOLCHAIN="nightly" cargo build # e.g., --features feat_os_unix cargo test # e.g., --features feat_os_unix test_pathchk grcov . -s . --binary-path ./target/debug/ -t html --branch --ignore-not-existing --ignore build.rs --excl-br-line "^\s*((debug_)?assert(_eq|_ne)?\#\[derive\()" -o ./target/debug/coverage/ From 77c349801486fc400b5ecde2bef850e61dbf2b5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=E1=BA=A3=20th=E1=BA=BF=20gi=E1=BB=9Bi=20l=C3=A0=20Rust?= <90588855+naoNao89@users.noreply.github.com> Date: Wed, 31 Dec 2025 00:37:12 +0700 Subject: [PATCH 008/425] feat(dd): add first benchmark suite for performance validation (#9136) * feat(dd): add comprehensive benchmark suite for O_DIRECT optimization - Create dd's first benchmark suite using divan framework - Benchmark various block sizes (4K, 8K, 64K, 1M) to measure performance - Test different dd scenarios: default, partial copy, skip, seek operations - Measure impact of separate input/output block sizes - All benchmarks use status=none to avoid output noise - Benchmarks verify the O_DIRECT buffer alignment optimization - Follows existing uutils benchmark patterns and conventions * bench(dd): increase dataset sizes for consistent timing Increase benchmark dataset sizes to achieve consistent 100-300ms timing: - dd_copy_default: 16 -> 32 MB - dd_copy_4k_blocks: 16 -> 24 MB - dd_copy_64k_blocks: 16 -> 64 MB - dd_copy_1m_blocks: 16 -> 128 MB - dd_copy_separate_blocks: 16 -> 48 MB - dd_copy_partial: 16 -> 32 MB - dd_copy_with_skip: 16 -> 48 MB - dd_copy_with_seek: 16 -> 48 MB - dd_copy_8k_blocks: 16 -> 32 MB This ensures stable, repeatable benchmark measurements across different systems. --------- Co-authored-by: Sylvestre Ledru --- .github/workflows/benchmarks.yml | 1 + Cargo.lock | 2 + src/uu/dd/Cargo.toml | 9 ++ src/uu/dd/benches/dd_bench.rs | 266 +++++++++++++++++++++++++++++++ 4 files changed, 278 insertions(+) create mode 100644 src/uu/dd/benches/dd_bench.rs diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index 205f6c1a2..1ffce535d 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -27,6 +27,7 @@ jobs: - { package: uu_cksum } - { package: uu_cp } - { package: uu_cut } + - { package: uu_dd } - { package: uu_du } - { package: uu_expand } - { package: uu_fold } diff --git a/Cargo.lock b/Cargo.lock index 277321cd9..909c03510 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3201,11 +3201,13 @@ name = "uu_dd" version = "0.5.0" dependencies = [ "clap", + "codspeed-divan-compat", "fluent", "gcd", "libc", "nix", "signal-hook", + "tempfile", "thiserror 2.0.17", "uucore", ] diff --git a/src/uu/dd/Cargo.toml b/src/uu/dd/Cargo.toml index d1ac79fb5..6dbc6c2ff 100644 --- a/src/uu/dd/Cargo.toml +++ b/src/uu/dd/Cargo.toml @@ -37,3 +37,12 @@ nix = { workspace = true, features = ["fs"] } [[bin]] name = "dd" path = "src/main.rs" + +[dev-dependencies] +divan = { workspace = true } +tempfile = { workspace = true } +uucore = { workspace = true, features = ["benchmark"] } + +[[bench]] +name = "dd_bench" +harness = false diff --git a/src/uu/dd/benches/dd_bench.rs b/src/uu/dd/benches/dd_bench.rs new file mode 100644 index 000000000..0a86f5de1 --- /dev/null +++ b/src/uu/dd/benches/dd_bench.rs @@ -0,0 +1,266 @@ +// This file is part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +use divan::{Bencher, black_box}; +use std::fs::{self, File}; +use std::io::Write; +use std::path::Path; +use tempfile::TempDir; +use uu_dd::uumain; +use uucore::benchmark::run_util_function; + +fn create_test_file(path: &Path, size_mb: usize) { + let buffer = vec![b'x'; size_mb * 1024 * 1024]; + let mut file = File::create(path).unwrap(); + file.write_all(&buffer).unwrap(); + file.sync_all().unwrap(); +} + +fn remove_file(path: &Path) { + if path.exists() { + fs::remove_file(path).unwrap(); + } +} + +/// Benchmark basic dd copy with default settings +#[divan::bench(args = [32])] +fn dd_copy_default(bencher: Bencher, size_mb: usize) { + let temp_dir = TempDir::new().unwrap(); + let input = temp_dir.path().join("input.bin"); + let output = temp_dir.path().join("output.bin"); + + create_test_file(&input, size_mb); + + let input_str = input.to_str().unwrap(); + let output_str = output.to_str().unwrap(); + + bencher.bench(|| { + remove_file(&output); + black_box(run_util_function( + uumain, + &[ + &format!("if={input_str}"), + &format!("of={output_str}"), + "status=none", + ], + )); + }); +} + +/// Benchmark dd copy with 4KB block size (common page size) +#[divan::bench(args = [24])] +fn dd_copy_4k_blocks(bencher: Bencher, size_mb: usize) { + let temp_dir = TempDir::new().unwrap(); + let input = temp_dir.path().join("input.bin"); + let output = temp_dir.path().join("output.bin"); + + create_test_file(&input, size_mb); + + let input_str = input.to_str().unwrap(); + let output_str = output.to_str().unwrap(); + + bencher.bench(|| { + remove_file(&output); + black_box(run_util_function( + uumain, + &[ + &format!("if={input_str}"), + &format!("of={output_str}"), + "bs=4K", + "status=none", + ], + )); + }); +} + +/// Benchmark dd copy with 64KB block size +#[divan::bench(args = [64])] +fn dd_copy_64k_blocks(bencher: Bencher, size_mb: usize) { + let temp_dir = TempDir::new().unwrap(); + let input = temp_dir.path().join("input.bin"); + let output = temp_dir.path().join("output.bin"); + + create_test_file(&input, size_mb); + + let input_str = input.to_str().unwrap(); + let output_str = output.to_str().unwrap(); + + bencher.bench(|| { + remove_file(&output); + black_box(run_util_function( + uumain, + &[ + &format!("if={input_str}"), + &format!("of={output_str}"), + "bs=64K", + "status=none", + ], + )); + }); +} + +/// Benchmark dd copy with 1MB block size +#[divan::bench(args = [128])] +fn dd_copy_1m_blocks(bencher: Bencher, size_mb: usize) { + let temp_dir = TempDir::new().unwrap(); + let input = temp_dir.path().join("input.bin"); + let output = temp_dir.path().join("output.bin"); + + create_test_file(&input, size_mb); + + let input_str = input.to_str().unwrap(); + let output_str = output.to_str().unwrap(); + + bencher.bench(|| { + remove_file(&output); + black_box(run_util_function( + uumain, + &[ + &format!("if={input_str}"), + &format!("of={output_str}"), + "bs=1M", + "status=none", + ], + )); + }); +} + +/// Benchmark dd copy with separate input and output block sizes +#[divan::bench(args = [48])] +fn dd_copy_separate_blocks(bencher: Bencher, size_mb: usize) { + let temp_dir = TempDir::new().unwrap(); + let input = temp_dir.path().join("input.bin"); + let output = temp_dir.path().join("output.bin"); + + create_test_file(&input, size_mb); + + let input_str = input.to_str().unwrap(); + let output_str = output.to_str().unwrap(); + + bencher.bench(|| { + remove_file(&output); + black_box(run_util_function( + uumain, + &[ + &format!("if={input_str}"), + &format!("of={output_str}"), + "ibs=8K", + "obs=16K", + "status=none", + ], + )); + }); +} + +/// Benchmark dd with count limit (partial copy) +#[divan::bench(args = [32])] +fn dd_copy_partial(bencher: Bencher, size_mb: usize) { + let temp_dir = TempDir::new().unwrap(); + let input = temp_dir.path().join("input.bin"); + let output = temp_dir.path().join("output.bin"); + + create_test_file(&input, size_mb); + + let input_str = input.to_str().unwrap(); + let output_str = output.to_str().unwrap(); + + bencher.bench(|| { + remove_file(&output); + black_box(run_util_function( + uumain, + &[ + &format!("if={input_str}"), + &format!("of={output_str}"), + "bs=4K", + "count=1024", + "status=none", + ], + )); + }); +} + +/// Benchmark dd with skip (seeking in input) +#[divan::bench(args = [48])] +fn dd_copy_with_skip(bencher: Bencher, size_mb: usize) { + let temp_dir = TempDir::new().unwrap(); + let input = temp_dir.path().join("input.bin"); + let output = temp_dir.path().join("output.bin"); + + create_test_file(&input, size_mb); + + let input_str = input.to_str().unwrap(); + let output_str = output.to_str().unwrap(); + + bencher.bench(|| { + remove_file(&output); + black_box(run_util_function( + uumain, + &[ + &format!("if={input_str}"), + &format!("of={output_str}"), + "bs=4K", + "skip=256", + "status=none", + ], + )); + }); +} + +/// Benchmark dd with seek (seeking in output) +#[divan::bench(args = [48])] +fn dd_copy_with_seek(bencher: Bencher, size_mb: usize) { + let temp_dir = TempDir::new().unwrap(); + let input = temp_dir.path().join("input.bin"); + let output = temp_dir.path().join("output.bin"); + + create_test_file(&input, size_mb); + + let input_str = input.to_str().unwrap(); + let output_str = output.to_str().unwrap(); + + bencher.bench(|| { + remove_file(&output); + black_box(run_util_function( + uumain, + &[ + &format!("if={input_str}"), + &format!("of={output_str}"), + "bs=4K", + "seek=256", + "status=none", + ], + )); + }); +} + +/// Benchmark dd with different block sizes for comparison +#[divan::bench(args = [32])] +fn dd_copy_8k_blocks(bencher: Bencher, size_mb: usize) { + let temp_dir = TempDir::new().unwrap(); + let input = temp_dir.path().join("input.bin"); + let output = temp_dir.path().join("output.bin"); + + create_test_file(&input, size_mb); + + let input_str = input.to_str().unwrap(); + let output_str = output.to_str().unwrap(); + + bencher.bench(|| { + remove_file(&output); + black_box(run_util_function( + uumain, + &[ + &format!("if={input_str}"), + &format!("of={output_str}"), + "bs=8K", + "status=none", + ], + )); + }); +} + +fn main() { + divan::main(); +} From 93efd1d883211fb36c58507f9c1b6ecfb1035c62 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 30 Dec 2025 17:38:43 +0000 Subject: [PATCH 009/425] chore(deps): update rust crate clap_complete to v4.5.64 --- Cargo.lock | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 909c03510..bda71b0f6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -367,9 +367,9 @@ dependencies = [ [[package]] name = "clap_complete" -version = "4.5.62" +version = "4.5.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "004eef6b14ce34759aa7de4aea3217e368f463f46a3ed3764ca4b5a4404003b4" +checksum = "4c0da80818b2d95eca9aa614a30783e42f62bf5fdfee24e68cfb960b071ba8d1" dependencies = [ "clap", ] @@ -1575,7 +1575,7 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde_core", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1873,7 +1873,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2439,7 +2439,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.11.0", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2745,7 +2745,7 @@ dependencies = [ "getrandom 0.3.3", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4410,7 +4410,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] From fbc0635110afb15fbcd38232cc9ca08aa4022fae Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 30 Dec 2025 17:38:49 +0000 Subject: [PATCH 010/425] chore(deps): update rust crate self_cell to v1.2.2 --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 909c03510..5c62830e0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2471,9 +2471,9 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "self_cell" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16c2f82143577edb4921b71ede051dac62ca3c16084e918bf7b40c96ae10eb33" +checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" [[package]] name = "selinux" From 98910ffc03e3927e9280c8e18e660ae2a6e97e45 Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Tue, 30 Dec 2025 18:43:36 +0100 Subject: [PATCH 011/425] benchmark: move some functions in uucore --- src/uu/cp/benches/cp_bench.rs | 26 +++--------- src/uu/dd/benches/dd_bench.rs | 54 +++++++++--------------- src/uucore/src/lib/features/benchmark.rs | 40 ++++++++++++++++++ 3 files changed, 64 insertions(+), 56 deletions(-) diff --git a/src/uu/cp/benches/cp_bench.rs b/src/uu/cp/benches/cp_bench.rs index ba29596d9..d673c14e4 100644 --- a/src/uu/cp/benches/cp_bench.rs +++ b/src/uu/cp/benches/cp_bench.rs @@ -4,24 +4,11 @@ // file that was distributed with this source code. use divan::{Bencher, black_box}; -use std::fs::{self, File}; -use std::io::Write; +use std::fs; use std::path::Path; use tempfile::TempDir; use uu_cp::uumain; -use uucore::benchmark::{fs_tree, run_util_function}; - -fn remove_path(path: &Path) { - if !path.exists() { - return; - } - - if path.is_dir() { - fs::remove_dir_all(path).unwrap(); - } else { - fs::remove_file(path).unwrap(); - } -} +use uucore::benchmark::{binary_data, fs_tree, fs_utils, run_util_function}; fn bench_cp_directory(bencher: Bencher, args: &[&str], setup_source: F) where @@ -38,7 +25,7 @@ where let dest_str = dest.to_str().unwrap(); bencher.bench(|| { - remove_path(&dest); + fs_utils::remove_path(&dest); let mut full_args = Vec::with_capacity(args.len() + 2); full_args.extend_from_slice(args); @@ -99,16 +86,13 @@ fn cp_large_file(bencher: Bencher, size_mb: usize) { let source = temp_dir.path().join("source.bin"); let dest = temp_dir.path().join("dest.bin"); - let buffer = vec![b'x'; size_mb * 1024 * 1024]; - let mut file = File::create(&source).unwrap(); - file.write_all(&buffer).unwrap(); - file.sync_all().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(|| { - remove_path(&dest); + fs_utils::remove_path(&dest); black_box(run_util_function(uumain, &[source_str, dest_str])); }); diff --git a/src/uu/dd/benches/dd_bench.rs b/src/uu/dd/benches/dd_bench.rs index 0a86f5de1..6e11ee7fe 100644 --- a/src/uu/dd/benches/dd_bench.rs +++ b/src/uu/dd/benches/dd_bench.rs @@ -4,25 +4,9 @@ // file that was distributed with this source code. use divan::{Bencher, black_box}; -use std::fs::{self, File}; -use std::io::Write; -use std::path::Path; use tempfile::TempDir; use uu_dd::uumain; -use uucore::benchmark::run_util_function; - -fn create_test_file(path: &Path, size_mb: usize) { - let buffer = vec![b'x'; size_mb * 1024 * 1024]; - let mut file = File::create(path).unwrap(); - file.write_all(&buffer).unwrap(); - file.sync_all().unwrap(); -} - -fn remove_file(path: &Path) { - if path.exists() { - fs::remove_file(path).unwrap(); - } -} +use uucore::benchmark::{binary_data, fs_utils, run_util_function}; /// Benchmark basic dd copy with default settings #[divan::bench(args = [32])] @@ -31,13 +15,13 @@ fn dd_copy_default(bencher: Bencher, size_mb: usize) { let input = temp_dir.path().join("input.bin"); let output = temp_dir.path().join("output.bin"); - create_test_file(&input, size_mb); + binary_data::create_file(&input, size_mb, b'x'); let input_str = input.to_str().unwrap(); let output_str = output.to_str().unwrap(); bencher.bench(|| { - remove_file(&output); + fs_utils::remove_path(&output); black_box(run_util_function( uumain, &[ @@ -56,13 +40,13 @@ fn dd_copy_4k_blocks(bencher: Bencher, size_mb: usize) { let input = temp_dir.path().join("input.bin"); let output = temp_dir.path().join("output.bin"); - create_test_file(&input, size_mb); + binary_data::create_file(&input, size_mb, b'x'); let input_str = input.to_str().unwrap(); let output_str = output.to_str().unwrap(); bencher.bench(|| { - remove_file(&output); + fs_utils::remove_path(&output); black_box(run_util_function( uumain, &[ @@ -82,13 +66,13 @@ fn dd_copy_64k_blocks(bencher: Bencher, size_mb: usize) { let input = temp_dir.path().join("input.bin"); let output = temp_dir.path().join("output.bin"); - create_test_file(&input, size_mb); + binary_data::create_file(&input, size_mb, b'x'); let input_str = input.to_str().unwrap(); let output_str = output.to_str().unwrap(); bencher.bench(|| { - remove_file(&output); + fs_utils::remove_path(&output); black_box(run_util_function( uumain, &[ @@ -108,13 +92,13 @@ fn dd_copy_1m_blocks(bencher: Bencher, size_mb: usize) { let input = temp_dir.path().join("input.bin"); let output = temp_dir.path().join("output.bin"); - create_test_file(&input, size_mb); + binary_data::create_file(&input, size_mb, b'x'); let input_str = input.to_str().unwrap(); let output_str = output.to_str().unwrap(); bencher.bench(|| { - remove_file(&output); + fs_utils::remove_path(&output); black_box(run_util_function( uumain, &[ @@ -134,13 +118,13 @@ fn dd_copy_separate_blocks(bencher: Bencher, size_mb: usize) { let input = temp_dir.path().join("input.bin"); let output = temp_dir.path().join("output.bin"); - create_test_file(&input, size_mb); + binary_data::create_file(&input, size_mb, b'x'); let input_str = input.to_str().unwrap(); let output_str = output.to_str().unwrap(); bencher.bench(|| { - remove_file(&output); + fs_utils::remove_path(&output); black_box(run_util_function( uumain, &[ @@ -161,13 +145,13 @@ fn dd_copy_partial(bencher: Bencher, size_mb: usize) { let input = temp_dir.path().join("input.bin"); let output = temp_dir.path().join("output.bin"); - create_test_file(&input, size_mb); + binary_data::create_file(&input, size_mb, b'x'); let input_str = input.to_str().unwrap(); let output_str = output.to_str().unwrap(); bencher.bench(|| { - remove_file(&output); + fs_utils::remove_path(&output); black_box(run_util_function( uumain, &[ @@ -188,13 +172,13 @@ fn dd_copy_with_skip(bencher: Bencher, size_mb: usize) { let input = temp_dir.path().join("input.bin"); let output = temp_dir.path().join("output.bin"); - create_test_file(&input, size_mb); + binary_data::create_file(&input, size_mb, b'x'); let input_str = input.to_str().unwrap(); let output_str = output.to_str().unwrap(); bencher.bench(|| { - remove_file(&output); + fs_utils::remove_path(&output); black_box(run_util_function( uumain, &[ @@ -215,13 +199,13 @@ fn dd_copy_with_seek(bencher: Bencher, size_mb: usize) { let input = temp_dir.path().join("input.bin"); let output = temp_dir.path().join("output.bin"); - create_test_file(&input, size_mb); + binary_data::create_file(&input, size_mb, b'x'); let input_str = input.to_str().unwrap(); let output_str = output.to_str().unwrap(); bencher.bench(|| { - remove_file(&output); + fs_utils::remove_path(&output); black_box(run_util_function( uumain, &[ @@ -242,13 +226,13 @@ fn dd_copy_8k_blocks(bencher: Bencher, size_mb: usize) { let input = temp_dir.path().join("input.bin"); let output = temp_dir.path().join("output.bin"); - create_test_file(&input, size_mb); + binary_data::create_file(&input, size_mb, b'x'); let input_str = input.to_str().unwrap(); let output_str = output.to_str().unwrap(); bencher.bench(|| { - remove_file(&output); + fs_utils::remove_path(&output); black_box(run_util_function( uumain, &[ diff --git a/src/uucore/src/lib/features/benchmark.rs b/src/uucore/src/lib/features/benchmark.rs index 306ffdc3d..8be0baf72 100644 --- a/src/uucore/src/lib/features/benchmark.rs +++ b/src/uucore/src/lib/features/benchmark.rs @@ -289,6 +289,46 @@ pub mod text_data { } } +/// Binary data generation utilities for benchmarking +pub mod binary_data { + use std::fs::File; + use std::io::Write; + use std::path::Path; + + /// Create a binary file filled with a repeated pattern + /// + /// Creates a file of the specified size (in MB) filled with the given byte pattern. + /// This is useful for benchmarking utilities that work with large binary files like dd, cp, etc. + pub fn create_file(path: &Path, size_mb: usize, pattern: u8) { + let buffer = vec![pattern; size_mb * 1024 * 1024]; + let mut file = File::create(path).unwrap(); + file.write_all(&buffer).unwrap(); + file.sync_all().unwrap(); + } +} + +/// Filesystem utilities for benchmarking +pub mod fs_utils { + use std::fs; + use std::path::Path; + + /// Remove a file or directory if it exists + /// + /// This is a convenience function for cleaning up between benchmark iterations. + /// It handles both files and directories, and is a no-op if the path doesn't exist. + pub fn remove_path(path: &Path) { + if !path.exists() { + return; + } + + if path.is_dir() { + fs::remove_dir_all(path).unwrap(); + } else { + fs::remove_file(path).unwrap(); + } + } +} + /// Filesystem tree generation utilities for benchmarking pub mod fs_tree { use std::fs::{self, File}; From 4f5e6653990c3439e3e0b78b4bc9c53a4804490c Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Tue, 30 Dec 2025 18:47:31 +0100 Subject: [PATCH 012/425] benchmark: don't pass the args in the divan function --- src/uu/dd/benches/dd_bench.rs | 45 +++++++++++++++++++++-------------- 1 file changed, 27 insertions(+), 18 deletions(-) diff --git a/src/uu/dd/benches/dd_bench.rs b/src/uu/dd/benches/dd_bench.rs index 6e11ee7fe..b08207e7e 100644 --- a/src/uu/dd/benches/dd_bench.rs +++ b/src/uu/dd/benches/dd_bench.rs @@ -9,8 +9,9 @@ use uu_dd::uumain; use uucore::benchmark::{binary_data, fs_utils, run_util_function}; /// Benchmark basic dd copy with default settings -#[divan::bench(args = [32])] -fn dd_copy_default(bencher: Bencher, size_mb: usize) { +#[divan::bench] +fn dd_copy_default(bencher: Bencher) { + let size_mb = 32; let temp_dir = TempDir::new().unwrap(); let input = temp_dir.path().join("input.bin"); let output = temp_dir.path().join("output.bin"); @@ -34,8 +35,9 @@ fn dd_copy_default(bencher: Bencher, size_mb: usize) { } /// Benchmark dd copy with 4KB block size (common page size) -#[divan::bench(args = [24])] -fn dd_copy_4k_blocks(bencher: Bencher, size_mb: usize) { +#[divan::bench] +fn dd_copy_4k_blocks(bencher: Bencher) { + let size_mb = 24; let temp_dir = TempDir::new().unwrap(); let input = temp_dir.path().join("input.bin"); let output = temp_dir.path().join("output.bin"); @@ -60,8 +62,9 @@ fn dd_copy_4k_blocks(bencher: Bencher, size_mb: usize) { } /// Benchmark dd copy with 64KB block size -#[divan::bench(args = [64])] -fn dd_copy_64k_blocks(bencher: Bencher, size_mb: usize) { +#[divan::bench] +fn dd_copy_64k_blocks(bencher: Bencher) { + let size_mb = 64; let temp_dir = TempDir::new().unwrap(); let input = temp_dir.path().join("input.bin"); let output = temp_dir.path().join("output.bin"); @@ -86,8 +89,9 @@ fn dd_copy_64k_blocks(bencher: Bencher, size_mb: usize) { } /// Benchmark dd copy with 1MB block size -#[divan::bench(args = [128])] -fn dd_copy_1m_blocks(bencher: Bencher, size_mb: usize) { +#[divan::bench] +fn dd_copy_1m_blocks(bencher: Bencher) { + let size_mb = 128; let temp_dir = TempDir::new().unwrap(); let input = temp_dir.path().join("input.bin"); let output = temp_dir.path().join("output.bin"); @@ -112,8 +116,9 @@ fn dd_copy_1m_blocks(bencher: Bencher, size_mb: usize) { } /// Benchmark dd copy with separate input and output block sizes -#[divan::bench(args = [48])] -fn dd_copy_separate_blocks(bencher: Bencher, size_mb: usize) { +#[divan::bench] +fn dd_copy_separate_blocks(bencher: Bencher) { + let size_mb = 48; let temp_dir = TempDir::new().unwrap(); let input = temp_dir.path().join("input.bin"); let output = temp_dir.path().join("output.bin"); @@ -139,8 +144,9 @@ fn dd_copy_separate_blocks(bencher: Bencher, size_mb: usize) { } /// Benchmark dd with count limit (partial copy) -#[divan::bench(args = [32])] -fn dd_copy_partial(bencher: Bencher, size_mb: usize) { +#[divan::bench] +fn dd_copy_partial(bencher: Bencher) { + let size_mb = 32; let temp_dir = TempDir::new().unwrap(); let input = temp_dir.path().join("input.bin"); let output = temp_dir.path().join("output.bin"); @@ -166,8 +172,9 @@ fn dd_copy_partial(bencher: Bencher, size_mb: usize) { } /// Benchmark dd with skip (seeking in input) -#[divan::bench(args = [48])] -fn dd_copy_with_skip(bencher: Bencher, size_mb: usize) { +#[divan::bench] +fn dd_copy_with_skip(bencher: Bencher) { + let size_mb = 48; let temp_dir = TempDir::new().unwrap(); let input = temp_dir.path().join("input.bin"); let output = temp_dir.path().join("output.bin"); @@ -193,8 +200,9 @@ fn dd_copy_with_skip(bencher: Bencher, size_mb: usize) { } /// Benchmark dd with seek (seeking in output) -#[divan::bench(args = [48])] -fn dd_copy_with_seek(bencher: Bencher, size_mb: usize) { +#[divan::bench] +fn dd_copy_with_seek(bencher: Bencher) { + let size_mb = 48; let temp_dir = TempDir::new().unwrap(); let input = temp_dir.path().join("input.bin"); let output = temp_dir.path().join("output.bin"); @@ -220,8 +228,9 @@ fn dd_copy_with_seek(bencher: Bencher, size_mb: usize) { } /// Benchmark dd with different block sizes for comparison -#[divan::bench(args = [32])] -fn dd_copy_8k_blocks(bencher: Bencher, size_mb: usize) { +#[divan::bench] +fn dd_copy_8k_blocks(bencher: Bencher) { + let size_mb = 32; let temp_dir = TempDir::new().unwrap(); let input = temp_dir.path().join("input.bin"); let output = temp_dir.path().join("output.bin"); From a15ca8f32c693b8182c8de3140b590f3b6de5f8c Mon Sep 17 00:00:00 2001 From: Etienne Cordonnier Date: Tue, 30 Dec 2025 22:54:50 +0100 Subject: [PATCH 013/425] nice: use Command::exec() instead of libc::execvp() (#9612) No need to use the unsafe `libc::execvp()`, the standard rust library provides the functionality via the safe function `Command::exec()`. Signed-off-by: Etienne Cordonnier Co-authored-by: Sylvestre Ledru --- src/uu/nice/src/nice.rs | 29 ++++++++++++----------------- 1 file changed, 12 insertions(+), 17 deletions(-) diff --git a/src/uu/nice/src/nice.rs b/src/uu/nice/src/nice.rs index 8e47e9d07..e68931287 100644 --- a/src/uu/nice/src/nice.rs +++ b/src/uu/nice/src/nice.rs @@ -3,13 +3,14 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore (ToDO) getpriority execvp setpriority nstr PRIO cstrs ENOENT +// spell-checker:ignore (ToDO) getpriority setpriority nstr PRIO use clap::{Arg, ArgAction, Command}; -use libc::{PRIO_PROCESS, c_char, c_int, execvp}; -use std::ffi::{CString, OsString}; -use std::io::{Error, Write}; -use std::ptr; +use libc::PRIO_PROCESS; +use std::ffi::OsString; +use std::io::{Error, ErrorKind, Write}; +use std::os::unix::process::CommandExt; +use std::process; use uucore::translate; use uucore::{ @@ -156,21 +157,15 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { } } - let cstrs: Vec = matches - .get_many::(options::COMMAND) - .unwrap() - .map(|x| CString::new(x.as_bytes()).unwrap()) - .collect(); + let mut cmd_iter = matches.get_many::(options::COMMAND).unwrap(); + let cmd = cmd_iter.next().unwrap(); + let args: Vec<&String> = cmd_iter.collect(); - let mut args: Vec<*const c_char> = cstrs.iter().map(|s| s.as_ptr()).collect(); - args.push(ptr::null::()); - unsafe { - execvp(args[0], args.as_mut_ptr()); - } + let err = process::Command::new(cmd).args(args).exec(); - show_error!("execvp: {}", Error::last_os_error()); + show_error!("{}: {}", cmd, err); - let exit_code = if Error::last_os_error().raw_os_error().unwrap() as c_int == libc::ENOENT { + let exit_code = if err.kind() == ErrorKind::NotFound { 127 } else { 126 From 28ac732bed0e6a3d3a50730cdb46143a24932401 Mon Sep 17 00:00:00 2001 From: WaterWhisperer Date: Wed, 31 Dec 2025 06:53:43 +0800 Subject: [PATCH 014/425] du: fix -l/--count-links option not counting hardlinks separately (#9884) * du: fix -l/--count-links option not counting hardlinks separately * du: add test for -l/--count-links counting hardlinks separately --------- Co-authored-by: Sylvestre Ledru --- src/uu/du/src/du.rs | 13 ++----------- tests/by-util/test_du.rs | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 11 deletions(-) diff --git a/src/uu/du/src/du.rs b/src/uu/du/src/du.rs index 5fd824d61..1b8084e2e 100644 --- a/src/uu/du/src/du.rs +++ b/src/uu/du/src/du.rs @@ -501,10 +501,7 @@ fn safe_du( // Handle inodes if let Some(inode) = this_stat.inode { - if seen_inodes.contains(&inode) && (!options.count_links || !options.all) { - if options.count_links && !options.all { - my_stat.inodes += 1; - } + if seen_inodes.contains(&inode) && !options.count_links { continue; } seen_inodes.insert(inode); @@ -660,13 +657,7 @@ fn du_regular( if let Some(inode) = this_stat.inode { // Check if the inode has been seen before and if we should skip it - if seen_inodes.contains(&inode) - && (!options.count_links || !options.all) - { - // If `count_links` is enabled and `all` is not, increment the inode count - if options.count_links && !options.all { - my_stat.inodes += 1; - } + if seen_inodes.contains(&inode) && !options.count_links { // Skip further processing for this inode continue; } diff --git a/tests/by-util/test_du.rs b/tests/by-util/test_du.rs index 01c612488..38d64d5b8 100644 --- a/tests/by-util/test_du.rs +++ b/tests/by-util/test_du.rs @@ -804,6 +804,44 @@ fn test_du_inodes_with_count_links_all() { assert_eq!(result_seq, ["1\td/d", "1\td/f", "1\td/h", "4\td"]); } +#[cfg(not(target_os = "android"))] +#[test] +fn test_du_count_links_hardlinks_separately() { + let ts = TestScenario::new(util_name!()); + let at = &ts.fixtures; + + at.mkdir("dir"); + at.touch("dir/file"); + at.hard_link("dir/file", "dir/hard_link"); + + let result_without_l = ts.ucmd().arg("-b").arg("dir").succeeds(); + let size_without_l: u64 = result_without_l + .stdout_str() + .split('\t') + .next() + .unwrap() + .trim() + .parse() + .unwrap(); + + for arg in ["-l", "--count-links"] { + let result_with_l = ts.ucmd().arg("-b").arg(arg).arg("dir").succeeds(); + let size_with_l: u64 = result_with_l + .stdout_str() + .split('\t') + .next() + .unwrap() + .trim() + .parse() + .unwrap(); + + assert!( + size_with_l >= size_without_l, + "With {arg}, size ({size_with_l}) should be >= size without -l ({size_without_l})" + ); + } +} + #[test] fn test_du_h_flag_empty_file() { new_ucmd!() From 2916d2b83f46feab527f809a3e5e03030f229fc8 Mon Sep 17 00:00:00 2001 From: Chris Dryden Date: Tue, 30 Dec 2025 18:04:42 -0500 Subject: [PATCH 015/425] cp: fix preserve-gid when canonicalize fails due to inaccessible parent dirs (#9803) Co-authored-by: Sylvestre Ledru --- src/uu/cp/src/cp.rs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/uu/cp/src/cp.rs b/src/uu/cp/src/cp.rs index 036e9f9ee..1502a7ada 100644 --- a/src/uu/cp/src/cp.rs +++ b/src/uu/cp/src/cp.rs @@ -2510,11 +2510,13 @@ fn copy_file( } if options.dereference(source_in_command_line) { - if let Ok(src) = canonicalize(source, MissingHandling::Normal, ResolveMode::Physical) { - if src.exists() { - copy_attributes(&src, dest, &options.attributes)?; - } - } + // Try to canonicalize, but if it fails (e.g., due to inaccessible parent directories), + // fall back to the original source path + let src_for_attrs = canonicalize(source, MissingHandling::Normal, ResolveMode::Physical) + .ok() + .filter(|p| p.exists()) + .unwrap_or_else(|| source.to_path_buf()); + copy_attributes(&src_for_attrs, dest, &options.attributes)?; } else if source_is_stream && !source.exists() { // Some stream files may not exist after we have copied it, // like anonymous pipes. Thus, we can't really copy its From d97bc73ea940f17e19d54fd1f8a12c93728c78d0 Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Tue, 30 Dec 2025 22:56:25 +0100 Subject: [PATCH 016/425] nice: simplify the code --- src/uu/nice/src/nice.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/uu/nice/src/nice.rs b/src/uu/nice/src/nice.rs index e68931287..fc1e9057b 100644 --- a/src/uu/nice/src/nice.rs +++ b/src/uu/nice/src/nice.rs @@ -163,7 +163,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { let err = process::Command::new(cmd).args(args).exec(); - show_error!("{}: {}", cmd, err); + show_error!("{cmd}: {err}"); let exit_code = if err.kind() == ErrorKind::NotFound { 127 From e33dadd45a88116c037a28cfeb36350dc6c25aeb Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Wed, 31 Dec 2025 22:24:35 +0900 Subject: [PATCH 017/425] Merge pull request #9943 from oech3/patch-4 GnuTests.yml: Stop manpage generation to reduce size of log --- util/build-gnu.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/util/build-gnu.sh b/util/build-gnu.sh index 15c72720f..734cde88c 100755 --- a/util/build-gnu.sh +++ b/util/build-gnu.sh @@ -125,6 +125,8 @@ if test -f gnu-built; then else # Disable useless checks "${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 CFLAGS="${CFLAGS} -pipe -O0 -s" ./configure -C --quiet --disable-gcc-warnings --disable-nls --disable-dependency-tracking --disable-bold-man-page-references \ --enable-single-binary=symlinks --enable-install-program="arch,kill,uptime,hostname" \ From 1ec809c0e1b327b620318ef2b12ae518de4322f4 Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Wed, 31 Dec 2025 22:25:02 +0900 Subject: [PATCH 018/425] CICD.yml: Avoid no space left again (#9939) --- .github/workflows/CICD.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/CICD.yml b/.github/workflows/CICD.yml index f0d2c8482..e6a7fd450 100644 --- a/.github/workflows/CICD.yml +++ b/.github/workflows/CICD.yml @@ -814,6 +814,8 @@ 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 }} From ef960d321768ac38f6d4fe02dae6dfb1deff6061 Mon Sep 17 00:00:00 2001 From: oech3 <> Date: Wed, 31 Dec 2025 22:39:51 +0900 Subject: [PATCH 019/425] GnuTests: Drop texinfo dep --- .github/workflows/GnuTests.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/GnuTests.yml b/.github/workflows/GnuTests.yml index 4d312388b..8716cf04f 100644 --- a/.github/workflows/GnuTests.yml +++ b/.github/workflows/GnuTests.yml @@ -2,7 +2,7 @@ name: GnuTests # spell-checker:ignore (abbrev/names) CodeCov gnulib GnuTests Swatinem # spell-checker:ignore (jargon) submodules devel -# spell-checker:ignore (libs/utils) autopoint chksum dpkg getenforce getlimits gperf lcov libexpect limactl pyinotify setenforce shopt texinfo valgrind libattr libcap taiki-e zstd cpio +# spell-checker:ignore (libs/utils) autopoint chksum dpkg getenforce getlimits gperf lcov libexpect limactl pyinotify setenforce shopt valgrind libattr libcap taiki-e zstd cpio # spell-checker:ignore (options) Ccodegen Coverflow Cpanic Zpanic # spell-checker:ignore (people) Dawid Dziurla * dawidd dtolnay # spell-checker:ignore (vars) FILESET SUBDIRS XPASS @@ -247,7 +247,7 @@ jobs: - name: Install dependencies in VM run: | lima sudo dnf -y update - lima sudo dnf -y install git autoconf autopoint bison texinfo gperf gcc gdb jq libacl-devel libattr-devel libcap-devel libselinux-devel attr rustup clang-devel texinfo-tex automake patch quilt + lima sudo dnf -y install git autoconf autopoint bison gperf gcc gdb jq libacl-devel libattr-devel libcap-devel libselinux-devel attr rustup clang-devel automake patch quilt lima rustup-init -y --default-toolchain stable - name: Copy the sources to VM run: | From d3484715bf310605b0a1e2182f315aad43d4b55e Mon Sep 17 00:00:00 2001 From: Daniel Hofstetter Date: Wed, 31 Dec 2025 18:14:10 +0100 Subject: [PATCH 020/425] GnuTests.yml: install Rust without rustfmt (#9947) --- .github/workflows/GnuTests.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/GnuTests.yml b/.github/workflows/GnuTests.yml index 8716cf04f..9298d3d85 100644 --- a/.github/workflows/GnuTests.yml +++ b/.github/workflows/GnuTests.yml @@ -47,7 +47,6 @@ jobs: - uses: dtolnay/rust-toolchain@master with: toolchain: stable - components: rustfmt - uses: Swatinem/rust-cache@v2 with: workspaces: "./uutils -> target" @@ -105,7 +104,7 @@ jobs: ## Build binaries cd 'uutils' env PROFILE=release-small bash util/build-gnu.sh - + - name: Save files for faster configure and skipping make uses: actions/cache/save@v5 if: always() && steps.cache-config-gnu.outputs.cache-hit != 'true' @@ -211,7 +210,6 @@ jobs: - uses: dtolnay/rust-toolchain@master with: toolchain: stable - components: rustfmt - uses: Swatinem/rust-cache@v2 with: workspaces: "./uutils -> target" @@ -331,7 +329,6 @@ jobs: - uses: dtolnay/rust-toolchain@master with: toolchain: stable - components: rustfmt - uses: Swatinem/rust-cache@v2 with: workspaces: "./uutils -> target" From e3be131bccd1c0e3d0e13898cf28f3a41db06860 Mon Sep 17 00:00:00 2001 From: cerdelen <95369756+cerdelen@users.noreply.github.com> Date: Wed, 31 Dec 2025 20:01:12 +0100 Subject: [PATCH 021/425] tac: fix error message (#9942) * tac: fix error message * tac: Remove obsolete error messages from locales --- src/uu/tac/locales/en-US.ftl | 2 +- src/uu/tac/locales/fr-FR.ftl | 2 +- src/uu/tac/src/error.rs | 6 +++--- src/uu/tac/src/tac.rs | 3 ++- tests/by-util/test_tac.rs | 2 +- 5 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/uu/tac/locales/en-US.ftl b/src/uu/tac/locales/en-US.ftl index 3c849c4d7..2632aa3db 100644 --- a/src/uu/tac/locales/en-US.ftl +++ b/src/uu/tac/locales/en-US.ftl @@ -6,7 +6,7 @@ tac-help-separator = use STRING as the separator instead of newline # Error messages tac-error-invalid-regex = invalid regular expression: { $error } -tac-error-invalid-argument = { $argument }: read error: Invalid argument +tac-error-invalid-directory-argument = { $argument }: read error: Is a directory tac-error-file-not-found = failed to open { $filename } for reading: No such file or directory tac-error-read-error = failed to read from { $filename }: { $error } tac-error-write-error = failed to write to stdout: { $error } diff --git a/src/uu/tac/locales/fr-FR.ftl b/src/uu/tac/locales/fr-FR.ftl index f49a39e8d..6c56de628 100644 --- a/src/uu/tac/locales/fr-FR.ftl +++ b/src/uu/tac/locales/fr-FR.ftl @@ -6,7 +6,7 @@ tac-help-separator = utiliser CHAÎNE comme séparateur au lieu du saut de ligne # Messages d'erreur tac-error-invalid-regex = expression régulière invalide : { $error } -tac-error-invalid-argument = { $argument } : erreur de lecture : Argument invalide tac-error-file-not-found = échec de l'ouverture de { $filename } en lecture : Aucun fichier ou répertoire de ce type tac-error-read-error = échec de la lecture depuis { $filename } : { $error } tac-error-write-error = échec de l'écriture vers stdout : { $error } +tac-error-invalid-directory-argument = { $argument } : erreur de lecture : Est un répertoire diff --git a/src/uu/tac/src/error.rs b/src/uu/tac/src/error.rs index 133a46266..098e997d4 100644 --- a/src/uu/tac/src/error.rs +++ b/src/uu/tac/src/error.rs @@ -15,9 +15,9 @@ pub enum TacError { /// A regular expression given by the user is invalid. #[error("{}", translate!("tac-error-invalid-regex", "error" => .0))] InvalidRegex(regex::Error), - /// An argument to tac is invalid. - #[error("{}", translate!("tac-error-invalid-argument", "argument" => .0.maybe_quote()))] - InvalidArgument(OsString), + /// The argument to tac is a directory. + #[error("{}", translate!("tac-error-invalid-directory-argument", "argument" => .0.maybe_quote()))] + InvalidDirectoryArgument(OsString), /// The specified file is not found on the filesystem. #[error("{}", translate!("tac-error-file-not-found", "filename" => .0.quote()))] FileNotFound(OsString), diff --git a/src/uu/tac/src/tac.rs b/src/uu/tac/src/tac.rs index 507dd1531..f38661d03 100644 --- a/src/uu/tac/src/tac.rs +++ b/src/uu/tac/src/tac.rs @@ -253,7 +253,8 @@ fn tac(filenames: &[OsString], before: bool, regex: bool, separator: &str) -> UR } else { let path = Path::new(filename); if path.is_dir() { - let e: Box = TacError::InvalidArgument(filename.clone()).into(); + let e: Box = + TacError::InvalidDirectoryArgument(filename.clone()).into(); show!(e); continue; } diff --git a/tests/by-util/test_tac.rs b/tests/by-util/test_tac.rs index 0f5aad488..feb79f581 100644 --- a/tests/by-util/test_tac.rs +++ b/tests/by-util/test_tac.rs @@ -100,7 +100,7 @@ fn test_invalid_input() { .ucmd() .arg("a") .fails() - .stderr_contains("a: read error: Invalid argument"); + .stderr_contains("a: read error: Is a directory"); } #[test] From c8790e67435ad285443dd95d39fec73915b3013f Mon Sep 17 00:00:00 2001 From: Chris Dryden Date: Wed, 31 Dec 2025 14:02:05 -0500 Subject: [PATCH 022/425] stty: use stdin for TTY operations instead of /dev/tty (#9881) * stty: use stdin for TTY operations instead of /dev/tty * Add tests for stty stdin behavior and fix platform-specific error messages --------- Co-authored-by: Sylvestre Ledru --- .../workspace.wordlist.txt | 1 + src/uu/stty/src/stty.rs | 71 +++++++++---------- tests/by-util/test_stty.rs | 70 ++++++++++++++++++ 3 files changed, 105 insertions(+), 37 deletions(-) diff --git a/.vscode/cspell.dictionaries/workspace.wordlist.txt b/.vscode/cspell.dictionaries/workspace.wordlist.txt index 8a8a1474a..f9c8d686b 100644 --- a/.vscode/cspell.dictionaries/workspace.wordlist.txt +++ b/.vscode/cspell.dictionaries/workspace.wordlist.txt @@ -379,6 +379,7 @@ istrip litout opost parodd +ENOTTY # translation tests CLICOLOR diff --git a/src/uu/stty/src/stty.rs b/src/uu/stty/src/stty.rs index d60d4d985..9153c1528 100644 --- a/src/uu/stty/src/stty.rs +++ b/src/uu/stty/src/stty.rs @@ -26,12 +26,12 @@ use nix::sys::termios::{ use nix::{ioctl_read_bad, ioctl_write_ptr_bad}; use std::cmp::Ordering; use std::fs::File; -use std::io::{self, Stdout, stdout}; +use std::io::{self, Stdin, stdin, stdout}; use std::num::IntErrorKind; use std::os::fd::{AsFd, BorrowedFd}; use std::os::unix::fs::OpenOptionsExt; use std::os::unix::io::{AsRawFd, RawFd}; -use uucore::error::{UError, UResult, USimpleError, UUsageError}; +use uucore::error::{FromIo, UError, UResult, USimpleError, UUsageError}; use uucore::format_usage; use uucore::parser::num_parser::ExtendedParser; use uucore::translate; @@ -124,12 +124,13 @@ struct Options<'a> { all: bool, save: bool, file: Device, + device_name: String, settings: Option>, } enum Device { File(File), - Stdout(Stdout), + Stdin(Stdin), } #[derive(Debug)] @@ -166,7 +167,7 @@ impl AsFd for Device { fn as_fd(&self) -> BorrowedFd<'_> { match self { Self::File(f) => f.as_fd(), - Self::Stdout(stdout) => stdout.as_fd(), + Self::Stdin(stdin) => stdin.as_fd(), } } } @@ -175,45 +176,42 @@ impl AsRawFd for Device { fn as_raw_fd(&self) -> RawFd { match self { Self::File(f) => f.as_raw_fd(), - Self::Stdout(stdout) => stdout.as_raw_fd(), + Self::Stdin(stdin) => stdin.as_raw_fd(), } } } impl<'a> Options<'a> { fn from(matches: &'a ArgMatches) -> io::Result { - Ok(Self { - all: matches.get_flag(options::ALL), - save: matches.get_flag(options::SAVE), - file: match matches.get_one::(options::FILE) { - // Two notes here: - // 1. O_NONBLOCK is needed because according to GNU docs, a - // POSIX tty can block waiting for carrier-detect if the - // "clocal" flag is not set. If your TTY is not connected - // to a modem, it is probably not relevant though. - // 2. We never close the FD that we open here, but the OS - // will clean up the FD for us on exit, so it doesn't - // matter. The alternative would be to have an enum of - // BorrowedFd/OwnedFd to handle both cases. - Some(f) => Device::File( + let (file, device_name) = match matches.get_one::(options::FILE) { + // Two notes here: + // 1. O_NONBLOCK is needed because according to GNU docs, a + // POSIX tty can block waiting for carrier-detect if the + // "clocal" flag is not set. If your TTY is not connected + // to a modem, it is probably not relevant though. + // 2. We never close the FD that we open here, but the OS + // will clean up the FD for us on exit, so it doesn't + // matter. The alternative would be to have an enum of + // BorrowedFd/OwnedFd to handle both cases. + Some(f) => ( + Device::File( std::fs::OpenOptions::new() .read(true) .custom_flags(O_NONBLOCK) .open(f)?, ), - // default to /dev/tty, if that does not exist then default to stdout - None => { - if let Ok(f) = std::fs::OpenOptions::new() - .read(true) - .custom_flags(O_NONBLOCK) - .open("/dev/tty") - { - Device::File(f) - } else { - Device::Stdout(stdout()) - } - } - }, + f.clone(), + ), + // Per POSIX, stdin is used for TTY operations when no device is specified. + // This matches GNU coreutils behavior: if stdin is not a TTY, + // tcgetattr will fail with "Inappropriate ioctl for device". + None => (Device::Stdin(stdin()), "standard input".to_string()), + }; + Ok(Self { + all: matches.get_flag(options::ALL), + save: matches.get_flag(options::SAVE), + file, + device_name, settings: matches .get_many::(options::SETTINGS) .map(|v| v.map(|s| s.as_ref()).collect()), @@ -412,8 +410,8 @@ fn stty(opts: &Options) -> UResult<()> { } } - // TODO: Figure out the right error message for when tcgetattr fails - let mut termios = tcgetattr(opts.file.as_fd())?; + let mut termios = + tcgetattr(opts.file.as_fd()).map_err_context(|| opts.device_name.clone())?; // iterate over valid_args, match on the arg type, do the matching apply function for arg in &valid_args { @@ -433,8 +431,7 @@ fn stty(opts: &Options) -> UResult<()> { } tcsetattr(opts.file.as_fd(), set_arg, &termios)?; } else { - // TODO: Figure out the right error message for when tcgetattr fails - let termios = tcgetattr(opts.file.as_fd())?; + let termios = tcgetattr(opts.file.as_fd()).map_err_context(|| opts.device_name.clone())?; print_settings(&termios, opts)?; } Ok(()) @@ -997,7 +994,7 @@ fn apply_char_mapping(termios: &mut Termios, mapping: &(S, u8)) { /// /// The state array contains: /// - `state[0]`: input flags -/// - `state[1]`: output flags +/// - `state[1]`: output flags /// - `state[2]`: control flags /// - `state[3]`: local flags /// - `state[4..]`: control characters (optional) diff --git a/tests/by-util/test_stty.rs b/tests/by-util/test_stty.rs index 136ea2768..ae64eb6ae 100644 --- a/tests/by-util/test_stty.rs +++ b/tests/by-util/test_stty.rs @@ -1557,6 +1557,76 @@ fn test_saved_state_with_control_chars() { .code_is(exp_result.code()); } +// Per POSIX, stty uses stdin for TTY operations. When stdin is a pipe, it should fail. +#[test] +#[cfg(unix)] +fn test_stdin_not_tty_fails() { + // ENOTTY error message varies by platform/libc: + // - glibc: "Inappropriate ioctl for device" + // - musl: "Not a tty" + // - Android: "Not a typewriter" + #[cfg(target_os = "android")] + let expected_error = "standard input: Not a typewriter"; + #[cfg(all(not(target_os = "android"), target_env = "musl"))] + let expected_error = "standard input: Not a tty"; + #[cfg(all(not(target_os = "android"), not(target_env = "musl")))] + let expected_error = "standard input: Inappropriate ioctl for device"; + + new_ucmd!() + .pipe_in("") + .fails() + .stderr_contains(expected_error); +} + +// Test that stty uses stdin for TTY operations per POSIX. +// Verifies: output redirection (#8012), save/restore pattern (#8608), stdin redirection (#8848) +#[test] +#[cfg(unix)] +fn test_stty_uses_stdin() { + use std::fs::File; + use std::process::Stdio; + + let (path, _controller, _replica) = pty_path(); + + // Output redirection: stty > file (stdin is still TTY) + let stdin = File::open(&path).unwrap(); + new_ucmd!() + .set_stdin(stdin) + .set_stdout(Stdio::piped()) + .succeeds() + .stdout_contains("speed"); + + // Save/restore: stty $(stty -g) pattern + let stdin = File::open(&path).unwrap(); + let saved = new_ucmd!() + .arg("-g") + .set_stdin(stdin) + .set_stdout(Stdio::piped()) + .succeeds() + .stdout_str() + .trim() + .to_string(); + assert!(saved.contains(':'), "Expected colon-separated saved state"); + + let stdin = File::open(&path).unwrap(); + new_ucmd!().arg(&saved).set_stdin(stdin).succeeds(); + + // Stdin redirection: stty rows 30 cols 100 < /dev/pts/N + let stdin = File::open(&path).unwrap(); + new_ucmd!() + .args(&["rows", "30", "cols", "100"]) + .set_stdin(stdin) + .succeeds(); + + let stdin = File::open(&path).unwrap(); + new_ucmd!() + .arg("--all") + .set_stdin(stdin) + .succeeds() + .stdout_contains("rows 30") + .stdout_contains("columns 100"); +} + #[test] #[cfg(unix)] fn test_columns_env_wrapping() { From 16c16f44ee7b4608d796b202df4ee47bbc593d63 Mon Sep 17 00:00:00 2001 From: Ibrahim Burak Yorulmaz Date: Wed, 31 Dec 2025 20:09:14 +0100 Subject: [PATCH 023/425] Use libc::UTIME_NOW in touch when updating time to now (#9870) * Use libc::UTIME_NOW in touch when updating time to now * Explicit libc import to satisfy clippy * Fix location of cfg in touch * Add cfg gate to libc import to satisfy clippy on windows * Change cfg gates in touch near libc::UTIME_NOW from unix to linux --------- Co-authored-by: Sylvestre Ledru --- src/uu/touch/src/touch.rs | 18 ++++++++++++++++-- tests/by-util/test_touch.rs | 7 +++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/uu/touch/src/touch.rs b/src/uu/touch/src/touch.rs index e00c1df82..bde22ab33 100644 --- a/src/uu/touch/src/touch.rs +++ b/src/uu/touch/src/touch.rs @@ -3,7 +3,7 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore (ToDO) filetime datetime lpszfilepath mktime DATETIME datelike timelike +// spell-checker:ignore (ToDO) filetime datetime lpszfilepath mktime DATETIME datelike timelike UTIME // spell-checker:ignore (FORMATS) MMDDhhmm YYYYMMDDHHMM YYMMDDHHMM YYYYMMDDHHMMS pub mod error; @@ -23,6 +23,8 @@ use std::io::{Error, ErrorKind}; use std::path::{Path, PathBuf}; use uucore::display::Quotable; use uucore::error::{FromIo, UResult, USimpleError}; +#[cfg(target_os = "linux")] +use uucore::libc; use uucore::parser::shortcut_value_parser::ShortcutValueParser; use uucore::translate; use uucore::{format_usage, show}; @@ -377,7 +379,19 @@ pub fn touch(files: &[InputFile], opts: &Options) -> Result<(), TouchError> { (atime, mtime) } Source::Now => { - let now = datetime_to_filetime(&Local::now()); + let now: FileTime; + #[cfg(target_os = "linux")] + { + if opts.date.is_none() { + now = FileTime::from_unix_time(0, libc::UTIME_NOW as u32); + } else { + now = datetime_to_filetime(&Local::now()); + } + } + #[cfg(not(target_os = "linux"))] + { + now = datetime_to_filetime(&Local::now()); + } (now, now) } &Source::Timestamp(ts) => (ts, ts), diff --git a/tests/by-util/test_touch.rs b/tests/by-util/test_touch.rs index 680758672..eb2b5c02f 100644 --- a/tests/by-util/test_touch.rs +++ b/tests/by-util/test_touch.rs @@ -1052,3 +1052,10 @@ fn test_touch_non_utf8_paths() { scene.ucmd().arg(non_utf8_name).succeeds().no_output(); assert!(std::fs::metadata(at.plus(non_utf8_name)).is_ok()); } + +#[test] +#[cfg(target_os = "linux")] +fn test_touch_dev_full() { + let (_, mut ucmd) = at_and_ucmd!(); + ucmd.args(&["/dev/full"]).succeeds().no_output(); +} From a297d26cfa16fc6f0bb69dbf5d17dc2b6c4d164d Mon Sep 17 00:00:00 2001 From: Chris Dryden Date: Wed, 31 Dec 2025 14:09:39 -0500 Subject: [PATCH 024/425] csplit: detect and report write errors (#9855) * csplit: detect and report write errors * Add Rust integration tests for csplit write error detection * csplit: fix doc comment for finish_split --- src/uu/csplit/src/csplit.rs | 35 ++++++++++++++++++++++++----------- tests/by-util/test_csplit.rs | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 11 deletions(-) diff --git a/src/uu/csplit/src/csplit.rs b/src/uu/csplit/src/csplit.rs index 01cc4e0dc..1c2978cea 100644 --- a/src/uu/csplit/src/csplit.rs +++ b/src/uu/csplit/src/csplit.rs @@ -127,7 +127,7 @@ where let ret = do_csplit(&mut split_writer, patterns_vec, &mut input_iter); // consume the rest, unless there was an error - if ret.is_ok() { + let ret = if ret.is_ok() { input_iter.rewind_buffer(); if let Some((_, line)) = input_iter.next() { // There is remaining input: create a final split and copy remainder @@ -136,14 +136,18 @@ where for (_, line) in input_iter { split_writer.writeln(&line?)?; } - split_writer.finish_split(); + split_writer.finish_split() } else if all_up_to_line && options.suppress_matched { // GNU semantics for integer patterns with --suppress-matched: // even if no remaining input, create a final (possibly empty) split split_writer.new_writer()?; - split_writer.finish_split(); + split_writer.finish_split() + } else { + Ok(()) } - } + } else { + ret + }; // delete files on error by default if ret.is_err() && !options.keep_files { split_writer.delete_all_splits()?; @@ -305,15 +309,24 @@ impl SplitWriter<'_> { /// /// # Errors /// - /// Some [`io::Error`] if the split could not be removed in case it should be elided. - fn finish_split(&mut self) { + /// Returns an error if flushing the writer fails. + fn finish_split(&mut self) -> Result<(), CsplitError> { if !self.dev_null { + // Flush the writer to ensure all data is written and errors are detected + if let Some(ref mut writer) = self.current_writer { + let file_name = self.options.split_name.get(self.counter - 1); + writer + .flush() + .map_err_context(|| file_name.clone()) + .map_err(CsplitError::from)?; + } if self.options.elide_empty_files && self.size == 0 { self.counter -= 1; } else if !self.options.quiet { println!("{}", self.size); } } + Ok(()) } /// Removes all the split files that were created. @@ -379,7 +392,7 @@ impl SplitWriter<'_> { } self.writeln(&line)?; } - self.finish_split(); + self.finish_split()?; ret } @@ -446,7 +459,7 @@ impl SplitWriter<'_> { self.writeln(&line?)?; } None => { - self.finish_split(); + self.finish_split()?; return Err(CsplitError::LineOutOfRange( pattern_as_str.to_string(), )); @@ -454,7 +467,7 @@ impl SplitWriter<'_> { } offset -= 1; } - self.finish_split(); + self.finish_split()?; // if we have to suppress one line after we take the next and do nothing if next_line_suppress_matched { @@ -495,7 +508,7 @@ impl SplitWriter<'_> { ); } - self.finish_split(); + self.finish_split()?; if input_iter.buffer_len() < offset_usize { return Err(CsplitError::LineOutOfRange(pattern_as_str.to_string())); } @@ -511,7 +524,7 @@ impl SplitWriter<'_> { } } - self.finish_split(); + self.finish_split()?; Err(CsplitError::MatchNotFound(pattern_as_str.to_string())) } } diff --git a/tests/by-util/test_csplit.rs b/tests/by-util/test_csplit.rs index bf4606310..76c217a29 100644 --- a/tests/by-util/test_csplit.rs +++ b/tests/by-util/test_csplit.rs @@ -1551,3 +1551,35 @@ fn test_csplit_non_utf8_paths() { ucmd.arg(&filename).arg("3").succeeds(); } + +/// Test write error detection using /dev/full +#[test] +#[cfg(target_os = "linux")] +fn test_write_error_dev_full() { + let (at, mut ucmd) = at_and_ucmd!(); + at.symlink_file("/dev/full", "xx01"); + + ucmd.args(&["-", "2"]) + .pipe_in("1\n2\n") + .fails_with_code(1) + .stderr_contains("xx01: No space left on device"); + + // Files cleaned up by default + assert!(!at.file_exists("xx00")); +} + +/// Test write error with -k keeps files +#[test] +#[cfg(target_os = "linux")] +fn test_write_error_dev_full_keep_files() { + let (at, mut ucmd) = at_and_ucmd!(); + at.symlink_file("/dev/full", "xx01"); + + ucmd.args(&["-k", "-", "2"]) + .pipe_in("1\n2\n") + .fails_with_code(1) + .stderr_contains("xx01: No space left on device"); + + assert!(at.file_exists("xx00")); + assert_eq!(at.read("xx00"), "1\n"); +} From 4e3a8605136112de64c60a898a97dc66197ec603 Mon Sep 17 00:00:00 2001 From: Chris Dryden Date: Wed, 31 Dec 2025 14:16:17 -0500 Subject: [PATCH 025/425] fix(sort): split locale benchmarks into separate files per locale (#9914) --- src/uu/sort/Cargo.toml | 10 +- src/uu/sort/benches/sort_locale_bench.rs | 189 ------------------ src/uu/sort/benches/sort_locale_c_bench.rs | 72 +++++++ src/uu/sort/benches/sort_locale_de_bench.rs | 40 ++++ src/uu/sort/benches/sort_locale_utf8_bench.rs | 102 ++++++++++ 5 files changed, 223 insertions(+), 190 deletions(-) delete mode 100644 src/uu/sort/benches/sort_locale_bench.rs create mode 100644 src/uu/sort/benches/sort_locale_c_bench.rs create mode 100644 src/uu/sort/benches/sort_locale_de_bench.rs create mode 100644 src/uu/sort/benches/sort_locale_utf8_bench.rs diff --git a/src/uu/sort/Cargo.toml b/src/uu/sort/Cargo.toml index 184f6776b..8a9570eaa 100644 --- a/src/uu/sort/Cargo.toml +++ b/src/uu/sort/Cargo.toml @@ -60,5 +60,13 @@ name = "sort_bench" harness = false [[bench]] -name = "sort_locale_bench" +name = "sort_locale_c_bench" +harness = false + +[[bench]] +name = "sort_locale_utf8_bench" +harness = false + +[[bench]] +name = "sort_locale_de_bench" harness = false diff --git a/src/uu/sort/benches/sort_locale_bench.rs b/src/uu/sort/benches/sort_locale_bench.rs deleted file mode 100644 index d00ec9f4a..000000000 --- a/src/uu/sort/benches/sort_locale_bench.rs +++ /dev/null @@ -1,189 +0,0 @@ -// This file is part of the uutils coreutils package. -// -// For the full copyright and license information, please view the LICENSE -// file that was distributed with this source code. - -use divan::{Bencher, black_box}; -use std::env; -use tempfile::NamedTempFile; -use uu_sort::uumain; -use uucore::benchmark::{run_util_function, setup_test_file, text_data}; - -/// Benchmark ASCII-only data sorting with C locale (byte comparison) -#[divan::bench] -fn sort_ascii_c_locale(bencher: Bencher) { - let data = text_data::generate_ascii_data_simple(100_000); - let file_path = setup_test_file(&data); - // Reuse the same output file across iterations to reduce filesystem variance - let output_file = NamedTempFile::new().unwrap(); - let output_path = output_file.path().to_str().unwrap().to_string(); - - bencher.bench(|| { - unsafe { - env::set_var("LC_ALL", "C"); - } - black_box(run_util_function( - uumain, - &["-o", &output_path, file_path.to_str().unwrap()], - )); - }); -} - -/// Benchmark ASCII-only data sorting with UTF-8 locale -#[divan::bench] -fn sort_ascii_utf8_locale(bencher: Bencher) { - let data = text_data::generate_ascii_data_simple(200_000); - let file_path = setup_test_file(&data); - // Reuse the same output file across iterations to reduce filesystem variance - let output_file = NamedTempFile::new().unwrap(); - let output_path = output_file.path().to_str().unwrap().to_string(); - - bencher.bench(|| { - unsafe { - env::set_var("LC_ALL", "en_US.UTF-8"); - } - black_box(run_util_function( - uumain, - &["-o", &output_path, file_path.to_str().unwrap()], - )); - }); -} - -/// Benchmark mixed ASCII/Unicode data with C locale -#[divan::bench] -fn sort_mixed_c_locale(bencher: Bencher) { - let data = text_data::generate_mixed_locale_data(50_000); - let file_path = setup_test_file(&data); - // Reuse the same output file across iterations to reduce filesystem variance - let output_file = NamedTempFile::new().unwrap(); - let output_path = output_file.path().to_str().unwrap().to_string(); - - bencher.bench(|| { - unsafe { - env::set_var("LC_ALL", "C"); - } - black_box(run_util_function( - uumain, - &["-o", &output_path, file_path.to_str().unwrap()], - )); - }); -} - -/// Benchmark mixed ASCII/Unicode data with UTF-8 locale -#[divan::bench] -fn sort_mixed_utf8_locale(bencher: Bencher) { - let data = text_data::generate_mixed_locale_data(50_000); - let file_path = setup_test_file(&data); - // Reuse the same output file across iterations to reduce filesystem variance - let output_file = NamedTempFile::new().unwrap(); - let output_path = output_file.path().to_str().unwrap().to_string(); - - bencher.bench(|| { - unsafe { - env::set_var("LC_ALL", "en_US.UTF-8"); - } - black_box(run_util_function( - uumain, - &["-o", &output_path, file_path.to_str().unwrap()], - )); - }); -} - -/// Benchmark German locale-specific data with C locale -#[divan::bench] -fn sort_german_c_locale(bencher: Bencher) { - let data = text_data::generate_german_locale_data(50_000); - let file_path = setup_test_file(&data); - // Reuse the same output file across iterations to reduce filesystem variance - let output_file = NamedTempFile::new().unwrap(); - let output_path = output_file.path().to_str().unwrap().to_string(); - - bencher.bench(|| { - unsafe { - env::set_var("LC_ALL", "C"); - } - black_box(run_util_function( - uumain, - &["-o", &output_path, file_path.to_str().unwrap()], - )); - }); -} - -/// Benchmark German locale-specific data with German locale -#[divan::bench] -fn sort_german_locale(bencher: Bencher) { - let data = text_data::generate_german_locale_data(50_000); - let file_path = setup_test_file(&data); - // Reuse the same output file across iterations to reduce filesystem variance - let output_file = NamedTempFile::new().unwrap(); - let output_path = output_file.path().to_str().unwrap().to_string(); - - bencher.bench(|| { - unsafe { - env::set_var("LC_ALL", "de_DE.UTF-8"); - } - black_box(run_util_function( - uumain, - &["-o", &output_path, file_path.to_str().unwrap()], - )); - }); -} - -/// Benchmark numeric sorting performance -#[divan::bench] -fn sort_numeric(bencher: Bencher) { - let mut data = Vec::new(); - for i in 0..50_000 { - let line = format!("{}\n", 50_000 - i); - data.extend_from_slice(line.as_bytes()); - } - let file_path = setup_test_file(&data); - - bencher.bench(|| { - unsafe { - env::set_var("LC_ALL", "en_US.UTF-8"); - } - black_box(run_util_function( - uumain, - &["-n", file_path.to_str().unwrap()], - )); - }); -} - -/// Benchmark reverse sorting -#[divan::bench] -fn sort_reverse_mixed(bencher: Bencher) { - let data = text_data::generate_mixed_locale_data(50_000); - let file_path = setup_test_file(&data); - - bencher.bench(|| { - unsafe { - env::set_var("LC_ALL", "en_US.UTF-8"); - } - black_box(run_util_function( - uumain, - &["-r", file_path.to_str().unwrap()], - )); - }); -} - -/// Benchmark unique sorting -#[divan::bench] -fn sort_unique_mixed(bencher: Bencher) { - let data = text_data::generate_mixed_locale_data(50_000); - let file_path = setup_test_file(&data); - - bencher.bench(|| { - unsafe { - env::set_var("LC_ALL", "en_US.UTF-8"); - } - black_box(run_util_function( - uumain, - &["-u", file_path.to_str().unwrap()], - )); - }); -} - -fn main() { - divan::main(); -} diff --git a/src/uu/sort/benches/sort_locale_c_bench.rs b/src/uu/sort/benches/sort_locale_c_bench.rs new file mode 100644 index 000000000..378a2abb9 --- /dev/null +++ b/src/uu/sort/benches/sort_locale_c_bench.rs @@ -0,0 +1,72 @@ +// This file is part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +//! Benchmarks for sort with C locale (fast byte-wise comparison). +//! +//! Note: The locale is set in main() BEFORE any benchmark runs because +//! the locale is cached on first access via OnceLock and cannot be changed afterwards. + +use divan::{Bencher, black_box}; +use tempfile::NamedTempFile; +use uu_sort::uumain; +use uucore::benchmark::{run_util_function, setup_test_file, text_data}; + +/// Benchmark ASCII-only data sorting with C locale (byte comparison) +#[divan::bench] +fn sort_ascii_c_locale(bencher: Bencher) { + let data = text_data::generate_ascii_data_simple(100_000); + let file_path = setup_test_file(&data); + let output_file = NamedTempFile::new().unwrap(); + let output_path = output_file.path().to_str().unwrap().to_string(); + + bencher.bench(|| { + black_box(run_util_function( + uumain, + &["-o", &output_path, file_path.to_str().unwrap()], + )); + }); +} + +/// Benchmark mixed ASCII/Unicode data with C locale (byte comparison) +#[divan::bench] +fn sort_mixed_c_locale(bencher: Bencher) { + let data = text_data::generate_mixed_locale_data(50_000); + let file_path = setup_test_file(&data); + let output_file = NamedTempFile::new().unwrap(); + let output_path = output_file.path().to_str().unwrap().to_string(); + + bencher.bench(|| { + black_box(run_util_function( + uumain, + &["-o", &output_path, file_path.to_str().unwrap()], + )); + }); +} + +/// Benchmark German locale-specific data with C locale (byte comparison) +#[divan::bench] +fn sort_german_c_locale(bencher: Bencher) { + let data = text_data::generate_german_locale_data(50_000); + let file_path = setup_test_file(&data); + let output_file = NamedTempFile::new().unwrap(); + let output_path = output_file.path().to_str().unwrap().to_string(); + + bencher.bench(|| { + black_box(run_util_function( + uumain, + &["-o", &output_path, file_path.to_str().unwrap()], + )); + }); +} + +fn main() { + // Set C locale BEFORE any benchmarks run. + // This must happen before divan::main() because the locale is cached + // on first access via OnceLock and cannot be changed afterwards. + unsafe { + std::env::set_var("LC_ALL", "C"); + } + divan::main(); +} diff --git a/src/uu/sort/benches/sort_locale_de_bench.rs b/src/uu/sort/benches/sort_locale_de_bench.rs new file mode 100644 index 000000000..5c760a694 --- /dev/null +++ b/src/uu/sort/benches/sort_locale_de_bench.rs @@ -0,0 +1,40 @@ +// This file is part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +//! Benchmarks for sort with German locale (de_DE.UTF-8 collation). +//! +//! Note: The locale is set in main() BEFORE any benchmark runs because +//! the locale is cached on first access via OnceLock and cannot be changed afterwards. + +use divan::{Bencher, black_box}; +use tempfile::NamedTempFile; +use uu_sort::uumain; +use uucore::benchmark::{run_util_function, setup_test_file, text_data}; + +/// Benchmark German locale-specific data with German locale +#[divan::bench] +fn sort_german_de_locale(bencher: Bencher) { + let data = text_data::generate_german_locale_data(50_000); + let file_path = setup_test_file(&data); + let output_file = NamedTempFile::new().unwrap(); + let output_path = output_file.path().to_str().unwrap().to_string(); + + bencher.bench(|| { + black_box(run_util_function( + uumain, + &["-o", &output_path, file_path.to_str().unwrap()], + )); + }); +} + +fn main() { + // Set German locale BEFORE any benchmarks run. + // This must happen before divan::main() because the locale is cached + // on first access via OnceLock and cannot be changed afterwards. + unsafe { + std::env::set_var("LC_ALL", "de_DE.UTF-8"); + } + divan::main(); +} diff --git a/src/uu/sort/benches/sort_locale_utf8_bench.rs b/src/uu/sort/benches/sort_locale_utf8_bench.rs new file mode 100644 index 000000000..b0ebb340d --- /dev/null +++ b/src/uu/sort/benches/sort_locale_utf8_bench.rs @@ -0,0 +1,102 @@ +// This file is part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +//! Benchmarks for sort with UTF-8 locale (locale-aware collation). +//! +//! Note: The locale is set in main() BEFORE any benchmark runs because +//! the locale is cached on first access via OnceLock and cannot be changed afterwards. + +use divan::{Bencher, black_box}; +use tempfile::NamedTempFile; +use uu_sort::uumain; +use uucore::benchmark::{run_util_function, setup_test_file, text_data}; + +/// Benchmark ASCII-only data sorting with UTF-8 locale +#[divan::bench] +fn sort_ascii_utf8_locale(bencher: Bencher) { + let data = text_data::generate_ascii_data_simple(100_000); + let file_path = setup_test_file(&data); + let output_file = NamedTempFile::new().unwrap(); + let output_path = output_file.path().to_str().unwrap().to_string(); + + bencher.bench(|| { + black_box(run_util_function( + uumain, + &["-o", &output_path, file_path.to_str().unwrap()], + )); + }); +} + +/// Benchmark mixed ASCII/Unicode data with UTF-8 locale +#[divan::bench] +fn sort_mixed_utf8_locale(bencher: Bencher) { + let data = text_data::generate_mixed_locale_data(50_000); + let file_path = setup_test_file(&data); + let output_file = NamedTempFile::new().unwrap(); + let output_path = output_file.path().to_str().unwrap().to_string(); + + bencher.bench(|| { + black_box(run_util_function( + uumain, + &["-o", &output_path, file_path.to_str().unwrap()], + )); + }); +} + +/// Benchmark numeric sorting with UTF-8 locale +#[divan::bench] +fn sort_numeric_utf8_locale(bencher: Bencher) { + let mut data = Vec::new(); + for i in 0..50_000 { + let line = format!("{}\n", 50_000 - i); + data.extend_from_slice(line.as_bytes()); + } + let file_path = setup_test_file(&data); + + bencher.bench(|| { + black_box(run_util_function( + uumain, + &["-n", file_path.to_str().unwrap()], + )); + }); +} + +/// Benchmark reverse sorting with UTF-8 locale +#[divan::bench] +fn sort_reverse_utf8_locale(bencher: Bencher) { + let data = text_data::generate_mixed_locale_data(50_000); + let file_path = setup_test_file(&data); + + bencher.bench(|| { + black_box(run_util_function( + uumain, + &["-r", file_path.to_str().unwrap()], + )); + }); +} + +/// Benchmark unique sorting with UTF-8 locale +#[divan::bench] +fn sort_unique_utf8_locale(bencher: Bencher) { + let data = text_data::generate_mixed_locale_data(50_000); + let file_path = setup_test_file(&data); + + bencher.bench(|| { + black_box(run_util_function( + uumain, + &["-u", file_path.to_str().unwrap()], + )); + }); +} + +fn main() { + // Set UTF-8 locale BEFORE any benchmarks run. + // This must happen before divan::main() because the locale is cached + // on first access via OnceLock and cannot be changed afterwards. + unsafe { + std::env::set_var("LC_ALL", "en_US.UTF-8"); + } + divan::main(); +} From 1eea517d3748e0190c8f05483e5eed35d6394e5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=E1=BA=A3=20th=E1=BA=BF=20gi=E1=BB=9Bi=20l=C3=A0=20Rust?= <90588855+naoNao89@users.noreply.github.com> Date: Thu, 1 Jan 2026 02:20:50 +0700 Subject: [PATCH 026/425] test(more): Fix test_from_line_option race condition by increasing PTY delay (#9629) * test(more): Fix test_from_line_option race condition by increasing PTY delay Increased the delay in run_more_with_pty() from 100ms to 500ms to allow more time to fully render output before the test reads from the PTY. The test was failing because it was reading too early, before more could initialize the terminal and render the file content. The -F flag itself works correctly. * test_more: decrease the delay --------- Co-authored-by: Sylvestre Ledru --- tests/by-util/test_more.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/by-util/test_more.rs b/tests/by-util/test_more.rs index 2bf130a18..b5256af61 100644 --- a/tests/by-util/test_more.rs +++ b/tests/by-util/test_more.rs @@ -35,7 +35,7 @@ fn run_more_with_pty( .arg(file) .run_no_wait(); - child.delay(100); + child.delay(200); let mut output = vec![0u8; 1024]; let n = read(&controller, &mut output).unwrap(); let output_str = String::from_utf8_lossy(&output[..n]).to_string(); From b591c4d35b13717d50a9e2580ffb57a3b7597174 Mon Sep 17 00:00:00 2001 From: xtqqczze <45661989+xtqqczze@users.noreply.github.com> Date: Wed, 31 Dec 2025 20:54:48 +0000 Subject: [PATCH 027/425] Revert "df: disable clippy::assigning_clones on OpenBSD" This reverts commit 225a1052a78d96bf561edeb20379535e71d2d691. --- src/uu/df/src/filesystem.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/uu/df/src/filesystem.rs b/src/uu/df/src/filesystem.rs index 25743941d..041f73959 100644 --- a/src/uu/df/src/filesystem.rs +++ b/src/uu/df/src/filesystem.rs @@ -291,9 +291,7 @@ mod tests { } #[test] - // clippy::assigning_clones added with Rust 1.78 - // Rust version = 1.76 on OpenBSD stable/7.5 - #[cfg_attr(not(target_os = "openbsd"), allow(clippy::assigning_clones))] + #[allow(clippy::assigning_clones)] fn test_dev_name_match() { let tmp = tempfile::TempDir::new().expect("Failed to create temp dir"); let dev_name = std::fs::canonicalize(tmp.path()) From 28965226aa538992acf99428389845d673151127 Mon Sep 17 00:00:00 2001 From: xtqqczze <45661989+xtqqczze@users.noreply.github.com> Date: Wed, 31 Dec 2025 20:55:15 +0000 Subject: [PATCH 028/425] Revert "cp: disable clippy::assigning_clones on OpenBSD" This reverts commit 7a556a6e82d38749a92b60a986f7430e82e79282. --- src/uu/cp/src/cp.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/uu/cp/src/cp.rs b/src/uu/cp/src/cp.rs index 1502a7ada..1de71d36a 100644 --- a/src/uu/cp/src/cp.rs +++ b/src/uu/cp/src/cp.rs @@ -1279,9 +1279,7 @@ fn parse_path_args( }; if options.strip_trailing_slashes { - // clippy::assigning_clones added with Rust 1.78 - // Rust version = 1.76 on OpenBSD stable/7.5 - #[cfg_attr(not(target_os = "openbsd"), allow(clippy::assigning_clones))] + #[allow(clippy::assigning_clones)] for source in &mut paths { *source = source.components().as_path().to_owned(); } From 6485735b14ba159fe63c434f834b8e7ce33d850e Mon Sep 17 00:00:00 2001 From: xtqqczze <45661989+xtqqczze@users.noreply.github.com> Date: Wed, 31 Dec 2025 20:55:29 +0000 Subject: [PATCH 029/425] Revert "tail: disable clippy::assigning_clones on OpenBSD" This reverts commit 14258b12ad15442dafca91301e4f2e68a7884aee. --- src/uu/tail/src/follow/watch.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/uu/tail/src/follow/watch.rs b/src/uu/tail/src/follow/watch.rs index 95f38aabc..b4b4d00ac 100644 --- a/src/uu/tail/src/follow/watch.rs +++ b/src/uu/tail/src/follow/watch.rs @@ -47,9 +47,7 @@ impl WatcherRx { Tested for notify::InotifyWatcher and for notify::PollWatcher. */ if let Some(parent) = path.parent() { - // clippy::assigning_clones added with Rust 1.78 - // Rust version = 1.76 on OpenBSD stable/7.5 - #[cfg_attr(not(target_os = "openbsd"), allow(clippy::assigning_clones))] + #[allow(clippy::assigning_clones)] if parent.is_dir() { path = parent.to_owned(); } else { From c442fdaadc5dd6ba5a2ea339baebf7f79fd0bca7 Mon Sep 17 00:00:00 2001 From: xtqqczze <45661989+xtqqczze@users.noreply.github.com> Date: Wed, 31 Dec 2025 21:16:04 +0000 Subject: [PATCH 030/425] uucore: switch to `BigDecimal::powi` implementation The implementation is identical. --- .../src/lib/features/parser/num_parser.rs | 73 +------------------ 1 file changed, 3 insertions(+), 70 deletions(-) diff --git a/src/uucore/src/lib/features/parser/num_parser.rs b/src/uucore/src/lib/features/parser/num_parser.rs index 178cd578f..b23f51fb5 100644 --- a/src/uucore/src/lib/features/parser/num_parser.rs +++ b/src/uucore/src/lib/features/parser/num_parser.rs @@ -7,10 +7,8 @@ // spell-checker:ignore powf copysign prec ilog inity infinit infs bigdecimal extendedbigdecimal biguint underflowed muls -use std::num::NonZeroU64; - use bigdecimal::{ - BigDecimal, Context, + BigDecimal, num_bigint::{BigInt, BigUint, Sign}, }; use num_traits::Signed; @@ -398,71 +396,6 @@ fn make_error(overflow: bool, negative: bool) -> ExtendedParserError -/// -/// TODO: Still pending discussion in , -/// we do lose a little bit of precision, and the last digits may not be correct. -/// Note: This has been copied from the latest revision in , -/// so it's using minimum Rust version of `bigdecimal-rs`. -fn pow_with_context(bd: &BigDecimal, exp: i64, ctx: &Context) -> BigDecimal { - if exp == 0 { - return 1.into(); - } - - // When performing a multiplication between 2 numbers, we may lose up to 2 digits - // of precision. - // "Proof": https://github.com/akubera/bigdecimal-rs/issues/147#issuecomment-2793431202 - const MARGIN_PER_MUL: u64 = 2; - // When doing many multiplication, we still introduce additional errors, add 1 more digit - // per 10 multiplications. - const MUL_PER_MARGIN_EXTRA: u64 = 10; - - fn trim_precision(bd: BigDecimal, ctx: &Context, margin: u64) -> BigDecimal { - let prec = ctx.precision().get() + margin; - if bd.digits() > prec { - bd.with_precision_round(NonZeroU64::new(prec).unwrap(), ctx.rounding_mode()) - } else { - bd - } - } - - // Count the number of multiplications we're going to perform, one per "1" binary digit - // in exp, and the number of times we can divide exp by 2. - let mut n = exp.unsigned_abs(); - // Note: 63 - n.leading_zeros() == n.ilog2, but that's only available in recent Rust versions. - let muls = (n.count_ones() + (63 - n.leading_zeros()) - 1) as u64; - // Note: div_ceil would be nice to use here, but only available in recent Rust versions. - // (see note above about minimum Rust version in use) - let margin_extra = (muls + MUL_PER_MARGIN_EXTRA / 2) / MUL_PER_MARGIN_EXTRA; - let mut margin = margin_extra + MARGIN_PER_MUL * muls; - - let mut bd_y: BigDecimal = 1.into(); - let mut bd_x = if exp >= 0 { - bd.clone() - } else { - bd.inverse_with_context(&ctx.with_precision( - NonZeroU64::new(ctx.precision().get() + margin + MARGIN_PER_MUL).unwrap(), - )) - }; - - while n > 1 { - if n % 2 == 1 { - bd_y = trim_precision(&bd_x * bd_y, ctx, margin); - margin -= MARGIN_PER_MUL; - n -= 1; - } - bd_x = trim_precision(bd_x.square(), ctx, margin); - margin -= MARGIN_PER_MUL; - n /= 2; - } - debug_assert_eq!(margin, margin_extra); - - trim_precision(bd_x * bd_y, ctx, 0) -} - /// Construct an [`ExtendedBigDecimal`] based on parsed data fn construct_extended_big_decimal( digits: BigUint, @@ -510,7 +443,7 @@ fn construct_extended_big_decimal( let bd = BigDecimal::from_bigint(signed_digits, 0) / BigDecimal::from_bigint(BigInt::from(16).pow(scale as u32), 0); - // pow_with_context "only" supports i64 values. Just overflow/underflow if the value provided + // powi "only" supports i64 values. Just overflow/underflow if the value provided // is > 2**64 or < 2**-64. let Some(exponent) = exponent.to_i64() else { return Err(make_error(exponent.is_positive(), negative)); @@ -520,7 +453,7 @@ fn construct_extended_big_decimal( let base: BigDecimal = 2.into(); // Note: We cannot overflow/underflow BigDecimal here, as we will not be able to reach the // maximum/minimum scale (i64 range). - let pow2 = pow_with_context(&base, exponent, &Context::default()); + let pow2 = base.powi(exponent); bd * pow2 } else { From f2b37cb312cb4d7db6d5abf520b3e98ee1c6852e Mon Sep 17 00:00:00 2001 From: xtqqczze <45661989+xtqqczze@users.noreply.github.com> Date: Thu, 1 Jan 2026 00:51:33 +0000 Subject: [PATCH 031/425] clippy: fix uninlined_format_args lint https://rust-lang.github.io/rust-clippy/master/index.html#uninlined_format_args --- src/uucore/src/lib/features/uptime.rs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/uucore/src/lib/features/uptime.rs b/src/uucore/src/lib/features/uptime.rs index 7e919b1ad..cc4d976ae 100644 --- a/src/uucore/src/lib/features/uptime.rs +++ b/src/uucore/src/lib/features/uptime.rs @@ -501,8 +501,7 @@ mod tests { // (This is just a sanity check) assert!( uptime < 365 * 86400, - "Uptime seems unreasonably high: {} seconds", - uptime + "Uptime seems unreasonably high: {uptime} seconds" ); } @@ -518,9 +517,7 @@ mod tests { let diff = (uptime1 - uptime2).abs(); assert!( diff <= 1, - "Consecutive uptime calls should be consistent, got {} and {}", - uptime1, - uptime2 + "Consecutive uptime calls should be consistent, got {uptime1} and {uptime2}" ); } } From 0c8dbc478b635e2992298f7d3a521a6fc61fab05 Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Thu, 1 Jan 2026 15:08:19 +0900 Subject: [PATCH 032/425] GnuTests.yml: Drop autopoint --- .github/workflows/GnuTests.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/GnuTests.yml b/.github/workflows/GnuTests.yml index 9298d3d85..d50074b90 100644 --- a/.github/workflows/GnuTests.yml +++ b/.github/workflows/GnuTests.yml @@ -2,7 +2,7 @@ name: GnuTests # spell-checker:ignore (abbrev/names) CodeCov gnulib GnuTests Swatinem # spell-checker:ignore (jargon) submodules devel -# spell-checker:ignore (libs/utils) autopoint chksum dpkg getenforce getlimits gperf lcov libexpect limactl pyinotify setenforce shopt valgrind libattr libcap taiki-e zstd cpio +# spell-checker:ignore (libs/utils) chksum dpkg getenforce getlimits gperf lcov libexpect limactl pyinotify setenforce shopt valgrind libattr libcap taiki-e zstd cpio # spell-checker:ignore (options) Ccodegen Coverflow Cpanic Zpanic # spell-checker:ignore (people) Dawid Dziurla * dawidd dtolnay # spell-checker:ignore (vars) FILESET SUBDIRS XPASS @@ -68,7 +68,7 @@ jobs: ## Install dependencies sudo apt-get update ## Check that build-gnu.sh works on the non SELinux system by installing libselinux only on lima - sudo apt-get install -y autopoint gperf gdb python3-pyinotify valgrind libexpect-perl libacl1-dev libattr1-dev libcap-dev attr quilt + sudo apt-get install -y gperf gdb python3-pyinotify valgrind libexpect-perl libacl1-dev libattr1-dev libcap-dev attr quilt curl http://launchpadlibrarian.net/831710181/automake_1.18.1-3_all.deb > automake-1.18.deb sudo dpkg -i --force-depends automake-1.18.deb - name: Add various locales @@ -245,7 +245,7 @@ jobs: - name: Install dependencies in VM run: | lima sudo dnf -y update - lima sudo dnf -y install git autoconf autopoint bison gperf gcc gdb jq libacl-devel libattr-devel libcap-devel libselinux-devel attr rustup clang-devel automake patch quilt + lima sudo dnf -y install git autoconf bison gperf gcc gdb jq libacl-devel libattr-devel libcap-devel libselinux-devel attr rustup clang-devel automake patch quilt lima rustup-init -y --default-toolchain stable - name: Copy the sources to VM run: | From 2cf3f32b300e7b90071276591155d89762ebeb6b Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Thu, 1 Jan 2026 17:26:22 +0900 Subject: [PATCH 033/425] GnuTests.yml: Drop git from VM --- .github/workflows/GnuTests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/GnuTests.yml b/.github/workflows/GnuTests.yml index d50074b90..0cac53657 100644 --- a/.github/workflows/GnuTests.yml +++ b/.github/workflows/GnuTests.yml @@ -245,7 +245,7 @@ jobs: - name: Install dependencies in VM run: | lima sudo dnf -y update - lima sudo dnf -y install git autoconf bison gperf gcc gdb jq libacl-devel libattr-devel libcap-devel libselinux-devel attr rustup clang-devel automake patch quilt + lima sudo dnf -y install autoconf bison gperf gcc gdb jq libacl-devel libattr-devel libcap-devel libselinux-devel attr rustup clang-devel automake patch quilt lima rustup-init -y --default-toolchain stable - name: Copy the sources to VM run: | From 88051cba9926952afca6e0b86f19e893cb81e8e7 Mon Sep 17 00:00:00 2001 From: Martin Paulsen <43757366+georgepaulsen@users.noreply.github.com> Date: Thu, 1 Jan 2026 04:58:18 -0500 Subject: [PATCH 034/425] test: fixing unary operators that are getting parsed as argument instead of string literal (#9951) * Check for three-string comparison in test * Add regression tests for unary operator in three-arg form --- src/uu/test/src/parser.rs | 11 ++++++++++- tests/by-util/test_test.rs | 7 +++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/uu/test/src/parser.rs b/src/uu/test/src/parser.rs index 167bf7702..c1c06e4c5 100644 --- a/src/uu/test/src/parser.rs +++ b/src/uu/test/src/parser.rs @@ -188,7 +188,16 @@ impl Parser { match symbol { Symbol::LParen => self.lparen()?, Symbol::Bang => self.bang()?, - Symbol::UnaryOp(_) => self.uop(symbol), + Symbol::UnaryOp(_) => { + // Three-argument string comparison: `-f = a` means "-f" = "a", not file test + let is_string_cmp = matches!(self.peek(), Symbol::Op(Operator::String(_))) + && !matches!(Symbol::new(self.tokens.clone().nth(1)), Symbol::None); + if is_string_cmp { + self.literal(symbol.into_literal())?; + } else { + self.uop(symbol); + } + } Symbol::None => self.stack.push(symbol), literal => self.literal(literal)?, } diff --git a/tests/by-util/test_test.rs b/tests/by-util/test_test.rs index 4b5460cfd..21ea1893e 100644 --- a/tests/by-util/test_test.rs +++ b/tests/by-util/test_test.rs @@ -1027,3 +1027,10 @@ fn test_string_lt_gt_operator() { .fails_with_code(1) .no_output(); } + +#[test] +fn test_unary_op_as_literal_in_three_arg_form() { + // `-f = a` is string comparison "-f" = "a", not file test + new_ucmd!().args(&["-f", "=", "a"]).fails_with_code(1); + new_ucmd!().args(&["-f", "=", "a", "-o", "b"]).succeeds(); +} From cc389e59276fc51ba11eb39b9f13131ebd85d56c Mon Sep 17 00:00:00 2001 From: Yuankun Zhang Date: Thu, 1 Jan 2026 18:01:11 +0800 Subject: [PATCH 035/425] mv: support moving folder containing symlinks to different filesystem (#8605) Co-authored-by: Sylvestre Ledru --- src/uu/mv/src/mv.rs | 29 +++++++++++++++------- tests/by-util/test_mv.rs | 52 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 9 deletions(-) diff --git a/src/uu/mv/src/mv.rs b/src/uu/mv/src/mv.rs index a43b92eb8..aa34a6294 100644 --- a/src/uu/mv/src/mv.rs +++ b/src/uu/mv/src/mv.rs @@ -1099,7 +1099,13 @@ fn copy_dir_contents_recursive( } #[cfg(not(unix))] { - fs::copy(&from_path, &to_path)?; + if from_path.is_symlink() { + // Copy a symlink file (no-follow). + rename_symlink_fallback(&from_path, &to_path)?; + } else { + // Copy a regular file. + fs::copy(&from_path, &to_path)?; + } } // Print verbose message for file @@ -1142,14 +1148,19 @@ fn copy_file_with_hardlinks_helper( return Ok(()); } - // Regular file copy - #[cfg(all(unix, not(any(target_os = "macos", target_os = "redox"))))] - { - fs::copy(from, to).and_then(|_| fsxattr::copy_xattrs(&from, &to))?; - } - #[cfg(any(target_os = "macos", target_os = "redox"))] - { - fs::copy(from, to)?; + if from.is_symlink() { + // Copy a symlink file (no-follow). + rename_symlink_fallback(from, to)?; + } else { + // Copy a regular file. + #[cfg(all(unix, not(any(target_os = "macos", target_os = "redox"))))] + { + fs::copy(from, to).and_then(|_| fsxattr::copy_xattrs(&from, &to))?; + } + #[cfg(any(target_os = "macos", target_os = "redox"))] + { + fs::copy(from, to)?; + } } Ok(()) diff --git a/tests/by-util/test_mv.rs b/tests/by-util/test_mv.rs index 7e22d930b..3c69d65a7 100644 --- a/tests/by-util/test_mv.rs +++ b/tests/by-util/test_mv.rs @@ -623,6 +623,58 @@ fn test_mv_symlink_into_target() { ucmd.arg("dir-link").arg("dir").succeeds(); } +#[cfg(all(unix, not(target_os = "android")))] +#[ignore = "requires sudo"] +#[test] +fn test_mv_broken_symlink_to_another_fs() { + let scene = TestScenario::new(util_name!()); + + scene.fixtures.mkdir("foo"); + + let output = scene + .cmd("sudo") + .env("PATH", env!("PATH")) + .args(&["-E", "--non-interactive", "ls"]) + .run(); + println!("test output: {output:?}"); + + let mount = scene + .cmd("sudo") + .env("PATH", env!("PATH")) + .args(&[ + "-E", + "--non-interactive", + "mount", + "none", + "-t", + "tmpfs", + "foo", + ]) + .run(); + + if !mount.succeeded() { + print!("Test skipped; requires root user"); + return; + } + + scene.fixtures.mkdir("bar"); + scene.fixtures.symlink_file("nonexistent", "bar/baz"); + + scene + .ucmd() + .arg("bar") + .arg("foo") + .succeeds() + .no_stderr() + .no_stdout(); + + scene + .cmd("sudo") + .env("PATH", env!("PATH")) + .args(&["-E", "--non-interactive", "umount", "foo"]) + .succeeds(); +} + #[test] #[cfg(all(unix, not(target_os = "android")))] fn test_mv_hardlink_to_symlink() { From abd581f62e97d0b147306ac40eac13af71c6fbba Mon Sep 17 00:00:00 2001 From: cerdelen <95369756+cerdelen@users.noreply.github.com> Date: Thu, 1 Jan 2026 11:41:05 +0100 Subject: [PATCH 036/425] chmod: fix error handling if multiple files are handled (#9793) * chmod: fix error handling if multiple files are handled * chmod: add regression test for correct exit codes * chmod: fix test expected error msg --------- Co-authored-by: Sylvestre Ledru --- src/uu/chmod/src/chmod.rs | 2 +- tests/by-util/test_chmod.rs | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/uu/chmod/src/chmod.rs b/src/uu/chmod/src/chmod.rs index 24566272b..b7e0f3fd9 100644 --- a/src/uu/chmod/src/chmod.rs +++ b/src/uu/chmod/src/chmod.rs @@ -411,7 +411,7 @@ impl Chmoder { return Err(ChmodError::PreserveRoot("/".into()).into()); } if self.recursive { - r = self.walk_dir_with_context(file, true); + r = self.walk_dir_with_context(file, true).and(r); } else { r = self.chmod_file(file).and(r); } diff --git a/tests/by-util/test_chmod.rs b/tests/by-util/test_chmod.rs index 446cdd6d3..5e3407328 100644 --- a/tests/by-util/test_chmod.rs +++ b/tests/by-util/test_chmod.rs @@ -375,6 +375,38 @@ fn test_permission_denied() { .stderr_is("chmod: cannot access 'd/no-x/y': Permission denied\n"); } +#[test] +#[allow(clippy::unreadable_literal)] +fn test_chmod_recursive_correct_exit_code() { + let (at, mut ucmd) = at_and_ucmd!(); + + // create 3 folders to test on + at.mkdir("a"); + at.mkdir("a/b"); + at.mkdir("z"); + + // remove read permissions for folder a so the chmod command for a/b fails + let mut perms = at.metadata("a").permissions(); + perms.set_mode(0o000); + set_permissions(at.plus_as_string("a"), perms).unwrap(); + + #[cfg(not(target_os = "linux"))] + let err_msg = "chmod: Permission denied\n"; + #[cfg(target_os = "linux")] + let err_msg = "chmod: cannot access 'a': Permission denied\n"; + + // order of command is a, a/b then c + // command is expected to fail and not just take the last exit code + ucmd.arg("-R") + .arg("--verbose") + .arg("a+w") + .arg("a") + .arg("z") + .umask(0) + .fails() + .stderr_is(err_msg); +} + #[test] #[allow(clippy::unreadable_literal)] fn test_chmod_recursive() { From b600c257ef971ccb5f85f7ca52f456f59c4fc3e3 Mon Sep 17 00:00:00 2001 From: Saathwik Dasari Date: Thu, 1 Jan 2026 17:06:58 +0530 Subject: [PATCH 037/425] pr: allow character, block, and fifo devices as input (#9946) --- src/uu/pr/src/pr.rs | 31 +++++++++++++++++++------------ tests/by-util/test_pr.rs | 6 ++++++ 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/src/uu/pr/src/pr.rs b/src/uu/pr/src/pr.rs index f5c5662aa..19e9f2a0c 100644 --- a/src/uu/pr/src/pr.rs +++ b/src/uu/pr/src/pr.rs @@ -757,22 +757,29 @@ fn open(path: &str) -> Result, PrError> { |i| { let path_string = path.to_string(); match i.file_type() { - #[cfg(unix)] - ft if ft.is_block_device() => Err(PrError::UnknownFiletype { file: path_string }), - #[cfg(unix)] - ft if ft.is_char_device() => Err(PrError::UnknownFiletype { file: path_string }), - #[cfg(unix)] - ft if ft.is_fifo() => Err(PrError::UnknownFiletype { file: path_string }), #[cfg(unix)] ft if ft.is_socket() => Err(PrError::IsSocket { file: path_string }), ft if ft.is_dir() => Err(PrError::IsDirectory { file: path_string }), - ft if ft.is_file() || ft.is_symlink() => { - Ok(Box::new(File::open(path).map_err(|e| PrError::Input { - source: e, - file: path.to_string(), - })?) as Box) + + ft => { + #[allow(unused_mut)] + let mut is_valid = ft.is_file() || ft.is_symlink(); + + #[cfg(unix)] + { + is_valid = + is_valid || ft.is_char_device() || ft.is_block_device() || ft.is_fifo(); + } + + if is_valid { + Ok(Box::new(File::open(path).map_err(|e| PrError::Input { + source: e, + file: path.to_string(), + })?) as Box) + } else { + Err(PrError::UnknownFiletype { file: path_string }) + } } - _ => Err(PrError::UnknownFiletype { file: path_string }), } }, ) diff --git a/tests/by-util/test_pr.rs b/tests/by-util/test_pr.rs index 1fa91dab2..0bb161fb8 100644 --- a/tests/by-util/test_pr.rs +++ b/tests/by-util/test_pr.rs @@ -610,3 +610,9 @@ fn test_help() { fn test_version() { new_ucmd!().arg("--version").succeeds(); } + +#[cfg(unix)] +#[test] +fn test_pr_char_device_dev_null() { + new_ucmd!().arg("/dev/null").succeeds(); +} From 72a3f751ebabd0463f3ce2d1439784ae64997e6e Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Thu, 1 Jan 2026 20:37:37 +0900 Subject: [PATCH 038/425] build-gnu.sh: Skip make at SELinux tests (#9970) --- util/build-gnu.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/util/build-gnu.sh b/util/build-gnu.sh index 734cde88c..f1cba68ad 100755 --- a/util/build-gnu.sh +++ b/util/build-gnu.sh @@ -139,6 +139,7 @@ else # 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}")" cp -f src/getlimits "${UU_BUILD_DIR}" From c06050b779eedcde0995ad5fd0cad7fabe193940 Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Thu, 1 Jan 2026 20:37:59 +0900 Subject: [PATCH 039/425] build-gnu.sh: Drop a variable & cleanup (#9953) --- util/build-gnu.sh | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/util/build-gnu.sh b/util/build-gnu.sh index f1cba68ad..7e92396bf 100755 --- a/util/build-gnu.sh +++ b/util/build-gnu.sh @@ -22,8 +22,8 @@ REPO_main_dir="$(dirname -- "${ME_dir}")" : ${PROFILE:=debug} # default profile -export PROFILE -CARGO_FEATURE_FLAGS="" +export PROFILE # tell to make +unset CARGOFLAGS ### * config (from environment with fallback defaults); note: GNU is expected to be a sibling repo directory @@ -63,15 +63,15 @@ echo "UU_BUILD_DIR='${UU_BUILD_DIR}'" cd "${path_UUTILS}" && echo "[ pwd:'${PWD}' ]" export SELINUX_ENABLED # Run this script with=1 for testing SELinux -[ "${SELINUX_ENABLED}" = 1 ] && CARGO_FEATURE_FLAGS="${CARGO_FEATURE_FLAGS} selinux" +[ "${SELINUX_ENABLED}" = 1 ] && CARGOFLAGS="${CARGOFLAGS} selinux" # Trim leading whitespace from feature flags -CARGO_FEATURE_FLAGS="$(echo "${CARGO_FEATURE_FLAGS}" | sed -e 's/^[[:space:]]*//')" +CARGOFLAGS="$(echo "${CARGOFLAGS}" | sed -e 's/^[[:space:]]*//')" # If we have feature flags, format them correctly for cargo -if [ ! -z "${CARGO_FEATURE_FLAGS}" ]; then - CARGO_FEATURE_FLAGS="--features ${CARGO_FEATURE_FLAGS}" - echo "Building with cargo flags: ${CARGO_FEATURE_FLAGS}" +if [ ! -z "${CARGOFLAGS}" ]; then + CARGOFLAGS="--features ${CARGOFLAGS}" + echo "Building with cargo flags: ${CARGOFLAGS}" fi # Set up quilt for patch management @@ -87,15 +87,16 @@ else fi cd - +export CARGOFLAGS # tell to make # bug: seq with MULTICALL=y breaks env-signal-handler.sh - "${MAKE}" UTILS="install seq" PROFILE="${PROFILE}" CARGOFLAGS="${CARGO_FEATURE_FLAGS}" + "${MAKE}" UTILS="install seq" ln -vf "${UU_BUILD_DIR}/install" "${UU_BUILD_DIR}/ginstall" # The GNU tests use renamed install to ginstall if [ "${SELINUX_ENABLED}" = 1 ];then # Build few utils for SELinux for faster build. MULTICALL=y fails... - "${MAKE}" UTILS="cat chcon cp cut echo env groups id ln ls mkdir mkfifo mknod mktemp mv printf rm rmdir runcon stat test touch tr true uname wc whoami" PROFILE="${PROFILE}" CARGOFLAGS="${CARGO_FEATURE_FLAGS}" + "${MAKE}" UTILS="cat chcon cp cut echo env groups id ln ls mkdir mkfifo mknod mktemp mv printf rm rmdir runcon stat test touch tr true uname wc whoami" else # Use MULTICALL=y for faster build - "${MAKE}" MULTICALL=y SKIP_UTILS="install more seq" PROFILE="${PROFILE}" CARGOFLAGS="${CARGO_FEATURE_FLAGS}" + "${MAKE}" MULTICALL=y SKIP_UTILS="install more seq" for binary in $("${UU_BUILD_DIR}"/coreutils --list) do ln -vf "${UU_BUILD_DIR}/coreutils" "${UU_BUILD_DIR}/${binary}" done @@ -109,9 +110,7 @@ cd "${path_GNU}" && echo "[ pwd:'${PWD}' ]" # Note that some test (e.g. runcon/runcon-compute.sh) incorrectly passes by this for binary in $(./build-aux/gen-lists-of-programs.sh --list-progs); do bin_path="${UU_BUILD_DIR}/${binary}" - test -f "${bin_path}" || { - cp -v /usr/bin/false "${bin_path}" - } + test -f "${bin_path}" || cp -v /usr/bin/false "${bin_path}" done # Always update the PATH to test the uutils coreutils instead of the GNU coreutils From 911bc15f82bf87537f201e53e3925ae6111603b5 Mon Sep 17 00:00:00 2001 From: Jane Illarionova Date: Thu, 1 Jan 2026 06:38:26 -0500 Subject: [PATCH 040/425] Unexpand: use byte count for multibyte characters for column width when using -a flag (#9949) * Remove Unicode width calculation for characters * Add test for unexpand with multibyte UTF-8 input --- src/uu/unexpand/src/unexpand.rs | 7 +------ tests/by-util/test_unexpand.rs | 12 ++++++++++++ 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/uu/unexpand/src/unexpand.rs b/src/uu/unexpand/src/unexpand.rs index b3990ac59..896318484 100644 --- a/src/uu/unexpand/src/unexpand.rs +++ b/src/uu/unexpand/src/unexpand.rs @@ -13,7 +13,6 @@ use std::num::IntErrorKind; use std::path::Path; use std::str::from_utf8; use thiserror::Error; -use unicode_width::UnicodeWidthChar; use uucore::display::Quotable; use uucore::error::{FromIo, UError, UResult, USimpleError}; use uucore::translate; @@ -279,11 +278,7 @@ fn next_char_info(uflag: bool, buf: &[u8], byte: usize) -> (CharType, usize, usi Some(' ') => (CharType::Space, 0, 1), Some('\t') => (CharType::Tab, 0, 1), Some('\x08') => (CharType::Backspace, 0, 1), - Some(c) => ( - CharType::Other, - UnicodeWidthChar::width(c).unwrap_or(0), - nbytes, - ), + Some(_) => (CharType::Other, nbytes, nbytes), None => { // invalid char snuck past the utf8_validation_iterator somehow??? (CharType::Other, 1, 1) diff --git a/tests/by-util/test_unexpand.rs b/tests/by-util/test_unexpand.rs index 0f2a6d464..0720dabb0 100644 --- a/tests/by-util/test_unexpand.rs +++ b/tests/by-util/test_unexpand.rs @@ -295,3 +295,15 @@ fn test_non_utf8_filename() { ucmd.arg(&filename).succeeds().stdout_is("\ta\n"); } + +#[test] +fn unexpand_multibyte_utf8_gnu_compat() { + // Verifies GNU-compatible behavior: column position uses byte count, not display width + // "1ΔΔΔ5" is 8 bytes (1 + 2*3 + 1), already at tab stop 8 + // So 3 spaces should NOT convert to tab (would need 8 more to reach tab stop 16) + new_ucmd!() + .args(&["-a"]) + .pipe_in("1ΔΔΔ5 99999\n") + .succeeds() + .stdout_is("1ΔΔΔ5 99999\n"); +} From 9cf774bed8919636f437caf0e7ee379fc701cdab Mon Sep 17 00:00:00 2001 From: Dylan Skelly <43255611+Dylans123@users.noreply.github.com> Date: Thu, 1 Jan 2026 09:04:47 -0500 Subject: [PATCH 041/425] cp: symlink flags fixing conflicting flag logic to use last flag (#9960) * Adding overrides_with_all for symlink flags for ordering * Adding regression tests for symlink flag ordering --- src/uu/cp/src/cp.rs | 32 +++++++++++++++++++++++++++-- tests/by-util/test_cp.rs | 44 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/src/uu/cp/src/cp.rs b/src/uu/cp/src/cp.rs index 1de71d36a..3048f38b7 100644 --- a/src/uu/cp/src/cp.rs +++ b/src/uu/cp/src/cp.rs @@ -689,7 +689,12 @@ pub fn uu_app() -> Command { Arg::new(options::NO_DEREFERENCE) .short('P') .long(options::NO_DEREFERENCE) - .overrides_with(options::DEREFERENCE) + .overrides_with_all([ + options::DEREFERENCE, + options::CLI_SYMBOLIC_LINKS, + options::ARCHIVE, + options::NO_DEREFERENCE_PRESERVE_LINKS, + ]) // -d sets this option .help(translate!("cp-help-no-dereference")) .action(ArgAction::SetTrue), @@ -698,13 +703,24 @@ pub fn uu_app() -> Command { Arg::new(options::DEREFERENCE) .short('L') .long(options::DEREFERENCE) - .overrides_with(options::NO_DEREFERENCE) + .overrides_with_all([ + options::NO_DEREFERENCE, + options::CLI_SYMBOLIC_LINKS, + options::ARCHIVE, + options::NO_DEREFERENCE_PRESERVE_LINKS, + ]) .help(translate!("cp-help-dereference")) .action(ArgAction::SetTrue), ) .arg( Arg::new(options::CLI_SYMBOLIC_LINKS) .short('H') + .overrides_with_all([ + options::DEREFERENCE, + options::NO_DEREFERENCE, + options::ARCHIVE, + options::NO_DEREFERENCE_PRESERVE_LINKS, + ]) .help(translate!("cp-help-cli-symbolic-links")) .action(ArgAction::SetTrue), ) @@ -712,12 +728,24 @@ pub fn uu_app() -> Command { Arg::new(options::ARCHIVE) .short('a') .long(options::ARCHIVE) + .overrides_with_all([ + options::DEREFERENCE, + options::NO_DEREFERENCE, + options::CLI_SYMBOLIC_LINKS, + options::NO_DEREFERENCE_PRESERVE_LINKS, + ]) .help(translate!("cp-help-archive")) .action(ArgAction::SetTrue), ) .arg( Arg::new(options::NO_DEREFERENCE_PRESERVE_LINKS) .short('d') + .overrides_with_all([ + options::DEREFERENCE, + options::NO_DEREFERENCE, + options::CLI_SYMBOLIC_LINKS, + options::ARCHIVE, + ]) .help(translate!("cp-help-no-dereference-preserve-links")) .action(ArgAction::SetTrue), ) diff --git a/tests/by-util/test_cp.rs b/tests/by-util/test_cp.rs index 2563e533a..5f4a44c4a 100644 --- a/tests/by-util/test_cp.rs +++ b/tests/by-util/test_cp.rs @@ -7400,3 +7400,47 @@ fn test_cp_recurse_verbose_output_with_symlink_already_exists() { .no_stderr() .stdout_is(output); } + +#[test] +#[cfg(unix)] +fn test_cp_hlp_flag_ordering() { + // GNU cp: "If more than one of -H, -L, and -P is specified, only the final one takes effect" + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("file.txt"); + at.symlink_file("file.txt", "symlink"); + + // -HP: P wins, copy symlink as symlink + ucmd.args(&["-HP", "symlink", "dest_hp"]).succeeds(); + assert!(at.is_symlink("dest_hp")); + + // -PH: H wins, copy target file + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("file.txt"); + at.symlink_file("file.txt", "symlink"); + ucmd.args(&["-PH", "symlink", "dest_ph"]).succeeds(); + assert!(!at.is_symlink("dest_ph")); + assert!(at.file_exists("dest_ph")); +} + +#[test] +#[cfg(unix)] +fn test_cp_archive_deref_flag_ordering() { + // (flags, expect_symlink): last flag wins; a/d imply -P, H/L dereference + for (flags, expect_symlink) in [ + ("-Ha", true), + ("-aH", false), + ("-Hd", true), + ("-dH", false), + ("-La", true), + ("-aL", false), + ("-Ld", true), + ("-dL", false), + ] { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("file.txt"); + at.symlink_file("file.txt", "symlink"); + let dest = format!("dest{flags}"); + ucmd.args(&[flags, "symlink", &dest]).succeeds(); + assert_eq!(at.is_symlink(&dest), expect_symlink, "failed for {flags}"); + } +} From 7309ccea19d19d09816eaa0edb64c89b162b2f44 Mon Sep 17 00:00:00 2001 From: Daniel Hofstetter Date: Thu, 1 Jan 2026 15:34:41 +0100 Subject: [PATCH 042/425] GnuTests.yml: use minimal Rust profile in VM --- .github/workflows/GnuTests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/GnuTests.yml b/.github/workflows/GnuTests.yml index 0cac53657..3a7bb002d 100644 --- a/.github/workflows/GnuTests.yml +++ b/.github/workflows/GnuTests.yml @@ -246,7 +246,7 @@ jobs: run: | lima sudo dnf -y update lima sudo dnf -y install autoconf bison gperf gcc gdb jq libacl-devel libattr-devel libcap-devel libselinux-devel attr rustup clang-devel automake patch quilt - lima rustup-init -y --default-toolchain stable + lima rustup-init -y --profile=minimal --default-toolchain stable - name: Copy the sources to VM run: | rsync -a -e ssh . lima-default:~/work/ From 1fdbf8d35965838ae08e3fbdb5f1fb38e07b5ed9 Mon Sep 17 00:00:00 2001 From: Daniel Hofstetter Date: Thu, 1 Jan 2026 16:37:34 +0100 Subject: [PATCH 043/425] Bump signal-hook from 0.3.18 to 0.4.1 --- Cargo.lock | 16 +++++++++++++--- Cargo.toml | 2 +- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0d84035b9..bf51e63c1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -757,7 +757,7 @@ dependencies = [ "mio", "parking_lot", "rustix", - "signal-hook", + "signal-hook 0.3.18", "signal-hook-mio", "winapi", ] @@ -2607,6 +2607,16 @@ dependencies = [ "signal-hook-registry", ] +[[package]] +name = "signal-hook" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a37d01603c37b5466f808de79f845c7116049b0579adb70a6b7d47c1fa3a952" +dependencies = [ + "libc", + "signal-hook-registry", +] + [[package]] name = "signal-hook-mio" version = "0.2.5" @@ -2615,7 +2625,7 @@ checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" dependencies = [ "libc", "mio", - "signal-hook", + "signal-hook 0.3.18", ] [[package]] @@ -3206,7 +3216,7 @@ dependencies = [ "gcd", "libc", "nix", - "signal-hook", + "signal-hook 0.4.1", "tempfile", "thiserror 2.0.17", "uucore", diff --git a/Cargo.toml b/Cargo.toml index d6737d16d..80af38b85 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -369,7 +369,7 @@ same-file = "1.0.6" self_cell = "1.0.4" # FIXME we use the exact version because the new 0.5.3 requires an MSRV of 1.88 selinux = "=0.5.2" -signal-hook = "0.3.17" +signal-hook = "0.4.1" tempfile = "3.15.0" terminal_size = "0.4.0" textwrap = { version = "0.16.1", features = ["terminal_size"] } From a413c9f099e327da8a360c224919715cb887e88e Mon Sep 17 00:00:00 2001 From: Daniel Hofstetter Date: Thu, 1 Jan 2026 16:39:51 +0100 Subject: [PATCH 044/425] deny.toml: add signal-hook to skip list --- deny.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/deny.toml b/deny.toml index 662474b65..eb0e02300 100644 --- a/deny.toml +++ b/deny.toml @@ -107,6 +107,8 @@ skip = [ { name = "zerocopy-derive", version = "0.7.35" }, # rustix { name = "linux-raw-sys", version = "0.11.0" }, + # crossterm + { name = "signal-hook", version = "0.3.18" }, ] # spell-checker: enable From 949eaab46b5df9c82d0af833e1f54c65c93bb297 Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Fri, 2 Jan 2026 01:04:53 +0900 Subject: [PATCH 045/425] hashsum: Drop benches as covered by cksum benches (#9842) Co-authored-by: oech3 <> Co-authored-by: Sylvestre Ledru --- .github/workflows/benchmarks.yml | 1 - src/uu/hashsum/BENCHMARKING.md | 11 -- src/uu/hashsum/Cargo.toml | 4 - src/uu/hashsum/benches/hashsum_bench.rs | 138 ------------------------ 4 files changed, 154 deletions(-) delete mode 100644 src/uu/hashsum/BENCHMARKING.md delete mode 100644 src/uu/hashsum/benches/hashsum_bench.rs diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index 1ffce535d..76fe09b7a 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -31,7 +31,6 @@ jobs: - { package: uu_du } - { package: uu_expand } - { package: uu_fold } - - { package: uu_hashsum } - { package: uu_ls } - { package: uu_mv } - { package: uu_nl } diff --git a/src/uu/hashsum/BENCHMARKING.md b/src/uu/hashsum/BENCHMARKING.md deleted file mode 100644 index 9508cae1b..000000000 --- a/src/uu/hashsum/BENCHMARKING.md +++ /dev/null @@ -1,11 +0,0 @@ -# Benchmarking hashsum - -## To bench blake2 - -Taken from: - -With a large file: - -```shell -hyperfine "./target/release/coreutils hashsum --b2sum large-file" "b2sum large-file" -``` diff --git a/src/uu/hashsum/Cargo.toml b/src/uu/hashsum/Cargo.toml index ec382870b..f77c2c52d 100644 --- a/src/uu/hashsum/Cargo.toml +++ b/src/uu/hashsum/Cargo.toml @@ -30,7 +30,3 @@ path = "src/main.rs" divan = { workspace = true } tempfile = { workspace = true } uucore = { workspace = true, features = ["benchmark"] } - -[[bench]] -name = "hashsum_bench" -harness = false diff --git a/src/uu/hashsum/benches/hashsum_bench.rs b/src/uu/hashsum/benches/hashsum_bench.rs deleted file mode 100644 index 27572c560..000000000 --- a/src/uu/hashsum/benches/hashsum_bench.rs +++ /dev/null @@ -1,138 +0,0 @@ -// This file is part of the uutils coreutils package. -// -// For the full copyright and license information, please view the LICENSE -// file that was distributed with this source code. - -use divan::{Bencher, black_box}; -use std::io::Write; -use tempfile::NamedTempFile; -use uu_hashsum::uumain; -use uucore::benchmark::{run_util_function, setup_test_file, text_data}; - -/// Benchmark MD5 hashing -#[divan::bench] -fn hashsum_md5(bencher: Bencher) { - let data = text_data::generate_by_size(10, 80); - let file_path = setup_test_file(&data); - - bencher.bench(|| { - black_box(run_util_function( - uumain, - &["--md5", file_path.to_str().unwrap()], - )); - }); -} - -/// Benchmark SHA1 hashing -#[divan::bench] -fn hashsum_sha1(bencher: Bencher) { - let data = text_data::generate_by_size(10, 80); - let file_path = setup_test_file(&data); - - bencher.bench(|| { - black_box(run_util_function( - uumain, - &["--sha1", file_path.to_str().unwrap()], - )); - }); -} - -/// Benchmark SHA256 hashing -#[divan::bench] -fn hashsum_sha256(bencher: Bencher) { - let data = text_data::generate_by_size(10, 80); - let file_path = setup_test_file(&data); - - bencher.bench(|| { - black_box(run_util_function( - uumain, - &["--sha256", file_path.to_str().unwrap()], - )); - }); -} - -/// Benchmark SHA512 hashing -#[divan::bench] -fn hashsum_sha512(bencher: Bencher) { - let data = text_data::generate_by_size(10, 80); - let file_path = setup_test_file(&data); - - bencher.bench(|| { - black_box(run_util_function( - uumain, - &["--sha512", file_path.to_str().unwrap()], - )); - }); -} - -/// Benchmark MD5 checksum verification -#[divan::bench] -fn hashsum_md5_check(bencher: Bencher) { - bencher - .with_inputs(|| { - // Create test file - let data = text_data::generate_by_size(10, 80); - let test_file = setup_test_file(&data); - - // Create checksum file - keep it alive by returning it - let checksum_file = NamedTempFile::new().unwrap(); - let checksum_path = checksum_file.path().to_str().unwrap().to_string(); - - // Write checksum content - { - let mut file = std::fs::File::create(&checksum_path).unwrap(); - writeln!( - file, - "d41d8cd98f00b204e9800998ecf8427e {}", - test_file.to_str().unwrap() - ) - .unwrap(); - } - - (checksum_file, checksum_path) - }) - .bench_values(|(_checksum_file, checksum_path)| { - black_box(run_util_function( - uumain, - &["--md5", "--check", &checksum_path], - )); - }); -} - -/// Benchmark SHA256 checksum verification -#[divan::bench] -fn hashsum_sha256_check(bencher: Bencher) { - bencher - .with_inputs(|| { - // Create test file - let data = text_data::generate_by_size(10, 80); - let test_file = setup_test_file(&data); - - // Create checksum file - keep it alive by returning it - let checksum_file = NamedTempFile::new().unwrap(); - let checksum_path = checksum_file.path().to_str().unwrap().to_string(); - - // Write checksum content - { - let mut file = std::fs::File::create(&checksum_path).unwrap(); - writeln!( - file, - "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 {}", - test_file.to_str().unwrap() - ) - .unwrap(); - } - - (checksum_file, checksum_path) - }) - .bench_values(|(_checksum_file, checksum_path)| { - black_box(run_util_function( - uumain, - &["--sha256", "--check", &checksum_path], - )); - }); -} - -fn main() { - divan::main(); -} From 6c5145c8559fe93452276528d52c79ceeb41e082 Mon Sep 17 00:00:00 2001 From: "Guillem L. Jara" <4lon3ly0@tutanota.com> Date: Wed, 29 Oct 2025 14:46:00 +0100 Subject: [PATCH 046/425] fix(stat): constrain mount point fetching Fixes issue #9072, where some AppArmor profiles designed for GNU coreutils would break under uutils because we would fetch this info unnecessarily. --- src/uu/stat/src/stat.rs | 49 +++++++++++++++++++++++++---------------- 1 file changed, 30 insertions(+), 19 deletions(-) diff --git a/src/uu/stat/src/stat.rs b/src/uu/stat/src/stat.rs index 327e89a68..8a304485e 100644 --- a/src/uu/stat/src/stat.rs +++ b/src/uu/stat/src/stat.rs @@ -908,6 +908,28 @@ impl Stater { Ok(tokens) } + fn populate_mount_list() -> UResult> { + let mut mount_list = read_fs_list() + .map_err(|e| { + USimpleError::new( + e.code(), + StatError::CannotReadFilesystem { + error: e.to_string(), + } + .to_string(), + ) + })? + .iter() + .map(|mi| mi.mount_dir.clone()) + .collect::>(); + + // Reverse sort. The longer comes first. + mount_list.sort(); + mount_list.reverse(); + + Ok(mount_list) + } + fn new(matches: &ArgMatches) -> UResult { let files: Vec = matches .get_many::(options::FILES) @@ -938,27 +960,16 @@ impl Stater { let default_dev_tokens = Self::generate_tokens(&Self::default_format(show_fs, terse, true), use_printf)?; - let mount_list = if show_fs { - // mount points aren't displayed when showing filesystem information + // mount points aren't displayed when showing filesystem information, or + // whenever the format string does not request the mount point. + let mount_list = if show_fs + || !default_tokens + .iter() + .any(|tok| matches!(tok, Token::Directive { format: 'm', .. })) + { None } else { - let mut mount_list = read_fs_list() - .map_err(|e| { - USimpleError::new( - e.code(), - StatError::CannotReadFilesystem { - error: e.to_string(), - } - .to_string(), - ) - })? - .iter() - .map(|mi| mi.mount_dir.clone()) - .collect::>(); - // Reverse sort. The longer comes first. - mount_list.sort(); - mount_list.reverse(); - Some(mount_list) + Some(Self::populate_mount_list()?) }; Ok(Self { From da819b792d143a6c0ef214cfabfd87311929d1f0 Mon Sep 17 00:00:00 2001 From: "Guillem L. Jara" <4lon3ly0@tutanota.com> Date: Wed, 29 Oct 2025 15:11:35 +0100 Subject: [PATCH 047/425] fix(stat): fix default opts and several format errors Fixes issue #9071, where the default options for the `Device` field where incorrect, as well as several format flags that were not working as intended: %R, %Hr, %Lr, %Hd, %Ld, %t. --- src/uu/stat/src/stat.rs | 38 ++++++++++++++++++++++++-------------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/src/uu/stat/src/stat.rs b/src/uu/stat/src/stat.rs index 8a304485e..b8ffb61b9 100644 --- a/src/uu/stat/src/stat.rs +++ b/src/uu/stat/src/stat.rs @@ -70,6 +70,8 @@ struct Flags { space: bool, sign: bool, group: bool, + major: bool, + minor: bool, } /// checks if the string is within the specified bound, @@ -299,6 +301,16 @@ fn group_num(s: &str) -> Cow<'_, str> { res.into() } +/// Keeps major part of an integer +fn major(n: u64) -> u64 { + (n >> 8) & 0xFF +} + +// Keeps minor part of an integer +fn minor(n: u64) -> u64 { + n & 0xFF +} + struct Stater { follow: bool, show_fs: bool, @@ -794,13 +806,14 @@ impl Stater { if let Some(&next_char) = chars.get(*i + 1) { if (chars[*i] == 'H' || chars[*i] == 'L') && (next_char == 'd' || next_char == 'r') { - let specifier = format!("{}{next_char}", chars[*i]); + flag.major = chars[*i] == 'H'; + flag.minor = chars[*i] == 'L'; *i += 1; return Ok(Token::Directive { flag, width, precision, - format: specifier.chars().next().unwrap(), + format: next_char, }); } } @@ -1063,6 +1076,8 @@ impl Stater { } } // device number in decimal + 'd' if flag.major => OutputType::Unsigned(major(meta.dev())), + 'd' if flag.minor => OutputType::Unsigned(minor(meta.dev())), 'd' => OutputType::Unsigned(meta.dev()), // device number in hex 'D' => OutputType::UnsignedHex(meta.dev()), @@ -1101,10 +1116,10 @@ impl Stater { 's' => OutputType::Integer(meta.len() as i64), // major device type in hex, for character/block device special // files - 't' => OutputType::UnsignedHex(meta.rdev() >> 8), + 't' => OutputType::UnsignedHex(major(meta.rdev())), // minor device type in hex, for character/block device special // files - 'T' => OutputType::UnsignedHex(meta.rdev() & 0xff), + 'T' => OutputType::UnsignedHex(minor(meta.rdev())), // user ID of owner 'u' => OutputType::Unsigned(meta.uid() as u64), // user name of owner @@ -1147,15 +1162,10 @@ impl Stater { .map_or((0, 0), system_time_to_sec); OutputType::Float(sec as f64 + nsec as f64 / 1_000_000_000.0) } - 'R' => { - let major = meta.rdev() >> 8; - let minor = meta.rdev() & 0xff; - OutputType::Str(format!("{major},{minor}")) - } + 'R' => OutputType::UnsignedHex(meta.rdev()), + 'r' if flag.major => OutputType::Unsigned(major(meta.rdev())), + 'r' if flag.minor => OutputType::Unsigned(minor(meta.rdev())), 'r' => OutputType::Unsigned(meta.rdev()), - 'H' => OutputType::Unsigned(meta.rdev() >> 8), // Major in decimal - 'L' => OutputType::Unsigned(meta.rdev() & 0xff), // Minor in decimal - _ => OutputType::Unknown, }; print_it(&output, flag, width, precision); @@ -1280,7 +1290,7 @@ impl Stater { } else { let device_line = if show_dev_type { format!( - "{}: %Dh/%dd\t{}: %-10i {}: %-5h {} {}: %t,%T\n", + "{}: %Hd,%Ld\t{}: %-10i {}: %-5h {} {}: %t,%T\n", translate!("stat-word-device"), translate!("stat-word-inode"), translate!("stat-word-links"), @@ -1289,7 +1299,7 @@ impl Stater { ) } else { format!( - "{}: %Dh/%dd\t{}: %-10i {}: %h\n", + "{}: %Hd,%Ld\t{}: %-10i {}: %h\n", translate!("stat-word-device"), translate!("stat-word-inode"), translate!("stat-word-links") From fa9dcd5d320666d8bcaa0fb368be5a7b96c4cb10 Mon Sep 17 00:00:00 2001 From: "Guillem L. Jara" <4lon3ly0@tutanota.com> Date: Wed, 29 Oct 2025 15:36:47 +0100 Subject: [PATCH 048/425] fix(stat): fix % escaping Previosuly, stat would ignore the next char after escaping it; e.g., "%%m" would become "%" instead of literally "%m". --- src/uu/stat/src/stat.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/uu/stat/src/stat.rs b/src/uu/stat/src/stat.rs index b8ffb61b9..aa670ff09 100644 --- a/src/uu/stat/src/stat.rs +++ b/src/uu/stat/src/stat.rs @@ -751,7 +751,6 @@ impl Stater { return Ok(Token::Char('%')); } if chars[*i] == '%' { - *i += 1; return Ok(Token::Char('%')); } From 7c489120dc1262181fb9cfac8dd3f3e30be9d743 Mon Sep 17 00:00:00 2001 From: "Guillem L. Jara" <4lon3ly0@tutanota.com> Date: Wed, 29 Oct 2025 19:55:32 +0100 Subject: [PATCH 049/425] chore(stat): Add test case for percent escaping --- tests/by-util/test_stat.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/by-util/test_stat.rs b/tests/by-util/test_stat.rs index 0aad7361b..eafa3ce49 100644 --- a/tests/by-util/test_stat.rs +++ b/tests/by-util/test_stat.rs @@ -567,3 +567,14 @@ fn test_mount_point_combined_with_other_specifiers() { "Should print mount point, file name, and size" ); } + +#[cfg(unix)] +#[test] +fn test_percent_escaping() { + let ts = TestScenario::new(util_name!()); + let result = ts + .ucmd() + .args(&["--printf", "%%%m%%m%m%%%", "/bin/sh"]) + .succeeds(); + assert_eq!(result.stdout_str(), "%/%m/%%"); +} From c30593a40a4bed0990deb336ec2fd66dbf551bd4 Mon Sep 17 00:00:00 2001 From: "Guillem L. Jara" <4lon3ly0@tutanota.com> Date: Wed, 31 Dec 2025 09:28:11 +0100 Subject: [PATCH 050/425] fix(stat, mknod): replace custom (flawed in stat) logic with libc's. Now uucore::fs reexports libc's major(), minor() and makedev() directives. --- .../cspell.dictionaries/jargon.wordlist.txt | 1 + src/uu/mknod/Cargo.toml | 2 +- src/uu/mknod/src/mknod.rs | 9 ++----- src/uu/stat/src/stat.rs | 24 ++++++------------- src/uucore/src/lib/features/fs.rs | 20 ++++++++++++++++ 5 files changed, 31 insertions(+), 25 deletions(-) diff --git a/.vscode/cspell.dictionaries/jargon.wordlist.txt b/.vscode/cspell.dictionaries/jargon.wordlist.txt index 9fa0b625a..a1bda0e76 100644 --- a/.vscode/cspell.dictionaries/jargon.wordlist.txt +++ b/.vscode/cspell.dictionaries/jargon.wordlist.txt @@ -86,6 +86,7 @@ listxattr llistxattr lossily lstat +makedev mebi mebibytes mergeable diff --git a/src/uu/mknod/Cargo.toml b/src/uu/mknod/Cargo.toml index 50e7e2fce..32b983134 100644 --- a/src/uu/mknod/Cargo.toml +++ b/src/uu/mknod/Cargo.toml @@ -21,7 +21,7 @@ path = "src/mknod.rs" [dependencies] clap = { workspace = true } libc = { workspace = true } -uucore = { workspace = true, features = ["mode"] } +uucore = { workspace = true, features = ["mode", "fs"] } fluent = { workspace = true } [features] diff --git a/src/uu/mknod/src/mknod.rs b/src/uu/mknod/src/mknod.rs index cc22aee5f..8a4cf82d0 100644 --- a/src/uu/mknod/src/mknod.rs +++ b/src/uu/mknod/src/mknod.rs @@ -13,6 +13,7 @@ use std::ffi::CString; use uucore::display::Quotable; use uucore::error::{UResult, USimpleError, UUsageError, set_exit_code}; use uucore::format_usage; +use uucore::fs::makedev; use uucore::translate; const MODE_RW_UGO: mode_t = S_IRUSR | S_IWUSR | S_IRGRP | S_IWGRP | S_IROTH | S_IWOTH; @@ -26,12 +27,6 @@ mod options { pub const CONTEXT: &str = "context"; } -#[inline(always)] -fn makedev(maj: u64, min: u64) -> dev_t { - // pick up from - ((min & 0xff) | ((maj & 0xfff) << 8) | ((min & !0xff) << 12) | ((maj & !0xfff) << 32)) as dev_t -} - #[derive(Clone, PartialEq)] enum FileType { Block, @@ -145,7 +140,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { translate!("mknod-error-fifo-no-major-minor"), )); } - (_, Some(&major), Some(&minor)) => makedev(major, minor), + (_, Some(&major), Some(&minor)) => makedev(major as _, minor as _), _ => { return Err(UUsageError::new( 1, diff --git a/src/uu/stat/src/stat.rs b/src/uu/stat/src/stat.rs index aa670ff09..bae89461c 100644 --- a/src/uu/stat/src/stat.rs +++ b/src/uu/stat/src/stat.rs @@ -9,7 +9,7 @@ use uucore::translate; use clap::builder::ValueParser; use uucore::display::Quotable; -use uucore::fs::display_permissions; +use uucore::fs::{display_permissions, major, minor}; use uucore::fsext::{ FsMeta, MetadataTimeField, StatFs, metadata_get_time, pretty_filetype, pretty_fstype, read_fs_list, statfs, @@ -301,16 +301,6 @@ fn group_num(s: &str) -> Cow<'_, str> { res.into() } -/// Keeps major part of an integer -fn major(n: u64) -> u64 { - (n >> 8) & 0xFF -} - -// Keeps minor part of an integer -fn minor(n: u64) -> u64 { - n & 0xFF -} - struct Stater { follow: bool, show_fs: bool, @@ -1075,8 +1065,8 @@ impl Stater { } } // device number in decimal - 'd' if flag.major => OutputType::Unsigned(major(meta.dev())), - 'd' if flag.minor => OutputType::Unsigned(minor(meta.dev())), + 'd' if flag.major => OutputType::Unsigned(major(meta.dev() as _) as u64), + 'd' if flag.minor => OutputType::Unsigned(minor(meta.dev() as _) as u64), 'd' => OutputType::Unsigned(meta.dev()), // device number in hex 'D' => OutputType::UnsignedHex(meta.dev()), @@ -1115,10 +1105,10 @@ impl Stater { 's' => OutputType::Integer(meta.len() as i64), // major device type in hex, for character/block device special // files - 't' => OutputType::UnsignedHex(major(meta.rdev())), + 't' => OutputType::UnsignedHex(major(meta.rdev() as _) as u64), // minor device type in hex, for character/block device special // files - 'T' => OutputType::UnsignedHex(minor(meta.rdev())), + 'T' => OutputType::UnsignedHex(minor(meta.rdev() as _) as u64), // user ID of owner 'u' => OutputType::Unsigned(meta.uid() as u64), // user name of owner @@ -1162,8 +1152,8 @@ impl Stater { OutputType::Float(sec as f64 + nsec as f64 / 1_000_000_000.0) } 'R' => OutputType::UnsignedHex(meta.rdev()), - 'r' if flag.major => OutputType::Unsigned(major(meta.rdev())), - 'r' if flag.minor => OutputType::Unsigned(minor(meta.rdev())), + 'r' if flag.major => OutputType::Unsigned(major(meta.rdev() as _) as u64), + 'r' if flag.minor => OutputType::Unsigned(minor(meta.rdev() as _) as u64), 'r' => OutputType::Unsigned(meta.rdev()), _ => OutputType::Unknown, }; diff --git a/src/uucore/src/lib/features/fs.rs b/src/uucore/src/lib/features/fs.rs index 16de054a3..9c7108580 100644 --- a/src/uucore/src/lib/features/fs.rs +++ b/src/uucore/src/lib/features/fs.rs @@ -13,6 +13,8 @@ use libc::{ S_IRUSR, S_ISGID, S_ISUID, S_ISVTX, S_IWGRP, S_IWOTH, S_IWUSR, S_IXGRP, S_IXOTH, S_IXUSR, mkfifo, mode_t, }; +#[cfg(all(unix, not(target_os = "redox")))] +pub use libc::{major, makedev, minor}; use std::collections::HashSet; use std::collections::VecDeque; use std::env; @@ -839,6 +841,24 @@ pub fn make_fifo(path: &Path) -> std::io::Result<()> { } } +// Redox's libc appears not to include the following utilities + +#[cfg(target_os = "redox")] +pub fn major(dev: libc::dev_t) -> libc::c_uint { + (((dev >> 8) & 0xFFF) | ((dev >> 32) & 0xFFFFF000)) as _ +} + +#[cfg(target_os = "redox")] +pub fn minor(dev: libc::dev_t) -> libc::c_uint { + ((dev & 0xFF) | ((dev >> 12) & 0xFFFFF00)) as _ +} + +#[cfg(target_os = "redox")] +pub fn makedev(maj: libc::c_uint, min: libc::c_uint) -> libc::dev_t { + let [maj, min] = [maj as libc::dev_t, min as libc::dev_t]; + (min & 0xff) | ((maj & 0xfff) << 8) | ((min & !0xff) << 12) | ((maj & !0xfff) << 32) +} + #[cfg(test)] mod tests { // Note this useful idiom: importing names from outer (for mod tests) scope. From 613e9be6bb0bcf3b07f2b0a8d9ce68fd9eb73001 Mon Sep 17 00:00:00 2001 From: "Guillem L. Jara" <4lon3ly0@tutanota.com> Date: Wed, 31 Dec 2025 20:29:57 +0100 Subject: [PATCH 051/425] chore(stat): add test case for file metadata --- tests/by-util/test_stat.rs | 54 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/tests/by-util/test_stat.rs b/tests/by-util/test_stat.rs index eafa3ce49..8347d49c7 100644 --- a/tests/by-util/test_stat.rs +++ b/tests/by-util/test_stat.rs @@ -9,6 +9,9 @@ use uutests::unwrap_or_return; use uutests::util::{TestScenario, expected_result}; use uutests::util_name; +use std::fs::metadata; +use std::os::unix::fs::MetadataExt; + #[test] fn test_invalid_arg() { new_ucmd!().arg("--definitely-invalid").fails_with_code(1); @@ -578,3 +581,54 @@ fn test_percent_escaping() { .succeeds(); assert_eq!(result.stdout_str(), "%/%m/%%"); } + +#[cfg(unix)] +#[test] +fn test_correct_metadata() { + use uucore::fs::{major, minor}; + + let ts = TestScenario::new(util_name!()); + let parse = |(i, str): (usize, &str)| { + // Some outputs (%[fDRtT]) are in hex; they're redundant, but we might + // as well also test case conversion. + let radix = if matches!(i, 2 | 10 | 14..) { 16 } else { 10 }; + i128::from_str_radix(str, radix) + }; + for device in ["/", "/dev/null"] { + let metadata = metadata(device).unwrap(); + // We avoid time vals because of fs race conditions, especially with + // access time and status time (this previously killed an otherwise + // perfect 11-hour-long CI run...). The large number of as-casts is + // due to inconsistencies on some platforms (read: BSDs), and we use + // i128 as a lowest-common denominator. + let test_str = "%u %g %f %b %s %h %i %d %Hd %Ld %D %r %Hr %Lr %R %t %T"; + let expected = [ + metadata.uid() as _, + metadata.gid() as _, + metadata.mode() as _, + metadata.blocks() as _, + metadata.size() as _, + metadata.nlink() as _, + metadata.ino() as _, + metadata.dev() as _, + major(metadata.dev() as _) as _, + minor(metadata.dev() as _) as _, + metadata.dev() as _, + metadata.rdev() as _, + major(metadata.rdev() as _) as _, + minor(metadata.rdev() as _) as _, + metadata.rdev() as _, + major(metadata.rdev() as _) as _, + minor(metadata.rdev() as _) as _, + ]; + let result = ts.ucmd().args(&["--printf", test_str, device]).succeeds(); + let output = result + .stdout_str() + .split(' ') + .enumerate() + .map(parse) + .collect::, _>>() + .unwrap(); + assert_eq!(output, &expected); + } +} From 84e6f03ccb0e1f9fb3c5afe38b9bcc2166d10104 Mon Sep 17 00:00:00 2001 From: cerdelen <95369756+cerdelen@users.noreply.github.com> Date: Thu, 1 Jan 2026 21:37:39 +0100 Subject: [PATCH 052/425] Merge pull request #9785 from cerdelen/fix_date_military_parsing Fix military date parsing not adjusting date --- src/uu/date/src/date.rs | 70 ++++++++++++++++++++++++++++++++------ tests/by-util/test_date.rs | 40 ++++++++++++++++++++++ 2 files changed, 99 insertions(+), 11 deletions(-) diff --git a/src/uu/date/src/date.rs b/src/uu/date/src/date.rs index c72b1c304..d6623e5a6 100644 --- a/src/uu/date/src/date.rs +++ b/src/uu/date/src/date.rs @@ -116,6 +116,20 @@ impl From<&str> for Rfc3339Format { } } +/// Indicates whether parsing a military timezone causes the date to remain the same, roll back to the previous day, or +/// advance to the next day. +/// This can occur when applying a military timezone with an optional hour offset crosses midnight +/// in either direction. +#[derive(PartialEq, Debug)] +enum DayDelta { + /// The date does not change + Same, + /// The date rolls back to the previous day. + Previous, + /// The date advances to the next day. + Next, +} + /// Parse military timezone with optional hour offset. /// Pattern: single letter (a-z except j) optionally followed by 1-2 digits. /// Returns Some(total_hours_in_utc) or None if pattern doesn't match. @@ -128,7 +142,7 @@ impl From<&str> for Rfc3339Format { /// /// The hour offset from digits is added to the base military timezone offset. /// Examples: "m" -> 12 (noon UTC), "m9" -> 21 (9pm UTC), "a5" -> 4 (4am UTC next day) -fn parse_military_timezone_with_offset(s: &str) -> Option { +fn parse_military_timezone_with_offset(s: &str) -> Option<(i32, DayDelta)> { if s.is_empty() || s.len() > 3 { return None; } @@ -160,11 +174,17 @@ fn parse_military_timezone_with_offset(s: &str) -> Option { _ => return None, }; + let day_delta = match additional_hours - tz_offset { + h if h < 0 => DayDelta::Previous, + h if h >= 24 => DayDelta::Next, + _ => DayDelta::Same, + }; + // Calculate total hours: midnight (0) + tz_offset + additional_hours // Midnight in timezone X converted to UTC - let total_hours = (0 - tz_offset + additional_hours).rem_euclid(24); + let hours_from_midnight = (0 - tz_offset + additional_hours).rem_euclid(24); - Some(total_hours) + Some((hours_from_midnight, day_delta)) } #[uucore::main] @@ -306,11 +326,24 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { format!("{date_part} 00:00 {offset}") }; parse_date(composed) - } else if let Some(total_hours) = military_tz_with_offset { + } else if let Some((total_hours, day_delta)) = military_tz_with_offset { // Military timezone with optional hour offset // Convert to UTC time: midnight + military_tz_offset + additional_hours - let date_part = - strtime::format("%F", &now).unwrap_or_else(|_| String::from("1970-01-01")); + + // When calculating a military timezone with an optional hour offset, midnight may + // be crossed in either direction. `day_delta` indicates whether the date remains + // the same, moves to the previous day, or advances to the next day. + // Changing day can result in error, this closure will help handle these errors + // gracefully. + let format_date_with_epoch_fallback = |date: Result| -> String { + date.and_then(|d| strtime::format("%F", &d)) + .unwrap_or_else(|_| String::from("1970-01-01")) + }; + let date_part = match day_delta { + DayDelta::Same => format_date_with_epoch_fallback(Ok(now)), + DayDelta::Next => format_date_with_epoch_fallback(now.tomorrow()), + DayDelta::Previous => format_date_with_epoch_fallback(now.yesterday()), + }; let composed = format!("{date_part} {total_hours:02}:00:00 +00:00"); parse_date(composed) } else if is_pure_digits { @@ -817,11 +850,26 @@ mod tests { #[test] fn test_parse_military_timezone_with_offset() { // Valid cases: letter only, letter + digit, uppercase - assert_eq!(parse_military_timezone_with_offset("m"), Some(12)); // UTC+12 -> 12:00 UTC - assert_eq!(parse_military_timezone_with_offset("m9"), Some(21)); // 12 + 9 = 21 - assert_eq!(parse_military_timezone_with_offset("a5"), Some(4)); // 23 + 5 = 28 % 24 = 4 - assert_eq!(parse_military_timezone_with_offset("z"), Some(0)); // UTC+0 -> 00:00 UTC - assert_eq!(parse_military_timezone_with_offset("M9"), Some(21)); // Uppercase works + assert_eq!( + parse_military_timezone_with_offset("m"), + Some((12, DayDelta::Previous)) + ); // UTC+12 -> 12:00 UTC + assert_eq!( + parse_military_timezone_with_offset("m9"), + Some((21, DayDelta::Previous)) + ); // 12 + 9 = 21 + assert_eq!( + parse_military_timezone_with_offset("a5"), + Some((4, DayDelta::Same)) + ); // 23 + 5 = 28 % 24 = 4 + assert_eq!( + parse_military_timezone_with_offset("z"), + Some((0, DayDelta::Same)) + ); // UTC+0 -> 00:00 UTC + assert_eq!( + parse_military_timezone_with_offset("M9"), + Some((21, DayDelta::Previous)) + ); // Uppercase works // Invalid cases: 'j' reserved, empty, too long, starts with digit assert_eq!(parse_military_timezone_with_offset("j"), None); // Reserved for local time diff --git a/tests/by-util/test_date.rs b/tests/by-util/test_date.rs index 9a98b1b03..b0613b146 100644 --- a/tests/by-util/test_date.rs +++ b/tests/by-util/test_date.rs @@ -1132,6 +1132,46 @@ fn test_date_military_timezone_with_offset_variations() { } } +#[test] +fn test_date_military_timezone_with_offset_and_date() { + use chrono::{Duration, Utc}; + + let today = Utc::now().date_naive(); + + let test_cases = vec![ + ("m", -1), // M = UTC+12 + ("a", -1), // A = UTC+1 + ("n", 0), // N = UTC-1 + ("y", 0), // Y = UTC-12 + ("z", 0), // Z = UTC + // same day hour offsets + ("n2", 0), + // midnight crossings with hour offsets back to today + ("a1", 0), // exactly to midnight + ("a5", 0), // "overflow" midnight + ("m23", 0), + // midnight crossings with hour offsets to tomorrow + ("n23", 1), + ("y23", 1), + // midnight crossing to yesterday even with positive offset + ("m9", -1), // M = UTC+12 (-12 h + 9h is still `yesterday`) + ]; + + for (input, day_delta) in test_cases { + let expected_date = today.checked_add_signed(Duration::days(day_delta)).unwrap(); + + let expected = format!("{}\n", expected_date.format("%F")); + + new_ucmd!() + .env("TZ", "UTC") + .arg("-d") + .arg(input) + .arg("+%F") + .succeeds() + .stdout_is(expected); + } +} + // Locale-aware hour formatting tests #[test] #[cfg(unix)] From 2c039d6e50fac63207b2faf8b9b93ee9d517e5cd Mon Sep 17 00:00:00 2001 From: "Tom D." Date: Sat, 27 Dec 2025 22:45:33 +0100 Subject: [PATCH 053/425] perf(tsort): avoid reading the whole input into memory and intern strings --- Cargo.lock | 12 ++ Cargo.toml | 3 +- src/uu/tsort/Cargo.toml | 7 +- src/uu/tsort/src/tsort.rs | 367 ++++++++++++++++++++++---------------- 4 files changed, 235 insertions(+), 154 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bf51e63c1..870a61284 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2711,6 +2711,16 @@ dependencies = [ "num-traits", ] +[[package]] +name = "string-interner" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23de088478b31c349c9ba67816fa55d9355232d63c3afea8bf513e31f0f1d2c0" +dependencies = [ + "hashbrown 0.15.4", + "serde", +] + [[package]] name = "strsim" version = "0.11.1" @@ -4043,6 +4053,8 @@ dependencies = [ "clap", "codspeed-divan-compat", "fluent", + "nix", + "string-interner", "tempfile", "thiserror 2.0.17", "uucore", diff --git a/Cargo.toml b/Cargo.toml index 80af38b85..6d34d5c1e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ # coreutils (uutils) # * see the repository LICENSE, README, and CONTRIBUTING files for more information -# spell-checker:ignore (libs) bigdecimal datetime serde bincode gethostid kqueue libselinux mangen memmap uuhelp startswith constness expl unnested logind cfgs +# spell-checker:ignore (libs) bigdecimal datetime serde bincode gethostid kqueue libselinux mangen memmap uuhelp startswith constness expl unnested logind cfgs interner [package] name = "coreutils" @@ -369,6 +369,7 @@ same-file = "1.0.6" self_cell = "1.0.4" # FIXME we use the exact version because the new 0.5.3 requires an MSRV of 1.88 selinux = "=0.5.2" +string-interner = "0.19.0" signal-hook = "0.4.1" tempfile = "3.15.0" terminal_size = "0.4.0" diff --git a/src/uu/tsort/Cargo.toml b/src/uu/tsort/Cargo.toml index 94b170223..72559199c 100644 --- a/src/uu/tsort/Cargo.toml +++ b/src/uu/tsort/Cargo.toml @@ -1,3 +1,4 @@ +#spell-checker:ignore (libs) interner [package] name = "uu_tsort" description = "tsort ~ (uutils) topologically sort input (partially ordered) pairs" @@ -19,9 +20,11 @@ path = "src/tsort.rs" [dependencies] clap = { workspace = true } -thiserror = { workspace = true } -uucore = { workspace = true } fluent = { workspace = true } +string-interner = { workspace = true } +thiserror = { workspace = true } +nix = { workspace = true, features = ["fs"] } +uucore = { workspace = true } [[bin]] name = "tsort" diff --git a/src/uu/tsort/src/tsort.rs b/src/uu/tsort/src/tsort.rs index 26c6f8ffc..8eab8ab21 100644 --- a/src/uu/tsort/src/tsort.rs +++ b/src/uu/tsort/src/tsort.rs @@ -2,108 +2,95 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -//spell-checker:ignore TAOCP indegree +//spell-checker:ignore TAOCP indegree fadvise FADV +//spell-checker:ignore (libs) interner uclibc use clap::{Arg, ArgAction, Command}; use std::collections::hash_map::Entry; use std::collections::{HashMap, VecDeque}; use std::ffi::OsString; +use std::fs::File; +use std::io::{self, BufRead, BufReader}; use std::path::Path; +use string_interner::StringInterner; +use string_interner::backend::StringBackend; use thiserror::Error; use uucore::display::Quotable; use uucore::error::{UError, UResult, USimpleError}; -use uucore::{format_usage, show}; +use uucore::{format_usage, show, translate}; -use uucore::translate; +// short types for switching interning behavior on the fly. +type Sym = string_interner::symbol::SymbolU32; +type Interner = StringInterner>; mod options { pub const FILE: &str = "file"; } -#[derive(Debug, Error)] -enum TsortError { - /// The input file is actually a directory. - #[error("{input}: {message}", input = .0.maybe_quote(), message = translate!("tsort-error-is-dir"))] - IsDir(OsString), - - /// The number of tokens in the input data is odd. - /// - /// The list of edges must be even because each edge has two - /// components: a source node and a target node. - #[error("{input}: {message}", input = .0.maybe_quote(), message = translate!("tsort-error-odd"))] - NumTokensOdd(OsString), - - /// The graph contains a cycle. - #[error("{input}: {message}", input = .0.maybe_quote(), message = translate!("tsort-error-loop"))] - Loop(OsString), -} - -// Auxiliary struct, just for printing loop nodes via show! macro -#[derive(Debug, Error)] -#[error("{0}")] -struct LoopNode<'a>(&'a str); - -impl UError for TsortError {} -impl UError for LoopNode<'_> {} - #[uucore::main] pub fn uumain(args: impl uucore::Args) -> UResult<()> { let matches = uucore::clap_localization::handle_clap_result(uu_app(), args)?; - let mut inputs: Vec = matches + let mut inputs = matches .get_many::(options::FILE) - .map(|vals| vals.cloned().collect()) - .unwrap_or_default(); - - if inputs.is_empty() { - inputs.push(OsString::from("-")); - } - - if inputs.len() > 1 { - return Err(USimpleError::new( - 1, - translate!( - "tsort-error-extra-operand", - "operand" => inputs[1].quote(), - "util" => uucore::util_name() - ), - )); - } - - let input = inputs .into_iter() - .next() - .expect(translate!("tsort-error-at-least-one-input").as_str()); + .flatten(); - let data = if input == "-" { - let stdin = std::io::stdin(); - std::io::read_to_string(stdin)? - } else { - let path = Path::new(&input); - if path.is_dir() { - return Err(TsortError::IsDir(input.clone()).into()); + let input = match (inputs.next(), inputs.next()) { + (None, _) => { + return Err(USimpleError::new( + 1, + translate!("tsort-error-at-least-one-input"), + )); + } + (Some(input), None) => input, + (Some(_), Some(extra)) => { + return Err(USimpleError::new( + 1, + translate!( + "tsort-error-extra-operand", + "operand" => extra.quote(), + "util" => uucore::util_name() + ), + )); } - std::fs::read_to_string(path)? }; // Create the directed graph from pairs of tokens in the input data. - let mut g = Graph::new(input.clone()); - // Input is considered to be in the format - // From1 To1 From2 To2 ... - // with tokens separated by whitespaces - let mut edge_tokens = data.split_whitespace(); - // Note: this is equivalent to iterating over edge_tokens.chunks(2) - // but chunks() exists only for slices and would require unnecessary Vec allocation. - // Itertools::chunks() is not used due to unnecessary overhead for internal RefCells - loop { - // Try take next pair of tokens - let Some(from) = edge_tokens.next() else { - // no more tokens -> end of input. Graph constructed - break; - }; - let Some(to) = edge_tokens.next() else { - return Err(TsortError::NumTokensOdd(input.clone()).into()); - }; - g.add_edge(from, to); + let mut g = Graph::new(input.to_string_lossy().to_string()); + if input == "-" { + process_input(io::stdin().lock(), &mut g)?; + } else { + let path = Path::new(&input); + if path.is_dir() { + return Err(TsortError::IsDir(input.to_string_lossy().to_string()).into()); + } + + let file = File::open(path)?; + + // advise the OS we will access the data sequentially if available. + #[cfg(any( + target_os = "linux", + target_os = "android", + target_os = "fuchsia", + target_os = "wasi", + target_env = "uclibc", + target_os = "freebsd", + ))] + { + use nix::fcntl::{PosixFadviseAdvice, posix_fadvise}; + use std::os::unix::io::AsFd; + + posix_fadvise( + file.as_fd(), // file descriptor + 0, // start of the file + 0, // length 0 = all + PosixFadviseAdvice::POSIX_FADV_SEQUENTIAL, + ) + .ok(); + } + + let reader = BufReader::new(file); + process_input(reader, &mut g)?; } g.run_tsort(); @@ -117,6 +104,7 @@ pub fn uu_app() -> Command { .override_usage(format_usage(&translate!("tsort-usage"))) .about(translate!("tsort-about")) .infer_long_args(true) + // no-op flag, needed for POSIX compatibility. .arg( Arg::new("warn") .short('w') @@ -128,11 +116,69 @@ pub fn uu_app() -> Command { .hide(true) .value_parser(clap::value_parser!(OsString)) .value_hint(clap::ValueHint::FilePath) - .num_args(0..) + .default_value("-") + .num_args(1..) .action(ArgAction::Append), ) } +#[derive(Debug, Error)] +enum TsortError { + /// The input file is actually a directory. + #[error("{input}: {message}", input = .0.maybe_quote(), message = translate!("tsort-error-is-dir"))] + IsDir(String), + + /// The number of tokens in the input data is odd. + /// + /// The length of the list of edges must be even because each edge has two + /// components: a source node and a target node. + #[error("{input}: {message}", input = .0.maybe_quote(), message = translate!("tsort-error-odd"))] + NumTokensOdd(String), + + /// The graph contains a cycle. + #[error("{input}: {message}", input = .0, message = translate!("tsort-error-loop"))] + Loop(String), + + /// Wrapper for bubbling up IO errors + #[error("{0}")] + IO(#[from] std::io::Error), +} + +// Auxiliary struct, just for printing loop nodes via show! macro +#[derive(Debug, Error)] +#[error("{0}")] +struct LoopNode<'a>(&'a str); + +impl UError for TsortError {} +impl UError for LoopNode<'_> {} + +fn process_input(reader: R, graph: &mut Graph) -> Result<(), TsortError> { + let mut pending: Option = None; + + // Input is considered to be in the format + // From1 To1 From2 To2 ... + // with tokens separated by whitespaces + + for line in reader.lines() { + let line = line?; + for token in line.split_whitespace() { + // Intern the token and get a Sym + let token_sym = graph.interner.get_or_intern(token); + + if let Some(from) = pending.take() { + graph.add_edge(from, token_sym); + } else { + pending = Some(token_sym); + } + } + } + if pending.is_some() { + return Err(TsortError::NumTokensOdd(graph.name())); + } + + Ok(()) +} + /// Find the element `x` in `vec` and remove it, returning its index. fn remove(vec: &mut Vec, x: T) -> Option where @@ -143,40 +189,54 @@ where }) } -// We use String as a representation of node here -// but using integer may improve performance. -#[derive(Default)] -struct Node<'input> { - successor_names: Vec<&'input str>, - predecessor_count: usize, -} - -impl<'input> Node<'input> { - fn add_successor(&mut self, successor_name: &'input str) { - self.successor_names.push(successor_name); - } -} - -struct Graph<'input> { - name: OsString, - nodes: HashMap<&'input str, Node<'input>>, -} - #[derive(Clone, Copy, PartialEq, Eq)] enum VisitedState { Opened, Closed, } -impl<'input> Graph<'input> { - fn new(name: OsString) -> Self { +#[derive(Default)] +struct Node { + successor_tokens: Vec, + predecessor_count: usize, +} + +impl Node { + fn add_successor(&mut self, successor_name: Sym) { + self.successor_tokens.push(successor_name); + } +} + +struct Graph { + name_sym: Sym, + nodes: HashMap, + interner: Interner, +} + +impl Graph { + fn new(name: String) -> Self { + let mut interner = Interner::new(); + let name_sym = interner.get_or_intern(name); Self { - name, + name_sym, + interner, nodes: HashMap::default(), } } - fn add_edge(&mut self, from: &'input str, to: &'input str) { + fn name(&self) -> String { + //SAFETY: the name is interned during graph creation and stored as name_sym. + // gives much better performance on lookup. + unsafe { self.interner.resolve_unchecked(self.name_sym).to_owned() } + } + fn get_node_name(&self, node_sym: Sym) -> &str { + //SAFETY: the only way to get a Sym is by manipulating an interned string. + // gives much better performance on lookup. + + unsafe { self.interner.resolve_unchecked(node_sym) } + } + + fn add_edge(&mut self, from: Sym, to: Sym) { let from_node = self.nodes.entry(from).or_default(); if from != to { from_node.add_successor(to); @@ -185,71 +245,76 @@ impl<'input> Graph<'input> { } } - fn remove_edge(&mut self, u: &'input str, v: &'input str) { - remove(&mut self.nodes.get_mut(u).unwrap().successor_names, v); - self.nodes.get_mut(v).unwrap().predecessor_count -= 1; + fn remove_edge(&mut self, u: Sym, v: Sym) { + remove( + &mut self + .nodes + .get_mut(&u) + .expect("node is part of the graph") + .successor_tokens, + v, + ); + self.nodes + .get_mut(&v) + .expect("node is part of the graph") + .predecessor_count -= 1; } /// Implementation of algorithm T from TAOCP (Don. Knuth), vol. 1. fn run_tsort(&mut self) { - // First, we find nodes that have no prerequisites (independent nodes). - // If no such node exists, then there is a cycle. - let mut independent_nodes_queue: VecDeque<&'input str> = self + let mut independent_nodes_queue: VecDeque = self .nodes .iter() - .filter_map(|(&name, node)| { + .filter_map(|(&sym, node)| { if node.predecessor_count == 0 { - Some(name) + Some(sym) } else { None } }) .collect(); - // To make sure the resulting ordering is deterministic we - // need to order independent nodes. - // - // FIXME: this doesn't comply entirely with the GNU coreutils - // implementation. - independent_nodes_queue.make_contiguous().sort_unstable(); + // Sort by resolved string for deterministic output + independent_nodes_queue + .make_contiguous() + .sort_unstable_by(|a, b| self.get_node_name(*a).cmp(self.get_node_name(*b))); while !self.nodes.is_empty() { - // Get the next node (breaking any cycles necessary to do so). let v = self.find_next_node(&mut independent_nodes_queue); - println!("{v}"); - if let Some(node_to_process) = self.nodes.remove(v) { - for successor_name in node_to_process.successor_names.into_iter().rev() { - let successor_node = self.nodes.get_mut(successor_name).unwrap(); + println!("{}", self.get_node_name(v)); + if let Some(node_to_process) = self.nodes.remove(&v) { + for successor_name in node_to_process.successor_tokens.into_iter().rev() { + // we reverse to match GNU tsort order + let successor_node = self + .nodes + .get_mut(&successor_name) + .expect("node is part of the graph"); successor_node.predecessor_count -= 1; if successor_node.predecessor_count == 0 { - // If we find nodes without any other prerequisites, we add them to the queue. independent_nodes_queue.push_back(successor_name); } } } } } - - /// Get the in-degree of the node with the given name. - fn indegree(&self, name: &str) -> Option { - self.nodes.get(name).map(|data| data.predecessor_count) + pub fn indegree(&self, sym: Sym) -> Option { + self.nodes.get(&sym).map(|data| data.predecessor_count) } - // Pre-condition: self.nodes is non-empty. - fn find_next_node(&mut self, frontier: &mut VecDeque<&'input str>) -> &'input str { + fn find_next_node(&mut self, frontier: &mut VecDeque) -> Sym { // If there are no nodes of in-degree zero but there are still // un-visited nodes in the graph, then there must be a cycle. - // We need to find the cycle, display it, and then break the - // cycle. + // We need to find the cycle, display it on stderr, and break it to go on. // // A cycle is guaranteed to be of length at least two. We break // the cycle by deleting an arbitrary edge (the first). That is // not necessarily the optimal thing, but it should be enough to - // continue making progress in the graph traversal. + // continue making progress in the graph traversal, and matches GNU tsort behavior. // // It is possible that deleting the edge does not actually // result in the target node having in-degree zero, so we repeat // the process until such a node appears. + loop { match frontier.pop_front() { None => self.find_and_break_cycle(frontier), @@ -258,27 +323,28 @@ impl<'input> Graph<'input> { } } - fn find_and_break_cycle(&mut self, frontier: &mut VecDeque<&'input str>) { + fn find_and_break_cycle(&mut self, frontier: &mut VecDeque) { let cycle = self.detect_cycle(); - show!(TsortError::Loop(self.name.clone())); - for &node in &cycle { - show!(LoopNode(node)); + show!(TsortError::Loop(self.name())); + for &sym in &cycle { + show!(LoopNode(self.get_node_name(sym))); } let u = *cycle.last().expect("cycle must be non-empty"); let v = cycle[0]; self.remove_edge(u, v); - if self.indegree(v).unwrap() == 0 { + if self.indegree(v).expect("node is part of the graph") == 0 { frontier.push_back(v); } } - fn detect_cycle(&self) -> Vec<&'input str> { - let mut nodes: Vec<_> = self.nodes.keys().collect(); - nodes.sort_unstable(); + fn detect_cycle(&self) -> Vec { + // Sort by resolved string for deterministic output + let mut nodes: Vec<_> = self.nodes.keys().copied().collect(); + nodes.sort_unstable_by(|a, b| self.get_node_name(*a).cmp(self.get_node_name(*b))); let mut visited = HashMap::new(); let mut stack = Vec::with_capacity(self.nodes.len()); - for node in nodes { + for &node in &nodes { if self.dfs(node, &mut visited, &mut stack) { let (loop_entry, _) = stack.pop().expect("loop is not empty"); @@ -294,13 +360,15 @@ impl<'input> Graph<'input> { fn dfs<'a>( &'a self, - node: &'input str, - visited: &mut HashMap<&'input str, VisitedState>, - stack: &mut Vec<(&'input str, &'a [&'input str])>, + node: Sym, + visited: &mut HashMap, + stack: &mut Vec<(Sym, &'a [Sym])>, ) -> bool { stack.push(( node, - self.nodes.get(node).map_or(&[], |n| &n.successor_names), + self.nodes + .get(&node) + .map_or(&[], |n: &Node| &n.successor_tokens), )); let state = *visited.entry(node).or_insert(VisitedState::Opened); @@ -320,22 +388,19 @@ impl<'input> Graph<'input> { match visited.entry(next_node) { Entry::Vacant(v) => { - // It's a first time we enter this node + // first visit of the node v.insert(VisitedState::Opened); stack.push(( next_node, self.nodes - .get(next_node) - .map_or(&[], |n| &n.successor_names), + .get(&next_node) + .map_or(&[], |n| &n.successor_tokens), )); } Entry::Occupied(o) => { if *o.get() == VisitedState::Opened { - // we are entering the same opened node again -> loop found - // stack contains it - // - // But part of the stack may not be belonging to this loop - // push found node to the stack to be able to trace the beginning of the loop + // We have found a node that was already visited by another iteration => loop completed + // the stack may contain unrelated nodes. This allows narrowing the loop down. stack.push((next_node, &[])); return true; } From 1e207892dc6fc1d4132106eae4168e061da2bb1b Mon Sep 17 00:00:00 2001 From: "Tom D." Date: Thu, 1 Jan 2026 22:34:51 +0100 Subject: [PATCH 054/425] perf(tsort): avoid redundant check on input --- src/uu/tsort/src/tsort.rs | 69 +++++++++++++++++++++++---------------- 1 file changed, 41 insertions(+), 28 deletions(-) diff --git a/src/uu/tsort/src/tsort.rs b/src/uu/tsort/src/tsort.rs index 8eab8ab21..50e8acb75 100644 --- a/src/uu/tsort/src/tsort.rs +++ b/src/uu/tsort/src/tsort.rs @@ -10,7 +10,6 @@ use std::collections::{HashMap, VecDeque}; use std::ffi::OsString; use std::fs::File; use std::io::{self, BufRead, BufReader}; -use std::path::Path; use string_interner::StringInterner; use string_interner::backend::StringBackend; use thiserror::Error; @@ -54,41 +53,49 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { )); } }; - + let file: File; // Create the directed graph from pairs of tokens in the input data. let mut g = Graph::new(input.to_string_lossy().to_string()); if input == "-" { process_input(io::stdin().lock(), &mut g)?; } else { - let path = Path::new(&input); - if path.is_dir() { - return Err(TsortError::IsDir(input.to_string_lossy().to_string()).into()); - } - - let file = File::open(path)?; - - // advise the OS we will access the data sequentially if available. - #[cfg(any( - target_os = "linux", - target_os = "android", - target_os = "fuchsia", - target_os = "wasi", - target_env = "uclibc", - target_os = "freebsd", - ))] + #[cfg(windows)] { - use nix::fcntl::{PosixFadviseAdvice, posix_fadvise}; - use std::os::unix::io::AsFd; + use std::path::Path; - posix_fadvise( - file.as_fd(), // file descriptor - 0, // start of the file - 0, // length 0 = all - PosixFadviseAdvice::POSIX_FADV_SEQUENTIAL, - ) - .ok(); + let path = Path::new(input); + if path.is_dir() { + return Err(TsortError::IsDir(input.to_string_lossy().to_string()).into()); + } + + file = File::open(path)?; } + #[cfg(not(windows))] + { + file = File::open(input)?; + // advise the OS we will access the data sequentially if available. + #[cfg(any( + target_os = "linux", + target_os = "android", + target_os = "fuchsia", + target_os = "wasi", + target_env = "uclibc", + target_os = "freebsd", + ))] + { + use nix::fcntl::{PosixFadviseAdvice, posix_fadvise}; + use std::os::unix::io::AsFd; + + posix_fadvise( + file.as_fd(), // file descriptor + 0, // start of the file + 0, // length 0 = all + PosixFadviseAdvice::POSIX_FADV_SEQUENTIAL, + ) + .ok(); + } + } let reader = BufReader::new(file); process_input(reader, &mut g)?; } @@ -160,7 +167,13 @@ fn process_input(reader: R, graph: &mut Graph) -> Result<(), TsortEr // with tokens separated by whitespaces for line in reader.lines() { - let line = line?; + let line = line.map_err(|e| { + if e.kind() == io::ErrorKind::IsADirectory { + TsortError::IsDir(graph.name()) + } else { + e.into() + } + })?; for token in line.split_whitespace() { // Intern the token and get a Sym let token_sym = graph.interner.get_or_intern(token); From 4420344edd681c54250a6f2b02d7fcc8bd2b5fb1 Mon Sep 17 00:00:00 2001 From: "Tom D." Date: Fri, 2 Jan 2026 11:59:31 +0100 Subject: [PATCH 055/425] perf(tsort): switch to the Bucket interning Backend for better lookup performance --- src/uu/tsort/src/tsort.rs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/uu/tsort/src/tsort.rs b/src/uu/tsort/src/tsort.rs index 50e8acb75..713c2f5c9 100644 --- a/src/uu/tsort/src/tsort.rs +++ b/src/uu/tsort/src/tsort.rs @@ -11,15 +11,15 @@ use std::ffi::OsString; use std::fs::File; use std::io::{self, BufRead, BufReader}; use string_interner::StringInterner; -use string_interner::backend::StringBackend; +use string_interner::backend::BucketBackend; use thiserror::Error; use uucore::display::Quotable; use uucore::error::{UError, UResult, USimpleError}; use uucore::{format_usage, show, translate}; // short types for switching interning behavior on the fly. -type Sym = string_interner::symbol::SymbolU32; -type Interner = StringInterner>; +type Sym = string_interner::symbol::SymbolUsize; +type Interner = StringInterner>; mod options { pub const FILE: &str = "file"; @@ -59,6 +59,8 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { if input == "-" { process_input(io::stdin().lock(), &mut g)?; } else { + // Windows reports a permission denied error when trying to read a directory. + // So we check manually beforehand. On other systems, we avoid this extra check for performance. #[cfg(windows)] { use std::path::Path; @@ -88,9 +90,9 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { use std::os::unix::io::AsFd; posix_fadvise( - file.as_fd(), // file descriptor - 0, // start of the file - 0, // length 0 = all + file.as_fd(), + 0, // offset 0 => from the start of the file + 0, // length 0 => for the whole file PosixFadviseAdvice::POSIX_FADV_SEQUENTIAL, ) .ok(); From 6ac95431698bd03c52b472bb7cd1e72c93889402 Mon Sep 17 00:00:00 2001 From: Max Ambaum Date: Fri, 2 Jan 2026 12:03:01 +0000 Subject: [PATCH 056/425] split: Added error when attempting to create file that already exists as directory (#9945) * split: Added error when attempting to create file that already exists as dir * split: Added integration test test_split::test_split_directory_already_exists * Fixed dependency error in windows.rs * Modified test to work on systems without /dev/zero * Attempt to fix windows error handling * Removed test for windows and made it more rigorous * Err made to look more like gnu * Updated test to reflect change in err message --- src/uu/split/locales/en-US.ftl | 1 + src/uu/split/src/platform/unix.rs | 11 +++++++---- src/uu/split/src/platform/windows.rs | 11 ++++++++--- tests/by-util/test_split.rs | 13 +++++++++++++ 4 files changed, 29 insertions(+), 7 deletions(-) diff --git a/src/uu/split/locales/en-US.ftl b/src/uu/split/locales/en-US.ftl index 4247eb5b9..629b8956d 100644 --- a/src/uu/split/locales/en-US.ftl +++ b/src/uu/split/locales/en-US.ftl @@ -43,6 +43,7 @@ split-error-unable-to-reopen-file = unable to re-open { $file }; aborting split-error-file-descriptor-limit = at file descriptor limit, but no file descriptor left to close. Closed { $count } writers before. split-error-shell-process-returned = Shell process returned { $code } split-error-shell-process-terminated = Shell process terminated by signal +split-error-is-a-directory = { $dir }: Is a directory # Help messages for command-line options split-help-bytes = put SIZE bytes per output file diff --git a/src/uu/split/src/platform/unix.rs b/src/uu/split/src/platform/unix.rs index d1257954d..d530ee259 100644 --- a/src/uu/split/src/platform/unix.rs +++ b/src/uu/split/src/platform/unix.rs @@ -4,8 +4,8 @@ // file that was distributed with this source code. use std::env; use std::ffi::OsStr; -use std::io::Write; use std::io::{BufWriter, Error, Result}; +use std::io::{ErrorKind, Write}; use std::path::Path; use std::process::{Child, Command, Stdio}; use uucore::error::USimpleError; @@ -139,10 +139,13 @@ pub fn instantiate_current_writer( .create(true) .truncate(true) .open(Path::new(&filename)) - .map_err(|_| { - Error::other( + .map_err(|e| match e.kind() { + ErrorKind::IsADirectory => Error::other( + translate!("split-error-is-a-directory", "dir" => filename), + ), + _ => Error::other( translate!("split-error-unable-to-open-file", "file" => filename), - ) + ), })? } else { // re-open file that we previously created to append to it diff --git a/src/uu/split/src/platform/windows.rs b/src/uu/split/src/platform/windows.rs index e443a9cfb..6693e4fe9 100644 --- a/src/uu/split/src/platform/windows.rs +++ b/src/uu/split/src/platform/windows.rs @@ -3,8 +3,8 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. use std::ffi::OsStr; -use std::io::Write; use std::io::{BufWriter, Error, Result}; +use std::io::{ErrorKind, Write}; use std::path::Path; use uucore::fs; use uucore::translate; @@ -25,8 +25,13 @@ pub fn instantiate_current_writer( .create(true) .truncate(true) .open(Path::new(&filename)) - .map_err(|_| { - Error::other(translate!("split-error-unable-to-open-file", "file" => filename)) + .map_err(|e| match e.kind() { + ErrorKind::IsADirectory => { + Error::other(translate!("split-error-is-a-directory", "dir" => filename)) + } + _ => { + Error::other(translate!("split-error-unable-to-open-file", "file" => filename)) + } })? } else { // re-open file that we previously created to append to it diff --git a/tests/by-util/test_split.rs b/tests/by-util/test_split.rs index f710e1442..497559aca 100644 --- a/tests/by-util/test_split.rs +++ b/tests/by-util/test_split.rs @@ -2078,3 +2078,16 @@ fn test_split_non_utf8_additional_suffix() { "Expected at least one split file to be created" ); } + +#[test] +#[cfg(target_os = "linux")] // To re-enable on Windows once I work out what goes wrong with it. +fn test_split_directory_already_exists() { + let (at, mut ucmd) = at_and_ucmd!(); + + at.mkdir("xaa"); // For collision with. + at.touch("file"); + ucmd.args(&["file"]) + .fails_with_code(1) + .no_stdout() + .stderr_is("split: xaa: Is a directory\n"); +} From 0566dfc6effe441f5a0d80459bcc64653c012d83 Mon Sep 17 00:00:00 2001 From: cerdelen <95369756+cerdelen@users.noreply.github.com> Date: Fri, 2 Jan 2026 16:38:14 +0100 Subject: [PATCH 057/425] rmdir: Remove all trailing slashes when checking for symlinks (#9983) * rmdir: Remove all trailing slashes when checking for symlinks rmdir: cargo fmt l * rmdir: Extract removal of trailing slashes to helper func * rmdir: Add regression test for removal of trailing slashes when checking for symlink * rmdir: add cfg flag to helper func which is only used on unix --- src/uu/rmdir/src/rmdir.rs | 14 ++++++++++++-- tests/by-util/test_rmdir.rs | 15 +++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/uu/rmdir/src/rmdir.rs b/src/uu/rmdir/src/rmdir.rs index 4f13afcbf..e0c9f73bc 100644 --- a/src/uu/rmdir/src/rmdir.rs +++ b/src/uu/rmdir/src/rmdir.rs @@ -66,10 +66,11 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { Ok(path.metadata()?.file_type().is_dir()) } - let bytes = path.as_os_str().as_bytes(); + let mut bytes = path.as_os_str().as_bytes(); if error.raw_os_error() == Some(libc::ENOTDIR) && bytes.ends_with(b"/") { // Strip the trailing slash or .symlink_metadata() will follow the symlink - let no_slash: &Path = OsStr::from_bytes(&bytes[..bytes.len() - 1]).as_ref(); + bytes = strip_trailing_slashes_from_path(bytes); + let no_slash: &Path = OsStr::from_bytes(bytes).as_ref(); if no_slash.is_symlink() && points_to_directory(no_slash).unwrap_or(true) { show_error!( "{}", @@ -119,6 +120,15 @@ fn remove_single(path: &Path, opts: Opts) -> Result<(), Error<'_>> { remove_dir(path).map_err(|error| Error { error, path }) } +#[cfg(unix)] +fn strip_trailing_slashes_from_path(path: &[u8]) -> &[u8] { + let mut end = path.len(); + while end > 0 && path[end - 1] == b'/' { + end -= 1; + } + &path[..end] +} + // POSIX: https://pubs.opengroup.org/onlinepubs/009696799/functions/rmdir.html #[cfg(not(windows))] const NOT_EMPTY_CODES: &[i32] = &[libc::ENOTEMPTY, libc::EEXIST]; diff --git a/tests/by-util/test_rmdir.rs b/tests/by-util/test_rmdir.rs index 0c52a2287..669884488 100644 --- a/tests/by-util/test_rmdir.rs +++ b/tests/by-util/test_rmdir.rs @@ -243,3 +243,18 @@ fn test_rmdir_remove_symlink_dangling() { .fails() .stderr_is("rmdir: failed to remove 'dl/': Symbolic link not followed\n"); } + +#[cfg(any(target_os = "linux", target_os = "android"))] +#[test] +fn test_rmdir_remove_symlink_dir_with_trailing_slashes() { + // a symlink with trailing slashes should still be printing the 'Symbolic link not followed' + // message + let (at, mut ucmd) = at_and_ucmd!(); + + at.mkdir("dir"); + at.symlink_dir("dir", "dl"); + + ucmd.arg("dl////") + .fails() + .stderr_is("rmdir: failed to remove 'dl////': Symbolic link not followed\n"); +} From fdf14099b79d294ec9859adf0ad4b9b52ee26152 Mon Sep 17 00:00:00 2001 From: Daniel Hofstetter Date: Fri, 2 Jan 2026 15:07:13 +0100 Subject: [PATCH 058/425] uucore/uptime: remove unreachable code on Windows --- src/uucore/src/lib/features/uptime.rs | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/uucore/src/lib/features/uptime.rs b/src/uucore/src/lib/features/uptime.rs index cc4d976ae..9dbf878d7 100644 --- a/src/uucore/src/lib/features/uptime.rs +++ b/src/uucore/src/lib/features/uptime.rs @@ -338,13 +338,8 @@ pub fn get_nusers() -> usize { continue; } - let username = if !buffer.is_null() { - let cstr = std::ffi::CStr::from_ptr(buffer as *const i8); - cstr.to_string_lossy().to_string() - } else { - String::new() - }; - if !username.is_empty() { + let cstr = std::ffi::CStr::from_ptr(buffer.cast()); + if !cstr.is_empty() { num_user += 1; } From 464ff21dd3f534f77e4b21acca5bbb041ad61682 Mon Sep 17 00:00:00 2001 From: Daniel Hofstetter Date: Fri, 2 Jan 2026 17:06:10 +0100 Subject: [PATCH 059/425] benchmarks: use simulation mode instrumentation mode has been deprecated --- .github/workflows/benchmarks.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index 76fe09b7a..55bdc8279 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -72,7 +72,7 @@ jobs: env: CODSPEED_LOG: debug with: - mode: instrumentation + mode: simulation run: | echo "Running benchmarks for ${{ matrix.benchmark-target.package }}" cargo codspeed run -p ${{ matrix.benchmark-target.package }} > /dev/null From 971b5d851fd103f8fea50847fa4f0f2e5b61363b Mon Sep 17 00:00:00 2001 From: Rostyslav Toch Date: Fri, 2 Jan 2026 16:17:24 +0000 Subject: [PATCH 060/425] date: add benchmark (#9911) * date: add benchmark * date: register benchmark in github actions list --------- Co-authored-by: Sylvestre Ledru --- .github/workflows/benchmarks.yml | 1 + Cargo.lock | 2 + src/uu/date/Cargo.toml | 9 ++++ src/uu/date/benches/date_bench.rs | 76 +++++++++++++++++++++++++++++++ 4 files changed, 88 insertions(+) create mode 100644 src/uu/date/benches/date_bench.rs diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index 76fe09b7a..33588cd80 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -45,6 +45,7 @@ jobs: - { package: uu_uniq } - { package: uu_wc } - { package: uu_factor } + - { package: uu_date } steps: - uses: actions/checkout@v6 with: diff --git a/Cargo.lock b/Cargo.lock index 870a61284..e0c8bb884 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3208,10 +3208,12 @@ name = "uu_date" version = "0.5.0" dependencies = [ "clap", + "codspeed-divan-compat", "fluent", "jiff", "nix", "parse_datetime", + "tempfile", "uucore", "windows-sys 0.61.2", ] diff --git a/src/uu/date/Cargo.toml b/src/uu/date/Cargo.toml index 431868b91..9bff97696 100644 --- a/src/uu/date/Cargo.toml +++ b/src/uu/date/Cargo.toml @@ -41,3 +41,12 @@ windows-sys = { workspace = true, features = [ [[bin]] name = "date" path = "src/main.rs" + +[dev-dependencies] +divan = { workspace = true } +tempfile = { workspace = true } +uucore = { workspace = true, features = ["benchmark"] } + +[[bench]] +name = "date_bench" +harness = false diff --git a/src/uu/date/benches/date_bench.rs b/src/uu/date/benches/date_bench.rs new file mode 100644 index 000000000..636f876c5 --- /dev/null +++ b/src/uu/date/benches/date_bench.rs @@ -0,0 +1,76 @@ +// This file is part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +use divan::{Bencher, black_box}; +use std::io::Write; +use tempfile::NamedTempFile; +use uu_date::uumain; +use uucore::benchmark::run_util_function; + +/// Helper to create a temporary file containing N lines of date strings. +fn setup_date_file(lines: usize, date_format: &str) -> NamedTempFile { + let mut file = NamedTempFile::new().unwrap(); + for _ in 0..lines { + writeln!(file, "{date_format}").unwrap(); + } + file +} + +/// Benchmarks processing a file containing simple ISO dates. +#[divan::bench(args = [100, 1_000, 10_000])] +fn file_iso_dates(bencher: Bencher, count: usize) { + let file = setup_date_file(count, "2023-05-10 12:00:00"); + let path = file.path().to_str().unwrap(); + + bencher.bench(|| { + black_box(run_util_function(uumain, &["-f", path])); + }); +} + +/// Benchmarks processing a file containing dates with Timezone abbreviations. +#[divan::bench(args = [100, 1_000, 10_000])] +fn file_tz_abbreviations(bencher: Bencher, count: usize) { + // "EST" triggers the abbreviation lookup and double-parsing logic + let file = setup_date_file(count, "2023-05-10 12:00:00 EST"); + let path = file.path().to_str().unwrap(); + + bencher.bench(|| { + black_box(run_util_function(uumain, &["-f", path])); + }); +} + +/// Benchmarks formatting speed using a custom output format. +#[divan::bench(args = [1_000])] +fn file_custom_format(bencher: Bencher, count: usize) { + let file = setup_date_file(count, "2023-05-10 12:00:00"); + let path = file.path().to_str().unwrap(); + + bencher.bench(|| { + black_box(run_util_function(uumain, &["-f", path, "+%A %d %B %Y"])); + }); +} + +/// Benchmarks the overhead of starting the utility for a single date (no file). +#[divan::bench] +fn single_date_now(bencher: Bencher) { + bencher.bench(|| { + black_box(run_util_function(uumain, &[])); + }); +} + +/// Benchmarks parsing a complex relative date string passed as an argument. +#[divan::bench] +fn complex_relative_date(bencher: Bencher) { + bencher.bench(|| { + black_box(run_util_function( + uumain, + &["--date=last friday 12:00 + 2 days"], + )); + }); +} + +fn main() { + divan::main(); +} From ae204c01b1ab4bc6b38741c25c4eb62a08f02f83 Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Fri, 2 Jan 2026 18:57:28 +0100 Subject: [PATCH 061/425] date benchmark: keep only one value --- src/uu/date/benches/date_bench.rs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/uu/date/benches/date_bench.rs b/src/uu/date/benches/date_bench.rs index 636f876c5..1c1d05aae 100644 --- a/src/uu/date/benches/date_bench.rs +++ b/src/uu/date/benches/date_bench.rs @@ -19,8 +19,9 @@ fn setup_date_file(lines: usize, date_format: &str) -> NamedTempFile { } /// Benchmarks processing a file containing simple ISO dates. -#[divan::bench(args = [100, 1_000, 10_000])] -fn file_iso_dates(bencher: Bencher, count: usize) { +#[divan::bench] +fn file_iso_dates(bencher: Bencher) { + let count = 1_000; let file = setup_date_file(count, "2023-05-10 12:00:00"); let path = file.path().to_str().unwrap(); @@ -30,8 +31,9 @@ fn file_iso_dates(bencher: Bencher, count: usize) { } /// Benchmarks processing a file containing dates with Timezone abbreviations. -#[divan::bench(args = [100, 1_000, 10_000])] -fn file_tz_abbreviations(bencher: Bencher, count: usize) { +#[divan::bench] +fn file_tz_abbreviations(bencher: Bencher) { + let count = 1_000; // "EST" triggers the abbreviation lookup and double-parsing logic let file = setup_date_file(count, "2023-05-10 12:00:00 EST"); let path = file.path().to_str().unwrap(); @@ -42,8 +44,9 @@ fn file_tz_abbreviations(bencher: Bencher, count: usize) { } /// Benchmarks formatting speed using a custom output format. -#[divan::bench(args = [1_000])] -fn file_custom_format(bencher: Bencher, count: usize) { +#[divan::bench] +fn file_custom_format(bencher: Bencher) { + let count = 1_000; let file = setup_date_file(count, "2023-05-10 12:00:00"); let path = file.path().to_str().unwrap(); From 0e85a93c6ca113e7b8d614e05be557005c0dccd9 Mon Sep 17 00:00:00 2001 From: Christopher Dryden Date: Fri, 2 Jan 2026 20:20:01 +0000 Subject: [PATCH 062/425] pr: add -b flag for backwards compatibility --- src/uu/pr/src/pr.rs | 8 ++++++++ tests/by-util/test_pr.rs | 6 ++++++ 2 files changed, 14 insertions(+) diff --git a/src/uu/pr/src/pr.rs b/src/uu/pr/src/pr.rs index 19e9f2a0c..fde237048 100644 --- a/src/uu/pr/src/pr.rs +++ b/src/uu/pr/src/pr.rs @@ -48,6 +48,7 @@ mod options { pub const COLUMN_WIDTH: &str = "width"; pub const PAGE_WIDTH: &str = "page-width"; pub const ACROSS: &str = "across"; + pub const COLUMN_DOWN: &str = "column-down"; pub const COLUMN: &str = "column"; pub const COLUMN_CHAR_SEPARATOR: &str = "separator"; pub const COLUMN_STRING_SEPARATOR: &str = "sep-string"; @@ -257,6 +258,13 @@ pub fn uu_app() -> Command { .help(translate!("pr-help-across")) .action(ArgAction::SetTrue), ) + .arg( + // -b is a no-op for backwards compatibility (column-down is now the default) + Arg::new(options::COLUMN_DOWN) + .short('b') + .hide(true) + .action(ArgAction::SetTrue), + ) .arg( Arg::new(options::COLUMN) .long(options::COLUMN) diff --git a/tests/by-util/test_pr.rs b/tests/by-util/test_pr.rs index 0bb161fb8..26f64e1dc 100644 --- a/tests/by-util/test_pr.rs +++ b/tests/by-util/test_pr.rs @@ -616,3 +616,9 @@ fn test_version() { fn test_pr_char_device_dev_null() { new_ucmd!().arg("/dev/null").succeeds(); } + +#[test] +fn test_b_flag_backwards_compat() { + // -b is a no-op for backwards compatibility (column-down is now the default) + new_ucmd!().args(&["-b", "-t"]).pipe_in("a\nb\n").succeeds(); +} From fe65d17b5cdb42e1285178e7c5280f576e092bb3 Mon Sep 17 00:00:00 2001 From: CrazyRoka Date: Fri, 2 Jan 2026 21:49:33 +0000 Subject: [PATCH 063/425] date: avoid double parsing when resolving timezone abbreviations --- src/uu/date/src/date.rs | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/src/uu/date/src/date.rs b/src/uu/date/src/date.rs index d6623e5a6..389e26923 100644 --- a/src/uu/date/src/date.rs +++ b/src/uu/date/src/date.rs @@ -671,9 +671,12 @@ fn tz_abbrev_to_iana(abbrev: &str) -> Option<&str> { cache.get(abbrev).map(|s| s.as_str()) } -/// Resolve timezone abbreviation in date string and replace with numeric offset. -/// Returns the modified string with offset, or original if no abbreviation found. -fn resolve_tz_abbreviation>(date_str: S) -> String { +/// Attempts to parse a date string that contains a timezone abbreviation (e.g. "EST"). +/// +/// If an abbreviation is found and the date is parsable, returns `Some(Zoned)`. +/// Returns `None` if no abbreviation is detected or if parsing fails, indicating +/// that standard parsing should be attempted. +fn try_parse_with_abbreviation>(date_str: S) -> Option { let s = date_str.as_ref(); // Look for timezone abbreviation at the end of the string @@ -697,11 +700,7 @@ fn resolve_tz_abbreviation>(date_str: S) -> String { let ts = parsed.timestamp(); // Get the offset for this specific timestamp in the target timezone - let zoned = ts.to_zoned(tz); - let offset_str = format!("{}", zoned.offset()); - - // Replace abbreviation with offset - return format!("{date_part} {offset_str}"); + return Some(ts.to_zoned(tz)); } } } @@ -709,7 +708,7 @@ fn resolve_tz_abbreviation>(date_str: S) -> String { } // No abbreviation found or couldn't resolve, return original - s.to_string() + None } /// Parse a `String` into a `DateTime`. @@ -724,10 +723,12 @@ fn resolve_tz_abbreviation>(date_str: S) -> String { fn parse_date + Clone>( s: S, ) -> Result { - // First, try to resolve any timezone abbreviations - let resolved = resolve_tz_abbreviation(s.as_ref()); + // First, try to parse any timezone abbreviations + if let Some(zoned) = try_parse_with_abbreviation(s.as_ref()) { + return Ok(zoned); + } - match parse_datetime::parse_datetime(&resolved) { + match parse_datetime::parse_datetime(s.as_ref()) { Ok(date) => { // Convert to system timezone for display // (parse_datetime 0.13 returns Zoned in the input's timezone) From c01e83eb76cc59e0a78eb2d5fa0ec99d80c4c44f Mon Sep 17 00:00:00 2001 From: oech3 <> Date: Sat, 3 Jan 2026 07:03:02 +0900 Subject: [PATCH 064/425] hashsum: Move --ckeck's deps to clap --- src/uu/hashsum/src/hashsum.rs | 33 +++++++++++++++------------------ tests/by-util/test_hashsum.rs | 6 +++--- util/build-gnu.sh | 2 ++ 3 files changed, 20 insertions(+), 21 deletions(-) diff --git a/src/uu/hashsum/src/hashsum.rs b/src/uu/hashsum/src/hashsum.rs index 19e8ad9db..eea434d94 100644 --- a/src/uu/hashsum/src/hashsum.rs +++ b/src/uu/hashsum/src/hashsum.rs @@ -157,19 +157,11 @@ pub fn uumain(mut args: impl uucore::Args) -> UResult<()> { }; let check = matches.get_flag("check"); - let check_flag = |flag| match (check, matches.get_flag(flag)) { - (_, false) => Ok(false), - (true, true) => Ok(true), - (false, true) => Err(ChecksumError::CheckOnlyFlag(flag.into())), - }; - - // Each of the following flags are only expected in --check mode. - // If we encounter them otherwise, end with an error. - let ignore_missing = check_flag("ignore-missing")?; - let warn = check_flag("warn")?; - let quiet = check_flag("quiet")?; - let strict = check_flag("strict")?; - let status = check_flag("status")?; + let ignore_missing = matches.get_flag("ignore-missing"); + let warn = matches.get_flag("warn"); + let quiet = matches.get_flag("quiet"); + let strict = matches.get_flag("strict"); + let status = matches.get_flag("status"); let files = matches.get_many::(options::FILE).map_or_else( // No files given, read from stdin. @@ -301,7 +293,8 @@ pub fn uu_app_common() -> Command { .long(options::QUIET) .help(translate!("hashsum-help-quiet")) .action(ArgAction::SetTrue) - .overrides_with_all([options::STATUS, options::WARN]), + .overrides_with_all([options::STATUS, options::WARN]) + .requires(options::CHECK), ) .arg( Arg::new(options::STATUS) @@ -309,19 +302,22 @@ pub fn uu_app_common() -> Command { .long("status") .help(translate!("hashsum-help-status")) .action(ArgAction::SetTrue) - .overrides_with_all([options::QUIET, options::WARN]), + .overrides_with_all([options::QUIET, options::WARN]) + .requires(options::CHECK), ) .arg( Arg::new(options::STRICT) .long("strict") .help(translate!("hashsum-help-strict")) - .action(ArgAction::SetTrue), + .action(ArgAction::SetTrue) + .requires(options::CHECK), ) .arg( Arg::new("ignore-missing") .long("ignore-missing") .help(translate!("hashsum-help-ignore-missing")) - .action(ArgAction::SetTrue), + .action(ArgAction::SetTrue) + .requires(options::CHECK), ) .arg( Arg::new(options::WARN) @@ -329,7 +325,8 @@ pub fn uu_app_common() -> Command { .long("warn") .help(translate!("hashsum-help-warn")) .action(ArgAction::SetTrue) - .overrides_with_all([options::QUIET, options::STATUS]), + .overrides_with_all([options::QUIET, options::STATUS]) + .requires(options::CHECK), ) .arg( Arg::new("zero") diff --git a/tests/by-util/test_hashsum.rs b/tests/by-util/test_hashsum.rs index 2f1719b0e..891cb9d4d 100644 --- a/tests/by-util/test_hashsum.rs +++ b/tests/by-util/test_hashsum.rs @@ -268,7 +268,7 @@ fn test_check_md5_ignore_missing() { .arg("--ignore-missing") .arg(at.subdir.join("testf.sha1")) .fails() - .stderr_contains("the --ignore-missing option is meaningful only when verifying checksums"); + .stderr_contains("the following required arguments were not provided"); //clap generated error } #[test] @@ -1021,13 +1021,13 @@ fn test_check_quiet() { .arg("--quiet") .arg(at.subdir.join("in.md5")) .fails() - .stderr_contains("md5sum: the --quiet option is meaningful only when verifying checksums"); + .stderr_contains("the following required arguments were not provided"); //clap generated error scene .ccmd("md5sum") .arg("--strict") .arg(at.subdir.join("in.md5")) .fails() - .stderr_contains("md5sum: the --strict option is meaningful only when verifying checksums"); + .stderr_contains("the following required arguments were not provided"); //clap generated error } #[test] diff --git a/util/build-gnu.sh b/util/build-gnu.sh index 7e92396bf..2ae6b1e61 100755 --- a/util/build-gnu.sh +++ b/util/build-gnu.sh @@ -322,6 +322,8 @@ 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 +# clap changes the error message + "${SED}" -i '/check-ignore-missing-4/,/EXIT=> 1/ { /ERR=>/,/try_help/d }' 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 From b92d74e2f8f6a0405f9e159b3679a1f6c1efd117 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 3 Jan 2026 00:46:49 +0000 Subject: [PATCH 065/425] chore(deps): update rust crate clap to v4.5.54 --- Cargo.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e0c8bb884..8934d048b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -345,18 +345,18 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.53" +version = "4.5.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8" +checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394" dependencies = [ "clap_builder", ] [[package]] name = "clap_builder" -version = "4.5.53" +version = "4.5.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00" +checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00" dependencies = [ "anstream", "anstyle", From d28cb30c63289c3878571534b9e1abd95dd73e6f Mon Sep 17 00:00:00 2001 From: cerdelen <95369756+cerdelen@users.noreply.github.com> Date: Sat, 3 Jan 2026 11:40:51 +0100 Subject: [PATCH 066/425] Merge pull request #9990 from cerdelen/chmod_recursive_hyper_nested_dirs Chmod recursive hyper nested dirs --- src/uu/chmod/src/chmod.rs | 12 +++++++++--- tests/by-util/test_chmod.rs | 10 ++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/uu/chmod/src/chmod.rs b/src/uu/chmod/src/chmod.rs index b7e0f3fd9..b77de93f2 100644 --- a/src/uu/chmod/src/chmod.rs +++ b/src/uu/chmod/src/chmod.rs @@ -432,14 +432,20 @@ impl Chmoder { // If the path is a directory (or we should follow symlinks), recurse into it if (!file_path.is_symlink() || should_follow_symlink) && file_path.is_dir() { + // We buffer all paths in this dir to not keep to be able to close the fd so not + // too many fd's are open during the recursion + let mut paths_in_this_dir = Vec::new(); + for dir_entry in file_path.read_dir()? { - let path = match dir_entry { - Ok(entry) => entry.path(), + match dir_entry { + Ok(entry) => paths_in_this_dir.push(entry.path()), Err(err) => { r = r.and(Err(err.into())); continue; } - }; + } + } + for path in paths_in_this_dir { if path.is_symlink() { r = self.handle_symlink_during_recursion(&path).and(r); } else { diff --git a/tests/by-util/test_chmod.rs b/tests/by-util/test_chmod.rs index 5e3407328..6d242020c 100644 --- a/tests/by-util/test_chmod.rs +++ b/tests/by-util/test_chmod.rs @@ -407,6 +407,16 @@ fn test_chmod_recursive_correct_exit_code() { .stderr_is(err_msg); } +#[test] +fn test_chmod_hyper_recursive_directory_tree_does_not_fail() { + let (at, mut ucmd) = at_and_ucmd!(); + let mkdir = "a/".repeat(400); + + at.mkdir_all(&mkdir); + + ucmd.arg("-R").arg("777").arg("a").succeeds(); +} + #[test] #[allow(clippy::unreadable_literal)] fn test_chmod_recursive() { From ea64612efbc2d5d6818f9b0ce85ef5a1e6af6338 Mon Sep 17 00:00:00 2001 From: Ramon <55579979+van-sprundel@users.noreply.github.com> Date: Sat, 3 Jan 2026 11:47:06 +0100 Subject: [PATCH 067/425] df: add binfmt_misc to is_dummy_filesystem (#9975) * df: add binfmt_misc to is_dummy_filesystem * df: add spell-checker ignore for binfmt * uucore: rmaix flag --- src/uucore/src/lib/features/fsext.rs | 14 +++++++-- tests/by-util/test_df.rs | 47 +++++++++++++++++++++++++++- 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/src/uucore/src/lib/features/fsext.rs b/src/uucore/src/lib/features/fsext.rs index ce734ff2d..f2ae59a76 100644 --- a/src/uucore/src/lib/features/fsext.rs +++ b/src/uucore/src/lib/features/fsext.rs @@ -380,7 +380,7 @@ impl From for MountInfo { } } -#[cfg(all(unix, not(any(target_os = "aix", target_os = "redox"))))] +#[cfg(all(unix, not(target_os = "redox")))] fn is_dummy_filesystem(fs_type: &str, mount_option: &str) -> bool { // spell-checker:disable match fs_type { @@ -392,7 +392,9 @@ fn is_dummy_filesystem(fs_type: &str, mount_option: &str) -> bool { // for NetBSD 3.0 | "kernfs" // for Irix 6.5 - | "ignore" => true, + | "ignore" + // Binary format support pseudo-filesystem + | "binfmt_misc" => true, _ => fs_type == "none" && !mount_option.contains(MOUNT_OPT_BIND) } @@ -1220,4 +1222,12 @@ mod tests { crate::os_str_from_bytes(b"/mnt/some- -dir-\xf3").unwrap() ); } + + #[test] + #[cfg(all(unix, not(target_os = "redox")))] + // spell-checker:ignore (word) binfmt + fn test_binfmt_misc_is_dummy() { + use super::is_dummy_filesystem; + assert!(is_dummy_filesystem("binfmt_misc", "")); + } } diff --git a/tests/by-util/test_df.rs b/tests/by-util/test_df.rs index 8b305ce42..4754acbfe 100644 --- a/tests/by-util/test_df.rs +++ b/tests/by-util/test_df.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 udev pcent iuse itotal iused ipcent +// spell-checker:ignore udev pcent iuse itotal iused ipcent binfmt #![allow( clippy::similar_names, clippy::cast_possible_truncation, @@ -1046,3 +1046,48 @@ fn test_nonexistent_file() { .stderr_is("df: does-not-exist: No such file or directory\n") .stdout_is("File\n.\n"); } + +#[test] +#[cfg(target_os = "linux")] +fn test_df_all_shows_binfmt_misc() { + // Check if binfmt_misc is mounted + let is_mounted = std::fs::read_to_string("/proc/self/mountinfo") + .map(|content| content.lines().any(|line| line.contains("binfmt_misc"))) + .unwrap_or(false); + + if is_mounted { + let output = new_ucmd!() + .args(&["--all", "--output=fstype,target"]) + .succeeds() + .stdout_str_lossy(); + + assert!( + output.contains("binfmt_misc"), + "Expected binfmt_misc filesystem to appear in df --all output when it's mounted" + ); + } + // If binfmt_misc is not mounted, skip the test silently +} + +#[test] +#[cfg(target_os = "linux")] +fn test_df_hides_binfmt_misc_by_default() { + // Check if binfmt_misc is mounted + let is_mounted = std::fs::read_to_string("/proc/self/mountinfo") + .map(|content| content.lines().any(|line| line.contains("binfmt_misc"))) + .unwrap_or(false); + + if is_mounted { + let output = new_ucmd!() + .args(&["--output=fstype,target"]) + .succeeds() + .stdout_str_lossy(); + + // binfmt_misc should NOT appear in the output without --all + assert!( + !output.contains("binfmt_misc"), + "Expected binfmt_misc filesystem to be hidden in df output without --all" + ); + } + // If binfmt_misc is not mounted, skip the test silently +} From 1d63bdd163f6cc5d01a8442a430d9561e36d440b Mon Sep 17 00:00:00 2001 From: oech3 <> Date: Sat, 3 Jan 2026 06:31:37 +0900 Subject: [PATCH 068/425] cksum: Move --ckeck's deps by clap --- src/uu/cksum/src/cksum.rs | 33 +++++++++++++++------------------ 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/src/uu/cksum/src/cksum.rs b/src/uu/cksum/src/cksum.rs index 30eabcaac..bb3e32511 100644 --- a/src/uu/cksum/src/cksum.rs +++ b/src/uu/cksum/src/cksum.rs @@ -140,19 +140,11 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { let check = matches.get_flag(options::CHECK); - let check_flag = |flag| match (check, matches.get_flag(flag)) { - (_, false) => Ok(false), - (true, true) => Ok(true), - (false, true) => Err(ChecksumError::CheckOnlyFlag(flag.into())), - }; - - // Each of the following flags are only expected in --check mode. - // If we encounter them otherwise, end with an error. - let ignore_missing = check_flag(options::IGNORE_MISSING)?; - let warn = check_flag(options::WARN)?; - let quiet = check_flag(options::QUIET)?; - let strict = check_flag(options::STRICT)?; - let status = check_flag(options::STATUS)?; + let ignore_missing = matches.get_flag(options::IGNORE_MISSING); + let warn = matches.get_flag(options::WARN); + let quiet = matches.get_flag(options::QUIET); + let strict = matches.get_flag(options::STRICT); + let status = matches.get_flag(options::STATUS); let algo_cli = matches .get_one::(options::ALGORITHM) @@ -284,7 +276,8 @@ pub fn uu_app() -> Command { Arg::new(options::STRICT) .long(options::STRICT) .help(translate!("cksum-help-strict")) - .action(ArgAction::SetTrue), + .action(ArgAction::SetTrue) + .requires(options::CHECK), ) .arg( Arg::new(options::CHECK) @@ -324,27 +317,31 @@ pub fn uu_app() -> Command { .long("warn") .help(translate!("cksum-help-warn")) .action(ArgAction::SetTrue) - .overrides_with_all([options::STATUS, options::QUIET]), + .overrides_with_all([options::STATUS, options::QUIET]) + .requires(options::CHECK), ) .arg( Arg::new(options::STATUS) .long("status") .help(translate!("cksum-help-status")) .action(ArgAction::SetTrue) - .overrides_with_all([options::WARN, options::QUIET]), + .overrides_with_all([options::WARN, options::QUIET]) + .requires(options::CHECK), ) .arg( Arg::new(options::QUIET) .long(options::QUIET) .help(translate!("cksum-help-quiet")) .action(ArgAction::SetTrue) - .overrides_with_all([options::WARN, options::STATUS]), + .overrides_with_all([options::WARN, options::STATUS]) + .requires(options::CHECK), ) .arg( Arg::new(options::IGNORE_MISSING) .long(options::IGNORE_MISSING) .help(translate!("cksum-help-ignore-missing")) - .action(ArgAction::SetTrue), + .action(ArgAction::SetTrue) + .requires(options::CHECK), ) .arg( Arg::new(options::ZERO) From e4d35b03bfe41facfde1d82c382d43c317aac31a Mon Sep 17 00:00:00 2001 From: WaterWhisperer Date: Sat, 3 Jan 2026 20:22:18 +0800 Subject: [PATCH 069/425] join: add benchmarks (#10005) --- .github/workflows/benchmarks.yml | 1 + Cargo.lock | 2 + src/uu/join/Cargo.toml | 9 +++ src/uu/join/benches/join_bench.rs | 115 ++++++++++++++++++++++++++++++ 4 files changed, 127 insertions(+) create mode 100644 src/uu/join/benches/join_bench.rs diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index e14de6c02..9f53a0167 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -31,6 +31,7 @@ jobs: - { package: uu_du } - { package: uu_expand } - { package: uu_fold } + - { package: uu_join } - { package: uu_ls } - { package: uu_mv } - { package: uu_nl } diff --git a/Cargo.lock b/Cargo.lock index 8934d048b..47ecd75af 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3462,8 +3462,10 @@ name = "uu_join" version = "0.5.0" dependencies = [ "clap", + "codspeed-divan-compat", "fluent", "memchr", + "tempfile", "thiserror 2.0.17", "uucore", ] diff --git a/src/uu/join/Cargo.toml b/src/uu/join/Cargo.toml index cc93d5e18..401cb3bb5 100644 --- a/src/uu/join/Cargo.toml +++ b/src/uu/join/Cargo.toml @@ -27,3 +27,12 @@ fluent = { workspace = true } [[bin]] name = "join" path = "src/main.rs" + +[dev-dependencies] +divan = { workspace = true } +tempfile = { workspace = true } +uucore = { workspace = true, features = ["benchmark"] } + +[[bench]] +name = "join_bench" +harness = false diff --git a/src/uu/join/benches/join_bench.rs b/src/uu/join/benches/join_bench.rs new file mode 100644 index 000000000..efa316fd2 --- /dev/null +++ b/src/uu/join/benches/join_bench.rs @@ -0,0 +1,115 @@ +// This file is part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +use divan::{Bencher, black_box}; +use std::{fs::File, io::Write}; +use tempfile::TempDir; +use uu_join::uumain; +use uucore::benchmark::run_util_function; + +/// Create two sorted files with matching keys for join benchmarking +fn create_join_files(temp_dir: &TempDir, num_lines: usize) -> (String, String) { + let file1_path = temp_dir.path().join("file1.txt"); + let file2_path = temp_dir.path().join("file2.txt"); + + let mut file1 = File::create(&file1_path).unwrap(); + let mut file2 = File::create(&file2_path).unwrap(); + + for i in 0..num_lines { + writeln!(file1, "{i:08} field1_{i} field2_{i}").unwrap(); + writeln!(file2, "{i:08} data1_{i} data2_{i}").unwrap(); + } + + ( + file1_path.to_str().unwrap().to_string(), + file2_path.to_str().unwrap().to_string(), + ) +} + +/// Create two files with partial overlap for join benchmarking +fn create_partial_overlap_files( + temp_dir: &TempDir, + num_lines: usize, + overlap_ratio: f64, +) -> (String, String) { + let file1_path = temp_dir.path().join("file1.txt"); + let file2_path = temp_dir.path().join("file2.txt"); + + let mut file1 = File::create(&file1_path).unwrap(); + let mut file2 = File::create(&file2_path).unwrap(); + + let overlap_count = (num_lines as f64 * overlap_ratio) as usize; + + // File 1: keys 0 to num_lines-1 + for i in 0..num_lines { + writeln!(file1, "{i:08} f1_data_{i}").unwrap(); + } + + // File 2: keys (num_lines - overlap_count) to (2*num_lines - overlap_count - 1) + let start = num_lines - overlap_count; + for i in 0..num_lines { + writeln!(file2, "{:08} f2_data_{}", start + i, i).unwrap(); + } + + ( + file1_path.to_str().unwrap().to_string(), + file2_path.to_str().unwrap().to_string(), + ) +} + +/// Benchmark basic join with fully matching keys +#[divan::bench] +fn join_full_match(bencher: Bencher) { + let num_lines = 10000; + let temp_dir = TempDir::new().unwrap(); + let (file1, file2) = create_join_files(&temp_dir, num_lines); + + bencher.bench(|| { + black_box(run_util_function(uumain, &[&file1, &file2])); + }); +} + +/// Benchmark join with partial overlap (50%) +#[divan::bench] +fn join_partial_overlap(bencher: Bencher) { + let num_lines = 10000; + let temp_dir = TempDir::new().unwrap(); + let (file1, file2) = create_partial_overlap_files(&temp_dir, num_lines, 0.5); + + bencher.bench(|| { + black_box(run_util_function(uumain, &[&file1, &file2])); + }); +} + +/// Benchmark join with custom field separator +#[divan::bench] +fn join_custom_separator(bencher: Bencher) { + let num_lines = 10000; + let temp_dir = TempDir::new().unwrap(); + let file1_path = temp_dir.path().join("file1.txt"); + let file2_path = temp_dir.path().join("file2.txt"); + + let mut file1 = File::create(&file1_path).unwrap(); + let mut file2 = File::create(&file2_path).unwrap(); + + for i in 0..num_lines { + writeln!(file1, "{i:08}\tfield1_{i}\tfield2_{i}").unwrap(); + writeln!(file2, "{i:08}\tdata1_{i}\tdata2_{i}").unwrap(); + } + + let file1_str = file1_path.to_str().unwrap(); + let file2_str = file2_path.to_str().unwrap(); + + bencher.bench(|| { + black_box(run_util_function( + uumain, + &["-t", "\t", file1_str, file2_str], + )); + }); +} + +fn main() { + divan::main(); +} From 8cb4f3094b4e659cfac4532342f4ddaab1840217 Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Sat, 3 Jan 2026 21:23:14 +0900 Subject: [PATCH 070/425] Merge pull request #9999 from oech3/cksum-text-clap-untagged cksum: Move handle_tag_text_binary_flags to clap --- src/uu/cksum/src/cksum.rs | 46 ++++++------------------------------- tests/by-util/test_cksum.rs | 2 +- 2 files changed, 8 insertions(+), 40 deletions(-) diff --git a/src/uu/cksum/src/cksum.rs b/src/uu/cksum/src/cksum.rs index bb3e32511..3d814ae6f 100644 --- a/src/uu/cksum/src/cksum.rs +++ b/src/uu/cksum/src/cksum.rs @@ -74,42 +74,6 @@ mod options { /// Returns a pair of boolean. The first one indicates if we should use tagged /// output format, the second one indicates if we should use the binary flag in /// the untagged case. -fn handle_tag_text_binary_flags>( - args: impl Iterator, -) -> UResult<(bool, bool)> { - let mut tag = true; - let mut binary = false; - let mut text = false; - - // --binary, --tag and --untagged are tight together: none of them - // conflicts with each other but --tag will reset "binary" and "text" and - // set "tag". - - for arg in args { - let arg = arg.as_ref(); - if arg == "-b" || arg == "--binary" { - text = false; - binary = true; - } else if arg == "--text" { - text = true; - binary = false; - } else if arg == "--tag" { - tag = true; - binary = false; - text = false; - } else if arg == "--untagged" { - tag = false; - } - } - - // Specifying --text without ever mentioning --untagged fails. - if text && tag { - return Err(ChecksumError::TextWithoutUntagged.into()); - } - - Ok((tag, binary)) -} - /// Sanitize the `--length` argument depending on `--algorithm` and `--length`. fn maybe_sanitize_length( algo_cli: Option, @@ -200,7 +164,8 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { // Set the default algorithm to CRC when not '--check'ing. let algo_kind = algo_cli.unwrap_or(AlgoKind::Crc); - let (tag, binary) = handle_tag_text_binary_flags(std::env::args_os())?; + let tag = matches.get_flag(options::TAG) || !matches.get_flag(options::UNTAGGED); + let binary = matches.get_flag(options::BINARY); let algo = SizedAlgoKind::from_unsized(algo_kind, length)?; let line_ending = LineEnding::from_zero_flag(matches.get_flag(options::ZERO)); @@ -257,7 +222,9 @@ pub fn uu_app() -> Command { .long(options::TAG) .help(translate!("cksum-help-tag")) .action(ArgAction::SetTrue) - .overrides_with(options::UNTAGGED), + .overrides_with(options::UNTAGGED) + .overrides_with(options::BINARY) + .overrides_with(options::TEXT), ) .arg( Arg::new(options::LENGTH) @@ -301,7 +268,8 @@ pub fn uu_app() -> Command { .short('t') .hide(true) .overrides_with(options::BINARY) - .action(ArgAction::SetTrue), + .action(ArgAction::SetTrue) + .requires(options::UNTAGGED), ) .arg( Arg::new(options::BINARY) diff --git a/tests/by-util/test_cksum.rs b/tests/by-util/test_cksum.rs index d4685d619..d1abe3409 100644 --- a/tests/by-util/test_cksum.rs +++ b/tests/by-util/test_cksum.rs @@ -1066,7 +1066,7 @@ mod output_format { .args(&["-a", "md5"]) .arg(at.subdir.join("f")) .fails_with_code(1) - .stderr_contains("--text mode is only supported with --untagged"); + .stderr_contains("the following required arguments were not provided"); //clap does not change the meaning } #[test] From b852b1bb2733ed59094f5e5d9453cac8b41112a9 Mon Sep 17 00:00:00 2001 From: WaterWhisperer Date: Sat, 3 Jan 2026 21:45:11 +0800 Subject: [PATCH 071/425] join: add benchmark with the French locale (#10025) --- src/uu/join/benches/join_bench.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/uu/join/benches/join_bench.rs b/src/uu/join/benches/join_bench.rs index efa316fd2..798f4344f 100644 --- a/src/uu/join/benches/join_bench.rs +++ b/src/uu/join/benches/join_bench.rs @@ -110,6 +110,22 @@ fn join_custom_separator(bencher: Bencher) { }); } +/// Benchmark join with French locale (fr_FR.UTF-8) +#[divan::bench] +fn join_french_locale(bencher: Bencher) { + let num_lines = 10000; + let temp_dir = TempDir::new().unwrap(); + let (file1, file2) = create_join_files(&temp_dir, num_lines); + + bencher + .with_inputs(|| unsafe { + std::env::set_var("LC_ALL", "fr_FR.UTF-8"); + }) + .bench_values(|_| { + black_box(run_util_function(uumain, &[&file1, &file2])); + }); +} + fn main() { divan::main(); } From 49466a3e0b1d9d46db567af05bc20e7298a92c4d Mon Sep 17 00:00:00 2001 From: ffgan Date: Sun, 4 Jan 2026 00:57:57 +0800 Subject: [PATCH 072/425] CI: Default build artifact for riscv64+musl on CICD.yml (#10029) Co-authored by: nijincheng@iscas.ac.cn; Signed-off-by: ffgan --- .cargo/config.toml | 2 ++ .github/workflows/CICD.yml | 9 ++++++++- Cross.toml | 3 +++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/.cargo/config.toml b/.cargo/config.toml index 364776950..803f62499 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -6,6 +6,8 @@ linker = "x86_64-unknown-redox-gcc" [target.aarch64-unknown-linux-gnu] linker = "aarch64-linux-gnu-gcc" +[target.riscv64gc-unknown-linux-musl] +rustflags = ["-C", "target-feature=+crt-static"] [env] # See feat_external_libstdbuf in src/uu/stdbuf/Cargo.toml diff --git a/.github/workflows/CICD.yml b/.github/workflows/CICD.yml index e6a7fd450..67169f984 100644 --- a/.github/workflows/CICD.yml +++ b/.github/workflows/CICD.yml @@ -309,7 +309,7 @@ jobs: ./target/release-fast/true # Check that the progs have prefix test -f /tmp/usr/local/bin/uu-tty - test -f /tmp/usr/local/libexec/uu-coreutils/libstdbuf.* + test -f /tmp/usr/local/libexec/uu-coreutils/libstdbuf.* # Check that the manpage is not present ! test -f /tmp/usr/local/share/man/man1/uu-whoami.1 # Check that the completion is not present @@ -576,6 +576,7 @@ jobs: - { os: ubuntu-latest , target: arm-unknown-linux-gnueabihf , features: feat_os_unix_gnueabihf , use-cross: use-cross , skip-tests: true } - { os: ubuntu-24.04-arm , target: aarch64-unknown-linux-gnu , features: feat_os_unix_gnueabihf } - { os: ubuntu-latest , target: aarch64-unknown-linux-musl , features: feat_os_unix_musl , use-cross: use-cross , skip-tests: true } + - { os: ubuntu-latest , target: riscv64gc-unknown-linux-musl , features: feat_os_unix_musl , use-cross: use-cross , skip-tests: true } # - { os: ubuntu-latest , target: x86_64-unknown-linux-gnu , features: feat_selinux , use-cross: use-cross } - { os: ubuntu-latest , target: i686-unknown-linux-gnu , features: "feat_os_unix,test_risky_names", use-cross: use-cross } - { os: ubuntu-latest , target: i686-unknown-linux-musl , features: feat_os_unix_musl , use-cross: use-cross } @@ -636,6 +637,7 @@ jobs: unset TARGET_ARCH case '${{ matrix.job.target }}' in aarch64-*) TARGET_ARCH=arm64 ;; + riscv64gc-*) TARGET_ARCH=riscv64 ;; arm-*-*hf) TARGET_ARCH=armhf ;; i686-*) TARGET_ARCH=i686 ;; x86_64-*) TARGET_ARCH=x86_64 ;; @@ -700,6 +702,7 @@ jobs: STRIP="strip" case ${{ matrix.job.target }} in aarch64-*-linux-*) STRIP="aarch64-linux-gnu-strip" ;; + riscv64gc-*-linux-*) STRIP="riscv64-linux-gnu-strip" ;; arm-*-linux-gnueabihf) STRIP="arm-linux-gnueabihf-strip" ;; *-pc-windows-msvc) STRIP="" ;; esac; @@ -726,6 +729,10 @@ jobs: sudo apt-get -y update sudo apt-get -y install gcc-aarch64-linux-gnu ;; + riscv64gc-unknown-linux-*) + sudo apt-get -y update + sudo apt-get -y install gcc-riscv64-linux-gnu + ;; *-redox*) sudo apt-get -y update sudo apt-get -y install fuse3 libfuse-dev diff --git a/Cross.toml b/Cross.toml index 52f5bad21..90d824e61 100644 --- a/Cross.toml +++ b/Cross.toml @@ -5,3 +5,6 @@ pre-build = [ ] [build.env] passthrough = ["CI", "RUST_BACKTRACE", "CARGO_TERM_COLOR"] + +[target.riscv64gc-unknown-linux-musl] +image = "ghcr.io/cross-rs/riscv64gc-unknown-linux-musl:main" From 730b0a65900c658f2358e0f1da56e036c625d726 Mon Sep 17 00:00:00 2001 From: Haowei Hsu Date: Sun, 4 Jan 2026 00:58:45 +0800 Subject: [PATCH 073/425] style(uudoc): update header formatting for options and examples (#10004) Adjust the formatting of the options and examples sections in the uudoc output to use Markdown headers: - Change `

Options

` to `## Options` - Change `Examples` to `## Examples` - Add regression test to prevent reverting to HTML headers --- src/bin/uudoc.rs | 6 ++++-- tests/uudoc/mod.rs | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/src/bin/uudoc.rs b/src/bin/uudoc.rs index 5a713e040..392375f9e 100644 --- a/src/bin/uudoc.rs +++ b/src/bin/uudoc.rs @@ -465,7 +465,9 @@ impl MDWriter<'_, '_> { /// # Errors /// Returns an error if the writer fails. fn options(&mut self) -> io::Result<()> { - writeln!(self.w, "

Options

")?; + writeln!(self.w)?; + writeln!(self.w, "## Options")?; + writeln!(self.w)?; write!(self.w, "
")?; for arg in self.command.get_arguments() { write!(self.w, "
")?; @@ -576,7 +578,7 @@ fn format_examples(content: String, output_markdown: bool) -> ResultOptions"), + "Generated markdown should not contain '

Options

' (use markdown format instead)" + ); + + // Also verify Examples if it exists + if content.contains("## Examples") { + assert!( + content.contains("## Examples"), + "Generated markdown should contain '## Examples' header in markdown format" + ); + } + } +} From 3df9444afa8ef3ec8113dc27f774ba439c275863 Mon Sep 17 00:00:00 2001 From: Rostyslav Toch Date: Sat, 3 Jan 2026 17:00:04 +0000 Subject: [PATCH 074/425] perf(date): wrap stdout in BufWriter to improve batch processing (#9994) --- src/uu/date/locales/en-US.ftl | 1 + src/uu/date/locales/fr-FR.ftl | 1 + src/uu/date/src/date.rs | 19 +++++++++++++------ 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/uu/date/locales/en-US.ftl b/src/uu/date/locales/en-US.ftl index 80f82649d..782275fec 100644 --- a/src/uu/date/locales/en-US.ftl +++ b/src/uu/date/locales/en-US.ftl @@ -105,3 +105,4 @@ date-error-setting-date-not-supported-macos = setting the date is not supported date-error-setting-date-not-supported-redox = setting the date is not supported by Redox date-error-cannot-set-date = cannot set date date-error-extra-operand = extra operand '{$operand}' +date-error-write = write error: {$error} diff --git a/src/uu/date/locales/fr-FR.ftl b/src/uu/date/locales/fr-FR.ftl index 1967c958a..15321c1fc 100644 --- a/src/uu/date/locales/fr-FR.ftl +++ b/src/uu/date/locales/fr-FR.ftl @@ -100,3 +100,4 @@ date-error-setting-date-not-supported-macos = la définition de la date n'est pa date-error-setting-date-not-supported-redox = la définition de la date n'est pas prise en charge par Redox date-error-cannot-set-date = impossible de définir la date date-error-extra-operand = opérande supplémentaire '{$operand}' +date-error-write = erreur d'écriture: {$error} diff --git a/src/uu/date/src/date.rs b/src/uu/date/src/date.rs index 389e26923..5baa75432 100644 --- a/src/uu/date/src/date.rs +++ b/src/uu/date/src/date.rs @@ -13,7 +13,7 @@ use jiff::tz::{TimeZone, TimeZoneDatabase}; use jiff::{Timestamp, Zoned}; use std::collections::HashMap; use std::fs::File; -use std::io::{BufRead, BufReader}; +use std::io::{BufRead, BufReader, BufWriter, Write}; use std::path::PathBuf; use std::sync::OnceLock; use uucore::display::Quotable; @@ -428,24 +428,31 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { }; let format_string = make_format_string(&settings); + let mut stdout = BufWriter::new(std::io::stdout().lock()); // Format all the dates for date in dates { match date { // TODO: Switch to lenient formatting. Ok(date) => match strtime::format(format_string, &date) { - Ok(s) => println!("{s}"), + Ok(s) => writeln!(stdout, "{s}").map_err(|e| { + USimpleError::new(1, translate!("date-error-write", "error" => e)) + })?, Err(e) => { + let _ = stdout.flush(); return Err(USimpleError::new( 1, translate!("date-error-invalid-format", "format" => format_string, "error" => e), )); } }, - Err((input, _err)) => show!(USimpleError::new( - 1, - translate!("date-error-invalid-date", "date" => input) - )), + Err((input, _err)) => { + let _ = stdout.flush(); + show!(USimpleError::new( + 1, + translate!("date-error-invalid-date", "date" => input) + )); + } } } From 09f9d024018ea490bf6fc22d4080bfdc9a848a0c Mon Sep 17 00:00:00 2001 From: Ruiyang Wang Date: Sat, 3 Jan 2026 13:43:29 -0800 Subject: [PATCH 075/425] cat: fix write error handling to propagate errors instead of panicking The write helper functions (write_to_end, write_tab_to_end, write_nonprint_to_end) were using .unwrap() on write operations, which would cause a panic if writing failed. This changes them to return io::Result and use ? to properly propagate errors. Changes: - write_to_end: returns io::Result, uses ? instead of .unwrap() - write_tab_to_end: returns io::Result, uses ? instead of .unwrap() - write_nonprint_to_end: returns io::Result, uses ? instead of .unwrap() - write_end: returns io::Result to propagate errors from helpers - Updated call site in write_lines to handle the Result with ? - Updated unit tests to call .unwrap() on the Result Fixes #10016 --- src/uu/cat/src/cat.rs | 49 +++++++++++++++++++++++-------------------- 1 file changed, 26 insertions(+), 23 deletions(-) diff --git a/src/uu/cat/src/cat.rs b/src/uu/cat/src/cat.rs index 26b28d916..3497429d2 100644 --- a/src/uu/cat/src/cat.rs +++ b/src/uu/cat/src/cat.rs @@ -565,7 +565,7 @@ fn write_lines( } // print to end of line or end of buffer - let offset = write_end(&mut writer, &in_buf[pos..], options); + let offset = write_end(&mut writer, &in_buf[pos..], options)?; // end of buffer? if offset + pos == in_buf.len() { @@ -628,7 +628,11 @@ fn write_new_line( Ok(()) } -fn write_end(writer: &mut W, in_buf: &[u8], options: &OutputOptions) -> usize { +fn write_end( + writer: &mut W, + in_buf: &[u8], + options: &OutputOptions, +) -> io::Result { if options.show_nonprint { write_nonprint_to_end(in_buf, writer, options.tab().as_bytes()) } else if options.show_tabs { @@ -644,21 +648,21 @@ fn write_end(writer: &mut W, in_buf: &[u8], options: &OutputOptions) - // however, write_nonprint_to_end doesn't need to stop at \r because it will always write \r as ^M. // Return the number of written symbols -fn write_to_end(in_buf: &[u8], writer: &mut W) -> usize { +fn write_to_end(in_buf: &[u8], writer: &mut W) -> io::Result { // using memchr2 significantly improves performances match memchr2(b'\n', b'\r', in_buf) { Some(p) => { - writer.write_all(&in_buf[..p]).unwrap(); - p + writer.write_all(&in_buf[..p])?; + Ok(p) } None => { - writer.write_all(in_buf).unwrap(); - in_buf.len() + writer.write_all(in_buf)?; + Ok(in_buf.len()) } } } -fn write_tab_to_end(mut in_buf: &[u8], writer: &mut W) -> usize { +fn write_tab_to_end(mut in_buf: &[u8], writer: &mut W) -> io::Result { let mut count = 0; loop { match in_buf @@ -666,25 +670,25 @@ fn write_tab_to_end(mut in_buf: &[u8], writer: &mut W) -> usize { .position(|c| *c == b'\n' || *c == b'\t' || *c == b'\r') { Some(p) => { - writer.write_all(&in_buf[..p]).unwrap(); + writer.write_all(&in_buf[..p])?; if in_buf[p] == b'\t' { - writer.write_all(b"^I").unwrap(); + writer.write_all(b"^I")?; in_buf = &in_buf[p + 1..]; count += p + 1; } else { // b'\n' or b'\r' - return count + p; + return Ok(count + p); } } None => { - writer.write_all(in_buf).unwrap(); - return in_buf.len() + count; + writer.write_all(in_buf)?; + return Ok(in_buf.len() + count); } } } } -fn write_nonprint_to_end(in_buf: &[u8], writer: &mut W, tab: &[u8]) -> usize { +fn write_nonprint_to_end(in_buf: &[u8], writer: &mut W, tab: &[u8]) -> io::Result { let mut count = 0; for byte in in_buf.iter().copied() { @@ -699,11 +703,10 @@ fn write_nonprint_to_end(in_buf: &[u8], writer: &mut W, tab: &[u8]) -> 128..=159 => writer.write_all(&[b'M', b'-', b'^', byte - 64]), 160..=254 => writer.write_all(&[b'M', b'-', byte - 128]), _ => writer.write_all(b"M-^?"), - } - .unwrap(); + }?; count += 1; } - count + Ok(count) } fn write_end_of_line( @@ -733,14 +736,14 @@ mod tests { fn test_write_tab_to_end_with_newline() { let mut writer = BufWriter::with_capacity(1024 * 64, stdout()); let in_buf = b"a\tb\tc\n"; - assert_eq!(super::write_tab_to_end(in_buf, &mut writer), 5); + assert_eq!(super::write_tab_to_end(in_buf, &mut writer).unwrap(), 5); } #[test] fn test_write_tab_to_end_no_newline() { let mut writer = BufWriter::with_capacity(1024 * 64, stdout()); let in_buf = b"a\tb\tc"; - assert_eq!(super::write_tab_to_end(in_buf, &mut writer), 5); + assert_eq!(super::write_tab_to_end(in_buf, &mut writer).unwrap(), 5); } #[test] @@ -748,7 +751,7 @@ mod tests { let mut writer = BufWriter::with_capacity(1024 * 64, stdout()); let in_buf = b"\n"; let tab = b""; - super::write_nonprint_to_end(in_buf, &mut writer, tab); + super::write_nonprint_to_end(in_buf, &mut writer, tab).unwrap(); assert_eq!(writer.buffer().len(), 0); } @@ -757,7 +760,7 @@ mod tests { let mut writer = BufWriter::with_capacity(1024 * 64, stdout()); let in_buf = &[9u8]; let tab = b"tab"; - super::write_nonprint_to_end(in_buf, &mut writer, tab); + super::write_nonprint_to_end(in_buf, &mut writer, tab).unwrap(); assert_eq!(writer.buffer(), tab); } @@ -767,7 +770,7 @@ mod tests { let mut writer = BufWriter::with_capacity(1024 * 64, stdout()); let in_buf = &[byte]; let tab = b""; - super::write_nonprint_to_end(in_buf, &mut writer, tab); + super::write_nonprint_to_end(in_buf, &mut writer, tab).unwrap(); assert_eq!(writer.buffer(), [b'^', byte + 64]); } } @@ -778,7 +781,7 @@ mod tests { let mut writer = BufWriter::with_capacity(1024 * 64, stdout()); let in_buf = &[byte]; let tab = b""; - super::write_nonprint_to_end(in_buf, &mut writer, tab); + super::write_nonprint_to_end(in_buf, &mut writer, tab).unwrap(); assert_eq!(writer.buffer(), [b'^', byte + 64]); } } From bbd5c4e66bd09127327ae1f040891e605e9ecef6 Mon Sep 17 00:00:00 2001 From: oech3 <> Date: Sat, 3 Jan 2026 22:35:39 +0900 Subject: [PATCH 076/425] Bump libc to 0.2.178 with fix for FreeBSD --- Cargo.lock | 4 ++-- src/uucore/src/lib/features/fs.rs | 14 ++++---------- src/uucore/src/lib/features/fsext.rs | 5 +---- 3 files changed, 7 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 47ecd75af..bbc4e73f0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1651,9 +1651,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.175" +version = "0.2.178" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" +checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" [[package]] name = "libloading" diff --git a/src/uucore/src/lib/features/fs.rs b/src/uucore/src/lib/features/fs.rs index 9c7108580..bebfd1821 100644 --- a/src/uucore/src/lib/features/fs.rs +++ b/src/uucore/src/lib/features/fs.rs @@ -138,7 +138,6 @@ impl FileInformation { any( target_vendor = "apple", target_os = "android", - target_os = "freebsd", target_os = "netbsd", target_os = "openbsd", target_os = "illumos", @@ -152,6 +151,8 @@ impl FileInformation { ) ))] return self.0.st_nlink.into(); + #[cfg(target_os = "freebsd")] + return self.0.st_nlink; #[cfg(target_os = "aix")] return self.0.st_nlink.try_into().unwrap(); #[cfg(windows)] @@ -160,16 +161,9 @@ impl FileInformation { #[cfg(unix)] pub fn inode(&self) -> u64 { - #[cfg(all( - not(any(target_os = "freebsd", target_os = "netbsd")), - target_pointer_width = "64" - ))] + #[cfg(all(not(any(target_os = "netbsd")), target_pointer_width = "64"))] return self.0.st_ino; - #[cfg(any( - target_os = "freebsd", - target_os = "netbsd", - not(target_pointer_width = "64") - ))] + #[cfg(any(target_os = "netbsd", not(target_pointer_width = "64")))] return self.0.st_ino.into(); } } diff --git a/src/uucore/src/lib/features/fsext.rs b/src/uucore/src/lib/features/fsext.rs index f2ae59a76..ec88a5e61 100644 --- a/src/uucore/src/lib/features/fsext.rs +++ b/src/uucore/src/lib/features/fsext.rs @@ -446,11 +446,8 @@ unsafe extern "C" { #[link_name = "getmntinfo"] fn get_mount_info(mount_buffer_p: *mut *mut StatFs, flags: c_int) -> c_int; - // Rust on FreeBSD uses 11.x ABI for filesystem metadata syscalls. - // Call the right version of the symbol for getmntinfo() result to - // match libc StatFS layout. #[cfg(target_os = "freebsd")] - #[link_name = "getmntinfo@FBSD_1.0"] + #[link_name = "getmntinfo"] fn get_mount_info(mount_buffer_p: *mut *mut StatFs, flags: c_int) -> c_int; } From 5ed3d02c769696dc101d3a5df4368ef003fee180 Mon Sep 17 00:00:00 2001 From: CrazyRoka Date: Sun, 4 Jan 2026 12:34:42 +0000 Subject: [PATCH 077/425] shuf: optimize numeric output by avoiding write!() --- src/uu/shuf/src/shuf.rs | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/uu/shuf/src/shuf.rs b/src/uu/shuf/src/shuf.rs index e69ad1e1c..4fd5ca85a 100644 --- a/src/uu/shuf/src/shuf.rs +++ b/src/uu/shuf/src/shuf.rs @@ -422,7 +422,26 @@ impl Writable for &OsStr { impl Writable for usize { fn write_all_to(&self, output: &mut impl OsWrite) -> Result<(), Error> { - write!(output, "{self}") + let mut n = *self; + + // Handle the zero case explicitly + if n == 0 { + return output.write_all(b"0"); + } + + // Maximum number of digits for u64 is 20 (18446744073709551615) + let mut buf = [0u8; 20]; + let mut i = 20; + + // Write digits from right to left + while n > 0 { + i -= 1; + buf[i] = b'0' + (n % 10) as u8; + n /= 10; + } + + // Write the relevant part of the buffer to output + output.write_all(&buf[i..]) } } From 83a52bf5615ead60d9a553ac2bd2f6b873ffdea8 Mon Sep 17 00:00:00 2001 From: max-amb Date: Sun, 4 Jan 2026 13:18:48 +0000 Subject: [PATCH 078/425] cp: Added test for permissions copying to an existing file --- tests/by-util/test_cp.rs | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/by-util/test_cp.rs b/tests/by-util/test_cp.rs index 5f4a44c4a..f0ad9d8ca 100644 --- a/tests/by-util/test_cp.rs +++ b/tests/by-util/test_cp.rs @@ -7444,3 +7444,28 @@ fn test_cp_archive_deref_flag_ordering() { assert_eq!(at.is_symlink(&dest), expect_symlink, "failed for {flags}"); } } + +/// Test that copying to an existing file maintains its permissions, unix only because .mode() only +/// works on Unix +#[test] +#[cfg(unix)] +fn test_cp_to_existing_file_permissions() { + let (at, mut ucmd) = at_and_ucmd!(); + + at.touch("src"); + at.touch("dst"); + + let src_path = at.plus("src"); + let dst_path = at.plus("dst"); + + let mut src_permissions = std::fs::metadata(&src_path).unwrap().permissions(); + src_permissions.set_readonly(true); + std::fs::set_permissions(&src_path, src_permissions).unwrap(); + + let dst_mode = std::fs::metadata(&dst_path).unwrap().permissions().mode(); + + ucmd.args(&["src", "dst"]).succeeds(); + + let new_dst_mode = std::fs::metadata(&dst_path).unwrap().permissions().mode(); + assert_eq!(dst_mode, new_dst_mode); +} From 53a5af02ac6b40e4674fbe325cc2c3d818367e90 Mon Sep 17 00:00:00 2001 From: Daniel Hofstetter Date: Sun, 4 Jan 2026 15:33:59 +0100 Subject: [PATCH 079/425] ci: set -no-metrics for Android emulator --- .github/workflows/android.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 93a9fec1e..a4a9b3bd0 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -166,7 +166,7 @@ jobs: disk-size: ${{ env.EMULATOR_DISK_SIZE }} cores: ${{ env.EMULATOR_CORES }} force-avd-creation: false - emulator-options: ${{ env.COMMON_EMULATOR_OPTIONS }} -no-snapshot-save -snapshot ${{ env.AVD_CACHE_KEY }} + emulator-options: ${{ env.COMMON_EMULATOR_OPTIONS }} -no-metrics -no-snapshot-save -snapshot ${{ env.AVD_CACHE_KEY }} emulator-boot-timeout: ${{ env.EMULATOR_BOOT_TIMEOUT }} # This is not a usual script. Every line is executed in a separate shell with `sh -c`. If # one of the lines returns with error the whole script is failed (like running a script with From 4b53e1063049841bbe671458c7d5a06757b2a8c9 Mon Sep 17 00:00:00 2001 From: Daniel Hofstetter Date: Sun, 4 Jan 2026 16:59:27 +0100 Subject: [PATCH 080/425] clippy: allow "cygwin" as value for "target_os" --- Cargo.toml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 6d34d5c1e..6a5796a46 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -619,9 +619,12 @@ workspace = true # This is the linting configuration for all crates. # In order to use these, all crates have `[lints] workspace = true` section. [workspace.lints.rust] -# Allow "fuzzing" as a "cfg" condition name +# Allow "fuzzing" as a "cfg" condition name and "cygwin" as a value for "target_os" # https://doc.rust-lang.org/nightly/rustc/check-cfg/cargo-specifics.html -unexpected_cfgs = { level = "warn", check-cfg = ['cfg(fuzzing)'] } +unexpected_cfgs = { level = "warn", check-cfg = [ + 'cfg(fuzzing)', + 'cfg(target_os, values("cygwin"))', +] } #unused_qualifications = "warn" // TODO: fix warnings in uucore, then re-enable this lint [workspace.lints.clippy] From 2c5e00e8dc502235a56059ac883433de4ee1a877 Mon Sep 17 00:00:00 2001 From: Daniel Hofstetter Date: Sun, 4 Jan 2026 17:48:35 +0100 Subject: [PATCH 081/425] libstdbuf: enable workspace lints --- src/uu/stdbuf/src/libstdbuf/Cargo.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/uu/stdbuf/src/libstdbuf/Cargo.toml b/src/uu/stdbuf/src/libstdbuf/Cargo.toml index 6460c441e..8a92fcbb5 100644 --- a/src/uu/stdbuf/src/libstdbuf/Cargo.toml +++ b/src/uu/stdbuf/src/libstdbuf/Cargo.toml @@ -10,6 +10,9 @@ keywords.workspace = true categories.workspace = true edition.workspace = true +[lints] +workspace = true + [lib] name = "stdbuf" path = "src/libstdbuf.rs" From 5e5c58ea93db180a6c4eba191f040f0a2a475214 Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Mon, 5 Jan 2026 04:22:00 +0900 Subject: [PATCH 082/425] cksum,hashsum: Drop a message replaced by clap --- src/uucore/src/lib/features/checksum/mod.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/uucore/src/lib/features/checksum/mod.rs b/src/uucore/src/lib/features/checksum/mod.rs index 2f3d28b41..7cf7fe129 100644 --- a/src/uucore/src/lib/features/checksum/mod.rs +++ b/src/uucore/src/lib/features/checksum/mod.rs @@ -374,9 +374,6 @@ pub enum ChecksumError { #[error("the --raw option is not supported with multiple files")] RawMultipleFiles, - #[error("the --{0} option is meaningful only when verifying checksums")] - CheckOnlyFlag(String), - // --length sanitization errors #[error("--length required for {}", .0.quote())] LengthRequired(String), From 28deca24cb1758eb84efceb4ba06c7b9a07fe0bf Mon Sep 17 00:00:00 2001 From: CrazyRoka Date: Sun, 4 Jan 2026 13:42:07 +0000 Subject: [PATCH 083/425] uniq: optimize memory usage for ignore-case comparison --- src/uu/uniq/src/uniq.rs | 29 +---------------------------- 1 file changed, 1 insertion(+), 28 deletions(-) diff --git a/src/uu/uniq/src/uniq.rs b/src/uu/uniq/src/uniq.rs index 3845ba459..9c95305e4 100644 --- a/src/uu/uniq/src/uniq.rs +++ b/src/uu/uniq/src/uniq.rs @@ -61,8 +61,6 @@ struct Uniq { struct LineMeta { key_start: usize, key_end: usize, - lowercase: Vec, - use_lowercase: bool, } macro_rules! write_line_terminator { @@ -152,18 +150,7 @@ impl Uniq { return first_slice != second_slice; } - let first_cmp = if first_meta.use_lowercase { - first_meta.lowercase.as_slice() - } else { - first_slice - }; - let second_cmp = if second_meta.use_lowercase { - second_meta.lowercase.as_slice() - } else { - second_slice - }; - - first_cmp != second_cmp + !first_slice.eq_ignore_ascii_case(second_slice) } fn key_bounds(&self, line: &[u8]) -> (usize, usize) { @@ -230,20 +217,6 @@ impl Uniq { let (key_start, key_end) = self.key_bounds(line); meta.key_start = key_start; meta.key_end = key_end; - - if self.ignore_case && key_start < key_end { - let slice = &line[key_start..key_end]; - if slice.iter().any(|b| b.is_ascii_uppercase()) { - meta.lowercase.clear(); - meta.lowercase.reserve(slice.len()); - meta.lowercase - .extend(slice.iter().map(|b| b.to_ascii_lowercase())); - meta.use_lowercase = true; - return; - } - } - - meta.use_lowercase = false; } fn read_line( From ce00c0b154f93c5eb6f3ea4224f8d16ad3990ece Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Mon, 5 Jan 2026 04:39:45 +0900 Subject: [PATCH 084/425] cksum.rs: Simple default tag variable --- src/uu/cksum/src/cksum.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/uu/cksum/src/cksum.rs b/src/uu/cksum/src/cksum.rs index 3d814ae6f..70c80ae37 100644 --- a/src/uu/cksum/src/cksum.rs +++ b/src/uu/cksum/src/cksum.rs @@ -164,7 +164,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { // Set the default algorithm to CRC when not '--check'ing. let algo_kind = algo_cli.unwrap_or(AlgoKind::Crc); - let tag = matches.get_flag(options::TAG) || !matches.get_flag(options::UNTAGGED); + let tag = !matches.get_flag(options::UNTAGGED); // Making TAG default at clap blocks --untagged let binary = matches.get_flag(options::BINARY); let algo = SizedAlgoKind::from_unsized(algo_kind, length)?; From bfdbf59646c0e63a4f0739f409fb6e34f11fa6e3 Mon Sep 17 00:00:00 2001 From: Jeffrey Finkelstein Date: Sun, 4 Jan 2026 18:09:57 -0500 Subject: [PATCH 085/425] pr: use 72 char line width for all page headers Set the default line width to 72 for all page headers in `pr`, regardless of whether a custom date format is being used. Before, a single space was used to separate the three components of the header (date, filename, and page number) if a custom date format was not given. --- src/uu/pr/src/pr.rs | 33 +- tests/by-util/test_pr.rs | 135 +++---- tests/fixtures/pr/0F | 10 +- tests/fixtures/pr/0Fnt-expected | 330 ++++++++++++++++++ tests/fixtures/pr/3-0F | 6 +- tests/fixtures/pr/3a3f-0F | 6 +- tests/fixtures/pr/3f-0F | 6 +- tests/fixtures/pr/a3-0F | 10 +- tests/fixtures/pr/a3f-0F | 10 +- tests/fixtures/pr/a3f-0Fnt-expected | 37 ++ tests/fixtures/pr/column.log.expected | 6 +- tests/fixtures/pr/column_across.log.expected | 6 +- .../pr/column_across_sep.log.expected | 6 +- .../pr/column_across_sep1.log.expected | 6 +- .../pr/column_spaces_across.log.expected | 6 +- tests/fixtures/pr/joined.log.expected | 4 +- tests/fixtures/pr/l24-FF | 26 +- tests/fixtures/pr/mpr.log.expected | 4 +- tests/fixtures/pr/mpr1.log.expected | 6 +- tests/fixtures/pr/mpr2.log.expected | 4 +- tests/fixtures/pr/stdin.log.expected | 4 +- .../fixtures/pr/test_num_page_2.log.expected | 4 +- .../pr/test_num_page_char.log.expected | 4 +- .../pr/test_num_page_char_one.log.expected | 4 +- tests/fixtures/pr/test_one_page.log.expected | 2 +- .../pr/test_one_page_double_line.log.expected | 4 +- .../pr/test_one_page_first_line.log.expected | 2 +- .../pr/test_one_page_header.log.expected | 2 +- .../fixtures/pr/test_page_length.log.expected | 4 +- .../pr/test_page_range_1.log.expected | 8 +- .../pr/test_page_range_2.log.expected | 6 +- 31 files changed, 531 insertions(+), 170 deletions(-) create mode 100644 tests/fixtures/pr/0Fnt-expected create mode 100644 tests/fixtures/pr/a3f-0Fnt-expected diff --git a/src/uu/pr/src/pr.rs b/src/uu/pr/src/pr.rs index fde237048..843b3b8f9 100644 --- a/src/uu/pr/src/pr.rs +++ b/src/uu/pr/src/pr.rs @@ -1180,34 +1180,23 @@ fn header_content(options: &OutputOptions, page: usize) -> Vec { // Use the line width if available, otherwise use default of 72 let total_width = options.line_width.unwrap_or(DEFAULT_COLUMN_WIDTH); - // GNU pr uses a specific layout: - // Date takes up the left part, filename is centered, page is right-aligned let date_len = date_part.chars().count(); let filename_len = filename.chars().count(); let page_len = page_part.chars().count(); let header_line = if date_len + filename_len + page_len + 2 < total_width { - // Check if we're using a custom date format that needs centered alignment - // This preserves backward compatibility while fixing the GNU time-style test - if date_part.starts_with('+') { - // GNU pr uses centered layout for headers with custom date formats - // The filename should be centered between the date and page parts - let space_for_filename = total_width - date_len - page_len; - let padding_before_filename = (space_for_filename - filename_len) / 2; - let padding_after_filename = - space_for_filename - filename_len - padding_before_filename; + // The filename should be centered between the date and page parts + let space_for_filename = total_width - date_len - page_len; + let padding_before_filename = (space_for_filename - filename_len) / 2; + let padding_after_filename = space_for_filename - filename_len - padding_before_filename; - format!( - "{date_part}{:width1$}{filename}{:width2$}{page_part}", - "", - "", - width1 = padding_before_filename, - width2 = padding_after_filename - ) - } else { - // For standard date formats, use simple spacing for backward compatibility - format!("{date_part} {filename} {page_part}") - } + format!( + "{date_part}{:width1$}{filename}{:width2$}{page_part}", + "", + "", + width1 = padding_before_filename, + width2 = padding_after_filename + ) } else { // If content is too long, just use single spaces format!("{date_part} {filename} {page_part}") diff --git a/tests/by-util/test_pr.rs b/tests/by-util/test_pr.rs index 26f64e1dc..63063a7e7 100644 --- a/tests/by-util/test_pr.rs +++ b/tests/by-util/test_pr.rs @@ -5,6 +5,7 @@ // spell-checker:ignore (ToDO) Sdivide use chrono::{DateTime, Duration, Utc}; +use regex::Regex; use std::fs::metadata; use uutests::new_ucmd; use uutests::util::UCommand; @@ -78,21 +79,22 @@ fn test_with_numbering_option_with_number_width() { #[test] fn test_with_long_header_option() { - let test_file_path = "test_one_page.log"; - let expected_test_file_path = "test_one_page_header.log.expected"; - let header = "new file"; - for args in [&["-h", header][..], &["--header=new file"][..]] { - let mut scenario = new_ucmd!(); - let value = file_last_modified_time(&scenario, test_file_path); - scenario - .args(args) - .arg(test_file_path) - .succeeds() - .stdout_is_templated_fixture( - expected_test_file_path, - &[("{last_modified_time}", &value), ("{header}", header)], - ); - } + let whitespace = " ".repeat(21); + let blank_lines = "\n".repeat(61); + let datetime_pattern = r"\d\d\d\d-\d\d-\d\d \d\d:\d\d"; + let pattern = + format!("\n\n{datetime_pattern}{whitespace}new file{whitespace}Page 1\n\n\na{blank_lines}"); + let regex = Regex::new(&pattern).unwrap(); + new_ucmd!() + .args(&["-h", "new file"]) + .pipe_in("a") + .succeeds() + .stdout_matches(®ex); + new_ucmd!() + .args(&["--header=new file"]) + .pipe_in("a") + .succeeds() + .stdout_matches(®ex); } #[test] @@ -400,99 +402,92 @@ fn test_with_offset_space_option() { #[test] fn test_with_date_format() { - let test_file_path = "test_one_page.log"; - let expected_test_file_path = "test_one_page.log.expected"; - let mut scenario = new_ucmd!(); - let value = file_last_modified_time_format(&scenario, test_file_path, "%Y__%s"); - scenario - .args(&[test_file_path, "-D", "%Y__%s"]) + let whitespace = " ".repeat(50); + let blank_lines = "\n".repeat(61); + let datetime_pattern = r"\d{4}__\d{10}"; + let pattern = format!("\n\n{datetime_pattern}{whitespace}Page 1\n\n\na{blank_lines}"); + let regex = Regex::new(&pattern).unwrap(); + new_ucmd!() + .args(&["-D", "%Y__%s"]) + .pipe_in("a") .succeeds() - .stdout_is_templated_fixture(expected_test_file_path, &[("{last_modified_time}", &value)]); + .stdout_matches(®ex); // "Format" doesn't need to contain any replaceable token. + let whitespace = " ".repeat(60); + let blank_lines = "\n".repeat(61); new_ucmd!() - .args(&[test_file_path, "-D", "Hello!"]) + .args(&["-D", "Hello!"]) + .pipe_in("a") .succeeds() - .stdout_is_templated_fixture( - expected_test_file_path, - &[("{last_modified_time}", "Hello!")], - ); + .stdout_only(format!("\n\nHello!{whitespace}Page 1\n\n\na{blank_lines}")); // Long option also works new_ucmd!() - .args(&[test_file_path, "--date-format=Hello!"]) + .args(&["--date-format=Hello!"]) + .pipe_in("a") .succeeds() - .stdout_is_templated_fixture( - expected_test_file_path, - &[("{last_modified_time}", "Hello!")], - ); + .stdout_only(format!("\n\nHello!{whitespace}Page 1\n\n\na{blank_lines}")); // Option takes precedence over environment variables new_ucmd!() .env("POSIXLY_CORRECT", "1") .env("LC_TIME", "POSIX") - .args(&[test_file_path, "-D", "Hello!"]) + .args(&["--date-format=Hello!"]) + .pipe_in("a") .succeeds() - .stdout_is_templated_fixture( - expected_test_file_path, - &[("{last_modified_time}", "Hello!")], - ); + .stdout_only(format!("\n\nHello!{whitespace}Page 1\n\n\na{blank_lines}")); } #[test] fn test_with_date_format_env() { - const POSIXLY_FORMAT: &str = "%b %e %H:%M %Y"; - // POSIXLY_CORRECT + LC_ALL/TIME=POSIX uses "%b %e %H:%M %Y" date format - let test_file_path = "test_one_page.log"; - let expected_test_file_path = "test_one_page.log.expected"; - let mut scenario = new_ucmd!(); - let value = file_last_modified_time_format(&scenario, test_file_path, POSIXLY_FORMAT); - scenario + let whitespace = " ".repeat(49); + let blank_lines = "\n".repeat(61); + let datetime_pattern = r"[A-Z][a-z][a-z] [ \d]\d \d\d:\d\d \d{4}"; + let pattern = format!("\n\n{datetime_pattern}{whitespace}Page 1\n\n\na{blank_lines}"); + let regex = Regex::new(&pattern).unwrap(); + new_ucmd!() .env("POSIXLY_CORRECT", "1") .env("LC_ALL", "POSIX") - .args(&[test_file_path]) + .pipe_in("a") .succeeds() - .stdout_is_templated_fixture(expected_test_file_path, &[("{last_modified_time}", &value)]); - - let mut scenario = new_ucmd!(); - let value = file_last_modified_time_format(&scenario, test_file_path, POSIXLY_FORMAT); - scenario + .stdout_matches(®ex); + new_ucmd!() .env("POSIXLY_CORRECT", "1") .env("LC_TIME", "POSIX") - .args(&[test_file_path]) + .pipe_in("a") .succeeds() - .stdout_is_templated_fixture(expected_test_file_path, &[("{last_modified_time}", &value)]); + .stdout_matches(®ex); // But not if POSIXLY_CORRECT/LC_ALL is something else. - let mut scenario = new_ucmd!(); - let value = file_last_modified_time_format(&scenario, test_file_path, DATE_TIME_FORMAT_DEFAULT); - scenario + let whitespace = " ".repeat(50); + let datetime_pattern = r"\d\d\d\d-\d\d-\d\d \d\d:\d\d"; + let pattern = format!("\n\n{datetime_pattern}{whitespace}Page 1\n\n\na{blank_lines}"); + let regex = Regex::new(&pattern).unwrap(); + new_ucmd!() .env("LC_TIME", "POSIX") - .args(&[test_file_path]) + .pipe_in("a") .succeeds() - .stdout_is_templated_fixture(expected_test_file_path, &[("{last_modified_time}", &value)]); - - let mut scenario = new_ucmd!(); - let value = file_last_modified_time_format(&scenario, test_file_path, DATE_TIME_FORMAT_DEFAULT); - scenario + .stdout_matches(®ex); + new_ucmd!() .env("POSIXLY_CORRECT", "1") .env("LC_TIME", "C") - .args(&[test_file_path]) + .pipe_in("a") .succeeds() - .stdout_is_templated_fixture(expected_test_file_path, &[("{last_modified_time}", &value)]); + .stdout_matches(®ex); } #[test] fn test_with_pr_core_utils_tests() { let test_cases = vec![ ("", vec!["0Ft"], vec!["0F"], 0), - ("", vec!["0Fnt"], vec!["0F"], 0), + ("", vec!["0Fnt"], vec!["0Fnt-expected"], 0), ("+3", vec!["0Ft"], vec!["3-0F"], 0), ("+3 -f", vec!["0Ft"], vec!["3f-0F"], 0), ("-a -3", vec!["0Ft"], vec!["a3-0F"], 0), ("-a -3 -f", vec!["0Ft"], vec!["a3f-0F"], 0), - ("-a -3 -f", vec!["0Fnt"], vec!["a3f-0F"], 0), + ("-a -3 -f", vec!["0Fnt"], vec!["a3f-0Fnt-expected"], 0), ("+3 -a -3 -f", vec!["0Ft"], vec!["3a3f-0F"], 0), ("-l 24", vec!["FnFn"], vec!["l24-FF"], 0), ("-W 20 -l24 -f", vec!["tFFt-ll"], vec!["W20l24f-ll"], 0), @@ -622,3 +617,13 @@ fn test_b_flag_backwards_compat() { // -b is a no-op for backwards compatibility (column-down is now the default) new_ucmd!().args(&["-b", "-t"]).pipe_in("a\nb\n").succeeds(); } + +#[test] +fn test_page_header_width() { + let whitespace = " ".repeat(50); + let blank_lines = "\n".repeat(61); + let datetime_pattern = r"\d\d\d\d-\d\d-\d\d \d\d:\d\d"; + let pattern = format!("\n\n{datetime_pattern}{whitespace}Page 1\n\n\na{blank_lines}"); + let regex = Regex::new(&pattern).unwrap(); + new_ucmd!().pipe_in("a").succeeds().stdout_matches(®ex); +} diff --git a/tests/fixtures/pr/0F b/tests/fixtures/pr/0F index 223765391..af35676ea 100644 --- a/tests/fixtures/pr/0F +++ b/tests/fixtures/pr/0F @@ -1,6 +1,6 @@ -{last_modified_time} {file_name} Page 1 +{last_modified_time} {file_name} Page 1 @@ -66,7 +66,7 @@ -{last_modified_time} {file_name} Page 2 +{last_modified_time} {file_name} Page 2 1 FF-Test: FF's at Start of File V @@ -132,7 +132,7 @@ -{last_modified_time} {file_name} Page 3 +{last_modified_time} {file_name} Page 3 @@ -198,7 +198,7 @@ -{last_modified_time} {file_name} Page 4 +{last_modified_time} {file_name} Page 4 15 xyzxyzxyz XYZXYZXYZ abcabcab @@ -264,7 +264,7 @@ -{last_modified_time} {file_name} Page 5 +{last_modified_time} {file_name} Page 5 29 xyzxyzxyz XYZXYZXYZ abcabcab diff --git a/tests/fixtures/pr/0Fnt-expected b/tests/fixtures/pr/0Fnt-expected new file mode 100644 index 000000000..ab2f28a09 --- /dev/null +++ b/tests/fixtures/pr/0Fnt-expected @@ -0,0 +1,330 @@ + + +{last_modified_time} {file_name} Page 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +{last_modified_time} {file_name} Page 2 + + +1 FF-Test: FF's at Start of File V +2 Options -b -3 / -a -3 / ... +3 -------------------------------------------- +4 3456789 123456789 123456789 123456789 12345678 +5 3 Columns downwards ..., <= 5 lines per page +6 FF-Arangements: Empty Pages at start +7 \ftext; \f\ntext; +8 \f\ftext; \f\f\ntext; \f\n\ftext; \f\n\f\n; +9 3456789 123456789 123456789 +10 zzzzzzzzzzzzzzzzzzzzzzzzzz123456789 +1 12345678 +2 12345678 +3 line truncation before FF; r_r_o_l-test: +14 456789 123456789 123456789 123456789 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +{last_modified_time} {file_name} Page 3 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +{last_modified_time} {file_name} Page 4 + + +15 xyzxyzxyz XYZXYZXYZ abcabcab +16 456789 123456789 xyzxyzxyz XYZXYZXYZ +7 12345678 +8 12345678 +9 3456789 ab +20 DEFGHI 123 +1 12345678 +2 12345678 +3 12345678 +4 12345678 +5 12345678 +6 12345678 +27 no truncation before FF; (r_l-test): +28 no trunc + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +{last_modified_time} {file_name} Page 5 + + +29 xyzxyzxyz XYZXYZXYZ abcabcab +30 456789 123456789 xyzxyzxyz XYZXYZXYZ +1 12345678 +2 3456789 abcdefghi +3 12345678 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/fixtures/pr/3-0F b/tests/fixtures/pr/3-0F index 25a9db171..3a9f0b657 100644 --- a/tests/fixtures/pr/3-0F +++ b/tests/fixtures/pr/3-0F @@ -1,6 +1,6 @@ -{last_modified_time} {file_name} Page 3 +{last_modified_time} {file_name} Page 3 @@ -66,7 +66,7 @@ -{last_modified_time} {file_name} Page 4 +{last_modified_time} {file_name} Page 4 15 xyzxyzxyz XYZXYZXYZ abcabcab @@ -132,7 +132,7 @@ -{last_modified_time} {file_name} Page 5 +{last_modified_time} {file_name} Page 5 29 xyzxyzxyz XYZXYZXYZ abcabcab diff --git a/tests/fixtures/pr/3a3f-0F b/tests/fixtures/pr/3a3f-0F index 6097374c7..f19823acc 100644 --- a/tests/fixtures/pr/3a3f-0F +++ b/tests/fixtures/pr/3a3f-0F @@ -1,11 +1,11 @@ -{last_modified_time} {file_name} Page 3 +{last_modified_time} {file_name} Page 3 -{last_modified_time} {file_name} Page 4 +{last_modified_time} {file_name} Page 4 15 xyzxyzxyz XYZXYZXYZ 16 456789 123456789 xyz 7 @@ -15,7 +15,7 @@ 27 no truncation before 28 no trunc -{last_modified_time} {file_name} Page 5 +{last_modified_time} {file_name} Page 5 29 xyzxyzxyz XYZXYZXYZ 30 456789 123456789 xyz 1 diff --git a/tests/fixtures/pr/3f-0F b/tests/fixtures/pr/3f-0F index d32c1f8f6..92805024a 100644 --- a/tests/fixtures/pr/3f-0F +++ b/tests/fixtures/pr/3f-0F @@ -1,11 +1,11 @@ -{last_modified_time} {file_name} Page 3 +{last_modified_time} {file_name} Page 3 -{last_modified_time} {file_name} Page 4 +{last_modified_time} {file_name} Page 4 15 xyzxyzxyz XYZXYZXYZ abcabcab @@ -25,7 +25,7 @@ -{last_modified_time} {file_name} Page 5 +{last_modified_time} {file_name} Page 5 29 xyzxyzxyz XYZXYZXYZ abcabcab diff --git a/tests/fixtures/pr/a3-0F b/tests/fixtures/pr/a3-0F index 58aeb07c2..302ab52d1 100644 --- a/tests/fixtures/pr/a3-0F +++ b/tests/fixtures/pr/a3-0F @@ -1,6 +1,6 @@ -{last_modified_time} {file_name} Page 1 +{last_modified_time} {file_name} Page 1 @@ -66,7 +66,7 @@ -{last_modified_time} {file_name} Page 2 +{last_modified_time} {file_name} Page 2 1 FF-Test: FF's at St 2 Options -b -3 / -a 3 ------------------- @@ -132,7 +132,7 @@ -{last_modified_time} {file_name} Page 3 +{last_modified_time} {file_name} Page 3 @@ -198,7 +198,7 @@ -{last_modified_time} {file_name} Page 4 +{last_modified_time} {file_name} Page 4 15 xyzxyzxyz XYZXYZXYZ 16 456789 123456789 xyz 7 @@ -264,7 +264,7 @@ -{last_modified_time} {file_name} Page 5 +{last_modified_time} {file_name} Page 5 29 xyzxyzxyz XYZXYZXYZ 30 456789 123456789 xyz 1 diff --git a/tests/fixtures/pr/a3f-0F b/tests/fixtures/pr/a3f-0F index 24939c004..54e0e80b5 100644 --- a/tests/fixtures/pr/a3f-0F +++ b/tests/fixtures/pr/a3f-0F @@ -1,11 +1,11 @@ -{last_modified_time} {file_name} Page 1 +{last_modified_time} {file_name} Page 1 -{last_modified_time} {file_name} Page 2 +{last_modified_time} {file_name} Page 2 1 FF-Test: FF's at St 2 Options -b -3 / -a 3 ------------------- @@ -15,12 +15,12 @@ 3 line truncation befor 14 456789 123456789 123 -{last_modified_time} {file_name} Page 3 +{last_modified_time} {file_name} Page 3 -{last_modified_time} {file_name} Page 4 +{last_modified_time} {file_name} Page 4 15 xyzxyzxyz XYZXYZXYZ 16 456789 123456789 xyz 7 @@ -30,7 +30,7 @@ 27 no truncation before 28 no trunc -{last_modified_time} {file_name} Page 5 +{last_modified_time} {file_name} Page 5 29 xyzxyzxyz XYZXYZXYZ 30 456789 123456789 xyz 1 diff --git a/tests/fixtures/pr/a3f-0Fnt-expected b/tests/fixtures/pr/a3f-0Fnt-expected new file mode 100644 index 000000000..14d51325b --- /dev/null +++ b/tests/fixtures/pr/a3f-0Fnt-expected @@ -0,0 +1,37 @@ + + +{last_modified_time} {file_name} Page 1 + + + + +{last_modified_time} {file_name} Page 2 + + +1 FF-Test: FF's at St 2 Options -b -3 / -a 3 ------------------- +4 3456789 123456789 123 5 3 Columns downwards 6 FF-Arangements: Emp +7 \ftext; \f\ntext; 8 \f\ftext; \f\f\ntex 9 3456789 123456789 123 +10 zzzzzzzzzzzzzzzzzzz 1 2 +3 line truncation befor 14 456789 123456789 123 + + +{last_modified_time} {file_name} Page 3 + + + + +{last_modified_time} {file_name} Page 4 + + +15 xyzxyzxyz XYZXYZXYZ 16 456789 123456789 xyz 7 +8 9 3456789 ab 20 DEFGHI 123 +1 2 3 +4 5 6 +27 no truncation before 28 no trunc + + +{last_modified_time} {file_name} Page 5 + + +29 xyzxyzxyz XYZXYZXYZ 30 456789 123456789 xyz 1 +2 3456789 abcdefghi 3 \ No newline at end of file diff --git a/tests/fixtures/pr/column.log.expected b/tests/fixtures/pr/column.log.expected index e548d4128..6e817eced 100644 --- a/tests/fixtures/pr/column.log.expected +++ b/tests/fixtures/pr/column.log.expected @@ -1,6 +1,6 @@ -{last_modified_time} column.log Page 3 +{last_modified_time} column.log Page 3 337 337 393 393 449 449 @@ -66,7 +66,7 @@ -{last_modified_time} column.log Page 4 +{last_modified_time} column.log Page 4 505 505 561 561 617 617 @@ -132,7 +132,7 @@ -{last_modified_time} column.log Page 5 +{last_modified_time} column.log Page 5 673 673 729 729 785 785 diff --git a/tests/fixtures/pr/column_across.log.expected b/tests/fixtures/pr/column_across.log.expected index 9d5a1dc1c..4b0c93856 100644 --- a/tests/fixtures/pr/column_across.log.expected +++ b/tests/fixtures/pr/column_across.log.expected @@ -1,6 +1,6 @@ -{last_modified_time} column.log Page 3 +{last_modified_time} column.log Page 3 337 337 338 338 339 339 @@ -66,7 +66,7 @@ -{last_modified_time} column.log Page 4 +{last_modified_time} column.log Page 4 505 505 506 506 507 507 @@ -132,7 +132,7 @@ -{last_modified_time} column.log Page 5 +{last_modified_time} column.log Page 5 673 673 674 674 675 675 diff --git a/tests/fixtures/pr/column_across_sep.log.expected b/tests/fixtures/pr/column_across_sep.log.expected index 65c3e71c8..aad7dff27 100644 --- a/tests/fixtures/pr/column_across_sep.log.expected +++ b/tests/fixtures/pr/column_across_sep.log.expected @@ -1,6 +1,6 @@ -{last_modified_time} column.log Page 3 +{last_modified_time} column.log Page 3 337 337 | 338 338 | 339 339 @@ -66,7 +66,7 @@ -{last_modified_time} column.log Page 4 +{last_modified_time} column.log Page 4 505 505 | 506 506 | 507 507 @@ -132,7 +132,7 @@ -{last_modified_time} column.log Page 5 +{last_modified_time} column.log Page 5 673 673 | 674 674 | 675 675 diff --git a/tests/fixtures/pr/column_across_sep1.log.expected b/tests/fixtures/pr/column_across_sep1.log.expected index f9dd454d7..e28885a4e 100644 --- a/tests/fixtures/pr/column_across_sep1.log.expected +++ b/tests/fixtures/pr/column_across_sep1.log.expected @@ -1,6 +1,6 @@ -{last_modified_time} column.log Page 3 +{last_modified_time} column.log Page 3 337 337 divide 338 338 divide 339 339 @@ -66,7 +66,7 @@ -{last_modified_time} column.log Page 4 +{last_modified_time} column.log Page 4 505 505 divide 506 506 divide 507 507 @@ -132,7 +132,7 @@ -{last_modified_time} column.log Page 5 +{last_modified_time} column.log Page 5 673 673 divide 674 674 divide 675 675 diff --git a/tests/fixtures/pr/column_spaces_across.log.expected b/tests/fixtures/pr/column_spaces_across.log.expected index 037dd814b..77303249b 100644 --- a/tests/fixtures/pr/column_spaces_across.log.expected +++ b/tests/fixtures/pr/column_spaces_across.log.expected @@ -1,6 +1,6 @@ -{last_modified_time} column.log Page 3 +{last_modified_time} column.log Page 3 337 337 338 338 339 339 @@ -66,7 +66,7 @@ -{last_modified_time} column.log Page 4 +{last_modified_time} column.log Page 4 505 505 506 506 507 507 @@ -132,7 +132,7 @@ -{last_modified_time} column.log Page 5 +{last_modified_time} column.log Page 5 673 673 674 674 675 675 diff --git a/tests/fixtures/pr/joined.log.expected b/tests/fixtures/pr/joined.log.expected index a9cee6e4f..4176944a6 100644 --- a/tests/fixtures/pr/joined.log.expected +++ b/tests/fixtures/pr/joined.log.expected @@ -1,6 +1,6 @@ -{last_modified_time} Page 1 +{last_modified_time} Page 1 ##ntation processAirPortStateChanges]: pppConnectionState 0 @@ -66,7 +66,7 @@ Mon Dec 10 11:42:59.352 Info: 802.1X changed -{last_modified_time} Page 2 +{last_modified_time} Page 2 Mon Dec 10 11:42:59.354 Info: -[AirPortExtraImplementation processAirPortStateChanges]: pppConnectionState 0 diff --git a/tests/fixtures/pr/l24-FF b/tests/fixtures/pr/l24-FF index de219b2fb..2da241047 100644 --- a/tests/fixtures/pr/l24-FF +++ b/tests/fixtures/pr/l24-FF @@ -1,6 +1,6 @@ -{last_modified_time} {file_name} Page 1 +{last_modified_time} {file_name} Page 1 1 FF-Test: FF's in Text V @@ -24,7 +24,7 @@ -{last_modified_time} {file_name} Page 2 +{last_modified_time} {file_name} Page 2 @@ -48,7 +48,7 @@ -{last_modified_time} {file_name} Page 3 +{last_modified_time} {file_name} Page 3 @@ -72,7 +72,7 @@ -{last_modified_time} {file_name} Page 4 +{last_modified_time} {file_name} Page 4 15 xyzxyzxyz XYZXYZXYZ abcabcab @@ -96,7 +96,7 @@ -{last_modified_time} {file_name} Page 5 +{last_modified_time} {file_name} Page 5 @@ -120,7 +120,7 @@ -{last_modified_time} {file_name} Page 6 +{last_modified_time} {file_name} Page 6 @@ -144,7 +144,7 @@ -{last_modified_time} {file_name} Page 7 +{last_modified_time} {file_name} Page 7 29 xyzxyzxyz XYZXYZXYZ abcabcab @@ -168,7 +168,7 @@ -{last_modified_time} {file_name} Page 8 +{last_modified_time} {file_name} Page 8 @@ -192,7 +192,7 @@ -{last_modified_time} {file_name} Page 9 +{last_modified_time} {file_name} Page 9 @@ -216,7 +216,7 @@ -{last_modified_time} {file_name} Page 10 +{last_modified_time} {file_name} Page 10 @@ -240,7 +240,7 @@ -{last_modified_time} {file_name} Page 11 +{last_modified_time} {file_name} Page 11 43 xyzxyzxyz XYZXYZXYZ abcabcab @@ -264,7 +264,7 @@ -{last_modified_time} {file_name} Page 12 +{last_modified_time} {file_name} Page 12 @@ -288,7 +288,7 @@ -{last_modified_time} {file_name} Page 13 +{last_modified_time} {file_name} Page 13 57 xyzxyzxyz XYZXYZXYZ abcabcab diff --git a/tests/fixtures/pr/mpr.log.expected b/tests/fixtures/pr/mpr.log.expected index f6fffd191..0f4d276b1 100644 --- a/tests/fixtures/pr/mpr.log.expected +++ b/tests/fixtures/pr/mpr.log.expected @@ -1,6 +1,6 @@ -{last_modified_time} Page 1 +{last_modified_time} Page 1 1 1 ## @@ -66,7 +66,7 @@ -{last_modified_time} Page 2 +{last_modified_time} Page 2 57 57 diff --git a/tests/fixtures/pr/mpr1.log.expected b/tests/fixtures/pr/mpr1.log.expected index 64d786d90..1d6915998 100644 --- a/tests/fixtures/pr/mpr1.log.expected +++ b/tests/fixtures/pr/mpr1.log.expected @@ -1,6 +1,6 @@ -{last_modified_time} Page 2 +{last_modified_time} Page 2 57 57 @@ -66,7 +66,7 @@ -{last_modified_time} Page 3 +{last_modified_time} Page 3 113 113 @@ -132,7 +132,7 @@ -{last_modified_time} Page 4 +{last_modified_time} Page 4 169 169 diff --git a/tests/fixtures/pr/mpr2.log.expected b/tests/fixtures/pr/mpr2.log.expected index 091f0f228..9c453924c 100644 --- a/tests/fixtures/pr/mpr2.log.expected +++ b/tests/fixtures/pr/mpr2.log.expected @@ -1,6 +1,6 @@ -{last_modified_time} Page 1 +{last_modified_time} Page 1 1 1 ## 1 @@ -100,7 +100,7 @@ -{last_modified_time} Page 2 +{last_modified_time} Page 2 91 91 91 diff --git a/tests/fixtures/pr/stdin.log.expected b/tests/fixtures/pr/stdin.log.expected index 6922ee594..5f9d6c235 100644 --- a/tests/fixtures/pr/stdin.log.expected +++ b/tests/fixtures/pr/stdin.log.expected @@ -1,6 +1,6 @@ -{last_modified_time} Page 1 +{last_modified_time} Page 1 1 ntation processAirPortStateChanges]: pppConnectionState 0 @@ -66,7 +66,7 @@ -{last_modified_time} Page 2 +{last_modified_time} Page 2 57 Mon Dec 10 11:42:59.354 Info: -[AirPortExtraImplementation processAirPortStateChanges]: pppConnectionState 0 diff --git a/tests/fixtures/pr/test_num_page_2.log.expected b/tests/fixtures/pr/test_num_page_2.log.expected index dae437ef8..bf9a6c174 100644 --- a/tests/fixtures/pr/test_num_page_2.log.expected +++ b/tests/fixtures/pr/test_num_page_2.log.expected @@ -1,6 +1,6 @@ -{last_modified_time} test_num_page.log Page 1 +{last_modified_time} test_num_page.log Page 1 1 ntation processAirPortStateChanges]: pppConnectionState 0 @@ -66,7 +66,7 @@ -{last_modified_time} test_num_page.log Page 2 +{last_modified_time} test_num_page.log Page 2 57 ntation processAirPortStateChanges]: pppConnectionState 0 diff --git a/tests/fixtures/pr/test_num_page_char.log.expected b/tests/fixtures/pr/test_num_page_char.log.expected index 169dbd844..0536b75c0 100644 --- a/tests/fixtures/pr/test_num_page_char.log.expected +++ b/tests/fixtures/pr/test_num_page_char.log.expected @@ -1,6 +1,6 @@ -{last_modified_time} test_num_page.log Page 1 +{last_modified_time} test_num_page.log Page 1 1cntation processAirPortStateChanges]: pppConnectionState 0 @@ -66,7 +66,7 @@ -{last_modified_time} test_num_page.log Page 2 +{last_modified_time} test_num_page.log Page 2 57cntation processAirPortStateChanges]: pppConnectionState 0 diff --git a/tests/fixtures/pr/test_num_page_char_one.log.expected b/tests/fixtures/pr/test_num_page_char_one.log.expected index dd7813192..cd0b12781 100644 --- a/tests/fixtures/pr/test_num_page_char_one.log.expected +++ b/tests/fixtures/pr/test_num_page_char_one.log.expected @@ -1,6 +1,6 @@ -{last_modified_time} test_num_page.log Page 1 +{last_modified_time} test_num_page.log Page 1 1cntation processAirPortStateChanges]: pppConnectionState 0 @@ -66,7 +66,7 @@ -{last_modified_time} test_num_page.log Page 2 +{last_modified_time} test_num_page.log Page 2 7cntation processAirPortStateChanges]: pppConnectionState 0 diff --git a/tests/fixtures/pr/test_one_page.log.expected b/tests/fixtures/pr/test_one_page.log.expected index 54f772392..fc354b41d 100644 --- a/tests/fixtures/pr/test_one_page.log.expected +++ b/tests/fixtures/pr/test_one_page.log.expected @@ -1,6 +1,6 @@ -{last_modified_time} test_one_page.log Page 1 +{last_modified_time} test_one_page.log Page 1 ntation processAirPortStateChanges]: pppConnectionState 0 diff --git a/tests/fixtures/pr/test_one_page_double_line.log.expected b/tests/fixtures/pr/test_one_page_double_line.log.expected index e32101fcf..49ed90c87 100644 --- a/tests/fixtures/pr/test_one_page_double_line.log.expected +++ b/tests/fixtures/pr/test_one_page_double_line.log.expected @@ -1,6 +1,6 @@ -{last_modified_time} test_one_page.log Page 1 +{last_modified_time} test_one_page.log Page 1 ntation processAirPortStateChanges]: pppConnectionState 0 @@ -66,7 +66,7 @@ Mon Dec 10 11:42:57.751 Info: -[AirPortExtraImplementati -{last_modified_time} test_one_page.log Page 2 +{last_modified_time} test_one_page.log Page 2 Mon Dec 10 11:42:57.896 Info: 802.1X changed diff --git a/tests/fixtures/pr/test_one_page_first_line.log.expected b/tests/fixtures/pr/test_one_page_first_line.log.expected index 303f01c73..5c7b2eebe 100644 --- a/tests/fixtures/pr/test_one_page_first_line.log.expected +++ b/tests/fixtures/pr/test_one_page_first_line.log.expected @@ -1,6 +1,6 @@ -{last_modified_time} test_one_page.log Page 1 +{last_modified_time} test_one_page.log Page 1 5 ntation processAirPortStateChanges]: pppConnectionState 0 diff --git a/tests/fixtures/pr/test_one_page_header.log.expected b/tests/fixtures/pr/test_one_page_header.log.expected index a00d5f855..06a69088c 100644 --- a/tests/fixtures/pr/test_one_page_header.log.expected +++ b/tests/fixtures/pr/test_one_page_header.log.expected @@ -1,6 +1,6 @@ -{last_modified_time} {header} Page 1 +{last_modified_time} {header} Page 1 ntation processAirPortStateChanges]: pppConnectionState 0 diff --git a/tests/fixtures/pr/test_page_length.log.expected b/tests/fixtures/pr/test_page_length.log.expected index 8f4ab82d1..38578c1dc 100644 --- a/tests/fixtures/pr/test_page_length.log.expected +++ b/tests/fixtures/pr/test_page_length.log.expected @@ -1,6 +1,6 @@ -{last_modified_time} test.log Page 2 +{last_modified_time} test.log Page 2 91 Mon Dec 10 11:43:31.748 )} took 0.0025 seconds, returned 10 results @@ -100,7 +100,7 @@ -{last_modified_time} test.log Page 3 +{last_modified_time} test.log Page 3 181 Mon Dec 10 11:52:32.715 AutoJoin: Successful cache-assisted scan request for locationd with channels {( diff --git a/tests/fixtures/pr/test_page_range_1.log.expected b/tests/fixtures/pr/test_page_range_1.log.expected index f254261d4..fa35f8445 100644 --- a/tests/fixtures/pr/test_page_range_1.log.expected +++ b/tests/fixtures/pr/test_page_range_1.log.expected @@ -1,6 +1,6 @@ -{last_modified_time} test.log Page 15 +{last_modified_time} test.log Page 15 Mon Dec 10 12:05:48.183 [channelNumber=12(2GHz), channelWidth={20MHz}, active] @@ -66,7 +66,7 @@ Mon Dec 10 12:06:28.765 Roam: ROAMING PROFILES updated to SINGLE -{last_modified_time} test.log Page 16 +{last_modified_time} test.log Page 16 Mon Dec 10 12:06:28.770 SC: airportdProcessSystemConfigurationEvent: Processing 'State:/Network/Interface/en0/AirPort/ProfileID' @@ -132,7 +132,7 @@ Mon Dec 10 12:06:50.945 BTC: __BluetoothCoexHandleUpdateForNode: -{last_modified_time} test.log Page 17 +{last_modified_time} test.log Page 17 Mon Dec 10 12:06:50.945 BTC: BluetoothCoexSetProfile: profile for band 2.4GHz didn't change @@ -198,7 +198,7 @@ Mon Dec 10 12:13:27.640 Info: link quality changed -{last_modified_time} test.log Page 18 +{last_modified_time} test.log Page 18 Mon Dec 10 12:14:46.658 Info: SCAN request received from pid 92 (locationd) with priority 2 diff --git a/tests/fixtures/pr/test_page_range_2.log.expected b/tests/fixtures/pr/test_page_range_2.log.expected index 4f260eb65..2ca5ed04d 100644 --- a/tests/fixtures/pr/test_page_range_2.log.expected +++ b/tests/fixtures/pr/test_page_range_2.log.expected @@ -1,6 +1,6 @@ -{last_modified_time} test.log Page 15 +{last_modified_time} test.log Page 15 Mon Dec 10 12:05:48.183 [channelNumber=12(2GHz), channelWidth={20MHz}, active] @@ -66,7 +66,7 @@ Mon Dec 10 12:06:28.765 Roam: ROAMING PROFILES updated to SINGLE -{last_modified_time} test.log Page 16 +{last_modified_time} test.log Page 16 Mon Dec 10 12:06:28.770 SC: airportdProcessSystemConfigurationEvent: Processing 'State:/Network/Interface/en0/AirPort/ProfileID' @@ -132,7 +132,7 @@ Mon Dec 10 12:06:50.945 BTC: __BluetoothCoexHandleUpdateForNode: -{last_modified_time} test.log Page 17 +{last_modified_time} test.log Page 17 Mon Dec 10 12:06:50.945 BTC: BluetoothCoexSetProfile: profile for band 2.4GHz didn't change From 22b63eeb10910da2a0d77d9ed458290bcea9c939 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=E1=BA=A3=20th=E1=BA=BF=20gi=E1=BB=9Bi=20l=C3=A0=20Rust?= Date: Mon, 5 Jan 2026 00:51:57 +0000 Subject: [PATCH 086/425] fix(timeout): use TimeoutFailed instead of missing WaitingFailed variant --- DEVELOPMENT.md | 1 + src/uu/timeout/src/timeout.rs | 21 ++++++++++++++++----- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 4f885e085..35291369c 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -262,6 +262,7 @@ To generate [gcov-based](https://github.com/mozilla/grcov#example-how-to-generat export CARGO_INCREMENTAL=0 export RUSTFLAGS="-Cinstrument-coverage -Ccodegen-units=1 -Copt-level=0 -Clink-dead-code -Coverflow-checks=off -Zpanic_abort_tests -Cpanic=abort" export RUSTDOCFLAGS="-Cpanic=abort" +export RUSTUP_TOOLCHAIN="nightly" cargo build # e.g., --features feat_os_unix cargo test # e.g., --features feat_os_unix test_pathchk grcov . -s . --binary-path ./target/debug/ -t html --branch --ignore-not-existing --ignore build.rs --excl-br-line "^\s*((debug_)?assert(_eq|_ne)?\#\[derive\()" -o ./target/debug/coverage/ diff --git a/src/uu/timeout/src/timeout.rs b/src/uu/timeout/src/timeout.rs index 3e1a35c45..290b5051f 100644 --- a/src/uu/timeout/src/timeout.rs +++ b/src/uu/timeout/src/timeout.rs @@ -263,7 +263,14 @@ fn wait_or_kill_process( match process.wait_or_timeout(duration, None) { Ok(Some(status)) => { if preserve_status { - Ok(status.code().unwrap_or_else(|| status.signal().unwrap())) + let exit_code = status.code().unwrap_or_else(|| { + status.signal().unwrap_or_else(|| { + // Extremely rare: process exited but we have neither exit code nor signal. + // This can happen on some platforms or in unusual termination scenarios. + ExitStatus::TimeoutFailed.into() + }) + }); + Ok(exit_code) } else { Ok(ExitStatus::TimeoutFailed.into()) } @@ -351,10 +358,14 @@ fn timeout( // structure of `wait_or_kill_process()`. They can probably be // refactored into some common function. match process.wait_or_timeout(duration, Some(&SIGNALED)) { - Ok(Some(status)) => Err(status - .code() - .unwrap_or_else(|| preserve_signal_info(status.signal().unwrap())) - .into()), + Ok(Some(status)) => { + let exit_code = status.code().unwrap_or_else(|| { + status + .signal() + .map_or_else(|| ExitStatus::TimeoutFailed.into(), preserve_signal_info) + }); + Err(exit_code.into()) + } Ok(None) => { report_if_verbose(signal, &cmd[0], verbose); send_signal(process, signal, foreground); From 29c777cfca914649f0ac7fd41dec08fec2c2f684 Mon Sep 17 00:00:00 2001 From: oech3 <> Date: Mon, 5 Jan 2026 16:54:18 +0900 Subject: [PATCH 087/425] build-gnu.sh: Let md5sum.pl clap compatible --- util/build-gnu.sh | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/util/build-gnu.sh b/util/build-gnu.sh index 2ae6b1e61..421b43d5e 100755 --- a/util/build-gnu.sh +++ b/util/build-gnu.sh @@ -320,11 +320,9 @@ 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 +# clap changes the error message. Check exit code only. "${SED}" -i -e "s|Try 'md5sum --help' for more information.\\\n||" tests/cksum/md5sum.pl -# clap changes the error message - "${SED}" -i '/check-ignore-missing-4/,/EXIT=> 1/ { /ERR=>/,/try_help/d }' tests/cksum/md5sum.pl - +"${SED}" -i '/check-ignore-missing-4/,/EXIT/c \ ['\''check-ignore-missing-4'\'', '\''--ignore-missing'\'', {IN=> {f=> '\'''\''}}, {ERR_SUBST=>"s/.*//s"}, {EXIT=> 1}],' 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. From 9cc2e096d83a3fd419cbd8040b939d4b309f34a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=E1=BA=A3=20th=E1=BA=BF=20gi=E1=BB=9Bi=20l=C3=A0=20Rust?= Date: Mon, 5 Jan 2026 03:21:51 +0000 Subject: [PATCH 088/425] fix: handle write errors gracefully instead of panicking Fixes #9769 Changed error.print().unwrap() to let _ = error.print() to prevent panic when writing to /dev/full. Added regression test in test_cat.rs. --- .../workspace.wordlist.txt | 1 + src/uucore/src/lib/mods/error.rs | 4 ++- tests/by-util/test_cat.rs | 31 +++++++++++++++++++ 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/.vscode/cspell.dictionaries/workspace.wordlist.txt b/.vscode/cspell.dictionaries/workspace.wordlist.txt index f9c8d686b..28c468d4f 100644 --- a/.vscode/cspell.dictionaries/workspace.wordlist.txt +++ b/.vscode/cspell.dictionaries/workspace.wordlist.txt @@ -182,6 +182,7 @@ LINESIZE NAMESIZE RTLD_NEXT RTLD +SIGABRT SIGINT SIGKILL SIGSTOP diff --git a/src/uucore/src/lib/mods/error.rs b/src/uucore/src/lib/mods/error.rs index 0b88e389b..ef270546c 100644 --- a/src/uucore/src/lib/mods/error.rs +++ b/src/uucore/src/lib/mods/error.rs @@ -748,7 +748,9 @@ 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> { - self.error.print().unwrap(); + // Intentionally ignore the result - error.print() writes directly to stderr + // and we always return Ok(()) to satisfy Display's contract + let _ = self.error.print(); Ok(()) } } diff --git a/tests/by-util/test_cat.rs b/tests/by-util/test_cat.rs index 33796f3ae..2d35a2e25 100644 --- a/tests/by-util/test_cat.rs +++ b/tests/by-util/test_cat.rs @@ -833,6 +833,37 @@ fn test_child_when_pipe_in() { ts.ucmd().pipe_in("content").run().stdout_is("content"); } +/// Regression test for GitHub issue #9769 +/// https://github.com/uutils/coreutils/issues/9769 +/// +/// Bug: Utilities panic when output is redirected to /dev/full +/// Location: src/uucore/src/lib/mods/error.rs:751 - `.unwrap()` causes panic +/// +/// This test verifies that cat handles write errors to /dev/full gracefully +/// instead of panicking with exit code 134 (SIGABRT). +/// +/// Expected behavior with current BUGGY code: +/// - Test WILL FAIL (cat panics with exit code 134) +/// +/// Expected behavior after fix: +/// - Test SHOULD PASS (cat exits gracefully with error code 1) +// Regression test for issue #9769: graceful error handling when writing to /dev/full +#[test] +#[cfg(target_os = "linux")] +fn test_write_error_handling() { + use std::fs::File; + + let dev_full = + File::create("/dev/full").expect("Failed to open /dev/full - test must run on Linux"); + + new_ucmd!() + .pipe_in("test content that should cause write error to /dev/full") + .set_stdout(dev_full) + .fails() + .code_is(1) + .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 From b8e3a984da82dda29690e49c0291fa7bbe245ec5 Mon Sep 17 00:00:00 2001 From: Daniel Hofstetter Date: Mon, 5 Jan 2026 15:08:34 +0100 Subject: [PATCH 089/425] uniq: rename keys_differ to keys_are_equal and adapt the code accordingly --- src/uu/uniq/src/uniq.rs | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/uu/uniq/src/uniq.rs b/src/uu/uniq/src/uniq.rs index 9c95305e4..ae9b88f2d 100644 --- a/src/uu/uniq/src/uniq.rs +++ b/src/uu/uniq/src/uniq.rs @@ -95,7 +95,15 @@ impl Uniq { self.build_meta(&next_buf, &mut next_meta); - if self.keys_differ(¤t_buf, ¤t_meta, &next_buf, &next_meta) { + if self.keys_are_equal(¤t_buf, ¤t_meta, &next_buf, &next_meta) { + if self.all_repeated { + self.print_line(writer, ¤t_buf, group_count, first_line_printed)?; + first_line_printed = true; + std::mem::swap(&mut current_buf, &mut next_buf); + std::mem::swap(&mut current_meta, &mut next_meta); + } + group_count += 1; + } else { if (group_count == 1 && !self.repeats_only) || (group_count > 1 && !self.uniques_only) { @@ -105,14 +113,6 @@ impl Uniq { std::mem::swap(&mut current_buf, &mut next_buf); std::mem::swap(&mut current_meta, &mut next_meta); group_count = 1; - } else { - if self.all_repeated { - self.print_line(writer, ¤t_buf, group_count, first_line_printed)?; - first_line_printed = true; - std::mem::swap(&mut current_buf, &mut next_buf); - std::mem::swap(&mut current_meta, &mut next_meta); - } - group_count += 1; } next_buf.clear(); } @@ -136,7 +136,7 @@ impl Uniq { if self.zero_terminated { 0 } else { b'\n' } } - fn keys_differ( + fn keys_are_equal( &self, first_line: &[u8], first_meta: &LineMeta, @@ -146,11 +146,11 @@ impl Uniq { let first_slice = &first_line[first_meta.key_start..first_meta.key_end]; let second_slice = &second_line[second_meta.key_start..second_meta.key_end]; - if !self.ignore_case { - return first_slice != second_slice; + if self.ignore_case { + first_slice.eq_ignore_ascii_case(second_slice) + } else { + first_slice == second_slice } - - !first_slice.eq_ignore_ascii_case(second_slice) } fn key_bounds(&self, line: &[u8]) -> (usize, usize) { From 88cf9bfd155d3e019e46448760a17d47b76d4594 Mon Sep 17 00:00:00 2001 From: oech3 <> Date: Tue, 6 Jan 2026 00:05:53 +0900 Subject: [PATCH 090/425] bump libc & tmp stop musl-i686 --- .github/workflows/CICD.yml | 4 +++- Cargo.lock | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/CICD.yml b/.github/workflows/CICD.yml index 67169f984..26620b012 100644 --- a/.github/workflows/CICD.yml +++ b/.github/workflows/CICD.yml @@ -579,7 +579,9 @@ jobs: - { os: ubuntu-latest , target: riscv64gc-unknown-linux-musl , features: feat_os_unix_musl , use-cross: use-cross , skip-tests: true } # - { os: ubuntu-latest , target: x86_64-unknown-linux-gnu , features: feat_selinux , use-cross: use-cross } - { os: ubuntu-latest , target: i686-unknown-linux-gnu , features: "feat_os_unix,test_risky_names", use-cross: use-cross } - - { os: ubuntu-latest , target: i686-unknown-linux-musl , features: feat_os_unix_musl , use-cross: use-cross } + # glibc 2.42 is important more than this platform + # Wait https://github.com/rust-lang/libc/pull/4914 + #- { os: ubuntu-latest , target: i686-unknown-linux-musl , features: feat_os_unix_musl , use-cross: use-cross } - { os: ubuntu-latest , target: x86_64-unknown-linux-gnu , features: "feat_os_unix,test_risky_names", use-cross: use-cross, skip-publish: true } - { os: ubuntu-latest , target: x86_64-unknown-linux-gnu , features: "feat_os_unix,uudoc" , use-cross: no, workspace-tests: true } - { os: ubuntu-latest , target: x86_64-unknown-linux-musl , features: feat_os_unix_musl , use-cross: use-cross } diff --git a/Cargo.lock b/Cargo.lock index bbc4e73f0..daed48437 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1651,9 +1651,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.178" +version = "0.2.179" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" +checksum = "c5a2d376baa530d1238d133232d15e239abad80d05838b4b59354e5268af431f" [[package]] name = "libloading" From 3378e6270f2100cfd0a75a1d1bfee6f558643eb0 Mon Sep 17 00:00:00 2001 From: xtqqczze <45661989+xtqqczze@users.noreply.github.com> Date: Sun, 4 Jan 2026 23:10:29 +0000 Subject: [PATCH 091/425] clippy: fix used_underscore_binding lint https://rust-lang.github.io/rust-clippy/master/index.html#used_underscore_binding --- Cargo.toml | 1 - src/uu/df/src/filesystem.rs | 6 +++--- src/uu/env/src/env.rs | 28 +++++++++++++--------------- src/uu/mv/src/mv.rs | 8 +------- src/uu/split/src/platform/unix.rs | 14 +++++++------- src/uu/stat/src/stat.rs | 5 +++-- src/uu/stty/src/stty.rs | 8 ++++++-- src/uu/tail/src/paths.rs | 4 ++-- src/uu/who/src/platform/unix.rs | 11 ++++------- src/uucore/src/lib/features/fs.rs | 7 +++++-- src/uucore/src/lib/features/sum.rs | 8 ++++---- 11 files changed, 48 insertions(+), 52 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index d6737d16d..8c5f7f64e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -657,7 +657,6 @@ ignored_unit_patterns = "allow" # 21 similar_names = "allow" # 20 large_stack_arrays = "allow" # 20 wildcard_imports = "allow" # 18 -used_underscore_binding = "allow" # 18 needless_pass_by_value = "allow" # 16 float_cmp = "allow" # 12 items_after_statements = "allow" # 11 diff --git a/src/uu/df/src/filesystem.rs b/src/uu/df/src/filesystem.rs index 25743941d..8c84c7405 100644 --- a/src/uu/df/src/filesystem.rs +++ b/src/uu/df/src/filesystem.rs @@ -121,7 +121,7 @@ where impl Filesystem { // TODO: resolve uuid in `mount_info.dev_name` if exists pub(crate) fn new(mount_info: MountInfo, file: Option) -> Option { - let _stat_path = if mount_info.mount_dir.is_empty() { + let stat_path = if mount_info.mount_dir.is_empty() { #[cfg(unix)] { mount_info.dev_name.clone().into() @@ -135,9 +135,9 @@ impl Filesystem { mount_info.mount_dir.clone() }; #[cfg(unix)] - let usage = FsUsage::new(statfs(&_stat_path).ok()?); + let usage = FsUsage::new(statfs(&stat_path).ok()?); #[cfg(windows)] - let usage = FsUsage::new(Path::new(&_stat_path)).ok()?; + let usage = FsUsage::new(Path::new(&stat_path)).ok()?; Some(Self { file, mount_info, diff --git a/src/uu/env/src/env.rs b/src/uu/env/src/env.rs index e71581f86..d715d3e9e 100644 --- a/src/uu/env/src/env.rs +++ b/src/uu/env/src/env.rs @@ -749,27 +749,25 @@ impl EnvAppData { do_debug_printing: bool, ) -> Result<(), Box> { let prog = Cow::from(opts.program[0]); - #[cfg(unix)] - let mut arg0 = prog.clone(); - #[cfg(not(unix))] - let arg0 = prog.clone(); - let args = &opts.program[1..]; - if let Some(_argv0) = opts.argv0 { - #[cfg(unix)] - { - arg0 = Cow::Borrowed(_argv0); + let arg0 = match opts.argv0 { + None => prog.clone(), + Some(argv0) if cfg!(unix) => { + let arg0 = Cow::Borrowed(argv0); if do_debug_printing { eprintln!("argv0: {}", arg0.quote()); } + arg0 } + Some(_) => { + return Err(USimpleError::new( + 2, + translate!("env-error-argv0-not-supported"), + )); + } + }; - #[cfg(not(unix))] - return Err(USimpleError::new( - 2, - translate!("env-error-argv0-not-supported"), - )); - } + let args = &opts.program[1..]; if do_debug_printing { eprintln!("executing: {}", prog.maybe_quote()); diff --git a/src/uu/mv/src/mv.rs b/src/uu/mv/src/mv.rs index a43b92eb8..1843402b3 100644 --- a/src/uu/mv/src/mv.rs +++ b/src/uu/mv/src/mv.rs @@ -1027,7 +1027,7 @@ fn copy_dir_contents( } #[cfg(not(unix))] { - copy_dir_contents_recursive(from, to, None, None, verbose, progress_bar, display_manager)?; + copy_dir_contents_recursive(from, to, verbose, progress_bar, display_manager)?; } Ok(()) @@ -1038,8 +1038,6 @@ fn copy_dir_contents_recursive( to_dir: &Path, #[cfg(unix)] hardlink_tracker: &mut HardlinkTracker, #[cfg(unix)] hardlink_scanner: &HardlinkGroupScanner, - #[cfg(not(unix))] _hardlink_tracker: Option<()>, - #[cfg(not(unix))] _hardlink_scanner: Option<()>, verbose: bool, progress_bar: Option<&ProgressBar>, display_manager: Option<&MultiProgress>, @@ -1078,10 +1076,6 @@ fn copy_dir_contents_recursive( hardlink_tracker, #[cfg(unix)] hardlink_scanner, - #[cfg(not(unix))] - _hardlink_tracker, - #[cfg(not(unix))] - _hardlink_scanner, verbose, progress_bar, display_manager, diff --git a/src/uu/split/src/platform/unix.rs b/src/uu/split/src/platform/unix.rs index d1257954d..25ea6b280 100644 --- a/src/uu/split/src/platform/unix.rs +++ b/src/uu/split/src/platform/unix.rs @@ -43,9 +43,9 @@ impl Write for FilterWriter { /// Have an environment variable set at a value during this lifetime struct WithEnvVarSet { /// Env var key - _previous_var_key: String, + previous_var_key: String, /// Previous value set to this key - _previous_var_value: std::result::Result, + previous_var_value: std::result::Result, } impl WithEnvVarSet { /// Save previous value assigned to key, set key=value @@ -55,8 +55,8 @@ impl WithEnvVarSet { env::set_var(key, value); } Self { - _previous_var_key: String::from(key), - _previous_var_value: previous_env_value, + previous_var_key: String::from(key), + previous_var_value: previous_env_value, } } } @@ -64,13 +64,13 @@ impl WithEnvVarSet { impl Drop for WithEnvVarSet { /// Restore previous value now that this is being dropped by context fn drop(&mut self) { - if let Ok(ref prev_value) = self._previous_var_value { + if let Ok(ref prev_value) = self.previous_var_value { unsafe { - env::set_var(&self._previous_var_key, prev_value); + env::set_var(&self.previous_var_key, prev_value); } } else { unsafe { - env::remove_var(&self._previous_var_key); + env::remove_var(&self.previous_var_key); } } } diff --git a/src/uu/stat/src/stat.rs b/src/uu/stat/src/stat.rs index 327e89a68..f730cc80a 100644 --- a/src/uu/stat/src/stat.rs +++ b/src/uu/stat/src/stat.rs @@ -1004,7 +1004,8 @@ impl Stater { file: &OsString, file_type: &FileType, from_user: bool, - _follow_symbolic_links: bool, + #[cfg(feature = "selinux")] follow_symbolic_links: bool, + #[cfg(not(feature = "selinux"))] _: bool, ) -> Result<(), i32> { match *t { Token::Byte(byte) => write_raw_byte(byte), @@ -1035,7 +1036,7 @@ impl Stater { if uucore::selinux::is_selinux_enabled() { match uucore::selinux::get_selinux_security_context( Path::new(file), - _follow_symbolic_links, + follow_symbolic_links, ) { Ok(ctx) => OutputType::Str(ctx), Err(_) => OutputType::Str(translate!( diff --git a/src/uu/stty/src/stty.rs b/src/uu/stty/src/stty.rs index 9153c1528..8808857b6 100644 --- a/src/uu/stty/src/stty.rs +++ b/src/uu/stty/src/stty.rs @@ -1033,11 +1033,15 @@ fn apply_special_setting( match setting { SpecialSetting::Rows(n) => size.rows = *n, SpecialSetting::Cols(n) => size.columns = *n, - SpecialSetting::Line(_n) => { + #[cfg_attr( + not(any(target_os = "linux", target_os = "android")), + expect(unused_variables) + )] + SpecialSetting::Line(n) => { // nix only defines Termios's `line_discipline` field on these platforms #[cfg(any(target_os = "linux", target_os = "android"))] { - _termios.line_discipline = *_n; + _termios.line_discipline = *n; } } } diff --git a/src/uu/tail/src/paths.rs b/src/uu/tail/src/paths.rs index 340a0b29d..3f37091d8 100644 --- a/src/uu/tail/src/paths.rs +++ b/src/uu/tail/src/paths.rs @@ -179,10 +179,10 @@ impl MetadataExtTail for Metadata { Ok(other.len() < self.len() && other.modified()? != self.modified()?) } - fn file_id_eq(&self, _other: &Metadata) -> bool { + fn file_id_eq(&self, #[cfg(unix)] other: &Metadata, #[cfg(not(unix))] _: &Metadata) -> bool { #[cfg(unix)] { - self.ino().eq(&_other.ino()) + self.ino().eq(&other.ino()) } #[cfg(windows)] { diff --git a/src/uu/who/src/platform/unix.rs b/src/uu/who/src/platform/unix.rs index 8e72a83ba..5cd27f26b 100644 --- a/src/uu/who/src/platform/unix.rs +++ b/src/uu/who/src/platform/unix.rs @@ -195,13 +195,10 @@ fn current_tty() -> String { impl Who { #[allow(clippy::cognitive_complexity)] fn exec(&mut self) -> UResult<()> { - let run_level_chk = |_record: i16| { - #[cfg(not(target_os = "linux"))] - return false; - - #[cfg(target_os = "linux")] - return _record == utmpx::RUN_LVL; - }; + #[cfg(target_os = "linux")] + let run_level_chk = |record: i16| record == utmpx::RUN_LVL; + #[cfg(not(target_os = "linux"))] + let run_level_chk = |_| false; let f = if self.args.len() == 1 { self.args[0].as_ref() diff --git a/src/uucore/src/lib/features/fs.rs b/src/uucore/src/lib/features/fs.rs index 16de054a3..8ede14244 100644 --- a/src/uucore/src/lib/features/fs.rs +++ b/src/uucore/src/lib/features/fs.rs @@ -765,10 +765,13 @@ pub mod sane_blksize { /// /// If the metadata contain invalid values a meaningful adaption /// of that value is done. - pub fn sane_blksize_from_metadata(_metadata: &std::fs::Metadata) -> u64 { + pub fn sane_blksize_from_metadata( + #[cfg(unix)] metadata: &std::fs::Metadata, + #[cfg(not(unix))] _: &std::fs::Metadata, + ) -> u64 { #[cfg(not(target_os = "windows"))] { - sane_blksize(_metadata.blksize()) + sane_blksize(metadata.blksize()) } #[cfg(target_os = "windows")] diff --git a/src/uucore/src/lib/features/sum.rs b/src/uucore/src/lib/features/sum.rs index 66fb752ab..6d190edbe 100644 --- a/src/uucore/src/lib/features/sum.rs +++ b/src/uucore/src/lib/features/sum.rs @@ -284,8 +284,8 @@ impl Digest for Bsd { } fn result(&mut self) -> DigestOutput { - let mut _out = [0; 2]; - self.hash_finalize(&mut _out); + let mut out = [0; 2]; + self.hash_finalize(&mut out); DigestOutput::U16(self.state) } @@ -319,8 +319,8 @@ impl Digest for SysV { } fn result(&mut self) -> DigestOutput { - let mut _out = [0; 2]; - self.hash_finalize(&mut _out); + let mut out = [0; 2]; + self.hash_finalize(&mut out); DigestOutput::U16((self.state & (u16::MAX as u32)) as u16) } From fbc22b26fecb97a8a6624e191bfe3920dc5d5fa1 Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Tue, 6 Jan 2026 01:01:30 +0900 Subject: [PATCH 092/425] fsext.rs: Replace getmntinfo by libc crate --- src/uucore/src/lib/features/fsext.rs | 35 ++-------------------------- 1 file changed, 2 insertions(+), 33 deletions(-) diff --git a/src/uucore/src/lib/features/fsext.rs b/src/uucore/src/lib/features/fsext.rs index ec88a5e61..0b6e59acb 100644 --- a/src/uucore/src/lib/features/fsext.rs +++ b/src/uucore/src/lib/features/fsext.rs @@ -420,37 +420,6 @@ fn mount_dev_id(mount_dir: &OsStr) -> String { } } -#[cfg(any( - target_os = "freebsd", - target_vendor = "apple", - target_os = "netbsd", - target_os = "openbsd" -))] -use libc::c_int; -#[cfg(any( - target_os = "freebsd", - target_vendor = "apple", - target_os = "netbsd", - target_os = "openbsd" -))] -unsafe extern "C" { - #[cfg(all(target_vendor = "apple", target_arch = "x86_64"))] - #[link_name = "getmntinfo$INODE64"] - fn get_mount_info(mount_buffer_p: *mut *mut StatFs, flags: c_int) -> c_int; - - #[cfg(any( - target_os = "netbsd", - target_os = "openbsd", - all(target_vendor = "apple", target_arch = "aarch64") - ))] - #[link_name = "getmntinfo"] - fn get_mount_info(mount_buffer_p: *mut *mut StatFs, flags: c_int) -> c_int; - - #[cfg(target_os = "freebsd")] - #[link_name = "getmntinfo"] - fn get_mount_info(mount_buffer_p: *mut *mut StatFs, flags: c_int) -> c_int; -} - use crate::error::UResult; #[cfg(any( target_os = "freebsd", @@ -505,9 +474,9 @@ pub fn read_fs_list() -> UResult> { ))] { let mut mount_buffer_ptr: *mut StatFs = ptr::null_mut(); - let len = unsafe { get_mount_info(&raw mut mount_buffer_ptr, 1_i32) }; + let len = unsafe { libc::getmntinfo(&raw mut mount_buffer_ptr, 1_i32) }; if len < 0 { - return Err(USimpleError::new(1, "get_mount_info() failed")); + return Err(USimpleError::new(1, "getmntinfo() failed")); } let mounts = unsafe { slice::from_raw_parts(mount_buffer_ptr, len as usize) }; Ok(mounts From ad5d56949c53a5aad258d26cc63e395bef849e58 Mon Sep 17 00:00:00 2001 From: Christopher Dryden Date: Mon, 29 Dec 2025 16:13:46 +0000 Subject: [PATCH 093/425] Add SMACK support to id, mkdir, mkfifo, mknod utilities --- Cargo.toml | 8 ++- src/uu/id/Cargo.toml | 1 + src/uu/id/src/id.rs | 80 ++++++++++++++++++---------- src/uu/mkdir/Cargo.toml | 1 + src/uu/mkdir/src/mkdir.rs | 10 ++++ src/uu/mkfifo/Cargo.toml | 1 + src/uu/mkfifo/src/mkfifo.rs | 17 ++++++ src/uu/mknod/Cargo.toml | 1 + src/uu/mknod/src/mknod.rs | 15 ++++++ src/uucore/locales/en-US.ftl | 5 ++ src/uucore/src/lib/features/smack.rs | 33 +++++++++++- util/run-gnu-tests-smack-ci.sh | 40 +++++++++----- 12 files changed, 170 insertions(+), 42 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 6a5796a46..6b7715903 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -68,7 +68,13 @@ feat_selinux = [ # "feat_smack" == enable support for SMACK Security Context (by using `--features feat_smack`) # NOTE: # * Running a uutils compiled with `feat_smack` requires a SMACK enabled Kernel at run time. -feat_smack = ["ls/smack"] +feat_smack = [ + "id/smack", + "ls/smack", + "mkdir/smack", + "mkfifo/smack", + "mknod/smack", +] ## ## feature sets ## (common/core and Tier1) feature sets diff --git a/src/uu/id/Cargo.toml b/src/uu/id/Cargo.toml index 9b947d956..de1752df1 100644 --- a/src/uu/id/Cargo.toml +++ b/src/uu/id/Cargo.toml @@ -29,3 +29,4 @@ path = "src/main.rs" [features] feat_selinux = ["selinux"] +smack = ["uucore/smack"] diff --git a/src/uu/id/src/id.rs b/src/uu/id/src/id.rs index 9ff314f62..96dc63e0e 100644 --- a/src/uu/id/src/id.rs +++ b/src/uu/id/src/id.rs @@ -62,9 +62,9 @@ macro_rules! cstr2cow { } fn get_context_help_text() -> String { - #[cfg(not(feature = "selinux"))] + #[cfg(not(any(feature = "selinux", feature = "smack")))] return translate!("id-context-help-disabled"); - #[cfg(feature = "selinux")] + #[cfg(any(feature = "selinux", feature = "smack"))] return translate!("id-context-help-enabled"); } @@ -98,7 +98,10 @@ struct State { rflag: bool, // --real zflag: bool, // --zero cflag: bool, // --context + #[cfg(feature = "selinux")] selinux_supported: bool, + #[cfg(feature = "smack")] + smack_supported: bool, ids: Option, // The behavior for calling GNU's `id` and calling GNU's `id $USER` is similar but different. // * The SELinux context is only displayed without a specified user. @@ -136,16 +139,10 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { zflag: matches.get_flag(options::OPT_ZERO), cflag: matches.get_flag(options::OPT_CONTEXT), - selinux_supported: { - #[cfg(feature = "selinux")] - { - uucore::selinux::is_selinux_enabled() - } - #[cfg(not(feature = "selinux"))] - { - false - } - }, + #[cfg(feature = "selinux")] + selinux_supported: uucore::selinux::is_selinux_enabled(), + #[cfg(feature = "smack")] + smack_supported: uucore::smack::is_smack_enabled(), user_specified: !users.is_empty(), ids: None, }; @@ -179,26 +176,42 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { let line_ending = LineEnding::from_zero_flag(state.zflag); if state.cflag { - return if state.selinux_supported { - // print SElinux context and exit - #[cfg(all(any(target_os = "linux", target_os = "android"), feature = "selinux"))] + // SELinux context + #[cfg(feature = "selinux")] + if state.selinux_supported { if let Ok(context) = selinux::SecurityContext::current(false) { let bytes = context.as_bytes(); print!("{}{line_ending}", String::from_utf8_lossy(bytes)); - } else { - // print error because `cflag` was explicitly requested - return Err(USimpleError::new( - 1, - translate!("id-error-cannot-get-context"), - )); + return Ok(()); } - Ok(()) - } else { - Err(USimpleError::new( + return Err(USimpleError::new( 1, - translate!("id-error-context-selinux-only"), - )) - }; + translate!("id-error-cannot-get-context"), + )); + } + + // SMACK label + #[cfg(feature = "smack")] + if state.smack_supported { + match uucore::smack::get_smack_label_for_self() { + Ok(label) => { + print!("{label}{line_ending}"); + return Ok(()); + } + Err(_) => { + return Err(USimpleError::new( + 1, + translate!("id-error-cannot-get-context"), + )); + } + } + } + + // Neither SELinux nor SMACK supported + return Err(USimpleError::new( + 1, + translate!("id-error-context-selinux-only"), + )); } for i in 0..=users.len() { @@ -666,7 +679,7 @@ fn id_print(state: &State, groups: &[u32]) { .join(",") ); - #[cfg(all(any(target_os = "linux", target_os = "android"), feature = "selinux"))] + #[cfg(feature = "selinux")] if state.selinux_supported && !state.user_specified && std::env::var_os("POSIXLY_CORRECT").is_none() @@ -677,6 +690,17 @@ fn id_print(state: &State, groups: &[u32]) { print!(" context={}", String::from_utf8_lossy(bytes)); } } + + #[cfg(feature = "smack")] + if state.smack_supported + && !state.user_specified + && std::env::var_os("POSIXLY_CORRECT").is_none() + { + // print SMACK label (does not depend on "-Z") + if let Ok(label) = uucore::smack::get_smack_label_for_self() { + print!(" context={label}"); + } + } } #[cfg(not(any(target_os = "linux", target_os = "android", target_os = "openbsd")))] diff --git a/src/uu/mkdir/Cargo.toml b/src/uu/mkdir/Cargo.toml index 7d81094cb..b2723e1cf 100644 --- a/src/uu/mkdir/Cargo.toml +++ b/src/uu/mkdir/Cargo.toml @@ -24,6 +24,7 @@ fluent = { workspace = true } [features] selinux = ["uucore/selinux"] +smack = ["uucore/smack"] [[bin]] name = "mkdir" diff --git a/src/uu/mkdir/src/mkdir.rs b/src/uu/mkdir/src/mkdir.rs index 76262f4bd..fc8bd2fab 100644 --- a/src/uu/mkdir/src/mkdir.rs +++ b/src/uu/mkdir/src/mkdir.rs @@ -300,6 +300,16 @@ fn create_single_dir(path: &Path, is_parent: bool, config: &Config) -> UResult<( } } + // Apply SMACK context if requested + #[cfg(feature = "smack")] + if config.set_selinux_context && uucore::smack::is_smack_enabled() { + if let Some(ctx) = config.context { + if let Err(e) = uucore::smack::set_smack_label_for_path(path, ctx) { + let _ = std::fs::remove_dir(path); + return Err(USimpleError::new(1, e.to_string())); + } + } + } Ok(()) } diff --git a/src/uu/mkfifo/Cargo.toml b/src/uu/mkfifo/Cargo.toml index 5edbfa6bd..ca0cc4dcb 100644 --- a/src/uu/mkfifo/Cargo.toml +++ b/src/uu/mkfifo/Cargo.toml @@ -25,6 +25,7 @@ fluent = { workspace = true } [features] selinux = ["uucore/selinux"] +smack = ["uucore/smack"] [[bin]] name = "mkfifo" diff --git a/src/uu/mkfifo/src/mkfifo.rs b/src/uu/mkfifo/src/mkfifo.rs index c55593dcb..f15f7fea6 100644 --- a/src/uu/mkfifo/src/mkfifo.rs +++ b/src/uu/mkfifo/src/mkfifo.rs @@ -75,6 +75,23 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { } } } + + // Apply SMACK context if requested + #[cfg(feature = "smack")] + { + let set_smack_context = matches.get_flag(options::SELINUX); + let context = matches.get_one::(options::CONTEXT); + + if (set_smack_context || context.is_some()) && uucore::smack::is_smack_enabled() { + if let Some(ctx) = context { + use std::path::Path; + if let Err(e) = uucore::smack::set_smack_label_for_path(Path::new(&f), ctx) { + let _ = fs::remove_file(&f); + return Err(USimpleError::new(1, e.to_string())); + } + } + } + } } Ok(()) diff --git a/src/uu/mknod/Cargo.toml b/src/uu/mknod/Cargo.toml index 32b983134..a32aa3e9a 100644 --- a/src/uu/mknod/Cargo.toml +++ b/src/uu/mknod/Cargo.toml @@ -26,6 +26,7 @@ fluent = { workspace = true } [features] selinux = ["uucore/selinux"] +smack = ["uucore/smack"] [[bin]] name = "mknod" diff --git a/src/uu/mknod/src/mknod.rs b/src/uu/mknod/src/mknod.rs index 8a4cf82d0..7b14b9111 100644 --- a/src/uu/mknod/src/mknod.rs +++ b/src/uu/mknod/src/mknod.rs @@ -100,6 +100,21 @@ fn mknod(file_name: &str, config: Config) -> i32 { } } + // Apply SMACK context if requested + #[cfg(feature = "smack")] + if config.set_selinux_context && uucore::smack::is_smack_enabled() { + if let Some(ctx) = config.context { + if let Err(e) = + uucore::smack::set_smack_label_for_path(std::path::Path::new(file_name), ctx) + { + // if it fails, delete the file + let _ = std::fs::remove_file(file_name); + eprintln!("{}: {}", uucore::util_name(), e); + return 1; + } + } + } + errno } } diff --git a/src/uucore/locales/en-US.ftl b/src/uucore/locales/en-US.ftl index fa77f5270..36cd9d942 100644 --- a/src/uucore/locales/en-US.ftl +++ b/src/uucore/locales/en-US.ftl @@ -46,6 +46,11 @@ selinux-error-context-retrieval-failure = failed to retrieve the security contex selinux-error-context-set-failure = failed to set default file creation context to '{ $context }': { $error } selinux-error-context-conversion-failure = failed to set default file creation context to '{ $context }': { $error } +# SMACK error messages +smack-error-not-enabled = SMACK is not enabled on this system +smack-error-label-retrieval-failure = failed to get security context: { $error } +smack-error-label-set-failure = failed to set default file creation context to '{ $context }': { $error } +smack-error-no-label-set = no security context set # Safe traversal error messages safe-traversal-error-path-contains-null = path contains null byte diff --git a/src/uucore/src/lib/features/smack.rs b/src/uucore/src/lib/features/smack.rs index 2a0250da5..0efb594a0 100644 --- a/src/uucore/src/lib/features/smack.rs +++ b/src/uucore/src/lib/features/smack.rs @@ -6,7 +6,8 @@ // spell-checker:ignore smackfs //! SMACK (Simplified Mandatory Access Control Kernel) support -use std::io; +use std::fs; +use std::io::{self, Read, Write}; use std::path::Path; use std::sync::OnceLock; @@ -50,6 +51,36 @@ pub fn is_smack_enabled() -> bool { *SMACK_ENABLED.get_or_init(|| Path::new("/sys/fs/smackfs").exists()) } +/// Gets the SMACK label for the current process. +pub fn get_smack_label_for_self() -> Result { + if !is_smack_enabled() { + return Err(SmackError::SmackNotEnabled); + } + + let mut label = String::new(); + fs::File::open("/proc/self/attr/current") + .map_err(SmackError::LabelRetrievalFailure)? + .read_to_string(&mut label) + .map_err(SmackError::LabelRetrievalFailure)?; + + Ok(label.trim().to_string()) +} + +/// Sets the SMACK label for the current process. +pub fn set_smack_label_for_self(label: &str) -> Result<(), SmackError> { + if !is_smack_enabled() { + return Err(SmackError::SmackNotEnabled); + } + + let label_owned = label.to_string(); + fs::File::create("/proc/self/attr/current") + .map_err(|e| SmackError::LabelSetFailure(label_owned.clone(), e))? + .write_all(label.as_bytes()) + .map_err(|e| SmackError::LabelSetFailure(label_owned, e))?; + + Ok(()) +} + /// Gets the SMACK label for a filesystem path via xattr. pub fn get_smack_label_for_path(path: &Path) -> Result { if !is_smack_enabled() { diff --git a/util/run-gnu-tests-smack-ci.sh b/util/run-gnu-tests-smack-ci.sh index 37a4631a5..0878ae4c2 100755 --- a/util/run-gnu-tests-smack-ci.sh +++ b/util/run-gnu-tests-smack-ci.sh @@ -1,7 +1,7 @@ #!/bin/bash # Run GNU SMACK tests in QEMU with SMACK-enabled kernel # Usage: run-gnu-tests-smack-ci.sh [GNU_DIR] [OUTPUT_DIR] -# spell-checker:ignore rootfs zstd unzstd cpio newc nographic smackfs devtmpfs tmpfs poweroff libm libgcc libpthread libdl librt sysfs rwxat +# spell-checker:ignore rootfs zstd unzstd cpio newc nographic smackfs devtmpfs tmpfs poweroff libm libgcc libpthread libdl librt sysfs rwxat setuidgid set -e SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" @@ -65,17 +65,22 @@ ln -sf /proc/mounts /etc/mtab mkdir -p /tmp && mount -t tmpfs tmpfs /tmp chmod 1777 /tmp export PATH="/bin:$PATH" srcdir="/gnu" LD_LIBRARY_PATH="/lib64" -cd /gnu/tests -sh "$TEST_SCRIPT" +if [ -n "$RUN_AS_USER" ]; then + # Run in /tmp so non-root user can create temp directories + cd /tmp + setuidgid "$RUN_AS_USER" sh "/gnu/tests/$TEST_SCRIPT" +else + cd /gnu/tests + sh "$TEST_SCRIPT" +fi echo "EXIT:$?" poweroff -f INIT chmod +x "$SMACK_DIR/rootfs/init" -# Build utilities with SMACK support (only ls has SMACK support for now) -# TODO: When other utilities have SMACK support, build: ls id mkdir mknod mkfifo +# Build utilities with SMACK support echo "Building utilities with SMACK support..." -cargo build --release --manifest-path="$REPO_DIR/Cargo.toml" --package uu_ls --bin ls --features uu_ls/smack +cargo build --release --manifest-path="$REPO_DIR/Cargo.toml" --package uu_id --features uu_id/smack --package uu_ls --features uu_ls/smack --package uu_mkdir --features uu_mkdir/smack --package uu_mkfifo --features uu_mkfifo/smack --package uu_mknod --features uu_mknod/smack # Find SMACK tests SMACK_TESTS=$(grep -l 'require_smack_' -r "$GNU_DIR/tests/" 2>/dev/null || true) @@ -95,19 +100,30 @@ for TEST_PATH in $SMACK_TESTS; do echo "Running: $TEST_REL" + # Determine if test needs non-root user + RUN_AS_USER="" + if echo "$TEST_REL" | grep -q "no-root"; then + RUN_AS_USER="nobody" + fi + # Create working copy WORK="/tmp/smack-test-$$" rm -rf "$WORK" "$WORK.gz" cp -a "$SMACK_DIR/rootfs" "$WORK" - # Copy built utilities (only ls has SMACK support for now) - # TODO: When other utilities have SMACK support, use: - # for U in ls id mkdir mknod mkfifo; do cp "$REPO_DIR/target/release/$U" "$WORK/bin/$U"; done - rm -f "$WORK/bin/ls" - cp "$REPO_DIR/target/release/ls" "$WORK/bin/ls" + # Copy built utilities with SMACK support + for U in id ls mkdir mkfifo mknod; do + rm -f "$WORK/bin/$U" + cp "$REPO_DIR/target/release/$U" "$WORK/bin/$U" + done - # Set test script path + # Set test script path and user sed -i "s|\$TEST_SCRIPT|$TEST_REL|g" "$WORK/init" + if [ -n "$RUN_AS_USER" ]; then + sed -i "s|\$RUN_AS_USER|$RUN_AS_USER|g" "$WORK/init" + else + sed -i "s|\$RUN_AS_USER||g" "$WORK/init" + fi # Build initramfs and run (cd "$WORK" && find . | cpio -o -H newc 2>/dev/null | gzip > "$WORK.gz") From f5db3c9b86ebe1a43a9245b79d8db7965f1de5af Mon Sep 17 00:00:00 2001 From: Christopher Dryden Date: Wed, 31 Dec 2025 20:46:29 +0000 Subject: [PATCH 094/425] Address review comments: refactor SMACK helpers and rename SELinux flags --- src/uu/id/locales/en-US.ftl | 2 +- src/uu/id/locales/fr-FR.ftl | 2 +- src/uu/id/src/id.rs | 2 +- src/uu/mkdir/src/mkdir.rs | 23 ++++++--------- src/uu/mkfifo/src/mkfifo.rs | 21 +++++--------- src/uu/mknod/src/mknod.rs | 32 +++++++++------------ src/uucore/src/lib/features/smack.rs | 42 +++++++++++++++++++++++----- 7 files changed, 67 insertions(+), 57 deletions(-) diff --git a/src/uu/id/locales/en-US.ftl b/src/uu/id/locales/en-US.ftl index 49264b30e..b9a93de01 100644 --- a/src/uu/id/locales/en-US.ftl +++ b/src/uu/id/locales/en-US.ftl @@ -18,7 +18,7 @@ id-error-names-real-ids-require-flags = printing only names or real IDs requires id-error-zero-not-permitted-default = option --zero not permitted in default format id-error-cannot-print-context-with-user = cannot print security context when user specified id-error-cannot-get-context = can't get process context -id-error-context-selinux-only = --context (-Z) works only on an SELinux-enabled kernel +id-error-context-security-only = --context (-Z) works only on an SELinux/SMACK-enabled kernel id-error-no-such-user = { $user }: no such user id-error-cannot-find-group-name = cannot find name for group ID { $gid } id-error-cannot-find-user-name = cannot find name for user ID { $uid } diff --git a/src/uu/id/locales/fr-FR.ftl b/src/uu/id/locales/fr-FR.ftl index 2e799ae37..0cf8cd758 100644 --- a/src/uu/id/locales/fr-FR.ftl +++ b/src/uu/id/locales/fr-FR.ftl @@ -18,7 +18,7 @@ id-error-names-real-ids-require-flags = l'affichage des noms uniquement ou des I id-error-zero-not-permitted-default = l'option --zero n'est pas autorisée dans le format par défaut id-error-cannot-print-context-with-user = impossible d'afficher le contexte de sécurité quand un utilisateur est spécifié id-error-cannot-get-context = impossible d'obtenir le contexte du processus -id-error-context-selinux-only = --context (-Z) ne fonctionne que sur un noyau avec SELinux activé +id-error-context-security-only = --context (-Z) ne fonctionne que sur un noyau avec SELinux/SMACK activé id-error-no-such-user = { $user } : utilisateur inexistant id-error-cannot-find-group-name = impossible de trouver le nom pour l'ID de groupe { $gid } id-error-cannot-find-user-name = impossible de trouver le nom pour l'ID utilisateur { $uid } diff --git a/src/uu/id/src/id.rs b/src/uu/id/src/id.rs index 96dc63e0e..e6ab3a696 100644 --- a/src/uu/id/src/id.rs +++ b/src/uu/id/src/id.rs @@ -210,7 +210,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { // Neither SELinux nor SMACK supported return Err(USimpleError::new( 1, - translate!("id-error-context-selinux-only"), + translate!("id-error-context-security-only"), )); } diff --git a/src/uu/mkdir/src/mkdir.rs b/src/uu/mkdir/src/mkdir.rs index fc8bd2fab..4b02b6d8a 100644 --- a/src/uu/mkdir/src/mkdir.rs +++ b/src/uu/mkdir/src/mkdir.rs @@ -27,7 +27,7 @@ mod options { pub const PARENTS: &str = "parents"; pub const VERBOSE: &str = "verbose"; pub const DIRS: &str = "dirs"; - pub const SELINUX: &str = "z"; + pub const SECURITY_CONTEXT: &str = "z"; pub const CONTEXT: &str = "context"; } @@ -42,8 +42,8 @@ pub struct Config<'a> { /// Print message for each created directory. pub verbose: bool, - /// Set `SELinux` security context. - pub set_selinux_context: bool, + /// Set security context (SELinux/SMACK). + pub set_security_context: bool, /// Specific `SELinux` context. pub context: Option<&'a String>, @@ -79,7 +79,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { let recursive = matches.get_flag(options::PARENTS); // Extract the SELinux related flags and options - let set_selinux_context = matches.get_flag(options::SELINUX); + let set_security_context = matches.get_flag(options::SECURITY_CONTEXT); let context = matches.get_one::(options::CONTEXT); match get_mode(&matches) { @@ -88,7 +88,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { recursive, mode, verbose, - set_selinux_context: set_selinux_context || context.is_some(), + set_security_context: set_security_context || context.is_some(), context, }; exec(dirs, &config) @@ -129,7 +129,7 @@ pub fn uu_app() -> Command { .action(ArgAction::SetTrue), ) .arg( - Arg::new(options::SELINUX) + Arg::new(options::SECURITY_CONTEXT) .short('Z') .help(translate!("mkdir-help-selinux")) .action(ArgAction::SetTrue), @@ -292,7 +292,7 @@ fn create_single_dir(path: &Path, is_parent: bool, config: &Config) -> UResult<( // Apply SELinux context if requested #[cfg(feature = "selinux")] - if config.set_selinux_context && uucore::selinux::is_selinux_enabled() { + if config.set_security_context && uucore::selinux::is_selinux_enabled() { if let Err(e) = uucore::selinux::set_selinux_security_context(path, config.context) { let _ = std::fs::remove_dir(path); @@ -302,13 +302,8 @@ fn create_single_dir(path: &Path, is_parent: bool, config: &Config) -> UResult<( // Apply SMACK context if requested #[cfg(feature = "smack")] - if config.set_selinux_context && uucore::smack::is_smack_enabled() { - if let Some(ctx) = config.context { - if let Err(e) = uucore::smack::set_smack_label_for_path(path, ctx) { - let _ = std::fs::remove_dir(path); - return Err(USimpleError::new(1, e.to_string())); - } - } + if config.set_security_context { + uucore::smack::set_smack_label_for_new_dir(path, config.context)?; } Ok(()) } diff --git a/src/uu/mkfifo/src/mkfifo.rs b/src/uu/mkfifo/src/mkfifo.rs index f15f7fea6..c9f1588d2 100644 --- a/src/uu/mkfifo/src/mkfifo.rs +++ b/src/uu/mkfifo/src/mkfifo.rs @@ -16,7 +16,7 @@ use uucore::{format_usage, show}; mod options { pub static MODE: &str = "mode"; - pub static SELINUX: &str = "Z"; + pub static SECURITY_CONTEXT: &str = "Z"; pub static CONTEXT: &str = "context"; pub static FIFO: &str = "fifo"; } @@ -62,10 +62,10 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { #[cfg(feature = "selinux")] { // Extract the SELinux related flags and options - let set_selinux_context = matches.get_flag(options::SELINUX); + let set_security_context = matches.get_flag(options::SECURITY_CONTEXT); let context = matches.get_one::(options::CONTEXT); - if set_selinux_context || context.is_some() { + if set_security_context || context.is_some() { use std::path::Path; if let Err(e) = uucore::selinux::set_selinux_security_context(Path::new(&f), context) @@ -79,17 +79,10 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { // Apply SMACK context if requested #[cfg(feature = "smack")] { - let set_smack_context = matches.get_flag(options::SELINUX); + let set_security_context = matches.get_flag(options::SECURITY_CONTEXT); let context = matches.get_one::(options::CONTEXT); - - if (set_smack_context || context.is_some()) && uucore::smack::is_smack_enabled() { - if let Some(ctx) = context { - use std::path::Path; - if let Err(e) = uucore::smack::set_smack_label_for_path(Path::new(&f), ctx) { - let _ = fs::remove_file(&f); - return Err(USimpleError::new(1, e.to_string())); - } - } + if set_security_context || context.is_some() { + uucore::smack::set_smack_label_for_new_file(&f, context)?; } } } @@ -112,7 +105,7 @@ pub fn uu_app() -> Command { .value_name("MODE"), ) .arg( - Arg::new(options::SELINUX) + Arg::new(options::SECURITY_CONTEXT) .short('Z') .help(translate!("mkfifo-help-selinux")) .action(ArgAction::SetTrue), diff --git a/src/uu/mknod/src/mknod.rs b/src/uu/mknod/src/mknod.rs index 7b14b9111..6bc0ea2ca 100644 --- a/src/uu/mknod/src/mknod.rs +++ b/src/uu/mknod/src/mknod.rs @@ -23,7 +23,7 @@ mod options { pub const TYPE: &str = "type"; pub const MAJOR: &str = "major"; pub const MINOR: &str = "minor"; - pub const SELINUX: &str = "z"; + pub const SECURITY_CONTEXT: &str = "z"; pub const CONTEXT: &str = "context"; } @@ -54,10 +54,10 @@ pub struct Config<'a> { pub dev: dev_t, - /// Set `SELinux` security context. - pub set_selinux_context: bool, + /// Set security context (SELinux/SMACK). + pub set_security_context: bool, - /// Specific `SELinux` context. + /// Specific security context (SELinux/SMACK). pub context: Option<&'a String>, } @@ -88,7 +88,7 @@ fn mknod(file_name: &str, config: Config) -> i32 { // Apply SELinux context if requested #[cfg(feature = "selinux")] - if config.set_selinux_context { + if config.set_security_context { if let Err(e) = uucore::selinux::set_selinux_security_context( std::path::Path::new(file_name), config.context, @@ -102,16 +102,10 @@ fn mknod(file_name: &str, config: Config) -> i32 { // Apply SMACK context if requested #[cfg(feature = "smack")] - if config.set_selinux_context && uucore::smack::is_smack_enabled() { - if let Some(ctx) = config.context { - if let Err(e) = - uucore::smack::set_smack_label_for_path(std::path::Path::new(file_name), ctx) - { - // if it fails, delete the file - let _ = std::fs::remove_file(file_name); - eprintln!("{}: {}", uucore::util_name(), e); - return 1; - } + if config.set_security_context { + if let Err(e) = uucore::smack::set_smack_label_for_new_file(file_name, config.context) { + eprintln!("{}: {}", uucore::util_name(), e); + return 1; } } @@ -139,8 +133,8 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { .get_one::("name") .expect("Missing argument 'NAME'"); - // Extract the SELinux related flags and options - let set_selinux_context = matches.get_flag(options::SELINUX); + // Extract the security context related flags and options + let set_security_context = matches.get_flag(options::SECURITY_CONTEXT); let context = matches.get_one::(options::CONTEXT); let dev = match ( @@ -168,7 +162,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { mode, use_umask, dev, - set_selinux_context: set_selinux_context || context.is_some(), + set_security_context: set_security_context || context.is_some(), context, }; @@ -219,7 +213,7 @@ pub fn uu_app() -> Command { .value_parser(value_parser!(u64)), ) .arg( - Arg::new(options::SELINUX) + Arg::new(options::SECURITY_CONTEXT) .short('Z') .help(translate!("mknod-help-selinux")) .action(ArgAction::SetTrue), diff --git a/src/uucore/src/lib/features/smack.rs b/src/uucore/src/lib/features/smack.rs index 0efb594a0..1b619211d 100644 --- a/src/uucore/src/lib/features/smack.rs +++ b/src/uucore/src/lib/features/smack.rs @@ -13,7 +13,7 @@ use std::sync::OnceLock; use thiserror::Error; -use crate::error::{UError, strip_errno}; +use crate::error::{UError, USimpleError, strip_errno}; use crate::translate; #[derive(Debug, Error)] @@ -72,13 +72,9 @@ pub fn set_smack_label_for_self(label: &str) -> Result<(), SmackError> { return Err(SmackError::SmackNotEnabled); } - let label_owned = label.to_string(); fs::File::create("/proc/self/attr/current") - .map_err(|e| SmackError::LabelSetFailure(label_owned.clone(), e))? - .write_all(label.as_bytes()) - .map_err(|e| SmackError::LabelSetFailure(label_owned, e))?; - - Ok(()) + .and_then(|mut f| f.write_all(label.as_bytes())) + .map_err(|e| SmackError::LabelSetFailure(label.to_string(), e)) } /// Gets the SMACK label for a filesystem path via xattr. @@ -106,3 +102,35 @@ pub fn set_smack_label_for_path(path: &Path, label: &str) -> Result<(), SmackErr xattr::set(path, "security.SMACK64", label.as_bytes()) .map_err(|e| SmackError::LabelSetFailure(label.to_string(), e)) } + +/// Sets SMACK label for a file, removing it on failure. +pub fn set_smack_label_for_new_file( + path: impl AsRef, + context: Option<&String>, +) -> Result<(), Box> { + let Some(ctx) = context else { return Ok(()) }; + if !is_smack_enabled() { + return Ok(()); + } + let path = path.as_ref(); + set_smack_label_for_path(path, ctx).map_err(|e| { + let _ = fs::remove_file(path); + USimpleError::new(1, e.to_string()) + }) +} + +/// Sets SMACK label for a directory, removing it on failure. +pub fn set_smack_label_for_new_dir( + path: impl AsRef, + context: Option<&String>, +) -> Result<(), Box> { + let Some(ctx) = context else { return Ok(()) }; + if !is_smack_enabled() { + return Ok(()); + } + let path = path.as_ref(); + set_smack_label_for_path(path, ctx).map_err(|e| { + let _ = fs::remove_dir(path); + USimpleError::new(1, e.to_string()) + }) +} From 6e588fed34b1773fe232e2d43c073669684860c5 Mon Sep 17 00:00:00 2001 From: Christopher Dryden Date: Fri, 2 Jan 2026 20:44:39 +0000 Subject: [PATCH 095/425] Use official Arch Linux download URL for SMACK CI --- util/run-gnu-tests-smack-ci.sh | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/util/run-gnu-tests-smack-ci.sh b/util/run-gnu-tests-smack-ci.sh index 0878ae4c2..5dc47eb87 100755 --- a/util/run-gnu-tests-smack-ci.sh +++ b/util/run-gnu-tests-smack-ci.sh @@ -17,10 +17,7 @@ mkdir -p "$SMACK_DIR"/{rootfs/{bin,lib64,proc,sys,dev,tmp,etc,gnu},kernel} # Download Arch Linux kernel (has SMACK built-in) if [ ! -f /tmp/arch-vmlinuz ]; then echo "Downloading Arch Linux kernel..." - MIRROR="https://geo.mirror.pkgbuild.com/core/os/x86_64" - KERNEL_PKG=$(curl -sL "$MIRROR/" | grep -oP 'linux-[0-9][^"]*-x86_64\.pkg\.tar\.zst' | grep -v headers | sort -V | tail -1) - [ -z "$KERNEL_PKG" ] && { echo "Error: Could not find kernel package"; exit 1; } - curl -sL -o /tmp/arch-kernel.pkg.tar.zst "$MIRROR/$KERNEL_PKG" + curl -sL -o /tmp/arch-kernel.pkg.tar.zst "https://archlinux.org/packages/core/x86_64/linux/download/" zstd -d /tmp/arch-kernel.pkg.tar.zst -o /tmp/arch-kernel.pkg.tar 2>/dev/null || unzstd /tmp/arch-kernel.pkg.tar.zst -o /tmp/arch-kernel.pkg.tar VMLINUZ_PATH=$(tar -tf /tmp/arch-kernel.pkg.tar | grep 'vmlinuz$' | head -1) tar -xf /tmp/arch-kernel.pkg.tar -C /tmp "$VMLINUZ_PATH" From 2da2c90dd721997ac3cec939ad93a174028e85f8 Mon Sep 17 00:00:00 2001 From: Chris Dryden Date: Mon, 5 Jan 2026 12:02:37 -0500 Subject: [PATCH 096/425] dd: fix nocache flag handling at EOF (#9818) * dd: fix nocache flag handling at EOF * Add tests for nocache EOF handling --------- Co-authored-by: Sylvestre Ledru --- src/uu/dd/src/dd.rs | 44 ++++++++-------------------------- tests/by-util/test_dd.rs | 51 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 60 insertions(+), 35 deletions(-) diff --git a/src/uu/dd/src/dd.rs b/src/uu/dd/src/dd.rs index 412b6668f..567f803d3 100644 --- a/src/uu/dd/src/dd.rs +++ b/src/uu/dd/src/dd.rs @@ -219,17 +219,6 @@ impl Source { Self::StdinFile(f) } - /// The length of the data source in number of bytes. - /// - /// If it cannot be determined, then this function returns 0. - fn len(&self) -> io::Result { - #[allow(clippy::match_wildcard_for_single_variants)] - match self { - Self::File(f) => Ok(f.metadata()?.len().try_into().unwrap_or(i64::MAX)), - _ => Ok(0), - } - } - fn skip(&mut self, n: u64) -> io::Result { match self { #[cfg(not(unix))] @@ -673,17 +662,6 @@ impl Dest { _ => Err(Errno::ESPIPE), // "Illegal seek" } } - - /// The length of the data destination in number of bytes. - /// - /// If it cannot be determined, then this function returns 0. - fn len(&self) -> io::Result { - #[allow(clippy::match_wildcard_for_single_variants)] - match self { - Self::File(f, _) => Ok(f.metadata()?.len().try_into().unwrap_or(i64::MAX)), - _ => Ok(0), - } - } } /// Decide whether the given buffer is all zeros. @@ -1063,21 +1041,12 @@ impl BlockWriter<'_> { /// depending on the command line arguments, this function /// informs the OS to flush/discard the caches for input and/or output file. fn flush_caches_full_length(i: &Input, o: &Output) -> io::Result<()> { - // TODO Better error handling for overflowing `len`. + // Using len=0 in posix_fadvise means "to end of file" if i.settings.iflags.nocache { - let offset = 0; - #[allow(clippy::useless_conversion)] - let len = i.src.len()?.try_into().unwrap(); - i.discard_cache(offset, len); + i.discard_cache(0, 0); } - // Similarly, discard the system cache for the output file. - // - // TODO Better error handling for overflowing `len`. if i.settings.oflags.nocache { - let offset = 0; - #[allow(clippy::useless_conversion)] - let len = o.dst.len()?.try_into().unwrap(); - o.discard_cache(offset, len); + o.discard_cache(0, 0); } Ok(()) @@ -1185,6 +1154,7 @@ fn dd_copy(mut i: Input, o: Output) -> io::Result<()> { let input_nocache = i.settings.iflags.nocache; let output_nocache = o.settings.oflags.nocache; + let output_direct = o.settings.oflags.direct; // Add partial block buffering, if needed. let mut o = if o.settings.buffered { @@ -1208,6 +1178,12 @@ fn dd_copy(mut i: Input, o: Output) -> io::Result<()> { let loop_bsize = calc_loop_bsize(i.settings.count, &rstat, &wstat, i.settings.ibs, bsize); let rstat_update = read_helper(&mut i, &mut buf, loop_bsize)?; if rstat_update.is_empty() { + if input_nocache { + i.discard_cache(read_offset.try_into().unwrap(), 0); + } + if output_nocache || output_direct { + o.discard_cache(write_offset.try_into().unwrap(), 0); + } break; } let wstat_update = o.write_blocks(&buf)?; diff --git a/tests/by-util/test_dd.rs b/tests/by-util/test_dd.rs index a6a52e66f..35a1561e4 100644 --- a/tests/by-util/test_dd.rs +++ b/tests/by-util/test_dd.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 fname, tname, fpath, specfile, testfile, unspec, ifile, ofile, outfile, fullblock, urand, fileio, atoe, atoibm, availible, behaviour, bmax, bremain, btotal, cflags, creat, ctable, ctty, datastructures, doesnt, etoa, fileout, fname, gnudd, iconvflags, iseek, nocache, noctty, noerror, nofollow, nolinks, nonblock, oconvflags, oseek, outfile, parseargs, rlen, rmax, rposition, rremain, rsofar, rstat, sigusr, sigval, wlen, wstat abcdefghijklm abcdefghi nabcde nabcdefg abcdefg fifoname seekable +// spell-checker:ignore fname, tname, fpath, specfile, testfile, unspec, ifile, ofile, outfile, fullblock, urand, fileio, atoe, atoibm, availible, behaviour, bmax, bremain, btotal, cflags, creat, ctable, ctty, datastructures, doesnt, etoa, fileout, fname, gnudd, iconvflags, iseek, nocache, noctty, noerror, nofollow, nolinks, nonblock, oconvflags, oseek, outfile, parseargs, rlen, rmax, rposition, rremain, rsofar, rstat, sigusr, sigval, wlen, wstat abcdefghijklm abcdefghi nabcde nabcdefg abcdefg fifoname seekable fadvise FADV DONTNEED use uutests::at_and_ucmd; use uutests::new_ucmd; @@ -1840,3 +1840,52 @@ fn test_skip_overflow() { "dd: invalid number: ‘9223372036854775808’: Value too large for defined data type", ); } + +#[test] +#[cfg(target_os = "linux")] +fn test_nocache_eof() { + let (at, mut ucmd) = at_and_ucmd!(); + at.write_bytes("in.f", &vec![0u8; 1234567]); + ucmd.args(&[ + "if=in.f", + "of=out.f", + "bs=1M", + "oflag=nocache,sync", + "status=noxfer", + ]) + .succeeds(); + assert_eq!(at.read_bytes("out.f").len(), 1234567); +} + +#[test] +#[cfg(all(target_os = "linux", feature = "printf"))] +fn test_nocache_eof_fadvise_zero_length() { + use std::process::Command; + let (at, _ucmd) = at_and_ucmd!(); + at.write_bytes("in.f", &vec![0u8; 1234567]); + + let strace_file = at.plus_as_string("strace.out"); + let result = Command::new("strace") + .args(["-o", &strace_file, "-e", "fadvise64,fadvise64_64"]) + .arg(get_tests_binary()) + .args([ + "dd", + "if=in.f", + "of=out.f", + "bs=1M", + "oflag=nocache,sync", + "status=none", + ]) + .current_dir(at.as_string()) + .output(); + + if result.is_err() { + return; // strace not available + } + + let strace = at.read("strace.out"); + assert!( + strace.contains(", 0, POSIX_FADV_DONTNEED"), + "Expected len=0 at EOF: {strace}" + ); +} From b263374429c1fbbc41104d82862a8430f0e82c6d Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Tue, 6 Jan 2026 01:30:58 +0900 Subject: [PATCH 097/425] FixPR.yml: Use cargo fetch --target $(rustc --print host-tuple) to save size of fetched crates --- .github/workflows/FixPR.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/FixPR.yml b/.github/workflows/FixPR.yml index e8451c525..70f42278c 100644 --- a/.github/workflows/FixPR.yml +++ b/.github/workflows/FixPR.yml @@ -46,7 +46,7 @@ jobs: # Ensure updated '*/Cargo.lock' # * '*/Cargo.lock' is required to be in a format that `cargo` of MinSRV can interpret (eg, v1-format for MinSRV < v1.38) for dir in "." "fuzz"; do - ( cd "$dir" && (cargo fetch --locked --quiet || cargo +${{ steps.vars.outputs.RUST_MIN_SRV }} update) ) + ( cd "$dir" && (cargo fetch --locked --quiet --target $(rustc --print host-tuple) || cargo +${{ steps.vars.outputs.RUST_MIN_SRV }} update) ) done - name: Info shell: bash @@ -65,7 +65,7 @@ jobs: cargo tree -V ## dependencies echo "## dependency list" - cargo fetch --locked --quiet + cargo fetch --locked --quiet --target $(rustc --print host-tuple) ## * using the 'stable' toolchain is necessary to avoid "unexpected '--filter-platform'" errors RUSTUP_TOOLCHAIN=stable cargo tree --locked --no-dedupe -e=no-dev --prefix=none --features ${{ matrix.job.features }} | grep -vE "$PWD" | sort --unique - name: Commit any changes (to '${{ env.BRANCH_TARGET }}') From a1a7603ecd960fc046e8edb8122dfcb8bc2061f1 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 6 Jan 2026 01:58:32 +0000 Subject: [PATCH 098/425] chore(deps): update rust crate jiff to v0.2.18 --- Cargo.lock | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bbc4e73f0..3697d1dbd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1565,9 +1565,9 @@ checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" [[package]] name = "jiff" -version = "0.2.17" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a87d9b8105c23642f50cbbae03d1f75d8422c5cb98ce7ee9271f7ff7505be6b8" +checksum = "e67e8da4c49d6d9909fe03361f9b620f58898859f5c7aded68351e85e71ecf50" dependencies = [ "jiff-static", "jiff-tzdb-platform", @@ -1575,14 +1575,14 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde_core", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] name = "jiff-static" -version = "0.2.17" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b787bebb543f8969132630c51fd0afab173a86c6abae56ff3b9e5e3e3f9f6e58" +checksum = "e0c84ee7f197eca9a86c6fd6cb771e55eb991632f15f2bc3ca6ec838929e6e78" dependencies = [ "proc-macro2", "quote", @@ -1873,7 +1873,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2439,7 +2439,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.11.0", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2765,7 +2765,7 @@ dependencies = [ "getrandom 0.3.3", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4436,7 +4436,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] From 8524903b36d7d5d0167f6be243a9ed73fdb8dd73 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 6 Jan 2026 01:58:39 +0000 Subject: [PATCH 099/425] chore(deps): update rust crate proc-macro2 to v1.0.105 --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bbc4e73f0..87a301a4e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2179,9 +2179,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.104" +version = "1.0.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9695f8df41bb4f3d222c95a67532365f569318332d03d5f3f67f37b20e6ebdf0" +checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7" dependencies = [ "unicode-ident", ] From 39d566275d5e67517bbdb475cf7414287e962350 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 6 Jan 2026 05:01:22 +0000 Subject: [PATCH 100/425] chore(deps): update rust crate quote to v1.0.43 --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bbc4e73f0..dcd88a954 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2212,9 +2212,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.42" +version = "1.0.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" dependencies = [ "proc-macro2", ] From 7274e7f6a132a9c38a2560e8632bc71b691b0dd7 Mon Sep 17 00:00:00 2001 From: Phan Trung Thanh Date: Tue, 6 Jan 2026 10:51:26 +0100 Subject: [PATCH 101/425] cp: set status code when encountering circular symbolic links (#9757) * cp: set exit code when encountering circular symbolic links error when copying directory * cp: add test to ensure that cp sets the status code when encountering circular symbolic links during directory copy * cp: check that the output of circular symbolic link test has the correct message * cp: update check for stderr message * cp: update circular symbolic link test to account for directory format in windows * cp: use std::path::MAIN_SEPARATOR_STR for test --- src/uu/cp/src/copydir.rs | 3 +-- tests/by-util/test_cp.rs | 29 +++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/uu/cp/src/copydir.rs b/src/uu/cp/src/copydir.rs index bbd3aba62..6ac1ae090 100644 --- a/src/uu/cp/src/copydir.rs +++ b/src/uu/cp/src/copydir.rs @@ -22,7 +22,6 @@ use uucore::fs::{ FileInformation, MissingHandling, ResolveMode, canonicalize, path_ends_with_terminator, }; use uucore::show; -use uucore::show_error; use uucore::translate; use uucore::uio_error; use walkdir::{DirEntry, WalkDir}; @@ -513,7 +512,7 @@ pub(crate) fn copy_directory( } // Print an error message, but continue traversing the directory. - Err(e) => show_error!("{e}"), + Err(e) => show!(CpError::WalkDirErr(e)), } } diff --git a/tests/by-util/test_cp.rs b/tests/by-util/test_cp.rs index f0ad9d8ca..dfd07ec4b 100644 --- a/tests/by-util/test_cp.rs +++ b/tests/by-util/test_cp.rs @@ -7445,6 +7445,35 @@ fn test_cp_archive_deref_flag_ordering() { } } +#[test] +fn test_cp_circular_symbolic_links_in_directory() { + let source_dir = "source_dir"; + let target_dir = "target_dir"; + let (at, mut ucmd) = at_and_ucmd!(); + let separator = std::path::MAIN_SEPARATOR_STR; + + at.mkdir(source_dir); + at.symlink_file( + format!("{source_dir}/a").as_str(), + format!("{source_dir}/b").as_str(), + ); + at.symlink_file( + format!("{source_dir}/b").as_str(), + format!("{source_dir}/a").as_str(), + ); + + ucmd.arg(source_dir) + .arg(target_dir) + .arg("-rL") + .fails_with_code(1) + .stderr_contains(format!( + "IO error for operation on {source_dir}{separator}a" + )) + .stderr_contains(format!( + "IO error for operation on {source_dir}{separator}b" + )); +} + /// Test that copying to an existing file maintains its permissions, unix only because .mode() only /// works on Unix #[test] From 0078c263154106c7418efee4b315a07e32eb5145 Mon Sep 17 00:00:00 2001 From: oech3 <> Date: Sun, 4 Jan 2026 16:26:57 +0900 Subject: [PATCH 102/425] hashsum, cksum: Move default stdin to clap --- src/uu/cksum/src/cksum.rs | 16 ++++++++-------- src/uu/hashsum/src/hashsum.rs | 13 +++++++------ 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/src/uu/cksum/src/cksum.rs b/src/uu/cksum/src/cksum.rs index 70c80ae37..447e90954 100644 --- a/src/uu/cksum/src/cksum.rs +++ b/src/uu/cksum/src/cksum.rs @@ -7,8 +7,7 @@ use clap::builder::ValueParser; use clap::{Arg, ArgAction, Command}; -use std::ffi::{OsStr, OsString}; -use std::iter; +use std::ffi::OsString; use uucore::checksum::compute::{ ChecksumComputeOptions, figure_out_output_format, perform_checksum_computation, }; @@ -121,12 +120,11 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { let length = maybe_sanitize_length(algo_cli, input_length)?; - let files = matches.get_many::(options::FILE).map_or_else( - // No files given, read from stdin. - || Box::new(iter::once(OsStr::new("-"))) as Box>, - // At least one file given, read from them. - |files| Box::new(files.map(OsStr::new)) as Box>, - ); + // clap provides the default value -. So we unwrap() safety. + let files = matches + .get_many::(options::FILE) + .unwrap() + .map(|s| s.as_os_str()); if check { // cksum does not support '--check'ing legacy algorithms @@ -200,6 +198,8 @@ pub fn uu_app() -> Command { .hide(true) .action(ArgAction::Append) .value_parser(ValueParser::os_string()) + .default_value("-") + .hide_default_value(true) .value_hint(clap::ValueHint::FilePath), ) .arg( diff --git a/src/uu/hashsum/src/hashsum.rs b/src/uu/hashsum/src/hashsum.rs index eea434d94..3bc6dcff5 100644 --- a/src/uu/hashsum/src/hashsum.rs +++ b/src/uu/hashsum/src/hashsum.rs @@ -163,12 +163,11 @@ pub fn uumain(mut args: impl uucore::Args) -> UResult<()> { let strict = matches.get_flag("strict"); let status = matches.get_flag("status"); - let files = matches.get_many::(options::FILE).map_or_else( - // No files given, read from stdin. - || Box::new(iter::once(OsStr::new("-"))) as Box>, - // At least one file given, read from them. - |files| Box::new(files.map(OsStr::new)) as Box>, - ); + // clap provides the default value -. So we unwrap() safety. + let files = matches + .get_many::(options::FILE) + .unwrap() + .map(|s| s.as_os_str()); if check { // on Windows, allow --binary/--text to be used with --check @@ -340,6 +339,8 @@ pub fn uu_app_common() -> Command { .index(1) .action(ArgAction::Append) .value_name(options::FILE) + .default_value("-") + .hide_default_value(true) .value_hint(clap::ValueHint::FilePath) .value_parser(ValueParser::os_string()), ) From 5c777995a11b8d8616029cf9b2cbfd61399d04da Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 6 Jan 2026 17:11:30 +0000 Subject: [PATCH 103/425] chore(deps): update rust crate libc to v0.2.179 --- fuzz/Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index 2b519a989..b891522cc 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -894,9 +894,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.178" +version = "0.2.179" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" +checksum = "c5a2d376baa530d1238d133232d15e239abad80d05838b4b59354e5268af431f" [[package]] name = "libfuzzer-sys" From cbf2e6bb7a115f82f07d513b4c1336e34457d6ee Mon Sep 17 00:00:00 2001 From: oech3 <> Date: Sun, 4 Jan 2026 12:37:12 +0900 Subject: [PATCH 104/425] hashsum, cksum: Move --ckeck confliction to clap --- src/uu/cksum/src/cksum.rs | 11 +++------ src/uu/hashsum/src/hashsum.rs | 26 ++++++++++----------- src/uucore/src/lib/features/checksum/mod.rs | 2 -- tests/by-util/test_cksum.rs | 4 ++-- 4 files changed, 18 insertions(+), 25 deletions(-) diff --git a/src/uu/cksum/src/cksum.rs b/src/uu/cksum/src/cksum.rs index 70c80ae37..3e8e147f4 100644 --- a/src/uu/cksum/src/cksum.rs +++ b/src/uu/cksum/src/cksum.rs @@ -134,14 +134,6 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { return Err(ChecksumError::AlgorithmNotSupportedWithCheck.into()); } - let text_flag = matches.get_flag(options::TEXT); - let binary_flag = matches.get_flag(options::BINARY); - let tag = matches.get_flag(options::TAG); - - if tag || binary_flag || text_flag { - return Err(ChecksumError::BinaryTextConflict.into()); - } - // Execute the checksum validation based on the presence of files or the use of stdin let verbose = ChecksumVerbose::new(status, quiet, warn); @@ -251,6 +243,9 @@ pub fn uu_app() -> Command { .short('c') .long(options::CHECK) .help(translate!("cksum-help-check")) + .conflicts_with(options::TAG) + .conflicts_with(options::BINARY) + .conflicts_with(options::TEXT) .action(ArgAction::SetTrue), ) .arg( diff --git a/src/uu/hashsum/src/hashsum.rs b/src/uu/hashsum/src/hashsum.rs index eea434d94..963be7080 100644 --- a/src/uu/hashsum/src/hashsum.rs +++ b/src/uu/hashsum/src/hashsum.rs @@ -171,18 +171,7 @@ pub fn uumain(mut args: impl uucore::Args) -> UResult<()> { ); if check { - // on Windows, allow --binary/--text to be used with --check - // and keep the behavior of defaulting to binary - #[cfg(not(windows))] - { - let text_flag = matches.get_flag("text"); - let binary_flag = matches.get_flag("binary"); - - if binary_flag || text_flag { - return Err(ChecksumError::BinaryTextConflict.into()); - } - } - + // No reason to allow --check with --binary/--text on Cygwin. It want to be same with Linux and --text was broken for a long time. let verbose = ChecksumVerbose::new(status, quiet, warn); let opts = ChecksumValidateOptions { @@ -232,6 +221,10 @@ mod options { } pub fn uu_app_common() -> Command { + // --text --arg-deps-check should be error by Arg::new(options::CHECK)...conflicts_with(options::TEXT) + // https://github.com/clap-rs/clap/issues/4520 ? + // Let --{warn,strict,quiet,status,ignore-missing} reject --text and remove them later. + // Bad error message, but not a lie... Command::new(uucore::util_name()) .version(uucore::crate_version!()) .help_template(uucore::localized_help_template(uucore::util_name())) @@ -261,7 +254,9 @@ pub fn uu_app_common() -> Command { .long("check") .help(translate!("hashsum-help-check")) .action(ArgAction::SetTrue) - .conflicts_with("tag"), + .conflicts_with(options::BINARY) + .conflicts_with(options::TEXT) + .conflicts_with(options::TAG), ) .arg( Arg::new(options::TAG) @@ -294,6 +289,7 @@ pub fn uu_app_common() -> Command { .help(translate!("hashsum-help-quiet")) .action(ArgAction::SetTrue) .overrides_with_all([options::STATUS, options::WARN]) + .conflicts_with("text") .requires(options::CHECK), ) .arg( @@ -303,6 +299,7 @@ pub fn uu_app_common() -> Command { .help(translate!("hashsum-help-status")) .action(ArgAction::SetTrue) .overrides_with_all([options::QUIET, options::WARN]) + .conflicts_with("text") .requires(options::CHECK), ) .arg( @@ -310,6 +307,7 @@ pub fn uu_app_common() -> Command { .long("strict") .help(translate!("hashsum-help-strict")) .action(ArgAction::SetTrue) + .conflicts_with("text") .requires(options::CHECK), ) .arg( @@ -317,6 +315,7 @@ pub fn uu_app_common() -> Command { .long("ignore-missing") .help(translate!("hashsum-help-ignore-missing")) .action(ArgAction::SetTrue) + .conflicts_with("text") .requires(options::CHECK), ) .arg( @@ -326,6 +325,7 @@ pub fn uu_app_common() -> Command { .help(translate!("hashsum-help-warn")) .action(ArgAction::SetTrue) .overrides_with_all([options::QUIET, options::STATUS]) + .conflicts_with("text") .requires(options::CHECK), ) .arg( diff --git a/src/uucore/src/lib/features/checksum/mod.rs b/src/uucore/src/lib/features/checksum/mod.rs index 7cf7fe129..e272cdea6 100644 --- a/src/uucore/src/lib/features/checksum/mod.rs +++ b/src/uucore/src/lib/features/checksum/mod.rs @@ -390,8 +390,6 @@ pub enum ChecksumError { #[error("--length is only supported with --algorithm blake2b, sha2, or sha3")] LengthOnlyForBlake2bSha2Sha3, - #[error("the --binary and --text options are meaningless when verifying checksums")] - BinaryTextConflict, #[error("--text mode is only supported with --untagged")] TextWithoutUntagged, #[error("--check is not supported with --algorithm={{bsd,sysv,crc,crc32b}}")] diff --git a/tests/by-util/test_cksum.rs b/tests/by-util/test_cksum.rs index d1abe3409..40f49fc70 100644 --- a/tests/by-util/test_cksum.rs +++ b/tests/by-util/test_cksum.rs @@ -1216,7 +1216,7 @@ fn test_conflicting_options() { .fails_with_code(1) .no_stdout() .stderr_contains( - "cksum: the --binary and --text options are meaningless when verifying checksums", + "cannot be used with", //clap generated error ); scene @@ -1228,7 +1228,7 @@ fn test_conflicting_options() { .fails_with_code(1) .no_stdout() .stderr_contains( - "cksum: the --binary and --text options are meaningless when verifying checksums", + "cannot be used with", //clap generated error ); } From 3078a6edddec96b887eb1b231529b4c65b1b6454 Mon Sep 17 00:00:00 2001 From: Christopher Dryden Date: Tue, 6 Jan 2026 17:57:30 +0000 Subject: [PATCH 105/425] runcon: fix SELinux test failures by adding chmod to build and fixing -c PATH behavior --- src/uu/runcon/src/runcon.rs | 25 ++++++++++++++++++++----- util/build-gnu.sh | 2 +- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src/uu/runcon/src/runcon.rs b/src/uu/runcon/src/runcon.rs index 75fdfbec0..60c71d1dc 100644 --- a/src/uu/runcon/src/runcon.rs +++ b/src/uu/runcon/src/runcon.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 (vars) RFILE +// spell-checker:ignore (vars) RFILE execv execvp #![cfg(target_os = "linux")] use clap::builder::ValueParser; @@ -48,7 +48,8 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { .map_err(RunconError::new)?; // On successful execution, the following call never returns, // and this process image is replaced. - execute_command(command, &options.arguments) + // PlainContext mode uses PATH search (like execvp). + execute_command(command, &options.arguments, false) } CommandLineMode::CustomContext { compute_transition_context, @@ -72,7 +73,8 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { .map_err(RunconError::new)?; // On successful execution, the following call never returns, // and this process image is replaced. - execute_command(command, &options.arguments) + // With -c flag, skip PATH search (like execv vs execvp). + execute_command(command, &options.arguments, *compute_transition_context) } None => print_current_context().map_err(|e| RunconError::new(e).into()), } @@ -367,8 +369,21 @@ fn get_custom_context( /// However, until the *never* type is stabilized, one way to indicate to the /// compiler the only valid return type is to say "if this returns, it will /// always return an error". -fn execute_command(command: &OsStr, arguments: &[OsString]) -> UResult<()> { - let err = process::Command::new(command).args(arguments).exec(); +/// +/// When `skip_path_search` is true (used with `-c` flag), the command is executed +/// without PATH lookup, matching GNU's use of execv() vs execvp(). +fn execute_command(command: &OsStr, arguments: &[OsString], skip_path_search: bool) -> UResult<()> { + // When skip_path_search is true and command has no path separator, + // prepend "./" to prevent PATH lookup (like execv vs execvp). + let command_path = if skip_path_search && !command.as_bytes().contains(&b'/') { + let mut path = OsString::from("./"); + path.push(command); + path + } else { + command.to_os_string() + }; + + let err = process::Command::new(&command_path).args(arguments).exec(); let exit_status = if err.kind() == io::ErrorKind::NotFound { error_exit_status::NOT_FOUND diff --git a/util/build-gnu.sh b/util/build-gnu.sh index 421b43d5e..6075c8637 100755 --- a/util/build-gnu.sh +++ b/util/build-gnu.sh @@ -93,7 +93,7 @@ export CARGOFLAGS # tell to make ln -vf "${UU_BUILD_DIR}/install" "${UU_BUILD_DIR}/ginstall" # The GNU tests use renamed install to ginstall if [ "${SELINUX_ENABLED}" = 1 ];then # Build few utils for SELinux for faster build. MULTICALL=y fails... - "${MAKE}" UTILS="cat chcon cp cut echo env groups id ln ls mkdir mkfifo mknod mktemp mv printf rm rmdir runcon stat test touch tr true uname wc whoami" + "${MAKE}" UTILS="cat chcon chmod cp cut echo env groups id ln ls mkdir mkfifo mknod mktemp mv printf rm rmdir runcon stat test touch tr true uname wc whoami" else # Use MULTICALL=y for faster build "${MAKE}" MULTICALL=y SKIP_UTILS="install more seq" From 557f1befb44cc363bdae5fd92cae9c19ebe0b33a Mon Sep 17 00:00:00 2001 From: Christopher Dryden Date: Tue, 6 Jan 2026 18:39:44 +0000 Subject: [PATCH 106/425] smack: refactor to single set_smack_label_and_cleanup function --- src/uu/mkdir/src/mkdir.rs | 4 +++- src/uu/mkfifo/src/mkfifo.rs | 4 +++- src/uu/mknod/src/mknod.rs | 6 +++++- src/uucore/src/lib/features/smack.rs | 23 ++++------------------- 4 files changed, 15 insertions(+), 22 deletions(-) diff --git a/src/uu/mkdir/src/mkdir.rs b/src/uu/mkdir/src/mkdir.rs index 4b02b6d8a..88a196ebb 100644 --- a/src/uu/mkdir/src/mkdir.rs +++ b/src/uu/mkdir/src/mkdir.rs @@ -303,7 +303,9 @@ fn create_single_dir(path: &Path, is_parent: bool, config: &Config) -> UResult<( // Apply SMACK context if requested #[cfg(feature = "smack")] if config.set_security_context { - uucore::smack::set_smack_label_for_new_dir(path, config.context)?; + uucore::smack::set_smack_label_and_cleanup(path, config.context, |p| { + std::fs::remove_dir(p) + })?; } Ok(()) } diff --git a/src/uu/mkfifo/src/mkfifo.rs b/src/uu/mkfifo/src/mkfifo.rs index c9f1588d2..225540873 100644 --- a/src/uu/mkfifo/src/mkfifo.rs +++ b/src/uu/mkfifo/src/mkfifo.rs @@ -82,7 +82,9 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { let set_security_context = matches.get_flag(options::SECURITY_CONTEXT); let context = matches.get_one::(options::CONTEXT); if set_security_context || context.is_some() { - uucore::smack::set_smack_label_for_new_file(&f, context)?; + uucore::smack::set_smack_label_and_cleanup(&f, context, |p| { + std::fs::remove_file(p) + })?; } } } diff --git a/src/uu/mknod/src/mknod.rs b/src/uu/mknod/src/mknod.rs index 6bc0ea2ca..56474e4b6 100644 --- a/src/uu/mknod/src/mknod.rs +++ b/src/uu/mknod/src/mknod.rs @@ -103,7 +103,11 @@ fn mknod(file_name: &str, config: Config) -> i32 { // Apply SMACK context if requested #[cfg(feature = "smack")] if config.set_security_context { - if let Err(e) = uucore::smack::set_smack_label_for_new_file(file_name, config.context) { + if let Err(e) = + uucore::smack::set_smack_label_and_cleanup(file_name, config.context, |p| { + std::fs::remove_file(p) + }) + { eprintln!("{}: {}", uucore::util_name(), e); return 1; } diff --git a/src/uucore/src/lib/features/smack.rs b/src/uucore/src/lib/features/smack.rs index 1b619211d..d901bde00 100644 --- a/src/uucore/src/lib/features/smack.rs +++ b/src/uucore/src/lib/features/smack.rs @@ -103,10 +103,11 @@ pub fn set_smack_label_for_path(path: &Path, label: &str) -> Result<(), SmackErr .map_err(|e| SmackError::LabelSetFailure(label.to_string(), e)) } -/// Sets SMACK label for a file, removing it on failure. -pub fn set_smack_label_for_new_file( +/// Sets SMACK label for a new path, calling cleanup on failure. +pub fn set_smack_label_and_cleanup( path: impl AsRef, context: Option<&String>, + cleanup: impl FnOnce(&Path) -> io::Result<()>, ) -> Result<(), Box> { let Some(ctx) = context else { return Ok(()) }; if !is_smack_enabled() { @@ -114,23 +115,7 @@ pub fn set_smack_label_for_new_file( } let path = path.as_ref(); set_smack_label_for_path(path, ctx).map_err(|e| { - let _ = fs::remove_file(path); - USimpleError::new(1, e.to_string()) - }) -} - -/// Sets SMACK label for a directory, removing it on failure. -pub fn set_smack_label_for_new_dir( - path: impl AsRef, - context: Option<&String>, -) -> Result<(), Box> { - let Some(ctx) = context else { return Ok(()) }; - if !is_smack_enabled() { - return Ok(()); - } - let path = path.as_ref(); - set_smack_label_for_path(path, ctx).map_err(|e| { - let _ = fs::remove_dir(path); + let _ = cleanup(path); USimpleError::new(1, e.to_string()) }) } From a83a243b24607eecca016257b4b6be11d5daab9c Mon Sep 17 00:00:00 2001 From: Christopher Dryden Date: Tue, 6 Jan 2026 20:40:13 +0000 Subject: [PATCH 107/425] ci: add Codecov Test Analytics integration --- .config/nextest.toml | 6 +++++ .github/workflows/CICD.yml | 50 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/.config/nextest.toml b/.config/nextest.toml index 473c46140..710ff26c5 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -5,9 +5,15 @@ final-status-level = "skip" failure-output = "immediate-final" fail-fast = false +[profile.ci.junit] +path = "junit.xml" + [profile.coverage] retries = 0 status-level = "all" final-status-level = "skip" failure-output = "immediate-final" fail-fast = false + +[profile.coverage.junit] +path = "junit.xml" diff --git a/.github/workflows/CICD.yml b/.github/workflows/CICD.yml index 26620b012..2f8e1035b 100644 --- a/.github/workflows/CICD.yml +++ b/.github/workflows/CICD.yml @@ -228,6 +228,16 @@ jobs: env: RUSTFLAGS: "-Awarnings" RUST_BACKTRACE: "1" + - name: Upload test results to Codecov + if: ${{ !cancelled() }} + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + report_type: test_results + files: target/nextest/ci/junit.xml + disable_search: true + flags: msrv,${{ matrix.job.os }} + fail_ci_if_error: false deps: name: Dependencies @@ -300,6 +310,16 @@ jobs: run: make nextest PROFILE=ci CARGOFLAGS="--hide-progress-bar" env: RUST_BACKTRACE: "1" + - name: Upload test results to Codecov + if: ${{ !cancelled() }} + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + report_type: test_results + files: target/nextest/ci/junit.xml + disable_search: true + flags: makefile,${{ matrix.job.os }} + fail_ci_if_error: false - name: "`make install PROG_PREFIX=uu- PROFILE=release-fast COMPLETIONS=n MANPAGES=n LOCALES=n`" shell: bash run: | @@ -410,6 +430,16 @@ jobs: run: cargo nextest run --hide-progress-bar --profile ci --features ${{ matrix.job.features }} env: RUST_BACKTRACE: "1" + - name: Upload test results to Codecov + if: ${{ !cancelled() }} + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + report_type: test_results + files: target/nextest/ci/junit.xml + disable_search: true + flags: stable,${{ matrix.job.os }} + fail_ci_if_error: false build_rust_nightly: name: Build/nightly @@ -439,6 +469,16 @@ jobs: run: cargo nextest run --hide-progress-bar --profile ci --features ${{ matrix.job.features }} env: RUST_BACKTRACE: "1" + - name: Upload test results to Codecov + if: ${{ !cancelled() }} + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + report_type: test_results + files: target/nextest/ci/junit.xml + disable_search: true + flags: nightly,${{ matrix.job.os }} + fail_ci_if_error: false compute_size: name: Binary sizes @@ -1158,6 +1198,16 @@ jobs: flags: ${{ steps.vars.outputs.CODECOV_FLAGS }} name: codecov-umbrella fail_ci_if_error: false + - name: Upload test results to Codecov + if: ${{ !cancelled() }} + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + report_type: test_results + files: target/nextest/coverage/junit.xml + disable_search: true + flags: coverage,${{ matrix.job.os }} + fail_ci_if_error: false test_separately: name: Separate Builds From e58060927d3bb5a633ae8a28959784969f4d50c0 Mon Sep 17 00:00:00 2001 From: Aaron Ang <67321817+aaron-ang@users.noreply.github.com> Date: Tue, 6 Jan 2026 15:06:35 -0800 Subject: [PATCH 108/425] fix: use jiff time for touch tests --- Cargo.lock | 1 + Cargo.toml | 1 + tests/by-util/test_touch.rs | 12 ++++++------ 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 41f3c0802..c2daa6604 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -546,6 +546,7 @@ dependencies = [ "fluent-syntax", "glob", "hex-literal", + "jiff", "libc", "nix", "num-prime", diff --git a/Cargo.toml b/Cargo.toml index 6a5796a46..8c2ce2661 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -539,6 +539,7 @@ chrono.workspace = true ctor.workspace = true filetime.workspace = true glob.workspace = true +jiff.workspace = true libc.workspace = true num-prime.workspace = true pretty_assertions = "1.4.0" diff --git a/tests/by-util/test_touch.rs b/tests/by-util/test_touch.rs index eb2b5c02f..25e6b301f 100644 --- a/tests/by-util/test_touch.rs +++ b/tests/by-util/test_touch.rs @@ -2,11 +2,12 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore (formats) cymdhm cymdhms mdhm mdhms ymdhm ymdhms datetime mktime +// spell-checker:ignore (formats) cymdhm cymdhms datetime mdhm mdhms mktime strtime ymdhm ymdhms use filetime::FileTime; #[cfg(not(target_os = "freebsd"))] use filetime::set_symlink_file_times; +use jiff::{fmt::strtime, tz::TimeZone}; use std::fs::remove_file; use std::path::PathBuf; use uutests::at_and_ucmd; @@ -36,11 +37,10 @@ fn set_file_times(at: &AtPath, path: &str, atime: FileTime, mtime: FileTime) { } fn str_to_filetime(format: &str, s: &str) -> FileTime { - let tm = chrono::NaiveDateTime::parse_from_str(s, format).unwrap(); - FileTime::from_unix_time( - tm.and_utc().timestamp(), - tm.and_utc().timestamp_subsec_nanos(), - ) + let tm = strtime::parse(format, s).unwrap(); + let dt = tm.to_datetime().unwrap(); + let ts = dt.to_zoned(TimeZone::UTC).unwrap().timestamp(); + FileTime::from_unix_time(ts.as_second(), ts.subsec_nanosecond() as u32) } #[test] From 7f3a359563f9101db47b2254897baaf1c1220ad9 Mon Sep 17 00:00:00 2001 From: Chris Dryden Date: Wed, 7 Jan 2026 01:29:57 -0500 Subject: [PATCH 109/425] cp: use FileInformation without dereference for symlink destination check to match GNU behaviour for test/nfs-removal-race (#10086) * cp: use lstat for destination check to support LD_PRELOAD tests * fs: fix Windows is_symlink type mismatch and add explanatory comment * cp: use stat for dest existence check to support LD_PRELOAD --- src/uu/cp/src/cp.rs | 4 ++-- util/fetch-gnu.sh | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/uu/cp/src/cp.rs b/src/uu/cp/src/cp.rs index 3048f38b7..cd84caa36 100644 --- a/src/uu/cp/src/cp.rs +++ b/src/uu/cp/src/cp.rs @@ -1390,8 +1390,8 @@ pub fn copy(sources: &[PathBuf], target: &Path, options: &Options) -> CopyResult let dest = construct_dest_path(source, target, target_type, options) .unwrap_or_else(|_| target.to_path_buf()); - if fs::metadata(&dest).is_ok() - && !fs::symlink_metadata(&dest)?.file_type().is_symlink() + if FileInformation::from_path(&dest, true).is_ok() + && !fs::symlink_metadata(&dest).is_ok_and(|m| m.file_type().is_symlink()) // if both `source` and `dest` are symlinks, it should be considered as an overwrite. || fs::metadata(source).is_ok() && fs::symlink_metadata(source)?.file_type().is_symlink() diff --git a/util/fetch-gnu.sh b/util/fetch-gnu.sh index 92e88ed75..34bb3fb9c 100755 --- a/util/fetch-gnu.sh +++ b/util/fetch-gnu.sh @@ -7,6 +7,7 @@ curl -L "${repo}/releases/download/v${ver}/coreutils-${ver}.tar.xz" | tar --stri curl -L ${repo}/raw/refs/heads/master/tests/mv/hardlink-case.sh > tests/mv/hardlink-case.sh curl -L ${repo}/raw/refs/heads/master/tests/mkdir/writable-under-readonly.sh > tests/mkdir/writable-under-readonly.sh curl -L ${repo}/raw/refs/heads/master/tests/cp/cp-mv-enotsup-xattr.sh > tests/cp/cp-mv-enotsup-xattr.sh #spell-checker:disable-line +curl -L ${repo}/raw/refs/heads/master/tests/cp/nfs-removal-race.sh > tests/cp/nfs-removal-race.sh curl -L ${repo}/raw/refs/heads/master/tests/csplit/csplit-io-err.sh > tests/csplit/csplit-io-err.sh # Avoid incorrect PASS curl -L ${repo}/raw/refs/heads/master/tests/runcon/runcon-compute.sh > tests/runcon/runcon-compute.sh From 3aed0227c4c8ca87b1afb02a35e3467914993f2d Mon Sep 17 00:00:00 2001 From: Chris Dryden Date: Wed, 7 Jan 2026 02:42:19 -0500 Subject: [PATCH 110/425] Add bad-speed.sh test script to fetch-gnu.sh (#10077) --- util/fetch-gnu.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/util/fetch-gnu.sh b/util/fetch-gnu.sh index 34bb3fb9c..caecdec7d 100755 --- a/util/fetch-gnu.sh +++ b/util/fetch-gnu.sh @@ -9,5 +9,6 @@ curl -L ${repo}/raw/refs/heads/master/tests/mkdir/writable-under-readonly.sh > t curl -L ${repo}/raw/refs/heads/master/tests/cp/cp-mv-enotsup-xattr.sh > tests/cp/cp-mv-enotsup-xattr.sh #spell-checker:disable-line curl -L ${repo}/raw/refs/heads/master/tests/cp/nfs-removal-race.sh > tests/cp/nfs-removal-race.sh curl -L ${repo}/raw/refs/heads/master/tests/csplit/csplit-io-err.sh > tests/csplit/csplit-io-err.sh +curl -L ${repo}/raw/refs/heads/master/tests/stty/bad-speed.sh > tests/stty/bad-speed.sh # Avoid incorrect PASS curl -L ${repo}/raw/refs/heads/master/tests/runcon/runcon-compute.sh > tests/runcon/runcon-compute.sh From 413055b378fa6fe2299c5e5f538c8e6e841ab810 Mon Sep 17 00:00:00 2001 From: cerdelen <95369756+cerdelen@users.noreply.github.com> Date: Wed, 7 Jan 2026 10:49:54 +0100 Subject: [PATCH 111/425] Chmod preserve root (#10033) * chmod: Fix --preserve-root not being bypassed by path that resolves to root * chmod: Regression tests for --preserve-root not being bypassed by path that resolves to root --- src/uu/chmod/src/chmod.rs | 6 +++++- tests/by-util/test_chmod.rs | 11 +++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/uu/chmod/src/chmod.rs b/src/uu/chmod/src/chmod.rs index b77de93f2..43760b450 100644 --- a/src/uu/chmod/src/chmod.rs +++ b/src/uu/chmod/src/chmod.rs @@ -407,7 +407,7 @@ impl Chmoder { // should not change the permissions in this case continue; } - if self.recursive && self.preserve_root && file == Path::new("/") { + if self.recursive && self.preserve_root && Self::is_root(file) { return Err(ChmodError::PreserveRoot("/".into()).into()); } if self.recursive { @@ -419,6 +419,10 @@ impl Chmoder { r } + fn is_root(file: impl AsRef) -> bool { + matches!(fs::canonicalize(&file), Ok(p) if p == Path::new("/")) + } + #[cfg(not(target_os = "linux"))] fn walk_dir_with_context(&self, file_path: &Path, is_command_line_arg: bool) -> UResult<()> { let mut r = self.chmod_file(file_path); diff --git a/tests/by-util/test_chmod.rs b/tests/by-util/test_chmod.rs index 6d242020c..a17fc4a2c 100644 --- a/tests/by-util/test_chmod.rs +++ b/tests/by-util/test_chmod.rs @@ -508,6 +508,17 @@ fn test_chmod_preserve_root() { .stderr_contains("chmod: it is dangerous to operate recursively on '/'"); } +#[test] +fn test_chmod_preserve_root_with_paths_that_resolve_to_root() { + new_ucmd!() + .arg("-R") + .arg("--preserve-root") + .arg("755") + .arg("/../") + .fails_with_code(1) + .stderr_contains("chmod: it is dangerous to operate recursively on '/'"); +} + #[test] fn test_chmod_symlink_non_existing_file() { let scene = TestScenario::new(util_name!()); From ecf335319277c5df31079789cbb3efea9e4f849e Mon Sep 17 00:00:00 2001 From: mattsu <35655889+mattsu2020@users.noreply.github.com> Date: Wed, 7 Jan 2026 20:16:13 +0900 Subject: [PATCH 112/425] bench(sort): add general numeric benchmark (#10101) --------- Co-authored-by: Sylvestre Ledru --- src/uu/sort/benches/sort_bench.rs | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/uu/sort/benches/sort_bench.rs b/src/uu/sort/benches/sort_bench.rs index a4da0ce6c..4bd72cf62 100644 --- a/src/uu/sort/benches/sort_bench.rs +++ b/src/uu/sort/benches/sort_bench.rs @@ -128,6 +128,32 @@ fn sort_numeric(bencher: Bencher, num_lines: usize) { }); } +/// Benchmark general numeric sorting (-g) with decimal and exponent notation +#[divan::bench(args = [200_000])] +fn sort_general_numeric(bencher: Bencher, num_lines: usize) { + let mut data = Vec::new(); + + // Generate numeric data with decimal points and exponents + for i in 0..num_lines { + let int_part = (i * 13) % 100_000; + let frac_part = (i * 7) % 1000; + let exp = (i % 5) as i32 - 2; // -2..=2 + let sign = if i % 2 == 0 { "" } else { "-" }; + data.extend_from_slice(format!("{sign}{int_part}.{frac_part:03}e{exp:+}\n").as_bytes()); + } + + let file_path = setup_test_file(&data); + let output_file = NamedTempFile::new().unwrap(); + let output_path = output_file.path().to_str().unwrap(); + + bencher.bench(|| { + black_box(run_util_function( + uumain, + &["-g", "-o", output_path, file_path.to_str().unwrap()], + )); + }); +} + /// Benchmark reverse sorting with locale-aware data #[divan::bench(args = [500_000])] fn sort_reverse_locale(bencher: Bencher, num_lines: usize) { From 606d07d1a2e9e174657a2d690cbda6fc56cebef8 Mon Sep 17 00:00:00 2001 From: mattsu Date: Wed, 7 Jan 2026 20:51:21 +0900 Subject: [PATCH 113/425] refactor(uptime): use FluentArgs for loadavg formatting in get_formatted_loadavg Refactored the `get_formatted_loadavg` function to explicitly build a `FluentArgs` struct with load average values formatted to two decimal places, then pass it to `crate::locale::get_message_with_args` instead of using the `translate!` macro inline. This improves argument handling for fluent localization, enhancing code structure and maintainability without altering functionality. --- src/uucore/src/lib/features/uptime.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/uucore/src/lib/features/uptime.rs b/src/uucore/src/lib/features/uptime.rs index 9dbf878d7..91352f1de 100644 --- a/src/uucore/src/lib/features/uptime.rs +++ b/src/uucore/src/lib/features/uptime.rs @@ -421,11 +421,13 @@ pub fn get_loadavg() -> UResult<(f64, f64, f64)> { #[inline] pub fn get_formatted_loadavg() -> UResult { let loadavg = get_loadavg()?; - Ok(translate!( + let mut args = fluent::FluentArgs::new(); + args.set("avg1", format!("{:.2}", loadavg.0)); + args.set("avg5", format!("{:.2}", loadavg.1)); + args.set("avg15", format!("{:.2}", loadavg.2)); + Ok(crate::locale::get_message_with_args( "uptime-lib-format-loadavg", - "avg1" => format!("{:.2}", loadavg.0), - "avg5" => format!("{:.2}", loadavg.1), - "avg15" => format!("{:.2}", loadavg.2), + args, )) } From c57f4fcf39ec68770a26b216e2940ca9f650fa23 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 7 Jan 2026 12:42:22 +0000 Subject: [PATCH 114/425] chore(deps): update rust crate divan to v4.2.1 --- Cargo.lock | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c2daa6604..c38215bff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -392,9 +392,9 @@ dependencies = [ [[package]] name = "codspeed" -version = "4.2.0" +version = "4.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb56923193c76a0e5b6b17b2c2bb1e151ef8a5e06b557e1cbe38c6db467763f9" +checksum = "5f0d98d97fd75ca4489a1a0997820a6521531085e7c8a98941bd0e1264d567dd" dependencies = [ "anyhow", "cc", @@ -410,9 +410,9 @@ dependencies = [ [[package]] name = "codspeed-divan-compat" -version = "4.2.0" +version = "4.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7558ff5740fbc26a5fc55c4934cfed94dfccee76abc17b57ecf5d0bee3592b5e" +checksum = "4179ec5518e79efcd02ed50aa483ff807902e43c85146e87fff58b9cffc06078" dependencies = [ "clap", "codspeed", @@ -423,9 +423,9 @@ dependencies = [ [[package]] name = "codspeed-divan-compat-macros" -version = "4.2.0" +version = "4.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de343ca0a4fbaabbd3422941fdee24407d00e2fa686a96021c21a78ab2bb895" +checksum = "15eaee97aa5bceb32cc683fe25cd6373b7fc48baee5c12471996b58b6ddf0d7c" dependencies = [ "divan-macros", "itertools 0.14.0", @@ -437,9 +437,9 @@ dependencies = [ [[package]] name = "codspeed-divan-compat-walltime" -version = "4.2.0" +version = "4.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d9de586cc7e9752fc232f08e0733c2016122e16065c4adf0c8a8d9e370749ee" +checksum = "c38671153aa73be075d6019cab5ab1e6b31d36644067c1ac4cef73bf9723ce33" dependencies = [ "cfg-if", "clap", @@ -1576,7 +1576,7 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde_core", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1874,7 +1874,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2440,7 +2440,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.11.0", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2766,7 +2766,7 @@ dependencies = [ "getrandom 0.3.3", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4437,7 +4437,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] From 2480e722216b2cfa8be32d93f2e7d9fb39657c18 Mon Sep 17 00:00:00 2001 From: Anurag Thakur Date: Tue, 6 Jan 2026 07:29:18 +0530 Subject: [PATCH 115/425] echo: Reduce memory allocation --- src/uu/echo/src/echo.rs | 75 ++++++++++++++++++++++------------------- 1 file changed, 41 insertions(+), 34 deletions(-) diff --git a/src/uu/echo/src/echo.rs b/src/uu/echo/src/echo.rs index 4bbff02e9..beb9c7dfd 100644 --- a/src/uu/echo/src/echo.rs +++ b/src/uu/echo/src/echo.rs @@ -98,33 +98,31 @@ fn is_flag(arg: &OsStr, options: &mut Options) -> bool { /// # Returns /// /// - Vector of non-flag arguments. -/// - [`Options`], describing how teh arguments should be interpreted. -fn filter_flags(mut args: impl Iterator) -> (Vec, Options) { - let mut arguments = Vec::with_capacity(args.size_hint().0); +/// - [`Options`], describing how the arguments should be interpreted. +fn filter_flags(args: impl Iterator) -> (impl Iterator, Options) { let mut options = Options::default(); + let mut args = args.peekable(); // Process arguments until first non-flag is found. - for arg in &mut args { + while let Some(arg) = args.peek() { // We parse flags and aggregate the options in `options`. - // First call to `is_echo_flag` to return false will break the loop. - if !is_flag(&arg, &mut options) { + // First call to `is_flag` to return false will break the loop. + if is_flag(arg, &mut options) { + args.next(); + } else { // Not a flag. Can break out of flag-processing loop. - // Don't forget to push it to the arguments too. - arguments.push(arg); break; } } - // Collect remaining non-flag arguments. - arguments.extend(args); - - (arguments, options) + // Return remaining non-flag arguments. + (args, options) } #[uucore::main] pub fn uumain(args: impl uucore::Args) -> UResult<()> { // args[0] is the name of the binary. - let args: Vec = args.skip(1).collect(); + let mut args = args.skip(1).peekable(); // Check POSIX compatibility mode // @@ -139,13 +137,13 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { // > representation. For example, echo -e '\x2dn'. let is_posixly_correct = env::var_os("POSIXLY_CORRECT").is_some(); - let (args, options) = if is_posixly_correct { - if args.first().is_some_and(|arg| arg == "-n") { + let (args, options): (Box>, Options) = if is_posixly_correct { + if args.peek().is_some_and(|arg| arg == "-n") { // if POSIXLY_CORRECT is set and the first argument is the "-n" flag // we filter flags normally but 'escaped' is activated nonetheless. - let (args, _) = filter_flags(args.into_iter()); + let (args, _) = filter_flags(args); ( - args, + Box::new(args), Options { trailing_newline: false, ..Options::posixly_correct_default() @@ -154,24 +152,29 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { } else { // if POSIXLY_CORRECT is set and the first argument is not the "-n" flag // we just collect all arguments as no arguments are interpreted as flags. - (args, Options::posixly_correct_default()) + (Box::new(args), Options::posixly_correct_default()) } - } else if args.len() == 1 && args[0] == "--help" { - // If POSIXLY_CORRECT is not set and the first argument - // is `--help`, GNU coreutils prints the help message. - // - // Verify this using: - // - // POSIXLY_CORRECT=1 echo --help - // echo --help - uu_app().print_help()?; - return Ok(()); - } else if args.len() == 1 && args[0] == "--version" { - print!("{}", uu_app().render_version()); - return Ok(()); - } else { + } else if let Some(first_arg) = args.next() { + if first_arg == "--help" && args.peek().is_none() { + // If POSIXLY_CORRECT is not set and the first argument + // is `--help`, GNU coreutils prints the help message. + // + // Verify this using: + // + // POSIXLY_CORRECT=1 echo --help + // echo --help + uu_app().print_help()?; + return Ok(()); + } else if first_arg == "--version" && args.peek().is_none() { + print!("{}", uu_app().render_version()); + return Ok(()); + } + // if POSIXLY_CORRECT is not set we filter the flags normally - filter_flags(args.into_iter()) + let (args, options) = filter_flags(std::iter::once(first_arg).chain(args)); + (Box::new(args), options) + } else { + (Box::new(args), Options::default()) }; execute(&mut io::stdout().lock(), args, options)?; @@ -221,7 +224,11 @@ pub fn uu_app() -> Command { ) } -fn execute(stdout: &mut StdoutLock, args: Vec, options: Options) -> UResult<()> { +fn execute( + stdout: &mut StdoutLock, + args: impl Iterator, + options: Options, +) -> UResult<()> { for (i, arg) in args.into_iter().enumerate() { let bytes = os_str_as_bytes(&arg)?; From db221179578e6d3f73fe94568e78eae5887e9b85 Mon Sep 17 00:00:00 2001 From: Anurag Thakur Date: Tue, 6 Jan 2026 07:32:13 +0530 Subject: [PATCH 116/425] echo: Add more tests --- tests/by-util/test_echo.rs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/by-util/test_echo.rs b/tests/by-util/test_echo.rs index 34e60e316..3398708f1 100644 --- a/tests/by-util/test_echo.rs +++ b/tests/by-util/test_echo.rs @@ -19,6 +19,11 @@ fn test_no_trailing_newline() { new_ucmd!().arg("-n").arg("hi").succeeds().stdout_only("hi"); } +#[test] +fn test_empty_args() { + new_ucmd!().succeeds().stdout_only("\n"); +} + #[test] fn test_escape_alert() { new_ucmd!() @@ -523,12 +528,30 @@ fn full_version_argument() { .stdout_matches(&Regex::new(r"^echo \(uutils coreutils\) (\d+\.\d+\.\d+)\n$").unwrap()); } +#[test] +fn multiple_version_argument() { + new_ucmd!() + .arg("--version") + .arg("--version") + .succeeds() + .stdout_is("--version --version\n"); +} + #[test] fn full_help_argument() { assert_ne!(new_ucmd!().arg("--help").succeeds().stdout(), b"--help\n"); assert_ne!(new_ucmd!().arg("--help").succeeds().stdout(), b"--help"); // This one is just in case. } +#[test] +fn multiple_help_argument() { + new_ucmd!() + .arg("--help") + .arg("--help") + .succeeds() + .stdout_is("--help --help\n"); +} + #[test] fn multibyte_escape_unicode() { // spell-checker:disable-next-line From fe08fb39896e1e341b415d405ad087b5d36181cc Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Wed, 7 Jan 2026 15:35:53 +0100 Subject: [PATCH 117/425] Add some intermittent tests to the list example: https://github.com/uutils/coreutils/pull/10099#issuecomment-3718087272 --- .github/workflows/ignore-intermittent.txt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ignore-intermittent.txt b/.github/workflows/ignore-intermittent.txt index 0d99da29b..1e5086c10 100644 --- a/.github/workflows/ignore-intermittent.txt +++ b/.github/workflows/ignore-intermittent.txt @@ -2,6 +2,9 @@ tests/tail/inotify-dir-recreate tests/tail/overlay-headers tests/timeout/timeout tests/rm/rm1 +tests/shuf/shuf-reservoir +tests/sort/sort-stale-thread-mem +tests/tty/tty-eof tests/misc/stdbuf tests/misc/usage_vs_getopt tests/misc/tee From ce8c8d57af63d29d15b842e8f934b6274fce531f Mon Sep 17 00:00:00 2001 From: Chris Dryden Date: Wed, 7 Jan 2026 10:06:52 -0500 Subject: [PATCH 118/425] pr: add default values for -s and -S separator options (#10073) * pr: add default values for -s and -S separator options * pr: fix -S default value to space, simplify test --- src/uu/pr/src/pr.rs | 8 ++++++-- tests/by-util/test_pr.rs | 14 ++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/uu/pr/src/pr.rs b/src/uu/pr/src/pr.rs index 843b3b8f9..58902cf17 100644 --- a/src/uu/pr/src/pr.rs +++ b/src/uu/pr/src/pr.rs @@ -276,14 +276,18 @@ pub fn uu_app() -> Command { .short('s') .long(options::COLUMN_CHAR_SEPARATOR) .help(translate!("pr-help-column-char-separator")) - .value_name("char"), + .value_name("char") + .num_args(0..=1) + .default_missing_value("\t"), ) .arg( Arg::new(options::COLUMN_STRING_SEPARATOR) .short('S') .long(options::COLUMN_STRING_SEPARATOR) .help(translate!("pr-help-column-string-separator")) - .value_name("string"), + .value_name("string") + .num_args(0..=1) + .default_missing_value(" "), ) .arg( Arg::new(options::MERGE) diff --git a/tests/by-util/test_pr.rs b/tests/by-util/test_pr.rs index 63063a7e7..d2e6cb675 100644 --- a/tests/by-util/test_pr.rs +++ b/tests/by-util/test_pr.rs @@ -627,3 +627,17 @@ fn test_page_header_width() { let regex = Regex::new(&pattern).unwrap(); new_ucmd!().pipe_in("a").succeeds().stdout_matches(®ex); } + +#[test] +fn test_separator_options_default_values() { + // -s and -S without arguments should use default values (TAB and space) + // TODO: verify output matches GNU pr behavior + new_ucmd!() + .args(&["-t", "-2", "-s"]) + .pipe_in("a\nb\n") + .succeeds(); + new_ucmd!() + .args(&["-t", "-2", "-S"]) + .pipe_in("a\nb\n") + .succeeds(); +} From 7e69f902cb4d5ef6af4d8b8b5da01e23ea2d2478 Mon Sep 17 00:00:00 2001 From: Christopher Dryden Date: Wed, 24 Dec 2025 03:11:32 +0000 Subject: [PATCH 119/425] dd: use ibs/obs-sized buffer for skip/seek on non-seekable files --- src/uu/dd/src/dd.rs | 38 +++++++++++++++++++++++++------------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/src/uu/dd/src/dd.rs b/src/uu/dd/src/dd.rs index 567f803d3..0bd1c5ece 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, BufReader, Read, Seek, SeekFrom, Stdout, Write}; #[cfg(any(target_os = "linux", target_os = "android"))] use std::os::fd::AsFd; #[cfg(any(target_os = "linux", target_os = "android"))] @@ -219,10 +219,13 @@ impl Source { Self::StdinFile(f) } - fn skip(&mut self, n: u64) -> io::Result { + fn skip(&mut self, n: u64, ibs: usize) -> io::Result { match self { #[cfg(not(unix))] - Self::Stdin(stdin) => match io::copy(&mut stdin.take(n), &mut io::sink()) { + Self::Stdin(stdin) => match io::copy( + &mut BufReader::with_capacity(ibs, stdin.take(n)), + &mut io::sink(), + ) { Ok(m) if m < n => { show_error!( "{}", @@ -247,7 +250,10 @@ impl Source { return Ok(len); } } - match io::copy(&mut f.take(n), &mut io::sink()) { + match io::copy( + &mut BufReader::with_capacity(ibs, f.take(n)), + &mut io::sink(), + ) { Ok(m) if m < n => { show_error!( "{}", @@ -261,7 +267,10 @@ impl Source { } Self::File(f) => f.seek(SeekFrom::Current(n.try_into().unwrap())), #[cfg(unix)] - Self::Fifo(f) => io::copy(&mut f.take(n), &mut io::sink()), + Self::Fifo(f) => io::copy( + &mut BufReader::with_capacity(ibs, f.take(n)), + &mut io::sink(), + ), } } @@ -346,7 +355,7 @@ impl<'a> Input<'a> { } } if settings.skip > 0 { - src.skip(settings.skip)?; + src.skip(settings.skip, settings.ibs)?; } Ok(Self { src, settings }) } @@ -369,7 +378,7 @@ impl<'a> Input<'a> { let mut src = Source::File(src); if settings.skip > 0 { - src.skip(settings.skip)?; + src.skip(settings.skip, settings.ibs)?; } Ok(Self { src, settings }) } @@ -383,7 +392,7 @@ impl<'a> Input<'a> { opts.custom_flags(make_linux_iflags(&settings.iflags).unwrap_or(0)); let mut src = Source::Fifo(opts.open(filename)?); if settings.skip > 0 { - src.skip(settings.skip)?; + src.skip(settings.skip, settings.ibs)?; } Ok(Self { src, settings }) } @@ -605,7 +614,7 @@ impl Dest { } } - fn seek(&mut self, n: u64) -> io::Result { + fn seek(&mut self, n: u64, obs: usize) -> io::Result { match self { Self::Stdout(stdout) => io::copy(&mut io::repeat(0).take(n), stdout), Self::File(f, _) => { @@ -627,7 +636,10 @@ impl Dest { #[cfg(unix)] Self::Fifo(f) => { // Seeking in a named pipe means *reading* from the pipe. - io::copy(&mut f.take(n), &mut io::sink()) + io::copy( + &mut BufReader::with_capacity(obs, f.take(n)), + &mut io::sink(), + ) } #[cfg(unix)] Self::Sink => Ok(0), @@ -781,7 +793,7 @@ 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()); - dst.seek(settings.seek) + dst.seek(settings.seek, settings.obs) .map_err_context(|| translate!("dd-error-write-error"))?; Ok(Self { dst, settings }) } @@ -829,7 +841,7 @@ impl<'a> Output<'a> { Density::Dense }; let mut dst = Dest::File(dst, density); - dst.seek(settings.seek) + dst.seek(settings.seek, settings.obs) .map_err_context(|| translate!("dd-error-failed-to-seek"))?; Ok(Self { dst, settings }) } @@ -859,7 +871,7 @@ impl<'a> Output<'a> { // file for reading. But then we need to close the file and // re-open it for writing. if settings.seek > 0 { - Dest::Fifo(File::open(filename)?).seek(settings.seek)?; + Dest::Fifo(File::open(filename)?).seek(settings.seek, settings.obs)?; } // If `count=0`, then we don't bother opening the file for // writing because that would cause this process to block From 8e70aff7ad1c06ed84e0f9e8d64c23ea4667e327 Mon Sep 17 00:00:00 2001 From: Christopher Dryden Date: Wed, 24 Dec 2025 05:51:53 +0000 Subject: [PATCH 120/425] dd: fix unused variable warning on Windows --- src/uu/dd/src/dd.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/uu/dd/src/dd.rs b/src/uu/dd/src/dd.rs index 0bd1c5ece..162bb04c4 100644 --- a/src/uu/dd/src/dd.rs +++ b/src/uu/dd/src/dd.rs @@ -614,6 +614,7 @@ impl Dest { } } + #[cfg_attr(not(unix), allow(unused_variables))] fn seek(&mut self, n: u64, obs: usize) -> io::Result { match self { Self::Stdout(stdout) => io::copy(&mut io::repeat(0).take(n), stdout), From 3aca9fe423bbf47e1001093adb3f68ac86a0e8db Mon Sep 17 00:00:00 2001 From: Christopher Dryden Date: Mon, 5 Jan 2026 17:40:41 +0000 Subject: [PATCH 121/425] dd: use direct read loop instead of io::copy for skip/seek --- src/uu/dd/src/dd.rs | 72 ++++++++++++++++++++++++++------------------- 1 file changed, 41 insertions(+), 31 deletions(-) diff --git a/src/uu/dd/src/dd.rs b/src/uu/dd/src/dd.rs index 162bb04c4..e2344952a 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, BufReader, Read, Seek, SeekFrom, Stdout, Write}; +use std::io::{self, Read, Seek, SeekFrom, Stdout, Write}; #[cfg(any(target_os = "linux", target_os = "android"))] use std::os::fd::AsFd; #[cfg(any(target_os = "linux", target_os = "android"))] @@ -183,6 +183,32 @@ impl Num { } } +/// Read and discard `n` bytes from `reader` using a buffer of size `buf_size`. +/// +/// This is more efficient than `io::copy` with `BufReader` because it reads +/// directly in `buf_size`-sized chunks, matching GNU dd's behavior. +/// Returns the total number of bytes actually read. +fn read_and_discard(reader: &mut R, n: u64, buf_size: usize) -> io::Result { + let mut buf = vec![0u8; buf_size]; + let mut total = 0u64; + let mut remaining = n; + + while remaining > 0 { + let to_read = cmp::min(remaining, buf_size as u64) as usize; + match reader.read(&mut buf[..to_read]) { + Ok(0) => break, // EOF + Ok(bytes_read) => { + total += bytes_read as u64; + remaining -= bytes_read as u64; + } + Err(e) if e.kind() == io::ErrorKind::Interrupted => continue, + Err(e) => return Err(e), + } + } + + Ok(total) +} + /// Data sources. /// /// Use [`Source::stdin_as_file`] if available to enable more @@ -222,20 +248,16 @@ impl Source { fn skip(&mut self, n: u64, ibs: usize) -> io::Result { match self { #[cfg(not(unix))] - Self::Stdin(stdin) => match io::copy( - &mut BufReader::with_capacity(ibs, stdin.take(n)), - &mut io::sink(), - ) { - Ok(m) if m < n => { + Self::Stdin(stdin) => { + let m = read_and_discard(stdin, n, ibs)?; + if m < n { show_error!( "{}", translate!("dd-error-cannot-skip-offset", "file" => "standard input") ); - Ok(m) } - Ok(m) => Ok(m), - Err(e) => Err(e), - }, + Ok(m) + } #[cfg(unix)] Self::StdinFile(f) => { if let Ok(Some(len)) = try_get_len_of_block_device(f) { @@ -250,27 +272,18 @@ impl Source { return Ok(len); } } - match io::copy( - &mut BufReader::with_capacity(ibs, f.take(n)), - &mut io::sink(), - ) { - Ok(m) if m < n => { - show_error!( - "{}", - translate!("dd-error-cannot-skip-offset", "file" => "standard input") - ); - Ok(m) - } - Ok(m) => Ok(m), - Err(e) => Err(e), + let m = read_and_discard(f, n, ibs)?; + if m < n { + show_error!( + "{}", + translate!("dd-error-cannot-skip-offset", "file" => "standard input") + ); } + Ok(m) } Self::File(f) => f.seek(SeekFrom::Current(n.try_into().unwrap())), #[cfg(unix)] - Self::Fifo(f) => io::copy( - &mut BufReader::with_capacity(ibs, f.take(n)), - &mut io::sink(), - ), + Self::Fifo(f) => read_and_discard(f, n, ibs), } } @@ -637,10 +650,7 @@ impl Dest { #[cfg(unix)] Self::Fifo(f) => { // Seeking in a named pipe means *reading* from the pipe. - io::copy( - &mut BufReader::with_capacity(obs, f.take(n)), - &mut io::sink(), - ) + read_and_discard(f, n, obs) } #[cfg(unix)] Self::Sink => Ok(0), From 9c193863a0185f1a7bcd6c0d60695fa361ecf3e3 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 7 Jan 2026 18:15:49 +0000 Subject: [PATCH 122/425] chore(deps): update rust crate clap_complete to v4.5.65 --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c38215bff..7b1c421c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -367,9 +367,9 @@ dependencies = [ [[package]] name = "clap_complete" -version = "4.5.64" +version = "4.5.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c0da80818b2d95eca9aa614a30783e42f62bf5fdfee24e68cfb960b071ba8d1" +checksum = "430b4dc2b5e3861848de79627b2bedc9f3342c7da5173a14eaa5d0f8dc18ae5d" dependencies = [ "clap", ] From 25d50515c43d43a5c60a13219b8c2609011c010e Mon Sep 17 00:00:00 2001 From: Christopher Dryden Date: Wed, 7 Jan 2026 18:23:47 +0000 Subject: [PATCH 123/425] pr: add -T/--omit-pagination option --- src/uu/pr/locales/en-US.ftl | 3 +++ src/uu/pr/src/pr.rs | 12 +++++++++++- tests/by-util/test_pr.rs | 11 +++++++++++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/uu/pr/locales/en-US.ftl b/src/uu/pr/locales/en-US.ftl index b4c0e10f2..c7fa178e4 100644 --- a/src/uu/pr/locales/en-US.ftl +++ b/src/uu/pr/locales/en-US.ftl @@ -30,6 +30,9 @@ pr-help-omit-header = Write neither the five-line identifying header nor the five-line trailer usually supplied for each page. Quit writing after the last line of each file without spacing to the end of the page. +pr-help-omit-pagination = + omit page headers and trailers, eliminate any pagination + by form feeds set in input files pr-help-page-length = Override the 66-line default (default number of lines of text 56, and with -F 63) and reset the page length to lines. If lines is not diff --git a/src/uu/pr/src/pr.rs b/src/uu/pr/src/pr.rs index 58902cf17..a5a8b7b57 100644 --- a/src/uu/pr/src/pr.rs +++ b/src/uu/pr/src/pr.rs @@ -42,6 +42,7 @@ mod options { pub const FIRST_LINE_NUMBER: &str = "first-line-number"; pub const PAGES: &str = "pages"; pub const OMIT_HEADER: &str = "omit-header"; + pub const OMIT_PAGINATION: &str = "omit-pagination"; pub const PAGE_LENGTH: &str = "length"; pub const NO_FILE_WARNINGS: &str = "no-file-warnings"; pub const FORM_FEED: &str = "form-feed"; @@ -215,6 +216,13 @@ pub fn uu_app() -> Command { .help(translate!("pr-help-omit-header")) .action(ArgAction::SetTrue), ) + .arg( + Arg::new(options::OMIT_PAGINATION) + .short('T') + .long(options::OMIT_PAGINATION) + .help(translate!("pr-help-omit-pagination")) + .action(ArgAction::SetTrue), + ) .arg( Arg::new(options::PAGE_LENGTH) .short('l') @@ -633,7 +641,9 @@ fn build_options( let page_length_le_ht = page_length < (HEADER_LINES_PER_PAGE + TRAILER_LINES_PER_PAGE); - let display_header_and_trailer = !page_length_le_ht && !matches.get_flag(options::OMIT_HEADER); + let display_header_and_trailer = !page_length_le_ht + && !matches.get_flag(options::OMIT_HEADER) + && !matches.get_flag(options::OMIT_PAGINATION); let content_lines_per_page = if page_length_le_ht { page_length diff --git a/tests/by-util/test_pr.rs b/tests/by-util/test_pr.rs index d2e6cb675..bd5e73b80 100644 --- a/tests/by-util/test_pr.rs +++ b/tests/by-util/test_pr.rs @@ -641,3 +641,14 @@ fn test_separator_options_default_values() { .pipe_in("a\nb\n") .succeeds(); } + +#[test] +fn test_omit_pagination_option() { + // -T/--omit-pagination omits headers/trailers and eliminates form feeds + // TODO: verify output matches GNU pr behavior (form feed elimination) + new_ucmd!().args(&["-T"]).pipe_in("a\nb\n").succeeds(); + new_ucmd!() + .args(&["--omit-pagination"]) + .pipe_in("a\nb\n") + .succeeds(); +} From 929bb342025eb23423879d8c10e77400b358bcc5 Mon Sep 17 00:00:00 2001 From: Christopher Dryden Date: Wed, 7 Jan 2026 18:59:10 +0000 Subject: [PATCH 124/425] numfmt: add --unit-separator option for output formatting --- src/uu/numfmt/locales/en-US.ftl | 1 + src/uu/numfmt/src/format.rs | 13 ++++++++++--- src/uu/numfmt/src/numfmt.rs | 13 +++++++++++++ src/uu/numfmt/src/options.rs | 2 ++ tests/by-util/test_numfmt.rs | 13 +++++++++++++ 5 files changed, 39 insertions(+), 3 deletions(-) diff --git a/src/uu/numfmt/locales/en-US.ftl b/src/uu/numfmt/locales/en-US.ftl index 14141b9cc..a2ad787bd 100644 --- a/src/uu/numfmt/locales/en-US.ftl +++ b/src/uu/numfmt/locales/en-US.ftl @@ -47,6 +47,7 @@ numfmt-help-padding = pad the output to N characters; positive N will right-alig numfmt-help-header = print (without converting) the first N header lines; N defaults to 1 if not specified numfmt-help-round = use METHOD for rounding when scaling numfmt-help-suffix = print SUFFIX after each formatted number, and accept inputs optionally ending with SUFFIX +numfmt-help-unit-separator = use STRING to separate the number from any unit when printing; by default, no separator is used numfmt-help-invalid = set the failure mode for invalid input numfmt-help-zero-terminated = line delimiter is NUL, not newline diff --git a/src/uu/numfmt/src/format.rs b/src/uu/numfmt/src/format.rs index e091f2320..33dc58bc2 100644 --- a/src/uu/numfmt/src/format.rs +++ b/src/uu/numfmt/src/format.rs @@ -275,6 +275,7 @@ fn transform_to( opts: &TransformOptions, round_method: RoundMethod, precision: usize, + unit_separator: &str, ) -> Result { let (i2, s) = consider_suffix(s, &opts.to, round_method, precision)?; let i2 = i2 / (opts.to_unit as f64); @@ -286,10 +287,15 @@ fn transform_to( ) } Some(s) if precision > 0 => { - format!("{i2:.precision$}{}", DisplayableSuffix(s, opts.to),) + format!( + "{i2:.precision$}{unit_separator}{}", + DisplayableSuffix(s, opts.to), + ) } - Some(s) if i2.abs() < 10.0 => format!("{i2:.1}{}", DisplayableSuffix(s, opts.to)), - Some(s) => format!("{i2:.0}{}", DisplayableSuffix(s, opts.to)), + Some(s) if i2.abs() < 10.0 => { + format!("{i2:.1}{unit_separator}{}", DisplayableSuffix(s, opts.to)) + } + Some(s) => format!("{i2:.0}{unit_separator}{}", DisplayableSuffix(s, opts.to)), }) } @@ -317,6 +323,7 @@ fn format_string( &options.transform, options.round, precision, + &options.unit_separator, )?; // bring back the suffix before applying padding diff --git a/src/uu/numfmt/src/numfmt.rs b/src/uu/numfmt/src/numfmt.rs index abeaca256..81e8ab9cd 100644 --- a/src/uu/numfmt/src/numfmt.rs +++ b/src/uu/numfmt/src/numfmt.rs @@ -234,6 +234,11 @@ fn parse_options(args: &ArgMatches) -> Result { let suffix = args.get_one::(SUFFIX).cloned(); + let unit_separator = args + .get_one::(UNIT_SEPARATOR) + .cloned() + .unwrap_or_default(); + let invalid = InvalidModes::from_str(args.get_one::(INVALID).unwrap()).unwrap(); let zero_terminated = args.get_flag(ZERO_TERMINATED); @@ -246,6 +251,7 @@ fn parse_options(args: &ArgMatches) -> Result { delimiter, round, suffix, + unit_separator, format, invalid, zero_terminated, @@ -370,6 +376,12 @@ pub fn uu_app() -> Command { .help(translate!("numfmt-help-suffix")) .value_name("SUFFIX"), ) + .arg( + Arg::new(UNIT_SEPARATOR) + .long(UNIT_SEPARATOR) + .help(translate!("numfmt-help-unit-separator")) + .value_name("STRING"), + ) .arg( Arg::new(INVALID) .long(INVALID) @@ -419,6 +431,7 @@ mod tests { delimiter: None, round: RoundMethod::Nearest, suffix: None, + unit_separator: String::new(), format: FormatOptions::default(), invalid: InvalidModes::Abort, zero_terminated: false, diff --git a/src/uu/numfmt/src/options.rs b/src/uu/numfmt/src/options.rs index 48f4a4dae..eaf0d8b8b 100644 --- a/src/uu/numfmt/src/options.rs +++ b/src/uu/numfmt/src/options.rs @@ -27,6 +27,7 @@ pub const TO: &str = "to"; pub const TO_DEFAULT: &str = "none"; pub const TO_UNIT: &str = "to-unit"; pub const TO_UNIT_DEFAULT: &str = "1"; +pub const UNIT_SEPARATOR: &str = "unit-separator"; pub const ZERO_TERMINATED: &str = "zero-terminated"; pub struct TransformOptions { @@ -52,6 +53,7 @@ pub struct NumfmtOptions { pub delimiter: Option, pub round: RoundMethod, pub suffix: Option, + pub unit_separator: String, pub format: FormatOptions, pub invalid: InvalidModes, pub zero_terminated: bool, diff --git a/tests/by-util/test_numfmt.rs b/tests/by-util/test_numfmt.rs index d947833f7..610e84adb 100644 --- a/tests/by-util/test_numfmt.rs +++ b/tests/by-util/test_numfmt.rs @@ -1115,3 +1115,16 @@ fn test_zero_terminated_embedded_newline() { // Newlines get replaced by a single space .stdout_is("1000 2000\x003000 4000\x00"); } + +#[test] +fn test_unit_separator() { + for (args, expected) in [ + (&["--to=si", "--unit-separator= ", "1000"][..], "1.0 k\n"), + (&["--to=iec", "--unit-separator= ", "1024"], "1.0 K\n"), + (&["--to=iec-i", "--unit-separator= ", "2048"], "2.0 Ki\n"), + (&["--to=si", "--unit-separator=__", "1000"], "1.0__k\n"), + (&["--to=si", "--unit-separator= ", "500"], "500\n"), // no unit = no separator + ] { + new_ucmd!().args(args).succeeds().stdout_only(expected); + } +} From 6a844812cf1228d8a6a58c77bdceb9cc78e87f06 Mon Sep 17 00:00:00 2001 From: Aaron Ang <67321817+aaron-ang@users.noreply.github.com> Date: Wed, 7 Jan 2026 22:25:55 -0800 Subject: [PATCH 125/425] refactor: use `jiff` for safer time features --- Cargo.lock | 5 +- Cargo.toml | 12 +- fuzz/Cargo.lock | 22 ++-- src/uu/touch/Cargo.toml | 3 +- src/uu/touch/src/error.rs | 2 +- src/uu/touch/src/touch.rs | 152 +++++++++++--------------- src/uu/uptime/Cargo.toml | 2 +- src/uu/uptime/src/uptime.rs | 9 +- src/uucore/Cargo.toml | 3 +- src/uucore/src/lib/features/uptime.rs | 12 +- tests/by-util/test_date.rs | 54 ++++----- tests/by-util/test_pr.rs | 28 ++--- 12 files changed, 132 insertions(+), 172 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c38215bff..4fc465b71 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -537,7 +537,6 @@ name = "coreutils" version = "0.5.0" dependencies = [ "bincode", - "chrono", "clap", "clap_complete", "clap_mangen", @@ -4011,7 +4010,6 @@ dependencies = [ name = "uu_touch" version = "0.5.0" dependencies = [ - "chrono", "clap", "filetime", "fluent", @@ -4122,9 +4120,9 @@ dependencies = [ name = "uu_uptime" version = "0.5.0" dependencies = [ - "chrono", "clap", "fluent", + "jiff", "thiserror 2.0.17", "utmp-classic", "uucore", @@ -4204,7 +4202,6 @@ dependencies = [ "blake2b_simd", "blake3", "bstr", - "chrono", "clap", "codspeed-divan-compat", "crc-fast", diff --git a/Cargo.toml b/Cargo.toml index 1f94036e5..c4109656b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -311,11 +311,6 @@ binary-heap-plus = "0.5.0" bstr = "1.9.1" bytecount = "0.6.8" byteorder = "1.5.0" -chrono = { version = "0.4.41", default-features = false, features = [ - "std", - "alloc", - "clock", -] } clap = { version = "4.5", features = ["wrap_help", "cargo", "color"] } clap_complete = "4.4" clap_mangen = "0.2" @@ -341,11 +336,7 @@ icu_locale = "2.0.0" icu_provider = "2.0.0" indicatif = "0.18.0" itertools = "0.14.0" -jiff = { version = "0.2.10", default-features = false, features = [ - "std", - "alloc", - "tz-system", -] } +jiff = "0.2.18" libc = "0.2.172" linux-raw-sys = "0.12" lscolors = { version = "0.21.0", default-features = false, features = [ @@ -541,7 +532,6 @@ yes = { optional = true, version = "0.5.0", package = "uu_yes", path = "src/uu/y #pin_cc = { version="1.0.61, < 1.0.62", package="cc" } ## cc v1.0.62 has compiler errors for MinRustV v1.32.0, requires 1.34 (for `std::str::split_ascii_whitespace()`) [dev-dependencies] -chrono.workspace = true ctor.workspace = true filetime.workspace = true glob.workspace = true diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index b891522cc..c8c369138 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -53,7 +53,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -64,7 +64,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -504,7 +504,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -824,9 +824,9 @@ dependencies = [ [[package]] name = "jiff" -version = "0.2.16" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49cce2b81f2098e7e3efc35bc2e0a6b7abec9d34128283d7a26fa8f32a6dbb35" +checksum = "e67e8da4c49d6d9909fe03361f9b620f58898859f5c7aded68351e85e71ecf50" dependencies = [ "jiff-static", "jiff-tzdb-platform", @@ -834,14 +834,14 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde_core", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] name = "jiff-static" -version = "0.2.16" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "980af8b43c3ad5d8d349ace167ec8170839f753a42d233ba19e08afe1850fa69" +checksum = "e0c84ee7f197eca9a86c6fd6cb771e55eb991632f15f2bc3ca6ec838929e6e78" dependencies = [ "proc-macro2", "quote", @@ -1281,7 +1281,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1447,7 +1447,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1902,7 +1902,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] diff --git a/src/uu/touch/Cargo.toml b/src/uu/touch/Cargo.toml index f5409ec7a..1bde504fb 100644 --- a/src/uu/touch/Cargo.toml +++ b/src/uu/touch/Cargo.toml @@ -21,8 +21,7 @@ path = "src/touch.rs" [dependencies] filetime = { workspace = true } clap = { workspace = true } -chrono = { workspace = true } -jiff = "0.2.15" +jiff = { workspace = true } parse_datetime = { workspace = true } thiserror = { workspace = true } uucore = { workspace = true, features = ["libc", "parser"] } diff --git a/src/uu/touch/src/error.rs b/src/uu/touch/src/error.rs index 8d23b7528..47823cde9 100644 --- a/src/uu/touch/src/error.rs +++ b/src/uu/touch/src/error.rs @@ -16,7 +16,7 @@ pub enum TouchError { #[error("{}", translate!("touch-error-unable-to-parse-date", "date" => .0.clone()))] InvalidDateFormat(String), - /// The source time couldn't be converted to a [`chrono::DateTime`] + /// The source time couldn't be converted to a [`jiff::Zoned`] #[error("{}", translate!("touch-error-invalid-filetime", "time" => .0))] InvalidFiletime(FileTime), diff --git a/src/uu/touch/src/touch.rs b/src/uu/touch/src/touch.rs index bde22ab33..3c1bab7a8 100644 --- a/src/uu/touch/src/touch.rs +++ b/src/uu/touch/src/touch.rs @@ -3,24 +3,24 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore (ToDO) filetime datetime lpszfilepath mktime DATETIME datelike timelike UTIME +// spell-checker:ignore (ToDO) datelike datetime filetime lpszfilepath mktime strtime timelike utime // spell-checker:ignore (FORMATS) MMDDhhmm YYYYMMDDHHMM YYMMDDHHMM YYYYMMDDHHMMS pub mod error; -use chrono::{ - DateTime, Datelike, Duration, Local, LocalResult, NaiveDate, NaiveDateTime, NaiveTime, - TimeZone, Timelike, -}; use clap::builder::{PossibleValue, ValueParser}; use clap::{Arg, ArgAction, ArgGroup, ArgMatches, Command}; use filetime::{FileTime, set_file_times, set_symlink_file_times}; -use jiff::{Timestamp, Zoned}; +use jiff::civil::Time; +use jiff::fmt::strtime; +use jiff::tz::TimeZone; +use jiff::{Timestamp, ToSpan, Zoned}; use std::borrow::Cow; use std::ffi::{OsStr, OsString}; use std::fs::{self, File}; use std::io::{Error, ErrorKind}; use std::path::{Path, PathBuf}; +use std::time::SystemTime; use uucore::display::Quotable; use uucore::error::{FromIo, UResult, USimpleError}; #[cfg(target_os = "linux")] @@ -125,16 +125,13 @@ mod format { pub(crate) const YYYYMMDDHHMM_OFFSET: &str = "%Y-%m-%d %H:%M %z"; } -/// Convert a [`DateTime`] with a TZ offset into a [`FileTime`] -/// -/// The [`DateTime`] is converted into a unix timestamp from which the [`FileTime`] is -/// constructed. -fn datetime_to_filetime(dt: &DateTime) -> FileTime { - FileTime::from_unix_time(dt.timestamp(), dt.timestamp_subsec_nanos()) +fn timestamp_to_filetime(ts: Timestamp) -> FileTime { + FileTime::from_system_time(SystemTime::from(ts)) } -fn filetime_to_datetime(ft: &FileTime) -> Option> { - Some(DateTime::from_timestamp(ft.unix_seconds(), ft.nanoseconds())?.into()) +fn filetime_to_zoned(ft: &FileTime) -> Option { + let ts = Timestamp::new(ft.unix_seconds(), ft.nanoseconds() as i32).ok()?; + Some(Zoned::new(ts, TimeZone::system())) } /// Whether all characters in the string are digits. @@ -385,12 +382,12 @@ pub fn touch(files: &[InputFile], opts: &Options) -> Result<(), TouchError> { if opts.date.is_none() { now = FileTime::from_unix_time(0, libc::UTIME_NOW as u32); } else { - now = datetime_to_filetime(&Local::now()); + now = timestamp_to_filetime(Timestamp::now()); } } #[cfg(not(target_os = "linux"))] { - now = datetime_to_filetime(&Local::now()); + now = timestamp_to_filetime(Timestamp::now()); } (now, now) } @@ -400,11 +397,11 @@ pub fn touch(files: &[InputFile], opts: &Options) -> Result<(), TouchError> { let (atime, mtime) = if let Some(date) = &opts.date { ( parse_date( - filetime_to_datetime(&atime).ok_or_else(|| TouchError::InvalidFiletime(atime))?, + filetime_to_zoned(&atime).ok_or_else(|| TouchError::InvalidFiletime(atime))?, date, )?, parse_date( - filetime_to_datetime(&mtime).ok_or_else(|| TouchError::InvalidFiletime(mtime))?, + filetime_to_zoned(&mtime).ok_or_else(|| TouchError::InvalidFiletime(mtime))?, date, )?, ) @@ -610,7 +607,7 @@ fn stat(path: &Path, follow: bool) -> std::io::Result<(FileTime, FileTime)> { )) } -fn parse_date(ref_time: DateTime, s: &str) -> Result { +fn parse_date(ref_zoned: Zoned, s: &str) -> Result { // This isn't actually compatible with GNU touch, but there doesn't seem to // be any simple specification for what format this parameter allows and I'm // not about to implement GNU parse_datetime. @@ -625,8 +622,11 @@ fn parse_date(ref_time: DateTime, s: &str) -> Result, s: &str) -> Result, s: &str) -> Result UResult { fn parse_timestamp(s: &str) -> UResult { use format::*; - let current_year = || Local::now().year(); + let current_year = || Timestamp::now().to_zoned(TimeZone::system()).year(); let (format, ts) = match s.chars().count() { 15 => (YYYYMMDDHHMM_DOT_SS, s.to_owned()), @@ -748,41 +722,37 @@ fn parse_timestamp(s: &str) -> UResult { } }; - let local = NaiveDateTime::parse_from_str(&ts, format).map_err(|_| { - USimpleError::new( - 1, - translate!("touch-error-invalid-date-ts-format", "date" => ts.quote()), - ) - })?; - let LocalResult::Single(mut local) = Local.from_local_datetime(&local) else { - return Err(USimpleError::new( - 1, - translate!("touch-error-invalid-date-ts-format", "date" => ts.quote()), - )); - }; + let mut dt = strtime::parse(format, &ts) + .and_then(|parsed| parsed.to_datetime()) + .map_err(|_| { + USimpleError::new( + 1, + translate!("touch-error-invalid-date-ts-format", "date" => ts.quote()), + ) + })?; - // Chrono caps seconds at 59, but 60 is valid. It might be a leap second + // Jiff caps seconds at 59, but 60 is valid. It might be a leap second // or wrap to the next minute. But that doesn't really matter, because we // only care about the timestamp anyway. // Tested in gnu/tests/touch/60-seconds - if local.second() == 59 && ts.ends_with(".60") { - local += Duration::try_seconds(1).unwrap(); + if dt.second() == 59 && ts.ends_with(".60") { + dt += 1.second(); } // Due to daylight saving time switch, local time can jump from 1:59 AM to - // 3:00 AM, in which case any time between 2:00 AM and 2:59 AM is not - // valid. If we are within this jump, chrono takes the offset from before - // the jump. If we then jump forward an hour, we get the new corrected - // offset. Jumping back will then now correctly take the jump into account. - let local2 = local + Duration::try_hours(1).unwrap() - Duration::try_hours(1).unwrap(); - if local.hour() != local2.hour() { - return Err(USimpleError::new( - 1, - translate!("touch-error-invalid-date-format", "date" => s.quote()), - )); - } + // 3:00 AM, in which case any time between 2:00 AM and 2:59 AM is not valid. + // Jiff's `to_ambiguous_zoned(...).unambiguous()` handles this case. + let local = TimeZone::system() + .to_ambiguous_zoned(dt) + .unambiguous() + .map_err(|_| { + USimpleError::new( + 1, + translate!("touch-error-invalid-date-ts-format", "date" => ts.quote()), + ) + })?; - Ok(datetime_to_filetime(&local)) + Ok(timestamp_to_filetime(local.timestamp())) } // TODO: this may be a good candidate to put in fsext.rs diff --git a/src/uu/uptime/Cargo.toml b/src/uu/uptime/Cargo.toml index e584fbb7d..651b342cd 100644 --- a/src/uu/uptime/Cargo.toml +++ b/src/uu/uptime/Cargo.toml @@ -23,11 +23,11 @@ feat_systemd_logind = ["uucore/feat_systemd_logind"] path = "src/uptime.rs" [dependencies] -chrono = { workspace = true } clap = { workspace = true } thiserror = { workspace = true } uucore = { workspace = true, features = ["libc", "utmpx", "uptime"] } fluent = { workspace = true } +jiff = { workspace = true } [target.'cfg(target_os = "openbsd")'.dependencies] utmp-classic = { workspace = true } diff --git a/src/uu/uptime/src/uptime.rs b/src/uu/uptime/src/uptime.rs index ca53d418a..89dc55d31 100644 --- a/src/uu/uptime/src/uptime.rs +++ b/src/uu/uptime/src/uptime.rs @@ -5,7 +5,8 @@ // spell-checker:ignore getloadavg behaviour loadavg uptime upsecs updays upmins uphours boottime nusers utmpxname gettime clockid couldnt -use chrono::{Local, TimeZone, Utc}; +use jiff::tz::TimeZone; +use jiff::{Timestamp, ToSpan}; #[cfg(unix)] use std::ffi::OsString; use std::io; @@ -196,10 +197,8 @@ fn uptime_since() -> UResult<()> { #[cfg(any(windows, target_os = "openbsd"))] let uptime = get_uptime(None)?; - let since_date = Local - .timestamp_opt(Utc::now().timestamp() - uptime, 0) - .unwrap(); - println!("{}", since_date.format("%Y-%m-%d %H:%M:%S")); + let since_date = (Timestamp::now() - uptime.seconds()).to_zoned(TimeZone::system()); + println!("{}", since_date.strftime("%Y-%m-%d %H:%M:%S")); Ok(()) } diff --git a/src/uucore/Cargo.toml b/src/uucore/Cargo.toml index 0362dc097..d58d2ccca 100644 --- a/src/uucore/Cargo.toml +++ b/src/uucore/Cargo.toml @@ -23,7 +23,6 @@ path = "src/lib/lib.rs" [dependencies] bstr = { workspace = true, optional = true } -chrono = { workspace = true, optional = true } clap = { workspace = true } uucore_procs = { workspace = true } unit-prefix = { workspace = true, optional = true } @@ -185,5 +184,5 @@ version-cmp = [] wide = [] tty = [] time = ["jiff"] -uptime = ["chrono", "libc", "windows-sys", "utmpx", "utmp-classic"] +uptime = ["jiff", "libc", "windows-sys", "utmpx", "utmp-classic"] benchmark = ["divan", "tempfile"] diff --git a/src/uucore/src/lib/features/uptime.rs b/src/uucore/src/lib/features/uptime.rs index 91352f1de..10b073ad5 100644 --- a/src/uucore/src/lib/features/uptime.rs +++ b/src/uucore/src/lib/features/uptime.rs @@ -14,7 +14,8 @@ use crate::error::{UError, UResult}; use crate::translate; -use chrono::Local; +use jiff::Timestamp; +use jiff::tz::TimeZone; use libc::time_t; use thiserror::Error; @@ -38,7 +39,10 @@ impl UError for UptimeError { /// Returns the formatted time string, e.g. "12:34:56" pub fn get_formatted_time() -> String { - Local::now().time().format("%H:%M:%S").to_string() + Timestamp::now() + .to_zoned(TimeZone::system()) + .strftime("%H:%M:%S") + .to_string() } /// Safely get macOS boot time using sysctl command @@ -187,7 +191,7 @@ pub fn get_uptime(boot_time: Option) -> UResult { }; if let Some(t) = derived_boot_time { - let now = Local::now().timestamp(); + let now = Timestamp::now().as_second(); #[cfg(target_pointer_width = "64")] let boottime: i64 = t; #[cfg(not(target_pointer_width = "64"))] @@ -470,7 +474,7 @@ mod tests { assert!(boot_time > 946684800, "Boot time should be after year 2000"); // Boot time should be before current time - let now = chrono::Local::now().timestamp(); + let now = Timestamp::now().as_second(); assert!( (boot_time as i64) < now, "Boot time should be before current time" diff --git a/tests/by-util/test_date.rs b/tests/by-util/test_date.rs index b0613b146..97b3d1056 100644 --- a/tests/by-util/test_date.rs +++ b/tests/by-util/test_date.rs @@ -5,7 +5,10 @@ // // spell-checker: ignore: AEDT AEST EEST NZDT NZST Kolkata Iseconds -use chrono::{DateTime, Datelike, Duration, NaiveTime, Utc}; // spell-checker:disable-line +use std::cmp::Ordering; + +use jiff::tz::TimeZone; +use jiff::{Timestamp, ToSpan}; use regex::Regex; #[cfg(all(unix, not(target_os = "macos")))] use uucore::process::geteuid; @@ -485,7 +488,7 @@ fn test_invalid_format_string() { #[test] fn test_capitalized_numeric_time_zone() { // %z +hhmm numeric time zone (e.g., -0400) - // # is supposed to capitalize, which makes little sense here, but chrono crashes + // # is supposed to capitalize, which makes little sense here, but keep coverage // on such format so it's good to test. let re = Regex::new(r"^[+-]\d{4,4}\n$").unwrap(); new_ucmd!().arg("+%#z").succeeds().stdout_matches(&re); @@ -528,10 +531,10 @@ fn test_date_string_human() { #[test] fn test_negative_offset() { let data_formats = vec![ - ("-1 hour", Duration::hours(1)), - ("-1 hours", Duration::hours(1)), - ("-1 day", Duration::days(1)), - ("-2 weeks", Duration::weeks(2)), + ("-1 hour", 1.hours()), + ("-1 hours", 1.hours()), + ("-1 day", 24.hours()), + ("-2 weeks", (14 * 24).hours()), ]; for (date_format, offset) in data_formats { new_ucmd!() @@ -540,11 +543,10 @@ fn test_negative_offset() { .arg("--rfc-3339=seconds") .succeeds() .stdout_str_check(|out| { - let date = DateTime::parse_from_rfc3339(out.trim()).unwrap(); - + let date = out.trim().parse::().unwrap(); // Is the resulting date roughly what is expected? - let expected_date = Utc::now() - offset; - (date.to_utc() - expected_date).abs() < Duration::minutes(10) + let expected_date = Timestamp::now() - offset; + (date - expected_date).abs().compare(10.minutes()).unwrap() == Ordering::Less }); } } @@ -552,14 +554,15 @@ fn test_negative_offset() { #[test] fn test_relative_weekdays() { // Truncate time component to midnight - let today = Utc::now().with_time(NaiveTime::MIN).unwrap(); + let today = Timestamp::now().to_zoned(TimeZone::UTC).date(); // Loop through each day of the week, starting with today for offset in 0..7 { for direction in ["last", "this", "next"] { - let weekday = (today + Duration::days(offset)) - .weekday() - .to_string() - .to_lowercase(); + let weekday = today + .checked_add(offset.days()) + .unwrap() + .strftime("%a") + .to_string(); new_ucmd!() .arg("-d") .arg(format!("{direction} {weekday}")) @@ -567,14 +570,15 @@ fn test_relative_weekdays() { .arg("--utc") .succeeds() .stdout_str_check(|out| { - let result = DateTime::parse_from_rfc3339(out.trim()).unwrap().to_utc(); + let result = out.trim().parse::().unwrap(); let expected = match (direction, offset) { - ("last", _) => today - Duration::days(7 - offset), + ("last", _) => today.checked_sub((7 - offset).days()).unwrap(), ("this", 0) => today, - ("next", 0) => today + Duration::days(7), - _ => today + Duration::days(offset), + ("next", 0) => today.checked_add(7.days()).unwrap(), + _ => today.checked_add(offset.days()).unwrap(), }; - result == expected + let expected_ts = expected.to_zoned(TimeZone::UTC).unwrap().timestamp(); + result == expected_ts }); } } @@ -862,7 +866,7 @@ fn test_date_resolution_no_combine() { fn test_date_numeric_d_basic_utc() { // Verify GNU-compatible pure-digit parsing for -d STRING under UTC // 0/00 -> today at 00:00; 7/07 -> today at 07:00; 0700 -> today at 07:00 - let today = Utc::now().date_naive(); + let today = Timestamp::now().to_zoned(TimeZone::UTC).date(); let yyyy = today.year(); let mm = today.month(); let dd = today.day(); @@ -1134,9 +1138,7 @@ fn test_date_military_timezone_with_offset_variations() { #[test] fn test_date_military_timezone_with_offset_and_date() { - use chrono::{Duration, Utc}; - - let today = Utc::now().date_naive(); + let today = Timestamp::now().to_zoned(TimeZone::UTC).date(); let test_cases = vec![ ("m", -1), // M = UTC+12 @@ -1158,9 +1160,9 @@ fn test_date_military_timezone_with_offset_and_date() { ]; for (input, day_delta) in test_cases { - let expected_date = today.checked_add_signed(Duration::days(day_delta)).unwrap(); + let expected_date = today.checked_add(day_delta.days()).unwrap(); - let expected = format!("{}\n", expected_date.format("%F")); + let expected = format!("{}\n", expected_date.strftime("%F")); new_ucmd!() .env("TZ", "UTC") diff --git a/tests/by-util/test_pr.rs b/tests/by-util/test_pr.rs index d2e6cb675..2f02b5e0f 100644 --- a/tests/by-util/test_pr.rs +++ b/tests/by-util/test_pr.rs @@ -4,7 +4,7 @@ // file that was distributed with this source code. // spell-checker:ignore (ToDO) Sdivide -use chrono::{DateTime, Duration, Utc}; +use jiff::{Timestamp, ToSpan}; use regex::Regex; use std::fs::metadata; use uutests::new_ucmd; @@ -17,8 +17,8 @@ fn file_last_modified_time_format(ucmd: &UCommand, path: &str, format: &str) -> metadata(tmp_dir_path) .and_then(|meta| meta.modified()) .map(|mtime| { - let dt: DateTime = mtime.into(); - dt.format(format).to_string() + let dt: Timestamp = mtime.try_into().unwrap(); + dt.strftime(format).to_string() }) .unwrap_or_default() } @@ -27,19 +27,19 @@ fn file_last_modified_time(ucmd: &UCommand, path: &str) -> String { file_last_modified_time_format(ucmd, path, DATE_TIME_FORMAT_DEFAULT) } -fn all_minutes(from: DateTime, to: DateTime) -> Vec { - let to = to + Duration::try_minutes(1).unwrap(); +fn all_minutes(from: Timestamp, to: Timestamp) -> Vec { + let to = to + 1.minute(); let mut vec = vec![]; let mut current = from; while current < to { - vec.push(current.format(DATE_TIME_FORMAT_DEFAULT).to_string()); - current += Duration::try_minutes(1).unwrap(); + vec.push(current.strftime(DATE_TIME_FORMAT_DEFAULT).to_string()); + current += 1.minute(); } vec } -fn valid_last_modified_template_vars(from: DateTime) -> Vec> { - all_minutes(from, Utc::now()) +fn valid_last_modified_template_vars(from: Timestamp) -> Vec> { + all_minutes(from, Timestamp::now()) .into_iter() .map(|time| vec![("{last_modified_time}".to_string(), time)]) .collect() @@ -264,7 +264,7 @@ fn test_with_suppress_error_option() { fn test_with_stdin() { let expected_file_path = "stdin.log.expected"; let mut scenario = new_ucmd!(); - let start = Utc::now(); + let start = Timestamp::now(); scenario .pipe_in_fixture("stdin.log") .args(&["--pages=1:2", "-n", "-"]) @@ -327,7 +327,7 @@ fn test_with_mpr() { let expected_test_file_path = "mpr.log.expected"; let expected_test_file_path1 = "mpr1.log.expected"; let expected_test_file_path2 = "mpr2.log.expected"; - let start = Utc::now(); + let start = Timestamp::now(); new_ucmd!() .args(&["--pages=1:2", "-m", "-n", test_file_path, test_file_path1]) .succeeds() @@ -336,7 +336,7 @@ fn test_with_mpr() { &valid_last_modified_template_vars(start), ); - let start = Utc::now(); + let start = Timestamp::now(); new_ucmd!() .args(&["--pages=2:4", "-m", "-n", test_file_path, test_file_path1]) .succeeds() @@ -345,7 +345,7 @@ fn test_with_mpr() { &valid_last_modified_template_vars(start), ); - let start = Utc::now(); + let start = Timestamp::now(); new_ucmd!() .args(&[ "--pages=1:2", @@ -530,7 +530,7 @@ fn test_with_join_lines_option() { let test_file_2 = "test.log"; let expected_file_path = "joined.log.expected"; let mut scenario = new_ucmd!(); - let start = Utc::now(); + let start = Timestamp::now(); scenario .args(&["+1:2", "-J", "-m", test_file_1, test_file_2]) .succeeds() From bbe9ffa4644db8a6900b7030941a853c7e210758 Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Thu, 8 Jan 2026 09:51:02 +0100 Subject: [PATCH 126/425] df: add benchmarks --- .github/workflows/benchmarks.yml | 1 + Cargo.lock | 1 + src/uu/df/Cargo.toml | 6 ++++ src/uu/df/benches/df_bench.rs | 52 ++++++++++++++++++++++++++++++++ 4 files changed, 60 insertions(+) create mode 100644 src/uu/df/benches/df_bench.rs diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index 9f53a0167..c7c24890e 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -28,6 +28,7 @@ jobs: - { package: uu_cp } - { package: uu_cut } - { package: uu_dd } + - { package: uu_df } - { package: uu_du } - { package: uu_expand } - { package: uu_fold } diff --git a/Cargo.lock b/Cargo.lock index 7b1c421c5..950fb5cc7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3240,6 +3240,7 @@ name = "uu_df" version = "0.5.0" dependencies = [ "clap", + "codspeed-divan-compat", "fluent", "tempfile", "thiserror 2.0.17", diff --git a/src/uu/df/Cargo.toml b/src/uu/df/Cargo.toml index 93017870d..0b0df7268 100644 --- a/src/uu/df/Cargo.toml +++ b/src/uu/df/Cargo.toml @@ -25,8 +25,14 @@ thiserror = { workspace = true } fluent = { workspace = true } [dev-dependencies] +divan = { workspace = true } tempfile = { workspace = true } +uucore = { workspace = true, features = ["benchmark"] } [[bin]] name = "df" path = "src/main.rs" + +[[bench]] +name = "df_bench" +harness = false diff --git a/src/uu/df/benches/df_bench.rs b/src/uu/df/benches/df_bench.rs new file mode 100644 index 000000000..b9453dd0c --- /dev/null +++ b/src/uu/df/benches/df_bench.rs @@ -0,0 +1,52 @@ +// This file is part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +use divan::{Bencher, black_box}; +use std::env; +use std::fs; +use std::path::PathBuf; +use tempfile::TempDir; +use uu_df::uumain; +use uucore::benchmark::run_util_function; + +fn create_deep_directory(base_dir: &std::path::Path, depth: usize) -> PathBuf { + let mut current = base_dir.to_path_buf(); + env::set_current_dir(¤t).unwrap(); + + for _ in 0..depth { + current = current.join("d"); + fs::create_dir("d").unwrap(); + env::set_current_dir("d").unwrap(); + } + current +} + +#[divan::bench] +fn df_deep_directory(bencher: Bencher) { + const DEPTH: usize = 20000; + + let original_dir = env::current_dir().unwrap(); + let temp_dir = TempDir::new().unwrap(); + let _deep_path = create_deep_directory(temp_dir.path(), DEPTH); + bencher.bench(|| { + black_box(run_util_function(uumain, &[] as &[&str])); + }); + + env::set_current_dir(original_dir).unwrap(); +} + +#[divan::bench] +fn df_with_path(bencher: Bencher) { + let temp_dir = TempDir::new().unwrap(); + let temp_path_str = temp_dir.path().to_str().unwrap(); + + bencher.bench(|| { + black_box(run_util_function(uumain, &[temp_path_str])); + }); +} + +fn main() { + divan::main(); +} From 95195cfb7501485f09eb5bf896f1585b41c8fe90 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 8 Jan 2026 12:33:04 +0000 Subject: [PATCH 127/425] chore(deps): update rust crate libc to v0.2.180 --- Cargo.lock | 4 ++-- fuzz/Cargo.lock | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 72fec0447..f39f32690 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1651,9 +1651,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.179" +version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5a2d376baa530d1238d133232d15e239abad80d05838b4b59354e5268af431f" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" [[package]] name = "libloading" diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index c8c369138..2554b8183 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -894,9 +894,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.179" +version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5a2d376baa530d1238d133232d15e239abad80d05838b4b59354e5268af431f" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" [[package]] name = "libfuzzer-sys" From dc19e7827f37512685a56294cbf482388ef67f48 Mon Sep 17 00:00:00 2001 From: karanabe <152078880+karanabe@users.noreply.github.com> Date: Fri, 14 Nov 2025 00:20:10 +0900 Subject: [PATCH 128/425] fix(ls): match GNU dangling color semantics Ensure StyleManager honors `or=`, `mi=` and `ln=target` the same as GNU ls when coloring dangling symlinks. Reset sequences are still printed for blank `or=` entries, and targets fall back to missing-file colors. --- src/uu/ls/src/colors.rs | 349 ++++++++++++++++++++++++++++++++++++---- src/uu/ls/src/ls.rs | 198 +++++++++++++++++------ 2 files changed, 466 insertions(+), 81 deletions(-) diff --git a/src/uu/ls/src/colors.rs b/src/uu/ls/src/colors.rs index a7f58d0fd..a7e7e92c1 100644 --- a/src/uu/ls/src/colors.rs +++ b/src/uu/ls/src/colors.rs @@ -3,9 +3,13 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. use super::PathData; -use lscolors::{Colorable, Indicator, LsColors, Style}; +use lscolors::{Indicator, LsColors, Style}; +use std::collections::HashMap; +use std::env; use std::ffi::OsString; -use std::fs::Metadata; +use std::fs::{self, Metadata}; +#[cfg(unix)] +use std::os::unix::fs::{FileTypeExt, MetadataExt}; /// We need this struct to be able to store the previous style. /// This because we need to check the previous value in case we don't need @@ -16,49 +20,82 @@ pub(crate) struct StyleManager<'a> { /// `true` if the initial reset is applied pub(crate) initial_reset_is_done: bool, pub(crate) colors: &'a LsColors, + /// raw indicator codes as specified in LS_COLORS (if available) + indicator_codes: HashMap, + /// whether ln=target is active + ln_color_from_target: bool, } impl<'a> StyleManager<'a> { pub(crate) fn new(colors: &'a LsColors) -> Self { + let (indicator_codes, ln_color_from_target) = parse_indicator_codes(); Self { initial_reset_is_done: false, current_style: None, colors, + indicator_codes, + ln_color_from_target, } } pub(crate) fn apply_style( &mut self, new_style: Option<&Style>, + path: Option<&PathData>, name: OsString, wrap: bool, ) -> OsString { let mut style_code = String::new(); let mut force_suffix_reset: bool = false; + let mut applied_raw_code = false; - // if reset is done we need to apply normal style before applying new style if self.is_reset() { if let Some(norm_sty) = self.get_normal_style().copied() { style_code.push_str(&self.get_style_code(&norm_sty)); } } - if let Some(new_style) = new_style { - // we only need to apply a new style if it's not the same as the current - // style for example if normal is the current style and a file with - // normal style is to be printed we could skip printing new color - // codes - if !self.is_current_style(new_style) { - style_code.push_str(self.reset(!self.initial_reset_is_done)); - style_code.push_str(&self.get_style_code(new_style)); + if let Some(path) = path { + if let Some(indicator) = self.indicator_for_raw_code(path) { + let should_skip = indicator == Indicator::SymbolicLink + && self.ln_color_from_target + && path.path().exists(); + + if !should_skip { + if let Some(raw) = self.indicator_codes.get(&indicator).cloned() { + if raw.is_empty() { + return self.apply_empty_style(name, wrap); + } + style_code.push_str(self.reset(!self.initial_reset_is_done)); + style_code.push_str("\x1b["); + style_code.push_str(&raw); + style_code.push('m'); + applied_raw_code = true; + self.current_style = None; + force_suffix_reset = true; + } + } } } - // if new style is None and current style is Normal we should reset it - else if matches!(self.get_normal_style().copied(), Some(norm_style) if self.is_current_style(&norm_style)) - { - style_code.push_str(self.reset(false)); - // even though this is an unnecessary reset for gnu compatibility we allow it here - force_suffix_reset = true; + + if !applied_raw_code { + if let Some(new_style) = new_style { + // we only need to apply a new style if it's not the same as the current + // style for example if normal is the current style and a file with + // normal style is to be printed we could skip printing new color + // codes + if !self.is_current_style(new_style) { + style_code.push_str(self.reset(!self.initial_reset_is_done)); + style_code.push_str(&self.get_style_code(new_style)); + } + } + // if new style is None and current style is Normal we should reset it + else if matches!(self.get_normal_style().copied(), Some(norm_style) if self.is_current_style(&norm_style)) + { + style_code.push_str(self.reset(false)); + // even though this is an unnecessary reset for gnu compatibility we allow it here + force_suffix_reset = true; + } } // we need this clear to eol code in some terminals, for instance if the @@ -130,17 +167,220 @@ impl<'a> StyleManager<'a> { let style = self .colors .style_for_path_with_metadata(&path.p_buf, md_option); - self.apply_style(style, name, wrap) + self.apply_style(style, Some(path), name, wrap) } - pub(crate) fn apply_style_based_on_colorable( + pub(crate) fn apply_style_for_path( &mut self, - path: &T, + path: &PathData, name: OsString, wrap: bool, ) -> OsString { let style = self.colors.style_for(path); - self.apply_style(style, name, wrap) + self.apply_style(style, Some(path), name, wrap) + } + + pub(crate) fn apply_indicator_style( + &mut self, + indicator: Indicator, + name: OsString, + wrap: bool, + ) -> OsString { + if let Some(raw) = self.indicator_codes.get(&indicator).cloned() { + if raw.is_empty() { + return self.apply_empty_style(name, wrap); + } + + let mut style_code = String::new(); + style_code.push_str(self.reset(!self.initial_reset_is_done)); + style_code.push_str("\x1b["); + style_code.push_str(&raw); + style_code.push('m'); + + let mut ret: OsString = style_code.into(); + ret.push(name); + ret.push(self.reset(true)); + if wrap { + ret.push("\x1b[K"); + } + ret + } else { + let style = self.colors.style_for_indicator(indicator); + self.apply_style(style, None, name, wrap) + } + } + + pub(crate) fn has_indicator_style(&self, indicator: Indicator) -> bool { + self.indicator_codes.contains_key(&indicator) + || self.colors.style_for_indicator(indicator).is_some() + } + + pub(crate) fn apply_orphan_link_style(&mut self, name: OsString, wrap: bool) -> OsString { + if self.has_indicator_style(Indicator::OrphanedSymbolicLink) { + self.apply_indicator_style(Indicator::OrphanedSymbolicLink, name, wrap) + } else { + self.apply_indicator_style(Indicator::MissingFile, name, wrap) + } + } + + pub(crate) fn apply_missing_target_style(&mut self, name: OsString, wrap: bool) -> OsString { + if self.has_indicator_style(Indicator::MissingFile) { + self.apply_indicator_style(Indicator::MissingFile, name, wrap) + } else { + self.apply_indicator_style(Indicator::OrphanedSymbolicLink, name, wrap) + } + } + + fn apply_empty_style(&mut self, name: OsString, wrap: bool) -> OsString { + let mut style_code = String::new(); + style_code.push_str(self.reset(!self.initial_reset_is_done)); + style_code.push_str("\x1b[m"); + + let mut ret: OsString = style_code.into(); + ret.push(name); + ret.push(self.reset(true)); + if wrap { + ret.push("\x1b[K"); + } + ret + } + + fn color_symlink_name( + &mut self, + path: &PathData, + name: OsString, + wrap: bool, + ) -> Option { + if path.must_dereference && path.metadata().is_none() { + return None; + } + let mut target = path.path().read_link().ok()?; + if target.is_relative() { + if let Some(parent) = path.path().parent() { + target = parent.join(target); + } + } + + match fs::metadata(&target) { + Ok(metadata) => { + if self.ln_color_from_target { + let style = self + .colors + .style_for_path_with_metadata(&target, Some(&metadata)); + Some(self.apply_style(style, None, name, wrap)) + } else { + None + } + } + Err(_) => { + if self.ln_color_from_target { + Some(self.apply_orphan_link_style(name, wrap)) + } else { + None + } + } + } + } + + fn indicator_has(&self, indicator: Indicator) -> bool { + self.indicator_codes.contains_key(&indicator) + } + + fn indicator_for_raw_code(&self, path: &PathData) -> Option { + if self.indicator_codes.is_empty() { + return None; + } + + let exists = path.path().exists(); + let Some(file_type) = path.file_type() else { + if self.indicator_has(Indicator::MissingFile) && !exists { + return Some(Indicator::MissingFile); + } + return None; + }; + + if file_type.is_symlink() { + let orphan_style = self.indicator_codes.get(&Indicator::OrphanedSymbolicLink); + let orphan_has_color = orphan_style.map(|s| !s.is_empty()).unwrap_or(false); + if !exists && (orphan_has_color || self.ln_color_from_target) { + return Some(Indicator::OrphanedSymbolicLink); + } + if self.indicator_has(Indicator::SymbolicLink) { + return Some(Indicator::SymbolicLink); + } + if !exists && self.indicator_has(Indicator::MissingFile) { + return Some(Indicator::MissingFile); + } + return None; + } + if self.indicator_has(Indicator::MissingFile) && !exists { + return Some(Indicator::MissingFile); + } + + if file_type.is_file() { + #[cfg(unix)] + { + if let Some(metadata) = path.metadata() { + let mode = metadata.mode(); + if self.indicator_has(Indicator::Setuid) && mode & 0o4000 != 0 { + return Some(Indicator::Setuid); + } + if self.indicator_has(Indicator::Setgid) && mode & 0o2000 != 0 { + return Some(Indicator::Setgid); + } + if self.indicator_has(Indicator::ExecutableFile) && mode & 0o0111 != 0 { + return Some(Indicator::ExecutableFile); + } + if self.indicator_has(Indicator::MultipleHardLinks) && metadata.nlink() > 1 { + return Some(Indicator::MultipleHardLinks); + } + } + } + + if self.indicator_has(Indicator::RegularFile) { + return Some(Indicator::RegularFile); + } + } else if file_type.is_dir() { + #[cfg(unix)] + { + if let Some(metadata) = path.metadata() { + let mode = metadata.mode(); + if self.indicator_has(Indicator::StickyAndOtherWritable) + && mode & 0o1002 == 0o1002 + { + return Some(Indicator::StickyAndOtherWritable); + } + if self.indicator_has(Indicator::OtherWritable) && mode & 0o0002 != 0 { + return Some(Indicator::OtherWritable); + } + if self.indicator_has(Indicator::Sticky) && mode & 0o1000 != 0 { + return Some(Indicator::Sticky); + } + } + } + + if self.indicator_has(Indicator::Directory) { + return Some(Indicator::Directory); + } + } else { + #[cfg(unix)] + { + if file_type.is_fifo() && self.indicator_has(Indicator::FIFO) { + return Some(Indicator::FIFO); + } + if file_type.is_socket() && self.indicator_has(Indicator::Socket) { + return Some(Indicator::Socket); + } + if file_type.is_block_device() && self.indicator_has(Indicator::BlockDevice) { + return Some(Indicator::BlockDevice); + } + if file_type.is_char_device() && self.indicator_has(Indicator::CharacterDevice) { + return Some(Indicator::CharacterDevice); + } + } + } + + None } } @@ -168,27 +408,70 @@ pub(crate) fn color_name( // If the file has capabilities, use a specific style for `ca` (capabilities) if has_capabilities { - return style_manager.apply_style(capabilities, name, wrap); + return style_manager.apply_style(capabilities, Some(path), name, wrap); } } - if !path.must_dereference { - // If we need to dereference (follow) a symlink, we will need to get the metadata - // There is a DirEntry, we don't need to get the metadata for the color - return style_manager.apply_style_based_on_colorable(path, name, wrap); + if target_symlink.is_none() && path.file_type().is_some_and(|ft| ft.is_symlink()) { + if let Some(colored) = style_manager.color_symlink_name(path, name.clone(), wrap) { + return colored; + } } if let Some(target) = target_symlink { // use the optional target_symlink // Use fn symlink_metadata directly instead of get_metadata() here because ls // should not exit with an err, if we are unable to obtain the target_metadata - style_manager.apply_style_based_on_colorable(target, name, wrap) - } else { - let md_option: Option = path - .metadata() - .cloned() - .or_else(|| path.p_buf.symlink_metadata().ok()); + return style_manager.apply_style_for_path(target, name, wrap); + } - style_manager.apply_style_based_on_metadata(path, md_option.as_ref(), name, wrap) + if !path.must_dereference { + // If we need to dereference (follow) a symlink, we will need to get the metadata + // There is a DirEntry, we don't need to get the metadata for the color + return style_manager.apply_style_for_path(path, name, wrap); + } + + let md_option: Option = path + .metadata() + .cloned() + .or_else(|| path.p_buf.symlink_metadata().ok()); + + style_manager.apply_style_based_on_metadata(path, md_option.as_ref(), name, wrap) +} + +fn parse_indicator_codes() -> (HashMap, bool) { + let mut indicator_codes = HashMap::new(); + let mut ln_color_from_target = false; + + if let Ok(ls_colors) = env::var("LS_COLORS") { + for entry in ls_colors.split(':') { + if entry.is_empty() { + continue; + } + let Some((key, value)) = entry.split_once('=') else { + continue; + }; + + if let Some(indicator) = Indicator::from(key) { + if indicator == Indicator::SymbolicLink && value == "target" { + ln_color_from_target = true; + continue; + } + indicator_codes.insert(indicator, canonicalize_indicator_value(value)); + } + } + } + + (indicator_codes, ln_color_from_target) +} + +fn canonicalize_indicator_value(value: &str) -> String { + if value.len() == 1 && value.chars().all(|c| c.is_ascii_digit()) { + let mut canonical = String::with_capacity(2); + canonical.push('0'); + canonical.push_str(value); + canonical + } else { + value.to_string() } } diff --git a/src/uu/ls/src/ls.rs b/src/uu/ls/src/ls.rs index 3a7e8014e..2d1ee6b32 100644 --- a/src/uu/ls/src/ls.rs +++ b/src/uu/ls/src/ls.rs @@ -3,7 +3,8 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore (ToDO) somegroup nlink tabsize dired subdired dtype colorterm stringly nohash strtime +// spell-checker:ignore (ToDO) somegroup nlink tabsize dired subdired dtype colorterm stringly +// spell-checker:ignore nohash strtime clocale #[cfg(unix)] use fnv::FnvHashMap as HashMap; @@ -18,7 +19,7 @@ use std::{ cell::{LazyCell, OnceCell}, cmp::Reverse, ffi::{OsStr, OsString}, - fmt::Write as FmtWrite, + fmt::Write as _, fs::{self, DirEntry, FileType, Metadata, ReadDir}, io::{BufWriter, ErrorKind, IsTerminal, Stdout, Write, stdout}, iter, @@ -338,6 +339,12 @@ enum IndicatorStyle { Classify, } +#[derive(Clone, Copy, PartialEq, Eq)] +enum LocaleQuoting { + Single, + Double, +} + pub struct Config { // Dir and vdir needs access to this field pub format: Format, @@ -361,6 +368,7 @@ pub struct Config { width: u16, // Dir and vdir needs access to this field pub quoting_style: QuotingStyle, + locale_quoting: Option, indicator_style: IndicatorStyle, time_format_recent: String, // Time format for recent dates time_format_older: Option, // Time format for older dates (optional, if not present, time_format_recent is used) @@ -655,18 +663,43 @@ fn extract_hyperlink(options: &clap::ArgMatches) -> bool { /// # Returns /// /// * An option with None if the style string is invalid, or a `QuotingStyle` wrapped in `Some`. -fn match_quoting_style_name(style: &str, show_control: bool) -> Option { - match style { - "literal" => Some(QuotingStyle::Literal { show_control }), - "shell" => Some(QuotingStyle::SHELL), - "shell-always" => Some(QuotingStyle::SHELL_QUOTE), - "shell-escape" => Some(QuotingStyle::SHELL_ESCAPE), - "shell-escape-always" => Some(QuotingStyle::SHELL_ESCAPE_QUOTE), - "c" => Some(QuotingStyle::C_DOUBLE), - "escape" => Some(QuotingStyle::C_NO_QUOTES), - _ => None, - } - .map(|qs| qs.show_control(show_control)) +fn match_quoting_style_name( + style: &str, + show_control: bool, +) -> Option<(QuotingStyle, Option)> { + let (qs, fixed_control, locale) = match style { + "literal" => ( + QuotingStyle::Literal { + show_control: false, + }, + false, + None, + ), + "shell" => (QuotingStyle::SHELL, false, None), + "shell-always" => (QuotingStyle::SHELL_QUOTE, false, None), + "shell-escape" => (QuotingStyle::SHELL_ESCAPE, false, None), + "shell-escape-always" => (QuotingStyle::SHELL_ESCAPE_QUOTE, false, None), + "c" => (QuotingStyle::C_DOUBLE, false, None), + "escape" => (QuotingStyle::C_NO_QUOTES, false, None), + "locale" => ( + QuotingStyle::Literal { + show_control: false, + }, + true, + Some(LocaleQuoting::Single), + ), + "clocale" => (QuotingStyle::C_DOUBLE, true, Some(LocaleQuoting::Double)), + _ => return None, + }; + + Some(( + if fixed_control { + qs + } else { + qs.show_control(show_control) + }, + locale, + )) } /// Extracts the quoting style to use based on the options provided. @@ -681,27 +714,30 @@ fn match_quoting_style_name(style: &str, show_control: bool) -> Option QuotingStyle { +fn extract_quoting_style( + options: &clap::ArgMatches, + show_control: bool, +) -> (QuotingStyle, Option) { let opt_quoting_style = options.get_one::(QUOTING_STYLE); if let Some(style) = opt_quoting_style { match match_quoting_style_name(style, show_control) { - Some(qs) => qs, + Some(pair) => pair, None => unreachable!("Should have been caught by Clap"), } } else if options.get_flag(options::quoting::LITERAL) { - QuotingStyle::Literal { show_control } + (QuotingStyle::Literal { show_control }, None) } else if options.get_flag(options::quoting::ESCAPE) { - QuotingStyle::C_NO_QUOTES + (QuotingStyle::C_NO_QUOTES, None) } else if options.get_flag(options::quoting::C) { - QuotingStyle::C_DOUBLE + (QuotingStyle::C_DOUBLE, None) } else if options.get_flag(options::DIRED) { - QuotingStyle::Literal { show_control } + (QuotingStyle::Literal { show_control }, None) } else { // If set, the QUOTING_STYLE environment variable specifies a default style. if let Ok(style) = std::env::var("QUOTING_STYLE") { match match_quoting_style_name(style.as_str(), show_control) { - Some(qs) => return qs, + Some(pair) => return pair, None => eprintln!( "{}", translate!("ls-invalid-quoting-style", "program" => std::env::args().next().unwrap_or_else(|| "ls".to_string()), "style" => style.clone()) @@ -712,9 +748,9 @@ fn extract_quoting_style(options: &clap::ArgMatches, show_control: bool) -> Quot // By default, `ls` uses Shell escape quoting style when writing to a terminal file // descriptor and Literal otherwise. if stdout().is_terminal() { - QuotingStyle::SHELL_ESCAPE.show_control(show_control) + (QuotingStyle::SHELL_ESCAPE.show_control(show_control), None) } else { - QuotingStyle::Literal { show_control } + (QuotingStyle::Literal { show_control }, None) } } } @@ -970,7 +1006,7 @@ impl Config { !stdout().is_terminal() }; - let mut quoting_style = extract_quoting_style(options, show_control); + let (mut quoting_style, mut locale_quoting) = extract_quoting_style(options, show_control); let indicator_style = extract_indicator_style(options); // Only parse the value to "--time-style" if it will become relevant. let dired = options.get_flag(options::DIRED); @@ -1093,6 +1129,7 @@ impl Config { .unwrap_or(0) { quoting_style = QuotingStyle::Literal { show_control }; + locale_quoting = None; } let color = if needs_color { @@ -1156,6 +1193,7 @@ impl Config { block_size, width, quoting_style, + locale_quoting, indicator_style, time_format_recent, time_format_older, @@ -1358,10 +1396,12 @@ pub fn uu_app() -> Command { .help(translate!("ls-help-set-quoting-style")) .value_parser(ShortcutValueParser::new([ PossibleValue::new("literal"), + PossibleValue::new("locale"), PossibleValue::new("shell"), PossibleValue::new("shell-escape"), PossibleValue::new("shell-always"), PossibleValue::new("shell-escape-always"), + PossibleValue::new("clocale"), PossibleValue::new("c").alias("c-maybe"), PossibleValue::new("escape"), ])) @@ -2034,8 +2074,7 @@ fn show_dir_name( out: &mut BufWriter, config: &Config, ) -> std::io::Result<()> { - let escaped_name = - locale_aware_escape_dir_name(path_data.path().as_os_str(), config.quoting_style); + let escaped_name = escape_dir_name_with_locale(path_data.path().as_os_str(), config); let name = if config.hyperlink && !config.dired { create_hyperlink(&escaped_name, path_data) @@ -2047,6 +2086,67 @@ fn show_dir_name( write!(out, ":") } +fn escape_dir_name_with_locale(name: &OsStr, config: &Config) -> OsString { + if let Some(locale) = config.locale_quoting { + locale_quote(name, locale) + } else { + locale_aware_escape_dir_name(name, config.quoting_style) + } +} + +fn escape_name_with_locale(name: &OsStr, config: &Config) -> OsString { + if let Some(locale) = config.locale_quoting { + locale_quote(name, locale) + } else { + locale_aware_escape_name(name, config.quoting_style) + } +} + +fn locale_quote(name: &OsStr, style: LocaleQuoting) -> OsString { + let bytes = os_str_as_bytes_lossy(name); + let mut quoted = String::new(); + match style { + LocaleQuoting::Single => quoted.push('\''), + LocaleQuoting::Double => quoted.push('"'), + } + for &byte in bytes.as_ref() { + push_locale_byte(&mut quoted, byte, style); + } + match style { + LocaleQuoting::Single => quoted.push('\''), + LocaleQuoting::Double => quoted.push('"'), + } + OsString::from(quoted) +} + +fn push_locale_byte(buf: &mut String, byte: u8, style: LocaleQuoting) { + match (style, byte) { + (LocaleQuoting::Single, b'\'') => buf.push_str("'\\''"), + (LocaleQuoting::Double, b'"') => buf.push_str("\\\""), + (_, b'\\') => buf.push_str("\\\\"), + _ => push_basic_escape(buf, byte), + } +} + +fn push_basic_escape(buf: &mut String, byte: u8) { + match byte { + b'\x07' => buf.push_str("\\a"), + b'\x08' => buf.push_str("\\b"), + b'\t' => buf.push_str("\\t"), + b'\n' => buf.push_str("\\n"), + b'\x0b' => buf.push_str("\\v"), + b'\x0c' => buf.push_str("\\f"), + b'\r' => buf.push_str("\\r"), + b'\x1b' => buf.push_str("\\e"), + b'"' => buf.push('"'), + b'\'' => buf.push('\''), + b if (0x20..=0x7e).contains(&b) => buf.push(b as char), + _ => { + let _ = write!(buf, "\\{byte:03o}"); + } + } +} + // A struct to encapsulate state that is passed around from `list` functions. struct ListState<'a> { out: BufWriter, @@ -2541,7 +2641,7 @@ fn display_items( // option, print the security context to the left of the size column. let quoted = items.iter().any(|item| { - let name = locale_aware_escape_name(item.display_name(), config.quoting_style); + let name = escape_name_with_locale(item.display_name(), config); os_str_starts_with(&name, b"'") }); @@ -3187,7 +3287,7 @@ fn display_item_name( current_column: LazyCell usize + '_>>, ) -> OsString { // This is our return value. We start by `&path.display_name` and modify it along the way. - let mut name = locale_aware_escape_name(path.display_name(), config.quoting_style); + let mut name = escape_name_with_locale(path.display_name(), config); let is_wrap = |namelen: usize| config.width != 0 && *current_column + namelen > config.width.into(); @@ -3248,6 +3348,7 @@ fn display_item_name( // This makes extra system calls, but provides important information that // people run `ls -l --color` are very interested in. if let Some(style_manager) = &mut state.style_manager { + let escaped_target = escape_name_with_locale(target_path.as_os_str(), config); // We get the absolute path to be able to construct PathData with valid Metadata. // This is because relative symlinks will fail to get_metadata. let mut absolute_target = target_path.clone(); @@ -3257,30 +3358,31 @@ fn display_item_name( } } - let target_data = PathData::new(absolute_target, None, None, config, false); - - // If we have a symlink to a valid file, we use the metadata of said file. - // Because we use an absolute path, we can assume this is guaranteed to exist. - // Otherwise, we use path.md(), which will guarantee we color to the same - // color of non-existent symlinks according to style_for_path_with_metadata. - if path.metadata().is_none() && target_data.metadata().is_none() { - name.push(target_path); - } else { - name.push(color_name( - locale_aware_escape_name(target_path.as_os_str(), config.quoting_style), - path, - style_manager, - Some(&target_data), - is_wrap(name.len()), - )); + match fs::metadata(&absolute_target) { + Ok(_) => { + let target_data = + PathData::new(absolute_target, None, None, config, false); + name.push(color_name( + escaped_target, + &target_data, + style_manager, + None, + is_wrap(name.len()), + )); + } + Err(_) => { + name.push( + style_manager.apply_missing_target_style( + escaped_target, + is_wrap(name.len()), + ), + ); + } } } else { // If no coloring is required, we just use target as is. // Apply the right quoting - name.push(locale_aware_escape_name( - target_path.as_os_str(), - config.quoting_style, - )); + name.push(escape_name_with_locale(target_path.as_os_str(), config)); } } Err(err) => { From f0f5abc5b78b8905e8b3b56aa927a5ab945c9a20 Mon Sep 17 00:00:00 2001 From: karanabe <152078880+karanabe@users.noreply.github.com> Date: Fri, 14 Nov 2025 00:21:34 +0900 Subject: [PATCH 129/425] test: cover GNU dangling symlink cases Add seven ls tests mirroring GNU tests/ls/ls-misc.pl sl-dangle3..9 to validate combinations of `ln=`, `or=` and `mi=` settings. The tests verify both file-level output and directory listings, including the `\x1b[m` reset requirement when `or=:` is set. Adjust existing Rust tests to GNU output. Existing color tests were updated to match GNU output. --- tests/by-util/test_ls.rs | 222 +++++++++++++++++++++++++++++++++++---- 1 file changed, 201 insertions(+), 21 deletions(-) diff --git a/tests/by-util/test_ls.rs b/tests/by-util/test_ls.rs index 38729d306..7a1edaa23 100644 --- a/tests/by-util/test_ls.rs +++ b/tests/by-util/test_ls.rs @@ -3,7 +3,7 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. // spell-checker:ignore (words) READMECAREFULLY birthtime doesntexist oneline somebackup lrwx somefile somegroup somehiddenbackup somehiddenfile tabsize aaaaaaaa bbbb cccc dddddddd ncccc neee naaaaa nbcdef nfffff dired subdired tmpfs mdir COLORTERM mexe bcdef mfoo timefile -// spell-checker:ignore (words) fakeroot setcap drwxr bcdlps +// spell-checker:ignore (words) fakeroot setcap drwxr bcdlps mdangling mentry #![allow( clippy::similar_names, clippy::too_many_lines, @@ -1446,31 +1446,213 @@ fn test_ls_long_dangling_symlink_color() { at.mkdir("dir1"); at.symlink_dir("foo", "dir1/dangling_symlink"); + let ls_colors = "ln=target:or=40:mi=34"; let result = ts .ucmd() + .env("LS_COLORS", ls_colors) .arg("-l") .arg("--color=always") .arg("dir1/dangling_symlink") .succeeds(); let stdout = result.stdout_str(); - // stdout contains output like in the below sequence. We match for the color i.e. 01;36 - // \x1b[0m\x1b[01;36mdir1/dangling_symlink\x1b[0m -> \x1b[01;36mfoo\x1b[0m - let color_regex = Regex::new(r"(\d\d;)\d\dm").unwrap(); - // colors_vec[0] contains the symlink color and style and colors_vec[1] contains the color and style of the file the - // symlink points to. - let colors_vec: Vec<_> = color_regex - .find_iter(stdout) - .map(|color| color.as_str()) - .collect(); + // Ensure dangling link name uses `or=` and target uses `mi=`. + let name_regex = + Regex::new(r"(?:\x1b\[[0-9;]*m)*\x1b\[([0-9;]*)mdir1/dangling_symlink\x1b\[0m").unwrap(); + let target_path = regex::escape(&at.plus_as_string("foo")); + let target_pattern = format!(r"(?:\x1b\[[0-9;]*m)*\x1b\[([0-9;]*)m{target_path}\x1b\[0m"); + let target_regex = Regex::new(&target_pattern).unwrap(); - assert_eq!(colors_vec[0], colors_vec[1]); - // constructs the string of file path with the color code - let symlink_color_name = colors_vec[0].to_owned() + "dir1/dangling_symlink\x1b"; - let target_color_name = colors_vec[1].to_owned() + at.plus_as_string("foo\x1b").as_str(); + let name_caps = name_regex + .captures(stdout) + .expect("failed to capture dangling symlink name color"); + let target_caps = target_regex + .captures(stdout) + .expect("failed to capture dangling target color"); - assert!(stdout.contains(&symlink_color_name)); - assert!(stdout.contains(&target_color_name)); + let name_color = name_caps.get(1).unwrap().as_str(); + let target_color = target_caps.get(1).unwrap().as_str(); + + assert_eq!(name_color, "40"); + assert_eq!(target_color, "34"); +} + +#[test] +/// Mirrors GNU `tests/ls/ls-misc.pl::sl-dangle3`. +fn test_ls_dangling_symlink_or_and_missing_colors() { + let ts = TestScenario::new(util_name!()); + let at = &ts.fixtures; + at.symlink_file("nowhere", "dangling"); + + let stdout = ts + .ucmd() + .env("LS_COLORS", "ln=target:or=40:mi=34") + .arg("-o") + .arg("--time-style=+:TIME:") + .arg("--color=always") + .arg("dangling") + .succeeds() + .stdout_str() + .to_string(); + + let color_regex = Regex::new( + r"\x1b\[0m\x1b\[(?P[0-9;]*)mdangling\x1b\[0m -> \x1b\[(?P[0-9;]*)m", + ) + .unwrap(); + let captures = color_regex + .captures(&stdout) + .expect("failed to capture dangling colors"); + + assert_eq!(captures.name("link").unwrap().as_str(), "40"); + assert_eq!(captures.name("target").unwrap().as_str(), "34"); +} + +#[test] +/// Mirrors GNU `tests/ls/ls-misc.pl::sl-dangle4`. +fn test_ls_dangling_symlink_ln_or_priority() { + let ts = TestScenario::new(util_name!()); + let at = &ts.fixtures; + at.symlink_file("nowhere", "dangling"); + + let stdout = ts + .ucmd() + .env("LS_COLORS", "ln=34:mi=35:or=36") + .arg("-o") + .arg("--time-style=+:TIME:") + .arg("--color=always") + .arg("dangling") + .succeeds() + .stdout_str() + .to_string(); + + let color_regex = Regex::new( + r"\x1b\[0m\x1b\[(?P[0-9;]*)mdangling\x1b\[0m -> \x1b\[(?P[0-9;]*)m", + ) + .unwrap(); + let captures = color_regex + .captures(&stdout) + .expect("failed to capture dangling colors"); + assert_eq!(captures.name("link").unwrap().as_str(), "36"); + assert_eq!(captures.name("target").unwrap().as_str(), "35"); +} + +#[test] +/// Mirrors GNU `tests/ls/ls-misc.pl::sl-dangle5`. +fn test_ls_dangling_symlink_ln_and_missing_colors() { + let ts = TestScenario::new(util_name!()); + let at = &ts.fixtures; + at.symlink_file("nowhere", "dangling"); + + let stdout = ts + .ucmd() + .env("LS_COLORS", "ln=34:mi=35") + .arg("-o") + .arg("--time-style=+:TIME:") + .arg("--color=always") + .arg("dangling") + .succeeds() + .stdout_str() + .to_string(); + + let color_regex = Regex::new( + r"\x1b\[0m\x1b\[(?P[0-9;]*)mdangling\x1b\[0m -> \x1b\[(?P[0-9;]*)m", + ) + .unwrap(); + let captures = color_regex + .captures(&stdout) + .expect("failed to capture dangling colors"); + assert_eq!(captures.name("link").unwrap().as_str(), "34"); + assert_eq!(captures.name("target").unwrap().as_str(), "35"); +} + +#[test] +/// Mirrors GNU `tests/ls/ls-misc.pl::sl-dangle7`. +fn test_ls_dangling_symlink_blank_or_still_emits_reset() { + let ts = TestScenario::new(util_name!()); + let at = &ts.fixtures; + at.symlink_file("nowhere", "dangling"); + + let stdout = ts + .ucmd() + .env("LS_COLORS", "ln=target:or=:ex=:") + .arg("--color=always") + .arg("dangling") + .succeeds() + .stdout_str() + .to_string(); + + assert!( + stdout.contains("\u{1b}[0m\u{1b}[mdangling\u{1b}[0m"), + "unexpected output: {stdout:?}" + ); +} + +#[test] +/// Mirrors GNU `tests/ls/ls-misc.pl::sl-dangle9`. +fn test_ls_dangling_symlink_blank_or_in_directory_listing() { + let ts = TestScenario::new(util_name!()); + let at = &ts.fixtures; + at.mkdir("dir"); + at.symlink_file("nowhere", "dir/entry"); + + let stdout = ts + .ucmd() + .env("LS_COLORS", "ln=target:or=:ex=:") + .arg("--color=always") + .arg("dir") + .succeeds() + .stdout_str() + .to_string(); + + assert!( + stdout.contains("\u{1b}[0m\u{1b}[mentry\u{1b}[0m"), + "unexpected output: {stdout:?}" + ); +} + +#[test] +/// Mirrors GNU `tests/ls/ls-misc.pl::sl-dangle8`. +fn test_ls_dangling_symlink_uses_ln_when_or_blank() { + let ts = TestScenario::new(util_name!()); + let at = &ts.fixtures; + at.symlink_file("nowhere", "dangling"); + + let stdout = ts + .ucmd() + .env("LS_COLORS", "ln=1;36:or=:") + .arg("--color=always") + .arg("dangling") + .succeeds() + .stdout_str() + .to_string(); + + assert!( + stdout.contains("\u{1b}[0m\u{1b}[1;36mdangling\u{1b}[0m"), + "unexpected output: {stdout:?}" + ); +} + +#[test] +/// Mirrors GNU `tests/ls/ls-misc.pl::sl-dangle6`. +fn test_ls_directory_dangling_symlink_uses_ln_when_or_blank() { + let ts = TestScenario::new(util_name!()); + let at = &ts.fixtures; + at.mkdir("dir"); + at.symlink_file("nowhere", "dir/entry"); + + let stdout = ts + .ucmd() + .env("LS_COLORS", "ln=1;36:or=:") + .arg("--color=always") + .arg("dir") + .succeeds() + .stdout_str() + .to_string(); + + assert!( + stdout.contains("\u{1b}[0m\u{1b}[1;36mentry\u{1b}[0m"), + "unexpected output: {stdout:?}" + ); } #[test] @@ -5862,8 +6044,7 @@ fn test_ls_color_norm() { .stdout_contains(expected); // uncolored ordinary files that do _not_ inherit from NORMAL. - let expected = - "\x1b[0m\x1b[07mnorm \x1b[0mno_color\x1b[0m\n\x1b[07mnorm \x1b[0m\x1b[01;32mexe\x1b[0m\n"; // spell-checker:disable-line + let expected = "\x1b[0m\x1b[07mnorm \x1b[0m\x1b[mno_color\x1b[0m\n\x1b[07mnorm \x1b[0m\x1b[01;32mexe\x1b[0m\n"; // spell-checker:disable-line scene .ucmd() .env("LS_COLORS", format!("{colors}:fi=")) @@ -5876,8 +6057,7 @@ fn test_ls_color_norm() { .stdout_str_apply(strip) .stdout_contains(expected); - let expected = - "\x1b[0m\x1b[07mnorm \x1b[0mno_color\x1b[0m\n\x1b[07mnorm \x1b[0m\x1b[01;32mexe\x1b[0m\n"; // spell-checker:disable-line + let expected = "\x1b[0m\x1b[07mnorm \x1b[0m\x1b[00mno_color\x1b[0m\n\x1b[07mnorm \x1b[0m\x1b[01;32mexe\x1b[0m\n"; // spell-checker:disable-line scene .ucmd() .env("LS_COLORS", format!("{colors}:fi=0")) @@ -6498,7 +6678,7 @@ fn test_f_overrides_sort_flags() { // Create files with different sizes for predictable sort order at.write("small.txt", "a"); // 1 byte - at.write("medium.txt", "bb"); // 2 bytes + at.write("medium.txt", "bb"); // 2 bytes at.write("large.txt", "ccc"); // 3 bytes // Get baseline outputs (include -a to match -f behavior which shows all files) From 3b0567bfc308e26530dc899e120e17a023a3b66c Mon Sep 17 00:00:00 2001 From: karanabe <152078880+karanabe@users.noreply.github.com> Date: Fri, 14 Nov 2025 02:14:00 +0900 Subject: [PATCH 130/425] fix(ls): align LS_COLORS handling with GNU MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Colors: make StyleManager rely on lscolors’ explicit-style flags, avoid unnecessary metadata/stat probes, and ensure ln=target, or=, mi= honor GNU coloring semantics. Tests: update test_ls_color_norm expectations so the fi= and fi=0 cases now match GNU ls output, ensuring the new color handling is verified. --- src/uu/ls/src/colors.rs | 168 +++++++++++++++++++++++++++++---------- tests/by-util/test_ls.rs | 6 +- 2 files changed, 132 insertions(+), 42 deletions(-) diff --git a/src/uu/ls/src/colors.rs b/src/uu/ls/src/colors.rs index a7e7e92c1..162baf301 100644 --- a/src/uu/ls/src/colors.rs +++ b/src/uu/ls/src/colors.rs @@ -212,7 +212,7 @@ impl<'a> StyleManager<'a> { pub(crate) fn has_indicator_style(&self, indicator: Indicator) -> bool { self.indicator_codes.contains_key(&indicator) - || self.colors.style_for_indicator(indicator).is_some() + || self.colors.has_explicit_style_for(indicator) } pub(crate) fn apply_orphan_link_style(&mut self, name: OsString, wrap: bool) -> OsString { @@ -251,6 +251,9 @@ impl<'a> StyleManager<'a> { name: OsString, wrap: bool, ) -> Option { + if !self.ln_color_from_target { + return None; + } if path.must_dereference && path.metadata().is_none() { return None; } @@ -263,17 +266,13 @@ impl<'a> StyleManager<'a> { match fs::metadata(&target) { Ok(metadata) => { - if self.ln_color_from_target { - let style = self - .colors - .style_for_path_with_metadata(&target, Some(&metadata)); - Some(self.apply_style(style, None, name, wrap)) - } else { - None - } + let style = self + .colors + .style_for_path_with_metadata(&target, Some(&metadata)); + Some(self.apply_style(style, None, name, wrap)) } Err(_) => { - if self.ln_color_from_target { + if self.has_indicator_style(Indicator::OrphanedSymbolicLink) { Some(self.apply_orphan_link_style(name, wrap)) } else { None @@ -282,99 +281,110 @@ impl<'a> StyleManager<'a> { } } - fn indicator_has(&self, indicator: Indicator) -> bool { - self.indicator_codes.contains_key(&indicator) - } - fn indicator_for_raw_code(&self, path: &PathData) -> Option { if self.indicator_codes.is_empty() { return None; } - let exists = path.path().exists(); + let mut existence_cache: Option = None; + let mut entry_exists = + || -> bool { *existence_cache.get_or_insert_with(|| path.path().exists()) }; + let Some(file_type) = path.file_type() else { - if self.indicator_has(Indicator::MissingFile) && !exists { + if self.has_indicator_style(Indicator::MissingFile) && !entry_exists() { return Some(Indicator::MissingFile); } return None; }; if file_type.is_symlink() { - let orphan_style = self.indicator_codes.get(&Indicator::OrphanedSymbolicLink); - let orphan_has_color = orphan_style.map(|s| !s.is_empty()).unwrap_or(false); - if !exists && (orphan_has_color || self.ln_color_from_target) { - return Some(Indicator::OrphanedSymbolicLink); + let orphan_enabled = self.has_indicator_style(Indicator::OrphanedSymbolicLink); + let missing_enabled = self.has_indicator_style(Indicator::MissingFile); + let needs_target_state = self.ln_color_from_target || orphan_enabled; + let target_missing = needs_target_state && !entry_exists(); + + if target_missing { + let orphan_raw = self.indicator_codes.get(&Indicator::OrphanedSymbolicLink); + let orphan_raw_is_empty = orphan_raw.is_some_and(|value| value.is_empty()); + if orphan_enabled && (!orphan_raw_is_empty || self.ln_color_from_target) { + return Some(Indicator::OrphanedSymbolicLink); + } + if self.ln_color_from_target && missing_enabled { + return Some(Indicator::MissingFile); + } } - if self.indicator_has(Indicator::SymbolicLink) { + if self.has_indicator_style(Indicator::SymbolicLink) { return Some(Indicator::SymbolicLink); } - if !exists && self.indicator_has(Indicator::MissingFile) { - return Some(Indicator::MissingFile); - } return None; } - if self.indicator_has(Indicator::MissingFile) && !exists { + + if self.has_indicator_style(Indicator::MissingFile) && !entry_exists() { return Some(Indicator::MissingFile); } if file_type.is_file() { #[cfg(unix)] - { + if self.needs_file_metadata() { if let Some(metadata) = path.metadata() { let mode = metadata.mode(); - if self.indicator_has(Indicator::Setuid) && mode & 0o4000 != 0 { + if self.has_indicator_style(Indicator::Setuid) && mode & 0o4000 != 0 { return Some(Indicator::Setuid); } - if self.indicator_has(Indicator::Setgid) && mode & 0o2000 != 0 { + if self.has_indicator_style(Indicator::Setgid) && mode & 0o2000 != 0 { return Some(Indicator::Setgid); } - if self.indicator_has(Indicator::ExecutableFile) && mode & 0o0111 != 0 { + if self.has_indicator_style(Indicator::ExecutableFile) && mode & 0o0111 != 0 { return Some(Indicator::ExecutableFile); } - if self.indicator_has(Indicator::MultipleHardLinks) && metadata.nlink() > 1 { + if self.has_indicator_style(Indicator::MultipleHardLinks) + && metadata.nlink() > 1 + { return Some(Indicator::MultipleHardLinks); } } } - if self.indicator_has(Indicator::RegularFile) { + if self.has_indicator_style(Indicator::RegularFile) { return Some(Indicator::RegularFile); } } else if file_type.is_dir() { #[cfg(unix)] - { + if self.needs_dir_metadata() { if let Some(metadata) = path.metadata() { let mode = metadata.mode(); - if self.indicator_has(Indicator::StickyAndOtherWritable) + if self.has_indicator_style(Indicator::StickyAndOtherWritable) && mode & 0o1002 == 0o1002 { return Some(Indicator::StickyAndOtherWritable); } - if self.indicator_has(Indicator::OtherWritable) && mode & 0o0002 != 0 { + if self.has_indicator_style(Indicator::OtherWritable) && mode & 0o0002 != 0 { return Some(Indicator::OtherWritable); } - if self.indicator_has(Indicator::Sticky) && mode & 0o1000 != 0 { + if self.has_indicator_style(Indicator::Sticky) && mode & 0o1000 != 0 { return Some(Indicator::Sticky); } } } - if self.indicator_has(Indicator::Directory) { + if self.has_indicator_style(Indicator::Directory) { return Some(Indicator::Directory); } } else { #[cfg(unix)] { - if file_type.is_fifo() && self.indicator_has(Indicator::FIFO) { + if file_type.is_fifo() && self.has_indicator_style(Indicator::FIFO) { return Some(Indicator::FIFO); } - if file_type.is_socket() && self.indicator_has(Indicator::Socket) { + if file_type.is_socket() && self.has_indicator_style(Indicator::Socket) { return Some(Indicator::Socket); } - if file_type.is_block_device() && self.indicator_has(Indicator::BlockDevice) { + if file_type.is_block_device() && self.has_indicator_style(Indicator::BlockDevice) { return Some(Indicator::BlockDevice); } - if file_type.is_char_device() && self.indicator_has(Indicator::CharacterDevice) { + if file_type.is_char_device() + && self.has_indicator_style(Indicator::CharacterDevice) + { return Some(Indicator::CharacterDevice); } } @@ -382,6 +392,21 @@ impl<'a> StyleManager<'a> { None } + + #[cfg(unix)] + fn needs_file_metadata(&self) -> bool { + self.has_indicator_style(Indicator::Setuid) + || self.has_indicator_style(Indicator::Setgid) + || self.has_indicator_style(Indicator::ExecutableFile) + || self.has_indicator_style(Indicator::MultipleHardLinks) + } + + #[cfg(unix)] + fn needs_dir_metadata(&self) -> bool { + self.has_indicator_style(Indicator::StickyAndOtherWritable) + || self.has_indicator_style(Indicator::OtherWritable) + || self.has_indicator_style(Indicator::Sticky) + } } /// Colors the provided name based on the style determined for the given path @@ -457,6 +482,17 @@ fn parse_indicator_codes() -> (HashMap, bool) { ln_color_from_target = true; continue; } + if indicator_value_is_disabled(indicator, value) { + if value.is_empty() + && matches!( + indicator, + Indicator::OrphanedSymbolicLink | Indicator::MissingFile + ) + { + indicator_codes.insert(indicator, String::new()); + } + continue; + } indicator_codes.insert(indicator, canonicalize_indicator_value(value)); } } @@ -475,3 +511,55 @@ fn canonicalize_indicator_value(value: &str) -> String { value.to_string() } } + +fn indicator_value_is_disabled(indicator: Indicator, value: &str) -> bool { + if value.is_empty() { + !matches!( + indicator, + Indicator::OrphanedSymbolicLink | Indicator::MissingFile + ) + } else { + value.chars().all(|c| c == '0') + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn style_manager( + colors: &LsColors, + indicator_codes: HashMap, + ) -> StyleManager<'_> { + StyleManager { + current_style: None, + initial_reset_is_done: false, + colors, + indicator_codes, + ln_color_from_target: false, + } + } + + #[test] + fn has_indicator_style_ignores_fallback_styles() { + let colors = LsColors::from_string("ex=00:fi=32"); + let manager = style_manager(&colors, HashMap::new()); + assert!(!manager.has_indicator_style(Indicator::ExecutableFile)); + } + + #[test] + fn has_indicator_style_detects_explicit_styles() { + let colors = LsColors::from_string("ex=01;32"); + let manager = style_manager(&colors, HashMap::new()); + assert!(manager.has_indicator_style(Indicator::ExecutableFile)); + } + + #[test] + fn has_indicator_style_detects_raw_codes() { + let colors = LsColors::empty(); + let mut indicator_codes = HashMap::new(); + indicator_codes.insert(Indicator::Directory, "01;34".to_string()); + let manager = style_manager(&colors, indicator_codes); + assert!(manager.has_indicator_style(Indicator::Directory)); + } +} diff --git a/tests/by-util/test_ls.rs b/tests/by-util/test_ls.rs index 7a1edaa23..0ee647987 100644 --- a/tests/by-util/test_ls.rs +++ b/tests/by-util/test_ls.rs @@ -6044,7 +6044,8 @@ fn test_ls_color_norm() { .stdout_contains(expected); // uncolored ordinary files that do _not_ inherit from NORMAL. - let expected = "\x1b[0m\x1b[07mnorm \x1b[0m\x1b[mno_color\x1b[0m\n\x1b[07mnorm \x1b[0m\x1b[01;32mexe\x1b[0m\n"; // spell-checker:disable-line + let expected = + "\x1b[0m\x1b[07mnorm \x1b[0mno_color\x1b[0m\n\x1b[07mnorm \x1b[0m\x1b[01;32mexe\x1b[0m\n"; // spell-checker:disable-line scene .ucmd() .env("LS_COLORS", format!("{colors}:fi=")) @@ -6057,7 +6058,8 @@ fn test_ls_color_norm() { .stdout_str_apply(strip) .stdout_contains(expected); - let expected = "\x1b[0m\x1b[07mnorm \x1b[0m\x1b[00mno_color\x1b[0m\n\x1b[07mnorm \x1b[0m\x1b[01;32mexe\x1b[0m\n"; // spell-checker:disable-line + let expected = + "\x1b[0m\x1b[07mnorm \x1b[0mno_color\x1b[0m\n\x1b[07mnorm \x1b[0m\x1b[01;32mexe\x1b[0m\n"; // spell-checker:disable-line scene .ucmd() .env("LS_COLORS", format!("{colors}:fi=0")) From 8eaaeddeeafd2298cb1b76bf78a0013401eb9400 Mon Sep 17 00:00:00 2001 From: karanabe <152078880+karanabe@users.noreply.github.com> Date: Fri, 14 Nov 2025 20:56:05 +0900 Subject: [PATCH 131/425] lint(ls): clarify ANSI escape handling - factor ANSI escape literals into named constants - document why we bypass LsColors fallbacks when applying raw SGR codes --- src/uu/ls/src/colors.rs | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/src/uu/ls/src/colors.rs b/src/uu/ls/src/colors.rs index 162baf301..62ebd5b5a 100644 --- a/src/uu/ls/src/colors.rs +++ b/src/uu/ls/src/colors.rs @@ -11,6 +11,13 @@ use std::fs::{self, Metadata}; #[cfg(unix)] use std::os::unix::fs::{FileTypeExt, MetadataExt}; +/// ANSI CSI (Control Sequence Introducer) +const ANSI_CSI: &str = "\x1b["; +const ANSI_SGR_END: &str = "m"; +const ANSI_RESET: &str = "\x1b[0m"; +const ANSI_CLEAR_EOL: &str = "\x1b[K"; +const EMPTY_STYLE: &str = "\x1b[m"; + /// We need this struct to be able to store the previous style. /// This because we need to check the previous value in case we don't need /// the reset @@ -56,6 +63,9 @@ impl<'a> StyleManager<'a> { } if let Some(path) = path { + // Fast-path: apply LS_COLORS raw SGR codes verbatim, + // bypassing LsColors fallbacks so the entry from LS_COLORS + // is honored exactly as specified. if let Some(indicator) = self.indicator_for_raw_code(path) { let should_skip = indicator == Indicator::SymbolicLink && self.ln_color_from_target @@ -67,9 +77,9 @@ impl<'a> StyleManager<'a> { return self.apply_empty_style(name, wrap); } style_code.push_str(self.reset(!self.initial_reset_is_done)); - style_code.push_str("\x1b["); + style_code.push_str(ANSI_CSI); style_code.push_str(&raw); - style_code.push('m'); + style_code.push_str(ANSI_SGR_END); applied_raw_code = true; self.current_style = None; force_suffix_reset = true; @@ -103,7 +113,7 @@ impl<'a> StyleManager<'a> { // scroll up in order to print new text in this situation if the clear // to eol code is not present the background of the text would stretch // till the end of line - let clear_to_eol = if wrap { "\x1b[K" } else { "" }; + let clear_to_eol = if wrap { ANSI_CLEAR_EOL } else { "" }; let mut ret: OsString = style_code.into(); ret.push(name); @@ -124,7 +134,7 @@ impl<'a> StyleManager<'a> { if self.current_style.is_some() || force { self.initial_reset_is_done = true; self.current_style = None; - return "\x1b[0m"; + return ANSI_RESET; } "" } @@ -193,15 +203,15 @@ impl<'a> StyleManager<'a> { let mut style_code = String::new(); style_code.push_str(self.reset(!self.initial_reset_is_done)); - style_code.push_str("\x1b["); + style_code.push_str(ANSI_CSI); style_code.push_str(&raw); - style_code.push('m'); + style_code.push_str(ANSI_SGR_END); let mut ret: OsString = style_code.into(); ret.push(name); ret.push(self.reset(true)); if wrap { - ret.push("\x1b[K"); + ret.push(ANSI_CLEAR_EOL); } ret } else { @@ -234,13 +244,13 @@ impl<'a> StyleManager<'a> { fn apply_empty_style(&mut self, name: OsString, wrap: bool) -> OsString { let mut style_code = String::new(); style_code.push_str(self.reset(!self.initial_reset_is_done)); - style_code.push_str("\x1b[m"); + style_code.push_str(EMPTY_STYLE); let mut ret: OsString = style_code.into(); ret.push(name); ret.push(self.reset(true)); if wrap { - ret.push("\x1b[K"); + ret.push(ANSI_CLEAR_EOL); } ret } From 8b03f185a6b55500efd5841c1bf9b2a08b9fd707 Mon Sep 17 00:00:00 2001 From: karanabe <152078880+karanabe@users.noreply.github.com> Date: Fri, 14 Nov 2025 21:38:51 +0900 Subject: [PATCH 132/425] refactor(ls): structure quoting style match --- src/uu/ls/src/ls.rs | 75 ++++++++++++++++++++++++++++----------------- 1 file changed, 47 insertions(+), 28 deletions(-) diff --git a/src/uu/ls/src/ls.rs b/src/uu/ls/src/ls.rs index 2d1ee6b32..84f21cc30 100644 --- a/src/uu/ls/src/ls.rs +++ b/src/uu/ls/src/ls.rs @@ -663,43 +663,62 @@ fn extract_hyperlink(options: &clap::ArgMatches) -> bool { /// # Returns /// /// * An option with None if the style string is invalid, or a `QuotingStyle` wrapped in `Some`. +struct QuotingStyleSpec { + style: QuotingStyle, + fixed_control: bool, + locale: Option, +} + +impl QuotingStyleSpec { + fn new(style: QuotingStyle) -> Self { + Self { + style, + fixed_control: false, + locale: None, + } + } + + fn with_locale(style: QuotingStyle, locale: LocaleQuoting) -> Self { + Self { + style, + fixed_control: true, + locale: Some(locale), + } + } +} + fn match_quoting_style_name( style: &str, show_control: bool, ) -> Option<(QuotingStyle, Option)> { - let (qs, fixed_control, locale) = match style { - "literal" => ( - QuotingStyle::Literal { + let spec = match style { + "literal" => QuotingStyleSpec::new(QuotingStyle::Literal { + show_control: false, + }), + "shell" => QuotingStyleSpec::new(QuotingStyle::SHELL), + "shell-always" => QuotingStyleSpec::new(QuotingStyle::SHELL_QUOTE), + "shell-escape" => QuotingStyleSpec::new(QuotingStyle::SHELL_ESCAPE), + "shell-escape-always" => QuotingStyleSpec::new(QuotingStyle::SHELL_ESCAPE_QUOTE), + "c" => QuotingStyleSpec::new(QuotingStyle::C_DOUBLE), + "escape" => QuotingStyleSpec::new(QuotingStyle::C_NO_QUOTES), + "locale" => QuotingStyleSpec { + style: QuotingStyle::Literal { show_control: false, }, - false, - None, - ), - "shell" => (QuotingStyle::SHELL, false, None), - "shell-always" => (QuotingStyle::SHELL_QUOTE, false, None), - "shell-escape" => (QuotingStyle::SHELL_ESCAPE, false, None), - "shell-escape-always" => (QuotingStyle::SHELL_ESCAPE_QUOTE, false, None), - "c" => (QuotingStyle::C_DOUBLE, false, None), - "escape" => (QuotingStyle::C_NO_QUOTES, false, None), - "locale" => ( - QuotingStyle::Literal { - show_control: false, - }, - true, - Some(LocaleQuoting::Single), - ), - "clocale" => (QuotingStyle::C_DOUBLE, true, Some(LocaleQuoting::Double)), + fixed_control: true, + locale: Some(LocaleQuoting::Single), + }, + "clocale" => QuotingStyleSpec::with_locale(QuotingStyle::C_DOUBLE, LocaleQuoting::Double), _ => return None, }; - Some(( - if fixed_control { - qs - } else { - qs.show_control(show_control) - }, - locale, - )) + let style = if spec.fixed_control { + spec.style + } else { + spec.style.show_control(show_control) + }; + + Some((style, spec.locale)) } /// Extracts the quoting style to use based on the options provided. From 788c0e80f36796d1e53a064c703b8f1252fd73a4 Mon Sep 17 00:00:00 2001 From: karanabe <152078880+karanabe@users.noreply.github.com> Date: Wed, 7 Jan 2026 21:52:48 +0900 Subject: [PATCH 133/425] ls: validate LS_COLORS and refactor styles Add GNU-like LS_COLORS validation and disable color output on parse errors with warnings. Refactor raw style application and fallback handling to reduce complexity and document empty entries. De-duplicate locale escaping and add warning strings for LS_COLORS errors. --- src/uu/ls/locales/en-US.ftl | 2 + src/uu/ls/locales/fr-FR.ftl | 2 + src/uu/ls/src/colors.rs | 291 ++++++++++++++++++++++++++++++------ src/uu/ls/src/ls.rs | 35 ++++- 4 files changed, 280 insertions(+), 50 deletions(-) diff --git a/src/uu/ls/locales/en-US.ftl b/src/uu/ls/locales/en-US.ftl index d5fc32b4f..004243c5e 100644 --- a/src/uu/ls/locales/en-US.ftl +++ b/src/uu/ls/locales/en-US.ftl @@ -123,6 +123,8 @@ ls-invalid-quoting-style = {$program}: Ignoring invalid value of environment var ls-invalid-columns-width = ignoring invalid width in environment variable COLUMNS: {$width} ls-invalid-ignore-pattern = Invalid pattern for ignore: {$pattern} ls-invalid-hide-pattern = Invalid pattern for hide: {$pattern} +ls-warning-unrecognized-ls-colors-prefix = unrecognized prefix: {$prefix} +ls-warning-unparsable-ls-colors = unparsable value for LS_COLORS environment variable ls-total = total {$size} # Security context warnings diff --git a/src/uu/ls/locales/fr-FR.ftl b/src/uu/ls/locales/fr-FR.ftl index 552e4095f..0ae8b06c9 100644 --- a/src/uu/ls/locales/fr-FR.ftl +++ b/src/uu/ls/locales/fr-FR.ftl @@ -123,4 +123,6 @@ ls-invalid-quoting-style = {$program} : Ignorer la valeur invalide de la variabl ls-invalid-columns-width = ignorer la largeur invalide dans la variable d'environnement COLUMNS : {$width} ls-invalid-ignore-pattern = Motif invalide pour ignore : {$pattern} ls-invalid-hide-pattern = Motif invalide pour hide : {$pattern} +ls-warning-unrecognized-ls-colors-prefix = préfixe non reconnu : {$prefix} +ls-warning-unparsable-ls-colors = valeur illisible pour la variable d'environnement LS_COLORS ls-total = total {$size} diff --git a/src/uu/ls/src/colors.rs b/src/uu/ls/src/colors.rs index 62ebd5b5a..b8c1da96b 100644 --- a/src/uu/ls/src/colors.rs +++ b/src/uu/ls/src/colors.rs @@ -18,6 +18,11 @@ const ANSI_RESET: &str = "\x1b[0m"; const ANSI_CLEAR_EOL: &str = "\x1b[K"; const EMPTY_STYLE: &str = "\x1b[m"; +enum RawIndicatorStyle { + Empty, + Code(String), +} + /// We need this struct to be able to store the previous style. /// This because we need to check the previous value in case we don't need /// the reset @@ -66,46 +71,24 @@ impl<'a> StyleManager<'a> { // Fast-path: apply LS_COLORS raw SGR codes verbatim, // bypassing LsColors fallbacks so the entry from LS_COLORS // is honored exactly as specified. - if let Some(indicator) = self.indicator_for_raw_code(path) { - let should_skip = indicator == Indicator::SymbolicLink - && self.ln_color_from_target - && path.path().exists(); - - if !should_skip { - if let Some(raw) = self.indicator_codes.get(&indicator).cloned() { - if raw.is_empty() { - return self.apply_empty_style(name, wrap); - } - style_code.push_str(self.reset(!self.initial_reset_is_done)); - style_code.push_str(ANSI_CSI); - style_code.push_str(&raw); - style_code.push_str(ANSI_SGR_END); - applied_raw_code = true; - self.current_style = None; - force_suffix_reset = true; - } + match self.raw_indicator_style_for_path(path) { + Some(RawIndicatorStyle::Empty) => { + // An explicit empty entry (e.g. "or=") disables coloring and + // bypasses fallbacks, matching GNU ls behavior. + return self.apply_empty_style(name, wrap); } + Some(RawIndicatorStyle::Code(raw)) => { + style_code.push_str(&self.build_raw_style_code(&raw)); + applied_raw_code = true; + self.current_style = None; + force_suffix_reset = true; + } + None => {} } } if !applied_raw_code { - if let Some(new_style) = new_style { - // we only need to apply a new style if it's not the same as the current - // style for example if normal is the current style and a file with - // normal style is to be printed we could skip printing new color - // codes - if !self.is_current_style(new_style) { - style_code.push_str(self.reset(!self.initial_reset_is_done)); - style_code.push_str(&self.get_style_code(new_style)); - } - } - // if new style is None and current style is Normal we should reset it - else if matches!(self.get_normal_style().copied(), Some(norm_style) if self.is_current_style(&norm_style)) - { - style_code.push_str(self.reset(false)); - // even though this is an unnecessary reset for gnu compatibility we allow it here - force_suffix_reset = true; - } + self.append_style_code_for_style(new_style, &mut style_code, &mut force_suffix_reset); } // we need this clear to eol code in some terminals, for instance if the @@ -122,6 +105,58 @@ impl<'a> StyleManager<'a> { ret } + fn raw_indicator_style_for_path(&self, path: &PathData) -> Option { + let indicator = self.indicator_for_raw_code(path)?; + let should_skip = indicator == Indicator::SymbolicLink + && self.ln_color_from_target + && path.path().exists(); + + if should_skip { + return None; + } + + let raw = self.indicator_codes.get(&indicator)?; + if raw.is_empty() { + Some(RawIndicatorStyle::Empty) + } else { + Some(RawIndicatorStyle::Code(raw.clone())) + } + } + + fn build_raw_style_code(&mut self, raw: &str) -> String { + let mut style_code = String::new(); + style_code.push_str(self.reset(!self.initial_reset_is_done)); + style_code.push_str(ANSI_CSI); + style_code.push_str(raw); + style_code.push_str(ANSI_SGR_END); + style_code + } + + fn append_style_code_for_style( + &mut self, + new_style: Option<&Style>, + style_code: &mut String, + force_suffix_reset: &mut bool, + ) { + if let Some(new_style) = new_style { + // we only need to apply a new style if it's not the same as the current + // style for example if normal is the current style and a file with + // normal style is to be printed we could skip printing new color + // codes + if !self.is_current_style(new_style) { + style_code.push_str(self.reset(!self.initial_reset_is_done)); + style_code.push_str(&self.get_style_code(new_style)); + } + } + // if new style is None and current style is Normal we should reset it + else if matches!(self.get_normal_style().copied(), Some(norm_style) if self.is_current_style(&norm_style)) + { + style_code.push_str(self.reset(false)); + // even though this is an unnecessary reset for gnu compatibility we allow it here + *force_suffix_reset = true; + } + } + /// Resets the current style and returns the default ANSI reset code to /// reset all text formatting attributes. If `force` is true, the reset is /// done even if the reset has been applied before. @@ -201,13 +236,7 @@ impl<'a> StyleManager<'a> { return self.apply_empty_style(name, wrap); } - let mut style_code = String::new(); - style_code.push_str(self.reset(!self.initial_reset_is_done)); - style_code.push_str(ANSI_CSI); - style_code.push_str(&raw); - style_code.push_str(ANSI_SGR_END); - - let mut ret: OsString = style_code.into(); + let mut ret: OsString = self.build_raw_style_code(&raw).into(); ret.push(name); ret.push(self.reset(true)); if wrap { @@ -474,10 +503,188 @@ pub(crate) fn color_name( style_manager.apply_style_based_on_metadata(path, md_option.as_ref(), name, wrap) } +#[derive(Debug)] +pub(crate) enum LsColorsParseError { + UnrecognizedPrefix(String), + InvalidSyntax, +} + +pub(crate) fn validate_ls_colors_env() -> Result<(), LsColorsParseError> { + let Ok(ls_colors) = env::var("LS_COLORS") else { + return Ok(()); + }; + + if ls_colors.is_empty() { + return Ok(()); + } + + validate_ls_colors(&ls_colors) +} + +fn validate_ls_colors(ls_colors: &str) -> Result<(), LsColorsParseError> { + let bytes = ls_colors.as_bytes(); + let mut idx = 0; + + while idx < bytes.len() { + match bytes[idx] { + b':' => { + idx += 1; + } + b'*' => { + idx += 1; + idx = parse_funky_string(bytes, idx, true)?; + if idx >= bytes.len() || bytes[idx] != b'=' { + return Err(LsColorsParseError::InvalidSyntax); + } + idx += 1; + idx = parse_funky_string(bytes, idx, false)?; + if idx < bytes.len() && bytes[idx] == b':' { + idx += 1; + } + } + _ => { + if idx + 1 >= bytes.len() { + return Err(LsColorsParseError::InvalidSyntax); + } + let label = [bytes[idx], bytes[idx + 1]]; + idx += 2; + if idx >= bytes.len() || bytes[idx] != b'=' { + return Err(LsColorsParseError::InvalidSyntax); + } + if !is_valid_ls_colors_prefix(label) { + let prefix = String::from_utf8_lossy(&label).into_owned(); + return Err(LsColorsParseError::UnrecognizedPrefix(prefix)); + } + idx += 1; + idx = parse_funky_string(bytes, idx, false)?; + if idx < bytes.len() && bytes[idx] == b':' { + idx += 1; + } + } + } + } + + Ok(()) +} + +fn parse_funky_string( + bytes: &[u8], + mut idx: usize, + equals_end: bool, +) -> Result { + enum State { + Ground, + Backslash, + Octal(u8), + Hex(u8), + Caret, + } + + let mut state = State::Ground; + loop { + let byte = if idx < bytes.len() { bytes[idx] } else { 0 }; + match state { + State::Ground => match byte { + b':' | 0 => return Ok(idx), + b'=' if equals_end => return Ok(idx), + b'\\' => { + state = State::Backslash; + idx += 1; + } + b'^' => { + state = State::Caret; + idx += 1; + } + _ => idx += 1, + }, + State::Backslash => match byte { + 0 => return Err(LsColorsParseError::InvalidSyntax), + b'0'..=b'7' => { + state = State::Octal(byte - b'0'); + idx += 1; + } + b'x' | b'X' => { + state = State::Hex(0); + idx += 1; + } + b'a' | b'b' | b'e' | b'f' | b'n' | b'r' | b't' | b'v' | b'?' | b'_' => { + state = State::Ground; + idx += 1; + } + _ => { + state = State::Ground; + idx += 1; + } + }, + State::Octal(num) => match byte { + b'0'..=b'7' => { + state = State::Octal(num.wrapping_mul(8).wrapping_add(byte - b'0')); + idx += 1; + } + _ => state = State::Ground, + }, + State::Hex(num) => match byte { + b'0'..=b'9' => { + state = State::Hex(num.wrapping_mul(16).wrapping_add(byte - b'0')); + idx += 1; + } + b'a'..=b'f' => { + state = State::Hex(num.wrapping_mul(16).wrapping_add(byte - b'a' + 10)); + idx += 1; + } + b'A'..=b'F' => { + state = State::Hex(num.wrapping_mul(16).wrapping_add(byte - b'A' + 10)); + idx += 1; + } + _ => state = State::Ground, + }, + State::Caret => match byte { + b'@'..=b'~' | b'?' => { + state = State::Ground; + idx += 1; + } + _ => return Err(LsColorsParseError::InvalidSyntax), + }, + } + } +} + +fn is_valid_ls_colors_prefix(label: [u8; 2]) -> bool { + matches!( + label, + [b'l', b'c'] + | [b'r', b'c'] + | [b'e', b'c'] + | [b'r', b's'] + | [b'n', b'o'] + | [b'f', b'i'] + | [b'd', b'i'] + | [b'l', b'n'] + | [b'p', b'i'] + | [b's', b'o'] + | [b'b', b'd'] + | [b'c', b'd'] + | [b'm', b'i'] + | [b'o', b'r'] + | [b'e', b'x'] + | [b'd', b'o'] + | [b's', b'u'] + | [b's', b'g'] + | [b's', b't'] + | [b'o', b'w'] + | [b't', b'w'] + | [b'c', b'a'] + | [b'm', b'h'] + | [b'c', b'l'] + ) +} + fn parse_indicator_codes() -> (HashMap, bool) { let mut indicator_codes = HashMap::new(); let mut ln_color_from_target = false; + // LS_COLORS validity is checked before enabling color output, so parse + // entries directly here for raw indicator overrides. if let Ok(ls_colors) = env::var("LS_COLORS") { for entry in ls_colors.split(':') { if entry.is_empty() { diff --git a/src/uu/ls/src/ls.rs b/src/uu/ls/src/ls.rs index 84f21cc30..84016a1af 100644 --- a/src/uu/ls/src/ls.rs +++ b/src/uu/ls/src/ls.rs @@ -82,7 +82,7 @@ mod dired; use dired::{DiredOutput, is_dired_arg_present}; mod colors; use crate::options::QUOTING_STYLE; -use colors::{StyleManager, color_name}; +use colors::{LsColorsParseError, StyleManager, color_name, validate_ls_colors_env}; pub mod options { pub mod format { @@ -1151,6 +1151,22 @@ impl Config { locale_quoting = None; } + if needs_color { + if let Err(err) = validate_ls_colors_env() { + if let LsColorsParseError::UnrecognizedPrefix(prefix) = &err { + show_warning!( + "{}", + translate!( + "ls-warning-unrecognized-ls-colors-prefix", + "prefix" => prefix.quote() + ) + ); + } + show_warning!("{}", translate!("ls-warning-unparsable-ls-colors")); + needs_color = false; + } + } + let color = if needs_color { Some(LsColors::from_env().unwrap_or_default()) } else { @@ -2105,20 +2121,23 @@ fn show_dir_name( write!(out, ":") } -fn escape_dir_name_with_locale(name: &OsStr, config: &Config) -> OsString { +fn escape_with_locale(name: &OsStr, config: &Config, fallback: F) -> OsString +where + F: FnOnce(&OsStr, QuotingStyle) -> OsString, +{ if let Some(locale) = config.locale_quoting { locale_quote(name, locale) } else { - locale_aware_escape_dir_name(name, config.quoting_style) + fallback(name, config.quoting_style) } } +fn escape_dir_name_with_locale(name: &OsStr, config: &Config) -> OsString { + escape_with_locale(name, config, locale_aware_escape_dir_name) +} + fn escape_name_with_locale(name: &OsStr, config: &Config) -> OsString { - if let Some(locale) = config.locale_quoting { - locale_quote(name, locale) - } else { - locale_aware_escape_name(name, config.quoting_style) - } + escape_with_locale(name, config, locale_aware_escape_name) } fn locale_quote(name: &OsStr, style: LocaleQuoting) -> OsString { From 0b0968a5f73f53f327af6e6a8fd3d27245e102ee Mon Sep 17 00:00:00 2001 From: karanabe <152078880+karanabe@users.noreply.github.com> Date: Wed, 7 Jan 2026 23:04:50 +0900 Subject: [PATCH 134/425] Refactor LS_COLORS handling and comments --- src/uu/ls/src/colors.rs | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/src/uu/ls/src/colors.rs b/src/uu/ls/src/colors.rs index b8c1da96b..8eb4b7097 100644 --- a/src/uu/ls/src/colors.rs +++ b/src/uu/ls/src/colors.rs @@ -20,7 +20,7 @@ const EMPTY_STYLE: &str = "\x1b[m"; enum RawIndicatorStyle { Empty, - Code(String), + Code(Indicator), } /// We need this struct to be able to store the previous style. @@ -77,8 +77,8 @@ impl<'a> StyleManager<'a> { // bypasses fallbacks, matching GNU ls behavior. return self.apply_empty_style(name, wrap); } - Some(RawIndicatorStyle::Code(raw)) => { - style_code.push_str(&self.build_raw_style_code(&raw)); + Some(RawIndicatorStyle::Code(indicator)) => { + self.append_raw_style_code_for_indicator(indicator, &mut style_code); applied_raw_code = true; self.current_style = None; force_suffix_reset = true; @@ -119,7 +119,25 @@ impl<'a> StyleManager<'a> { if raw.is_empty() { Some(RawIndicatorStyle::Empty) } else { - Some(RawIndicatorStyle::Code(raw.clone())) + Some(RawIndicatorStyle::Code(indicator)) + } + } + + // Append a raw SGR sequence for a validated LS_COLORS indicator. + fn append_raw_style_code_for_indicator( + &mut self, + indicator: Indicator, + style_code: &mut String, + ) { + if !self.indicator_codes.contains_key(&indicator) { + return; + } + style_code.push_str(self.reset(!self.initial_reset_is_done)); + style_code.push_str(ANSI_CSI); + if let Some(raw) = self.indicator_codes.get(&indicator) { + debug_assert!(!raw.is_empty()); + style_code.push_str(raw); + style_code.push_str(ANSI_SGR_END); } } @@ -521,6 +539,7 @@ pub(crate) fn validate_ls_colors_env() -> Result<(), LsColorsParseError> { validate_ls_colors(&ls_colors) } +// GNU-like parser: ensure LS_COLORS has valid labels and well-formed escapes. fn validate_ls_colors(ls_colors: &str) -> Result<(), LsColorsParseError> { let bytes = ls_colors.as_bytes(); let mut idx = 0; @@ -567,6 +586,7 @@ fn validate_ls_colors(ls_colors: &str) -> Result<(), LsColorsParseError> { Ok(()) } +// Parse a value with GNU-compatible escape sequences, returning the index of the terminator. fn parse_funky_string( bytes: &[u8], mut idx: usize, From d4b0ab54db0548cd4b787bace289dd2cc4c6119b Mon Sep 17 00:00:00 2001 From: Daniel Hofstetter Date: Thu, 8 Jan 2026 14:27:25 +0100 Subject: [PATCH 135/425] ci: re-enable i686-musl platform --- .github/workflows/CICD.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/CICD.yml b/.github/workflows/CICD.yml index 2f8e1035b..0a6c9ab13 100644 --- a/.github/workflows/CICD.yml +++ b/.github/workflows/CICD.yml @@ -619,9 +619,7 @@ jobs: - { os: ubuntu-latest , target: riscv64gc-unknown-linux-musl , features: feat_os_unix_musl , use-cross: use-cross , skip-tests: true } # - { os: ubuntu-latest , target: x86_64-unknown-linux-gnu , features: feat_selinux , use-cross: use-cross } - { os: ubuntu-latest , target: i686-unknown-linux-gnu , features: "feat_os_unix,test_risky_names", use-cross: use-cross } - # glibc 2.42 is important more than this platform - # Wait https://github.com/rust-lang/libc/pull/4914 - #- { os: ubuntu-latest , target: i686-unknown-linux-musl , features: feat_os_unix_musl , use-cross: use-cross } + - { os: ubuntu-latest , target: i686-unknown-linux-musl , features: feat_os_unix_musl , use-cross: use-cross } - { os: ubuntu-latest , target: x86_64-unknown-linux-gnu , features: "feat_os_unix,test_risky_names", use-cross: use-cross, skip-publish: true } - { os: ubuntu-latest , target: x86_64-unknown-linux-gnu , features: "feat_os_unix,uudoc" , use-cross: no, workspace-tests: true } - { os: ubuntu-latest , target: x86_64-unknown-linux-musl , features: feat_os_unix_musl , use-cross: use-cross } From 63d3c9a83b0abaace09e72948d72f4fed39c6b81 Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Thu, 8 Jan 2026 23:27:19 +0100 Subject: [PATCH 136/425] replace bincode by wincode --- Cargo.lock | 113 +++++++++++++++++++++-------------- Cargo.toml | 7 +-- tests/by-util/test_uptime.rs | 21 +++---- 3 files changed, 80 insertions(+), 61 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 78fd65353..0ee582fe7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -165,26 +165,6 @@ dependencies = [ "compare", ] -[[package]] -name = "bincode" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740" -dependencies = [ - "bincode_derive", - "serde", - "unty", -] - -[[package]] -name = "bincode_derive" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf95709a440f45e986983918d0e8a1f30a9b1df04918fc828670606804ac3c09" -dependencies = [ - "virtue", -] - [[package]] name = "bindgen" version = "0.72.1" @@ -536,7 +516,6 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" name = "coreutils" version = "0.5.0" dependencies = [ - "bincode", "clap", "clap_complete", "clap_mangen", @@ -558,7 +537,6 @@ dependencies = [ "rstest", "selinux", "serde", - "serde-big-array", "sha1", "tempfile", "textwrap", @@ -669,6 +647,8 @@ dependencies = [ "uucore", "uutests", "walkdir", + "wincode", + "wincode-derive", "xattr", "zip", ] @@ -813,6 +793,41 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "data-encoding" version = "2.9.0" @@ -1471,6 +1486,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "indexmap" version = "2.9.0" @@ -2518,15 +2539,6 @@ dependencies = [ "serde_derive", ] -[[package]] -name = "serde-big-array" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11fc7cc2c76d73e0f27ee52abbd64eec84d46f370c88371120433196934e4b7f" -dependencies = [ - "serde", -] - [[package]] name = "serde_core" version = "1.0.228" @@ -2729,9 +2741,9 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "syn" -version = "2.0.103" +version = "2.0.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4307e30089d6fd6aff212f2da3a1f9e32f3223b1f010fb09b7c95f90f3ca1e8" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" dependencies = [ "proc-macro2", "quote", @@ -2974,12 +2986,6 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" -[[package]] -name = "unty" -version = "0.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" - [[package]] name = "utf16_iter" version = "1.0.5" @@ -4299,12 +4305,6 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" -[[package]] -name = "virtue" -version = "0.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "051eb1abcf10076295e815102942cc58f9d5e3b4560e46e53c21e8ff6f3af7b1" - [[package]] name = "vsimd" version = "0.8.0" @@ -4444,6 +4444,29 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "wincode" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5cec722a3274e47d1524cbe2cea762f2c19d615bd9d73ada21db9066349d57e" +dependencies = [ + "proc-macro2", + "quote", + "thiserror 2.0.17", +] + +[[package]] +name = "wincode-derive" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8961eb04054a1b2e026b5628e24da7e001350249a787e1a85aa961f33dc5f286" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-core" version = "0.62.2" diff --git a/Cargo.toml b/Cargo.toml index c4109656b..77738518f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ # coreutils (uutils) # * see the repository LICENSE, README, and CONTRIBUTING files for more information -# spell-checker:ignore (libs) bigdecimal datetime serde bincode gethostid kqueue libselinux mangen memmap uuhelp startswith constness expl unnested logind cfgs interner +# spell-checker:ignore (libs) bigdecimal datetime serde wincode gethostid kqueue libselinux mangen memmap uuhelp startswith constness expl unnested logind cfgs interner [package] name = "coreutils" @@ -572,9 +572,8 @@ xattr.workspace = true # to deserialize an utmpx struct into a binary file [target.'cfg(all(target_family= "unix",not(target_os = "macos")))'.dev-dependencies] serde = { version = "1.0.202", features = ["derive"] } -bincode = { version = "2.0.1", features = ["serde"] } -serde-big-array = "0.5.1" - +wincode = "0.2.5" +wincode-derive = "0.2.3" [build-dependencies] phf_codegen.workspace = true diff --git a/tests/by-util/test_uptime.rs b/tests/by-util/test_uptime.rs index e47599912..5c1d5d15f 100644 --- a/tests/by-util/test_uptime.rs +++ b/tests/by-util/test_uptime.rs @@ -3,7 +3,7 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. // -// spell-checker:ignore bincode serde utmp runlevel testusr testx boottime +// spell-checker:ignore wincode serde utmp runlevel testusr testx boottime #![allow(clippy::cast_possible_wrap, clippy::unreadable_literal)] use uutests::at_and_ucmd; @@ -95,11 +95,10 @@ fn test_uptime_with_non_existent_file() { )] #[allow(clippy::too_many_lines, clippy::items_after_statements)] fn test_uptime_with_file_containing_valid_boot_time_utmpx_record() { - use bincode::{config, serde::encode_to_vec}; - use serde::Serialize; - use serde_big_array::BigArray; use std::fs::File; use std::{io::Write, path::PathBuf}; + use wincode::serialize; + use wincode_derive::SchemaWrite; // This test will pass for freebsd but we currently don't support changing the utmpx file for // freebsd. @@ -133,21 +132,21 @@ fn test_uptime_with_file_containing_valid_boot_time_utmpx_record() { const RUN_LVL: i32 = 1; const USER_PROCESS: i32 = 7; - #[derive(Serialize)] + #[derive(SchemaWrite)] #[repr(C)] pub struct TimeVal { pub tv_sec: i32, pub tv_usec: i32, } - #[derive(Serialize)] + #[derive(SchemaWrite)] #[repr(C)] pub struct ExitStatus { e_termination: i16, e_exit: i16, } - #[derive(Serialize)] + #[derive(SchemaWrite)] #[repr(C, align(4))] pub struct Utmp { pub ut_type: i32, @@ -156,7 +155,6 @@ fn test_uptime_with_file_containing_valid_boot_time_utmpx_record() { pub ut_id: [i8; 4], pub ut_user: [i8; 32], - #[serde(with = "BigArray")] pub ut_host: [i8; 256], pub ut_exit: ExitStatus, pub ut_session: i32, @@ -224,10 +222,9 @@ fn test_uptime_with_file_containing_valid_boot_time_utmpx_record() { glibc_reserved: [0; 20], }; - let config = config::legacy(); - let mut buf = encode_to_vec(utmp, config).unwrap(); - buf.append(&mut encode_to_vec(utmp1, config).unwrap()); - buf.append(&mut encode_to_vec(utmp2, config).unwrap()); + let mut buf = serialize(&utmp).unwrap(); + buf.append(&mut serialize(&utmp1).unwrap()); + buf.append(&mut serialize(&utmp2).unwrap()); let mut f = File::create(path).unwrap(); f.write_all(&buf).unwrap(); } From 24a8ff0328455e5f13b5b598a0521a7b5019269b Mon Sep 17 00:00:00 2001 From: Aaron Ang <67321817+aaron-ang@users.noreply.github.com> Date: Thu, 8 Jan 2026 17:20:14 -0800 Subject: [PATCH 137/425] fix: reduce factor parallel test runtime --- tests/by-util/test_factor.rs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/tests/by-util/test_factor.rs b/tests/by-util/test_factor.rs index 818885970..0a9e6c3e5 100644 --- a/tests/by-util/test_factor.rs +++ b/tests/by-util/test_factor.rs @@ -60,15 +60,15 @@ fn test_repeated_exponents() { fn test_parallel() { use hex_literal::hex; use sha1::{Digest, Sha1}; - use std::{fs::OpenOptions, time::Duration}; + use std::fs::OpenOptions; use tempfile::TempDir; use uutests::{ util::{AtPath, TestScenario}, util_name, }; // factor should only flush the buffer at line breaks - let n_integers = 100_000; - let mut input_string = String::new(); + let n_integers = 50_000; + let mut input_string = String::with_capacity(n_integers * 6); for i in 0..=n_integers { let _ = write!(input_string, "{i} "); } @@ -81,10 +81,9 @@ fn test_parallel() { .open(tmp_dir.plus("output")) .unwrap(); - for child in (0..10) + for child in (0..8) .map(|_| { new_ucmd!() - .timeout(Duration::from_secs(240)) .set_stdout(output.try_clone().unwrap()) .pipe_in(input_string.clone()) .run_no_wait() @@ -103,7 +102,7 @@ fn test_parallel() { let hash_check = hasher.finalize(); assert_eq!( hash_check[..], - hex!("cc743607c0ff300ff575d92f4ff0c87d5660c393") + hex!("73f104b140449feac7ccf27b4c13ef6b9a4c5ee4") ); } From 2b1d40af7cdd9ba80b8f6a754067c1e966e1db7b Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 8 Jan 2026 20:01:17 +0000 Subject: [PATCH 138/425] chore(deps): update rust crate blake3 to v1.8.3 --- Cargo.lock | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0ee582fe7..e8d1969ef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -217,20 +217,21 @@ checksum = "06e903a20b159e944f91ec8499fe1e55651480c541ea0a584f5d967c49ad9d99" dependencies = [ "arrayref", "arrayvec", - "constant_time_eq", + "constant_time_eq 0.3.1", ] [[package]] name = "blake3" -version = "1.8.2" +version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3888aaa89e4b2a40fca9848e400f6a658a5a3978de7be858e209cafa8be9a4a0" +checksum = "2468ef7d57b3fb7e16b576e8377cdbde2320c60e1491e961d11da40fc4f02a2d" dependencies = [ "arrayref", "arrayvec", "cc", "cfg-if", - "constant_time_eq", + "constant_time_eq 0.4.2", + "cpufeatures", ] [[package]] @@ -497,6 +498,12 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + [[package]] name = "convert_case" version = "0.7.1" From 30239e69a328e76d2377f2a0bc02fbde61c34280 Mon Sep 17 00:00:00 2001 From: Jake Abendroth Date: Thu, 8 Jan 2026 23:22:56 -0800 Subject: [PATCH 139/425] feat: Expand safe directory traversal to all Unix platforms and fix related type conversions. (#9792) --------- Co-authored-by: Sylvestre Ledru --- .../acronyms+names.wordlist.txt | 1 + src/uu/chmod/Cargo.toml | 11 ++- src/uu/chmod/src/chmod.rs | 33 +++++---- src/uu/cp/src/cp.rs | 7 +- src/uu/du/Cargo.toml | 4 +- src/uu/du/src/du.rs | 34 +++++---- src/uu/install/src/install.rs | 24 +++---- src/uu/mkfifo/src/mkfifo.rs | 2 +- src/uu/rm/Cargo.toml | 5 +- src/uu/rm/src/platform/mod.rs | 8 +-- src/uu/rm/src/platform/{linux.rs => unix.rs} | 24 ++++--- src/uu/rm/src/rm.rs | 32 +++------ src/uu/stat/src/stat.rs | 4 +- src/uucore/src/lib/features.rs | 2 +- src/uucore/src/lib/features/safe_traversal.rs | 69 +++++++++---------- src/uucore/src/lib/lib.rs | 2 +- tests/by-util/test_chmod.rs | 13 ++-- 17 files changed, 145 insertions(+), 130 deletions(-) rename src/uu/rm/src/platform/{linux.rs => unix.rs} (94%) diff --git a/.vscode/cspell.dictionaries/acronyms+names.wordlist.txt b/.vscode/cspell.dictionaries/acronyms+names.wordlist.txt index 180111d3d..4de6f38f0 100644 --- a/.vscode/cspell.dictionaries/acronyms+names.wordlist.txt +++ b/.vscode/cspell.dictionaries/acronyms+names.wordlist.txt @@ -34,6 +34,7 @@ RISCV RNG # random number generator RNGs Solaris +TOCTOU # time-of-check time-of-use UID # user ID UIDs UUID # universally unique identifier diff --git a/src/uu/chmod/Cargo.toml b/src/uu/chmod/Cargo.toml index bae2961ae..e1be6896f 100644 --- a/src/uu/chmod/Cargo.toml +++ b/src/uu/chmod/Cargo.toml @@ -20,15 +20,12 @@ path = "src/chmod.rs" [dependencies] clap = { workspace = true } thiserror = { workspace = true } -uucore = { workspace = true, features = [ - "entries", - "fs", - "mode", - "perms", - "safe-traversal", -] } +uucore = { workspace = true, features = ["entries", "fs", "mode", "perms"] } fluent = { workspace = true } +[target.'cfg(all(unix, not(target_os = "redox")))'.dependencies] +uucore = { workspace = true, features = ["safe-traversal"] } + [[bin]] name = "chmod" path = "src/main.rs" diff --git a/src/uu/chmod/src/chmod.rs b/src/uu/chmod/src/chmod.rs index 43760b450..6ff03e503 100644 --- a/src/uu/chmod/src/chmod.rs +++ b/src/uu/chmod/src/chmod.rs @@ -18,7 +18,7 @@ use uucore::libc::mode_t; use uucore::mode; use uucore::perms::{TraverseSymlinks, configure_symlink_and_recursion}; -#[cfg(target_os = "linux")] +#[cfg(all(unix, not(target_os = "redox")))] use uucore::safe_traversal::DirFd; use uucore::{format_usage, show, show_error}; @@ -338,7 +338,7 @@ impl Chmoder { } /// Handle symlinks during directory traversal based on traversal mode - #[cfg(not(target_os = "linux"))] + #[cfg(not(unix))] fn handle_symlink_during_traversal( &self, path: &Path, @@ -423,7 +423,8 @@ impl Chmoder { matches!(fs::canonicalize(&file), Ok(p) if p == Path::new("/")) } - #[cfg(not(target_os = "linux"))] + // Non-safe traversal implementation for platforms without safe_traversal support + #[cfg(any(not(unix), target_os = "redox"))] fn walk_dir_with_context(&self, file_path: &Path, is_command_line_arg: bool) -> UResult<()> { let mut r = self.chmod_file(file_path); @@ -436,8 +437,7 @@ impl Chmoder { // If the path is a directory (or we should follow symlinks), recurse into it if (!file_path.is_symlink() || should_follow_symlink) && file_path.is_dir() { - // We buffer all paths in this dir to not keep to be able to close the fd so not - // too many fd's are open during the recursion + // We buffer all paths in this dir to not keep too many fd's open during recursion let mut paths_in_this_dir = Vec::new(); for dir_entry in file_path.read_dir()? { @@ -450,9 +450,16 @@ impl Chmoder { } } for path in paths_in_this_dir { - if path.is_symlink() { - r = self.handle_symlink_during_recursion(&path).and(r); - } else { + #[cfg(not(unix))] + { + if path.is_symlink() { + r = self.handle_symlink_during_recursion(&path).and(r); + } else { + r = self.walk_dir_with_context(path.as_path(), false).and(r); + } + } + #[cfg(target_os = "redox")] + { r = self.walk_dir_with_context(path.as_path(), false).and(r); } } @@ -460,7 +467,7 @@ impl Chmoder { r } - #[cfg(target_os = "linux")] + #[cfg(all(unix, not(target_os = "redox")))] fn walk_dir_with_context(&self, file_path: &Path, is_command_line_arg: bool) -> UResult<()> { let mut r = self.chmod_file(file_path); @@ -490,7 +497,7 @@ impl Chmoder { r } - #[cfg(target_os = "linux")] + #[cfg(all(unix, not(target_os = "redox")))] fn safe_traverse_dir(&self, dir_fd: &DirFd, dir_path: &Path) -> UResult<()> { let mut r = Ok(()); @@ -546,7 +553,7 @@ impl Chmoder { r } - #[cfg(target_os = "linux")] + #[cfg(all(unix, not(target_os = "redox")))] fn handle_symlink_during_safe_recursion( &self, path: &Path, @@ -578,7 +585,7 @@ impl Chmoder { } } - #[cfg(target_os = "linux")] + #[cfg(all(unix, not(target_os = "redox")))] fn safe_chmod_file( &self, file_path: &Path, @@ -608,7 +615,7 @@ impl Chmoder { Ok(()) } - #[cfg(not(target_os = "linux"))] + #[cfg(not(unix))] fn handle_symlink_during_recursion(&self, path: &Path) -> UResult<()> { // Use the common symlink handling logic self.handle_symlink_during_traversal(path, false) diff --git a/src/uu/cp/src/cp.rs b/src/uu/cp/src/cp.rs index cd84caa36..c3d75b225 100644 --- a/src/uu/cp/src/cp.rs +++ b/src/uu/cp/src/cp.rs @@ -1566,6 +1566,7 @@ fn file_mode_for_interactive_overwrite( match path.metadata() { Ok(me) => { // Cast is necessary on some platforms + #[allow(clippy::unnecessary_cast)] let mode: mode_t = me.mode() as mode_t; // It looks like this extra information is added to the prompt iff the file's user write bit is 0 @@ -1758,7 +1759,7 @@ pub(crate) fn copy_attributes( Ok(()) })?; - #[cfg(feature = "selinux")] + #[cfg(all(feature = "selinux", target_os = "linux"))] handle_preserve(&attributes.context, || -> CopyResult<()> { // Get the source context and apply it to the destination if let Ok(context) = selinux::SecurityContext::of_path(source, false, false) { @@ -2552,7 +2553,7 @@ fn copy_file( copy_attributes(source, dest, &options.attributes)?; } - #[cfg(feature = "selinux")] + #[cfg(all(feature = "selinux", target_os = "linux"))] if options.set_selinux_context && uucore::selinux::is_selinux_enabled() { // Set the given selinux permissions on the copied file. if let Err(e) = @@ -2620,8 +2621,10 @@ fn handle_no_preserve_mode(options: &Options, org_mode: u32) -> u32 { target_os = "redox", ))] { + #[allow(clippy::unnecessary_cast)] const MODE_RW_UGO: u32 = (S_IRUSR | S_IWUSR | S_IRGRP | S_IWGRP | S_IROTH | S_IWOTH) as u32; + #[allow(clippy::unnecessary_cast)] const S_IRWXUGO: u32 = (S_IRWXU | S_IRWXG | S_IRWXO) as u32; return if is_explicit_no_preserve_mode { MODE_RW_UGO diff --git a/src/uu/du/Cargo.toml b/src/uu/du/Cargo.toml index 1241746e7..192ec9dea 100644 --- a/src/uu/du/Cargo.toml +++ b/src/uu/du/Cargo.toml @@ -27,11 +27,13 @@ uucore = { workspace = true, features = [ "parser-size", "parser-glob", "time", - "safe-traversal", ] } thiserror = { workspace = true } fluent = { workspace = true } +[target.'cfg(all(unix, not(target_os = "redox")))'.dependencies] +uucore = { workspace = true, features = ["safe-traversal"] } + [target.'cfg(target_os = "windows")'.dependencies] windows-sys = { workspace = true, features = [ "Win32_Storage_FileSystem", diff --git a/src/uu/du/src/du.rs b/src/uu/du/src/du.rs index 1b8084e2e..3ad6e07f9 100644 --- a/src/uu/du/src/du.rs +++ b/src/uu/du/src/du.rs @@ -25,7 +25,7 @@ use uucore::display::{Quotable, print_verbatim}; use uucore::error::{FromIo, UError, UResult, USimpleError, set_exit_code}; use uucore::fsext::{MetadataTimeField, metadata_get_time}; use uucore::line_ending::LineEnding; -#[cfg(target_os = "linux")] +#[cfg(all(unix, not(target_os = "redox")))] use uucore::safe_traversal::DirFd; use uucore::translate; @@ -164,7 +164,7 @@ impl Stat { } /// Create a Stat using safe traversal methods with `DirFd` for the root directory - #[cfg(target_os = "linux")] + #[cfg(all(unix, not(target_os = "redox")))] fn new_from_dirfd(dir_fd: &DirFd, full_path: &Path) -> std::io::Result { // Get metadata for the directory itself using fstat let safe_metadata = dir_fd.metadata()?; @@ -293,9 +293,9 @@ fn read_block_size(s: Option<&str>) -> UResult { } } -#[cfg(target_os = "linux")] -// For now, implement safe_du only on Linux -// This is done for Ubuntu but should be extended to other platforms that support openat +#[cfg(all(unix, not(target_os = "redox")))] +// Implement safe_du on Unix (except Redox which lacks full stat support) +// This is done for TOCTOU safety fn safe_du( path: &Path, options: &TraversalOptions, @@ -439,7 +439,8 @@ fn safe_du( const S_IFMT: u32 = 0o170_000; const S_IFDIR: u32 = 0o040_000; const S_IFLNK: u32 = 0o120_000; - let is_symlink = (lstat.st_mode & S_IFMT) == S_IFLNK; + #[allow(clippy::unnecessary_cast)] + let is_symlink = (lstat.st_mode as u32 & S_IFMT) == S_IFLNK; // Handle symlinks with -L option // For safe traversal with -L, we skip symlinks to directories entirely @@ -450,12 +451,14 @@ fn safe_du( continue; } - let is_dir = (lstat.st_mode & S_IFMT) == S_IFDIR; + #[allow(clippy::unnecessary_cast)] + let is_dir = (lstat.st_mode as u32 & S_IFMT) == S_IFDIR; let entry_stat = lstat; + #[allow(clippy::unnecessary_cast)] let file_info = (entry_stat.st_ino != 0).then_some(FileInfo { file_id: entry_stat.st_ino as u128, - dev_id: entry_stat.st_dev, + dev_id: entry_stat.st_dev as u64, }); // For safe traversal, we need to handle stats differently @@ -465,6 +468,7 @@ fn safe_du( Stat { path: entry_path.clone(), size: 0, + #[allow(clippy::unnecessary_cast)] blocks: entry_stat.st_blocks as u64, inodes: 1, inode: file_info, @@ -476,7 +480,9 @@ fn safe_du( // For files Stat { path: entry_path.clone(), + #[allow(clippy::unnecessary_cast)] size: entry_stat.st_size as u64, + #[allow(clippy::unnecessary_cast)] blocks: entry_stat.st_blocks as u64, inodes: 1, inode: file_info, @@ -1096,14 +1102,14 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { let mut seen_inodes: HashSet = HashSet::new(); // Determine which traversal method to use - #[cfg(target_os = "linux")] + #[cfg(all(unix, not(target_os = "redox")))] let use_safe_traversal = traversal_options.dereference != Deref::All; - #[cfg(not(target_os = "linux"))] + #[cfg(not(all(unix, not(target_os = "redox"))))] let use_safe_traversal = false; if use_safe_traversal { - // Use safe traversal (Linux only, when not using -L) - #[cfg(target_os = "linux")] + // Use safe traversal (Unix except Redox, when not using -L) + #[cfg(all(unix, not(target_os = "redox")))] { // Pre-populate seen_inodes with the starting directory to detect cycles if let Ok(stat) = Stat::new(&path, None, &traversal_options) { @@ -1158,9 +1164,9 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { .send(Ok(StatPrintInfo { stat, depth: 0 })) .map_err(|e| USimpleError::new(1, e.to_string()))?; } else { - #[cfg(target_os = "linux")] + #[cfg(unix)] let error_msg = translate!("du-error-cannot-access", "path" => path.quote()); - #[cfg(not(target_os = "linux"))] + #[cfg(not(unix))] let error_msg = translate!("du-error-cannot-access-no-such-file", "path" => path.quote()); diff --git a/src/uu/install/src/install.rs b/src/uu/install/src/install.rs index 8e43d1fd2..d3e0b3e89 100644 --- a/src/uu/install/src/install.rs +++ b/src/uu/install/src/install.rs @@ -10,7 +10,7 @@ mod mode; use clap::{Arg, ArgAction, ArgMatches, Command}; use file_diff::diff; use filetime::{FileTime, set_file_times}; -#[cfg(feature = "selinux")] +#[cfg(all(feature = "selinux", target_os = "linux"))] use selinux::SecurityContext; use std::ffi::OsString; use std::fmt::Debug; @@ -27,7 +27,7 @@ use uucore::error::{FromIo, UError, UResult, UUsageError}; use uucore::fs::dir_strip_dot_for_creation; use uucore::perms::{Verbosity, VerbosityLevel, wrap_chown}; use uucore::process::{getegid, geteuid}; -#[cfg(feature = "selinux")] +#[cfg(all(feature = "selinux", target_os = "linux"))] use uucore::selinux::{ SeLinuxError, contexts_differ, get_selinux_security_context, is_selinux_enabled, selinux_error_description, set_selinux_security_context, @@ -118,7 +118,7 @@ enum InstallError { #[error("{}", translate!("install-error-extra-operand", "operand" => .0.quote(), "usage" => .1.clone()))] ExtraOperand(OsString, String), - #[cfg(feature = "selinux")] + #[cfg(all(feature = "selinux", target_os = "linux"))] #[error("{}", .0)] SelinuxContextFailed(String), } @@ -1030,7 +1030,7 @@ fn copy(from: &Path, to: &Path, b: &Behavior) -> UResult<()> { Ok(()) } -#[cfg(feature = "selinux")] +#[cfg(all(feature = "selinux", target_os = "linux"))] fn get_context_for_selinux(b: &Behavior) -> Option<&String> { if b.default_context { None @@ -1165,7 +1165,7 @@ fn need_copy(from: &Path, to: &Path, b: &Behavior) -> bool { false } -#[cfg(feature = "selinux")] +#[cfg(all(feature = "selinux", target_os = "linux"))] /// Sets the `SELinux` security context for install's -Z flag behavior. /// /// This function implements the specific behavior needed for install's -Z flag, @@ -1199,7 +1199,7 @@ pub fn set_selinux_default_context(path: &Path) -> Result<(), SeLinuxError> { } } -#[cfg(feature = "selinux")] +#[cfg(all(feature = "selinux", target_os = "linux"))] /// Gets the default `SELinux` context for a path based on the system's security policy. /// /// This function attempts to determine what the "correct" `SELinux` context should be @@ -1255,7 +1255,7 @@ fn get_default_context_for_path(path: &Path) -> Result, SeLinuxEr Ok(None) } -#[cfg(feature = "selinux")] +#[cfg(all(feature = "selinux", target_os = "linux"))] /// Derives an appropriate `SELinux` context based on a parent directory context. /// /// This is a heuristic function that attempts to generate an appropriate @@ -1293,7 +1293,7 @@ fn derive_context_from_parent(parent_context: &str) -> String { } } -#[cfg(feature = "selinux")] +#[cfg(all(feature = "selinux", target_os = "linux"))] /// Helper function to collect paths that need `SELinux` context setting. /// /// Traverses from the given starting path up to existing parent directories. @@ -1307,7 +1307,7 @@ fn collect_paths_for_context_setting(starting_path: &Path) -> Vec<&Path> { paths } -#[cfg(feature = "selinux")] +#[cfg(all(feature = "selinux", target_os = "linux"))] /// Sets the `SELinux` security context for a directory hierarchy. /// /// This function traverses from the given starting path up to existing parent directories @@ -1347,7 +1347,7 @@ fn set_selinux_context_for_directories(target_path: &Path, context: Option<&Stri } } -#[cfg(feature = "selinux")] +#[cfg(all(feature = "selinux", target_os = "linux"))] /// Sets `SELinux` context for created directories using install's -Z default behavior. /// /// Similar to `set_selinux_context_for_directories` but uses install's @@ -1371,10 +1371,10 @@ pub fn set_selinux_context_for_directories_install(target_path: &Path, context: #[cfg(test)] mod tests { - #[cfg(feature = "selinux")] + #[cfg(all(feature = "selinux", target_os = "linux"))] use super::derive_context_from_parent; - #[cfg(feature = "selinux")] + #[cfg(all(feature = "selinux", target_os = "linux"))] #[test] fn test_derive_context_from_parent() { // Test cases: (input_context, file_type, expected_output, description) diff --git a/src/uu/mkfifo/src/mkfifo.rs b/src/uu/mkfifo/src/mkfifo.rs index 225540873..351e8fba1 100644 --- a/src/uu/mkfifo/src/mkfifo.rs +++ b/src/uu/mkfifo/src/mkfifo.rs @@ -59,7 +59,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { } // Apply SELinux context if requested - #[cfg(feature = "selinux")] + #[cfg(all(feature = "selinux", target_os = "linux"))] { // Extract the SELinux related flags and options let set_security_context = matches.get_flag(options::SECURITY_CONTEXT); diff --git a/src/uu/rm/Cargo.toml b/src/uu/rm/Cargo.toml index ccf1bf93e..cf53e0323 100644 --- a/src/uu/rm/Cargo.toml +++ b/src/uu/rm/Cargo.toml @@ -20,10 +20,13 @@ path = "src/rm.rs" [dependencies] thiserror = { workspace = true } clap = { workspace = true } -uucore = { workspace = true, features = ["fs", "parser", "safe-traversal"] } +uucore = { workspace = true, features = ["fs", "parser"] } fluent = { workspace = true } indicatif = { workspace = true } +[target.'cfg(all(unix, not(target_os = "redox")))'.dependencies] +uucore = { workspace = true, features = ["safe-traversal"] } + [target.'cfg(unix)'.dependencies] libc = { workspace = true } diff --git a/src/uu/rm/src/platform/mod.rs b/src/uu/rm/src/platform/mod.rs index 1f2911acb..db37b7845 100644 --- a/src/uu/rm/src/platform/mod.rs +++ b/src/uu/rm/src/platform/mod.rs @@ -5,8 +5,8 @@ // Platform-specific implementations for the rm utility -#[cfg(target_os = "linux")] -pub mod linux; +#[cfg(all(unix, not(target_os = "redox")))] +pub mod unix; -#[cfg(target_os = "linux")] -pub use linux::*; +#[cfg(all(unix, not(target_os = "redox")))] +pub use unix::*; diff --git a/src/uu/rm/src/platform/linux.rs b/src/uu/rm/src/platform/unix.rs similarity index 94% rename from src/uu/rm/src/platform/linux.rs rename to src/uu/rm/src/platform/unix.rs index 3e29bf85e..5c8e0981b 100644 --- a/src/uu/rm/src/platform/linux.rs +++ b/src/uu/rm/src/platform/unix.rs @@ -3,7 +3,7 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// Linux-specific implementations for the rm utility +// Unix-specific implementations for the rm utility // spell-checker:ignore fstatat unlinkat statx behaviour @@ -42,8 +42,8 @@ fn prompt_file_with_stat(path: &Path, stat: &libc::stat, options: &Options) -> b return true; } - let is_symlink = (stat.st_mode & libc::S_IFMT) == libc::S_IFLNK; - let writable = mode_writable(stat.st_mode); + let is_symlink = ((stat.st_mode as libc::mode_t) & libc::S_IFMT) == libc::S_IFLNK; + let writable = mode_writable(stat.st_mode as libc::mode_t); let len = stat.st_size as u64; let stdin_ok = options.__presume_input_tty.unwrap_or(false) || stdin().is_terminal(); @@ -82,8 +82,8 @@ fn prompt_dir_with_mode(path: &Path, mode: libc::mode_t, options: &Options) -> b return true; } - let readable = mode_readable(mode); - let writable = mode_writable(mode); + let readable = mode_readable(mode as libc::mode_t); + let writable = mode_writable(mode as libc::mode_t); let stdin_ok = options.__presume_input_tty.unwrap_or(false) || stdin().is_terminal(); match (stdin_ok, readable, writable, options.interactive) { @@ -317,7 +317,7 @@ pub fn safe_remove_dir_recursive( } else { // Ask user permission if needed if options.interactive == InteractiveMode::Always - && !prompt_dir_with_mode(path, initial_mode, options) + && !prompt_dir_with_mode(path, initial_mode as libc::mode_t, options) { return false; } @@ -345,6 +345,7 @@ pub fn safe_remove_dir_recursive( } } +#[cfg(not(target_os = "redox"))] pub fn safe_remove_dir_recursive_impl(path: &Path, dir_fd: &DirFd, options: &Options) -> bool { // Read directory entries using safe traversal let entries = match dir_fd.read_dir() { @@ -376,7 +377,7 @@ pub fn safe_remove_dir_recursive_impl(path: &Path, dir_fd: &DirFd, options: &Opt }; // Check if it's a directory - let is_dir = (entry_stat.st_mode & libc::S_IFMT) == libc::S_IFDIR; + let is_dir = ((entry_stat.st_mode as libc::mode_t) & libc::S_IFMT) == libc::S_IFDIR; if is_dir { // Ask user if they want to descend into this directory @@ -413,7 +414,7 @@ pub fn safe_remove_dir_recursive_impl(path: &Path, dir_fd: &DirFd, options: &Opt // Ask user permission if needed for this subdirectory if !child_error && options.interactive == InteractiveMode::Always - && !prompt_dir_with_mode(&entry_path, entry_stat.st_mode, options) + && !prompt_dir_with_mode(&entry_path, entry_stat.st_mode as libc::mode_t, options) { continue; } @@ -432,3 +433,10 @@ pub fn safe_remove_dir_recursive_impl(path: &Path, dir_fd: &DirFd, options: &Opt error } + +#[cfg(target_os = "redox")] +pub fn safe_remove_dir_recursive_impl(_path: &Path, _dir_fd: &DirFd, _options: &Options) -> bool { + // safe_traversal stat_at is not supported on Redox + // This shouldn't be called on Redox, but provide a stub for compilation + true // Return error +} diff --git a/src/uu/rm/src/rm.rs b/src/uu/rm/src/rm.rs index ce1ce47a1..55c5b932f 100644 --- a/src/uu/rm/src/rm.rs +++ b/src/uu/rm/src/rm.rs @@ -26,7 +26,7 @@ use uucore::translate; use uucore::{format_usage, os_str_as_bytes, prompt_yes, show_error}; mod platform; -#[cfg(target_os = "linux")] +#[cfg(all(unix, not(target_os = "redox")))] use platform::{safe_remove_dir_recursive, safe_remove_empty_dir, safe_remove_file}; #[derive(Debug, Error)] @@ -538,17 +538,7 @@ fn is_readable_metadata(metadata: &Metadata) -> bool { } /// Whether the given file or directory is readable. -#[cfg(unix)] -#[cfg(not(target_os = "linux"))] -fn is_readable(path: &Path) -> bool { - match fs::metadata(path) { - Err(_) => false, - Ok(metadata) => is_readable_metadata(&metadata), - } -} - -/// Whether the given file or directory is readable. -#[cfg(not(unix))] +#[cfg(any(not(unix), target_os = "redox"))] fn is_readable(_path: &Path) -> bool { true } @@ -605,14 +595,14 @@ fn remove_dir_recursive( return false; } - // Use secure traversal on Linux for all recursive directory removals - #[cfg(target_os = "linux")] + // Use secure traversal on Unix (except Redox) for all recursive directory removals + #[cfg(all(unix, not(target_os = "redox")))] { safe_remove_dir_recursive(path, options, progress_bar) } - // Fallback for non-Linux or use fs::remove_dir_all for very long paths - #[cfg(not(target_os = "linux"))] + // Fallback for non-Unix, Redox, or use fs::remove_dir_all for very long paths + #[cfg(any(not(unix), target_os = "redox"))] { if let Some(s) = path.to_str() { if s.len() > 1000 { @@ -734,8 +724,8 @@ fn remove_dir(path: &Path, options: &Options, progress_bar: Option<&ProgressBar> return true; } - // Use safe traversal on Linux for empty directory removal - #[cfg(target_os = "linux")] + // Use safe traversal on Unix (except Redox) for empty directory removal + #[cfg(all(unix, not(target_os = "redox")))] { if let Some(result) = safe_remove_empty_dir(path, options, progress_bar) { return result; @@ -758,15 +748,15 @@ fn remove_file(path: &Path, options: &Options, progress_bar: Option<&ProgressBar pb.inc(1); } - // Use safe traversal on Linux for individual file removal - #[cfg(target_os = "linux")] + // Use safe traversal on Unix (except Redox) for individual file removal + #[cfg(all(unix, not(target_os = "redox")))] { if let Some(result) = safe_remove_file(path, options, progress_bar) { return result; } } - // Fallback method for non-Linux or when safe traversal is unavailable + // Fallback method for non-Unix, Redox, or when safe traversal is unavailable match fs::remove_file(path) { Ok(_) => { verbose_removed_file(path, options); diff --git a/src/uu/stat/src/stat.rs b/src/uu/stat/src/stat.rs index 48430a261..a7a876b08 100644 --- a/src/uu/stat/src/stat.rs +++ b/src/uu/stat/src/stat.rs @@ -1044,7 +1044,7 @@ impl Stater { 'B' => OutputType::Unsigned(512), // SELinux security context string 'C' => { - #[cfg(feature = "selinux")] + #[cfg(all(feature = "selinux", target_os = "linux"))] { if uucore::selinux::is_selinux_enabled() { match uucore::selinux::get_selinux_security_context( @@ -1060,7 +1060,7 @@ impl Stater { OutputType::Str(translate!("stat-selinux-unsupported-system")) } } - #[cfg(not(feature = "selinux"))] + #[cfg(not(all(feature = "selinux", target_os = "linux")))] { OutputType::Str(translate!("stat-selinux-unsupported-os")) } diff --git a/src/uucore/src/lib/features.rs b/src/uucore/src/lib/features.rs index e56968c50..cd2ce405f 100644 --- a/src/uucore/src/lib/features.rs +++ b/src/uucore/src/lib/features.rs @@ -72,7 +72,7 @@ pub mod pipes; pub mod proc_info; #[cfg(all(unix, feature = "process"))] pub mod process; -#[cfg(target_os = "linux")] +#[cfg(all(unix, not(target_os = "redox")))] pub mod safe_traversal; #[cfg(all(target_os = "linux", feature = "tty"))] pub mod tty; diff --git a/src/uucore/src/lib/features/safe_traversal.rs b/src/uucore/src/lib/features/safe_traversal.rs index 6574910a9..3b1ac7067 100644 --- a/src/uucore/src/lib/features/safe_traversal.rs +++ b/src/uucore/src/lib/features/safe_traversal.rs @@ -6,7 +6,7 @@ // Safe directory traversal using openat() and related syscalls // This module provides TOCTOU-safe filesystem operations for recursive traversal // -// Only available on Linux +// Available on Unix // // spell-checker:ignore CLOEXEC RDONLY TOCTOU closedir dirp fdopendir fstatat openat REMOVEDIR unlinkat smallfile // spell-checker:ignore RAII dirfd fchownat fchown FchmodatFlags fchmodat fchmod @@ -85,15 +85,11 @@ fn read_dir_entries(fd: &OwnedFd) -> io::Result> { // Duplicate the fd for Dir (it takes ownership) let dup_fd = nix::unistd::dup(fd).map_err(|e| io::Error::from_raw_os_error(e as i32))?; - let mut dir = Dir::from_fd(dup_fd).map_err(|e| io::Error::from_raw_os_error(e as i32))?; - for entry_result in dir.iter() { let entry = entry_result.map_err(|e| io::Error::from_raw_os_error(e as i32))?; - let name = entry.file_name(); let name_os = OsStr::from_bytes(name.to_bytes()); - if name_os != "." && name_os != ".." { entries.push(name_os.to_os_string()); } @@ -117,7 +113,6 @@ impl DirFd { source: io::Error::from_raw_os_error(e as i32), } })?; - Ok(Self { fd }) } @@ -125,7 +120,6 @@ impl DirFd { pub fn open_subdir(&self, name: &OsStr) -> io::Result { let name_cstr = CString::new(name.as_bytes()).map_err(|_| SafeTraversalError::PathContainsNull)?; - let flags = OFlag::O_RDONLY | OFlag::O_DIRECTORY | OFlag::O_CLOEXEC; let fd = openat(&self.fd, name_cstr.as_c_str(), flags, Mode::empty()).map_err(|e| { SafeTraversalError::OpenFailed { @@ -133,7 +127,6 @@ impl DirFd { source: io::Error::from_raw_os_error(e as i32), } })?; - Ok(Self { fd }) } @@ -174,7 +167,6 @@ impl DirFd { path: translate!("safe-traversal-current-directory").into(), source: io::Error::from_raw_os_error(e as i32), })?; - Ok(stat) } @@ -254,7 +246,7 @@ impl DirFd { FchmodatFlags::NoFollowSymlink }; - let mode = Mode::from_bits_truncate(mode); + let mode = Mode::from_bits_truncate(mode as libc::mode_t); let name_cstr = CString::new(name.as_bytes()).map_err(|_| SafeTraversalError::PathContainsNull)?; @@ -267,7 +259,7 @@ impl DirFd { /// Change mode of this directory pub fn fchmod(&self, mode: u32) -> io::Result<()> { - let mode = Mode::from_bits_truncate(mode); + let mode = Mode::from_bits_truncate(mode as libc::mode_t); nix::sys::stat::fchmod(&self.fd, mode) .map_err(|e| io::Error::from_raw_os_error(e as i32))?; @@ -378,30 +370,30 @@ impl Metadata { } pub fn file_type(&self) -> FileType { - FileType::from_mode(self.stat.st_mode) + FileType::from_mode(self.stat.st_mode as libc::mode_t) } pub fn file_info(&self) -> FileInfo { FileInfo::from_stat(&self.stat) } + // st_size type varies by platform (i64 vs u64) + #[allow(clippy::unnecessary_cast)] pub fn size(&self) -> u64 { self.stat.st_size as u64 } + // st_mode type varies by platform (u16 on macOS, u32 on Linux) + #[allow(clippy::unnecessary_cast)] pub fn mode(&self) -> u32 { - self.stat.st_mode + self.stat.st_mode as u32 } pub fn nlink(&self) -> u64 { - // st_nlink is u32 on most platforms except x86_64 - #[cfg(target_arch = "x86_64")] + // st_nlink type varies by platform (u16 on FreeBSD, u32/u64 on others) + #[allow(clippy::unnecessary_cast)] { - self.stat.st_nlink - } - #[cfg(not(target_arch = "x86_64"))] - { - self.stat.st_nlink.into() + self.stat.st_nlink as u64 } } @@ -421,34 +413,31 @@ impl Metadata { // Add MetadataExt trait implementation for compatibility impl std::os::unix::fs::MetadataExt for Metadata { + // st_dev type varies by platform (i32 on macOS, u64 on Linux) + #[allow(clippy::unnecessary_cast)] fn dev(&self) -> u64 { - self.stat.st_dev + self.stat.st_dev as u64 } fn ino(&self) -> u64 { - #[cfg(target_pointer_width = "32")] + // st_ino type varies by platform (u32 on FreeBSD, u64 on Linux) + #[allow(clippy::unnecessary_cast)] { - self.stat.st_ino.into() - } - #[cfg(not(target_pointer_width = "32"))] - { - self.stat.st_ino + self.stat.st_ino as u64 } } + // st_mode type varies by platform (u16 on macOS, u32 on Linux) + #[allow(clippy::unnecessary_cast)] fn mode(&self) -> u32 { - self.stat.st_mode + self.stat.st_mode as u32 } fn nlink(&self) -> u64 { - // st_nlink is u32 on most platforms except x86_64 - #[cfg(target_arch = "x86_64")] + // st_nlink type varies by platform (u16 on FreeBSD, u32/u64 on others) + #[allow(clippy::unnecessary_cast)] { - self.stat.st_nlink - } - #[cfg(not(target_arch = "x86_64"))] - { - self.stat.st_nlink.into() + self.stat.st_nlink as u64 } } @@ -460,10 +449,14 @@ impl std::os::unix::fs::MetadataExt for Metadata { self.stat.st_gid } + // st_rdev type varies by platform (i32 on macOS, u64 on Linux) + #[allow(clippy::unnecessary_cast)] fn rdev(&self) -> u64 { - self.stat.st_rdev + self.stat.st_rdev as u64 } + // st_size type varies by platform (i64 on some platforms, u64 on others) + #[allow(clippy::unnecessary_cast)] fn size(&self) -> u64 { self.stat.st_size as u64 } @@ -534,10 +527,14 @@ impl std::os::unix::fs::MetadataExt for Metadata { } } + // st_blksize type varies by platform (i32/i64/u32/u64 depending on platform) + #[allow(clippy::unnecessary_cast)] fn blksize(&self) -> u64 { self.stat.st_blksize as u64 } + // st_blocks type varies by platform (i64 on some platforms, u64 on others) + #[allow(clippy::unnecessary_cast)] fn blocks(&self) -> u64 { self.stat.st_blocks as u64 } diff --git a/src/uucore/src/lib/lib.rs b/src/uucore/src/lib/lib.rs index 7931a6920..c1ece8bff 100644 --- a/src/uucore/src/lib/lib.rs +++ b/src/uucore/src/lib/lib.rs @@ -99,7 +99,7 @@ pub use crate::features::perms; pub use crate::features::pipes; #[cfg(all(unix, feature = "process"))] pub use crate::features::process; -#[cfg(target_os = "linux")] +#[cfg(all(unix, not(target_os = "redox")))] pub use crate::features::safe_traversal; #[cfg(all(unix, not(target_os = "fuchsia"), feature = "signals"))] pub use crate::features::signals; diff --git a/tests/by-util/test_chmod.rs b/tests/by-util/test_chmod.rs index a17fc4a2c..18c180bd0 100644 --- a/tests/by-util/test_chmod.rs +++ b/tests/by-util/test_chmod.rs @@ -390,10 +390,12 @@ fn test_chmod_recursive_correct_exit_code() { perms.set_mode(0o000); set_permissions(at.plus_as_string("a"), perms).unwrap(); - #[cfg(not(target_os = "linux"))] - let err_msg = "chmod: Permission denied\n"; - #[cfg(target_os = "linux")] + // With safe_traversal enabled on all Unix platforms (except Redox), + // we get detailed error messages that include the file path + #[cfg(all(unix, not(target_os = "redox")))] let err_msg = "chmod: cannot access 'a': Permission denied\n"; + #[cfg(not(all(unix, not(target_os = "redox"))))] + let err_msg = "chmod: Permission denied\n"; // order of command is a, a/b then c // command is expected to fail and not just take the last exit code @@ -434,9 +436,8 @@ fn test_chmod_recursive() { make_file(&at.plus_as_string("a/b/b"), 0o100444); make_file(&at.plus_as_string("a/b/c/c"), 0o100444); make_file(&at.plus_as_string("z/y"), 0o100444); - #[cfg(not(target_os = "linux"))] - let err_msg = "chmod: Permission denied\n"; - #[cfg(target_os = "linux")] + // With safe_traversal enabled on all Unix platforms, the error message + // now includes the file path consistently across platforms let err_msg = "chmod: cannot access 'z': Permission denied\n"; // only the permissions of folder `a` and `z` are changed From 2e26454f57f3d81761eb9999de1e1069058bce16 Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Fri, 9 Jan 2026 09:36:11 +0100 Subject: [PATCH 140/425] df: improve perfs (#10122) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * df: fix O(n²) performance in deeply nested directories by checking is_absolute() before is_symlink() * add "sysfs" to the spell-checker ignore --- src/uu/df/src/df.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/uu/df/src/df.rs b/src/uu/df/src/df.rs index d7746b915..ff72dce34 100644 --- a/src/uu/df/src/df.rs +++ b/src/uu/df/src/df.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 itotal iused iavail ipcent pcent tmpfs squashfs lofs +// spell-checker:ignore itotal iused iavail ipcent pcent tmpfs squashfs lofs sysfs mod blocks; mod columns; mod filesystem; @@ -311,7 +311,11 @@ fn get_all_filesystems(opt: &Options) -> UResult> { // but `vmi` is probably not very long in practice. if is_included(&mi, opt) && is_best(&mounts, &mi) { let dev_path: &Path = Path::new(&mi.dev_name); - if dev_path.is_symlink() { + // Only check is_symlink() for absolute paths. For non-absolute paths + // like "tmpfs", "sysfs", etc., is_symlink() would resolve relative to + // the current working directory, which is extremely slow in deeply + // nested directories (O(n) syscalls where n is the directory depth). + if dev_path.is_absolute() && dev_path.is_symlink() { if let Ok(canonicalized_symlink) = uucore::fs::canonicalize( dev_path, uucore::fs::MissingHandling::Existing, From 23b11eaf678c4f3724eaac937e979892b3e5e0a6 Mon Sep 17 00:00:00 2001 From: Daniel Hofstetter Date: Fri, 9 Jan 2026 09:42:42 +0100 Subject: [PATCH 141/425] deny.toml: add constant_time_eq to skip list (#10137) --- deny.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/deny.toml b/deny.toml index eb0e02300..f2925b1ae 100644 --- a/deny.toml +++ b/deny.toml @@ -109,6 +109,8 @@ skip = [ { name = "linux-raw-sys", version = "0.11.0" }, # crossterm { name = "signal-hook", version = "0.3.18" }, + # blake2b_simd + { name = "constant_time_eq", version = "0.3.1" }, ] # spell-checker: enable From c78ccfa2a01da190770bd9b6ebd2033dd212b2cc Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Thu, 8 Jan 2026 00:35:32 +0100 Subject: [PATCH 142/425] use the enable_pipe_errors instead of libc::signal --- src/uu/cat/Cargo.toml | 2 +- src/uu/cat/src/cat.rs | 6 +----- src/uu/env/src/env.rs | 4 +--- src/uu/tail/Cargo.toml | 2 +- src/uu/tail/src/tail.rs | 4 +--- src/uu/tr/Cargo.toml | 2 +- src/uu/tr/src/tr.rs | 6 +----- 7 files changed, 7 insertions(+), 19 deletions(-) diff --git a/src/uu/cat/Cargo.toml b/src/uu/cat/Cargo.toml index 632a0d97b..7dbd1ecbf 100644 --- a/src/uu/cat/Cargo.toml +++ b/src/uu/cat/Cargo.toml @@ -21,7 +21,7 @@ path = "src/cat.rs" clap = { workspace = true } memchr = { workspace = true } thiserror = { workspace = true } -uucore = { workspace = true, features = ["fast-inc", "fs", "pipes"] } +uucore = { workspace = true, features = ["fast-inc", "fs", "pipes", "signals"] } fluent = { workspace = true } [target.'cfg(unix)'.dependencies] diff --git a/src/uu/cat/src/cat.rs b/src/uu/cat/src/cat.rs index 3497429d2..4e5f07205 100644 --- a/src/uu/cat/src/cat.rs +++ b/src/uu/cat/src/cat.rs @@ -20,8 +20,6 @@ use std::os::unix::fs::FileTypeExt; use thiserror::Error; use uucore::display::Quotable; use uucore::error::UResult; -#[cfg(not(target_os = "windows"))] -use uucore::libc; use uucore::translate; use uucore::{fast_inc::fast_inc_one, format_usage}; @@ -225,9 +223,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { // (see https://github.com/rust-lang/rust/issues/62569), so we restore it's // default action here. #[cfg(not(target_os = "windows"))] - unsafe { - libc::signal(libc::SIGPIPE, libc::SIG_DFL); - } + let _ = uucore::signals::enable_pipe_errors(); let matches = uucore::clap_localization::handle_clap_result(uu_app(), args)?; diff --git a/src/uu/env/src/env.rs b/src/uu/env/src/env.rs index d715d3e9e..40f32b765 100644 --- a/src/uu/env/src/env.rs +++ b/src/uu/env/src/env.rs @@ -1098,9 +1098,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { // Rust ignores SIGPIPE (see https://github.com/rust-lang/rust/issues/62569). // We restore its default action here. #[cfg(unix)] - unsafe { - libc::signal(libc::SIGPIPE, libc::SIG_DFL); - } + let _ = uucore::signals::enable_pipe_errors(); EnvAppData::default().run_env(args) } diff --git a/src/uu/tail/Cargo.toml b/src/uu/tail/Cargo.toml index 7d7b57a74..732278732 100644 --- a/src/uu/tail/Cargo.toml +++ b/src/uu/tail/Cargo.toml @@ -23,7 +23,7 @@ clap = { workspace = true } libc = { workspace = true } memchr = { workspace = true } notify = { workspace = true } -uucore = { workspace = true, features = ["fs", "parser-size"] } +uucore = { workspace = true, features = ["fs", "parser-size", "signals"] } same-file = { workspace = true } fluent = { workspace = true } diff --git a/src/uu/tail/src/tail.rs b/src/uu/tail/src/tail.rs index cd10203b3..56bf15504 100644 --- a/src/uu/tail/src/tail.rs +++ b/src/uu/tail/src/tail.rs @@ -45,9 +45,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { // (see https://github.com/rust-lang/rust/issues/62569), so we restore it's // default action here. #[cfg(not(target_os = "windows"))] - unsafe { - libc::signal(libc::SIGPIPE, libc::SIG_DFL); - } + let _ = uucore::signals::enable_pipe_errors(); let settings = parse_args(args)?; diff --git a/src/uu/tr/Cargo.toml b/src/uu/tr/Cargo.toml index c20e102d1..0ab2ca9dd 100644 --- a/src/uu/tr/Cargo.toml +++ b/src/uu/tr/Cargo.toml @@ -20,7 +20,7 @@ path = "src/tr.rs" [dependencies] nom = { workspace = true } clap = { workspace = true } -uucore = { workspace = true, features = ["fs"] } +uucore = { workspace = true, features = ["fs", "signals"] } fluent = { workspace = true } bytecount = { workspace = true, features = ["runtime-dispatch-simd"] } diff --git a/src/uu/tr/src/tr.rs b/src/uu/tr/src/tr.rs index d9349baa5..2b20d29ce 100644 --- a/src/uu/tr/src/tr.rs +++ b/src/uu/tr/src/tr.rs @@ -18,8 +18,6 @@ use std::io::{stdin, stdout}; use uucore::display::Quotable; use uucore::error::{UResult, USimpleError, UUsageError}; use uucore::fs::is_stdin_directory; -#[cfg(not(target_os = "windows"))] -use uucore::libc; use uucore::translate; use uucore::{format_usage, os_str_as_bytes, show}; @@ -38,9 +36,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { // (see https://github.com/rust-lang/rust/issues/62569), so we restore it's // default action here. #[cfg(not(target_os = "windows"))] - unsafe { - libc::signal(libc::SIGPIPE, libc::SIG_DFL); - } + let _ = uucore::signals::enable_pipe_errors(); let matches = uucore::clap_localization::handle_clap_result(uu_app(), args)?; From 23fed675045b3f63a4e4a0652267adf7453f139d Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Thu, 8 Jan 2026 00:28:53 +0100 Subject: [PATCH 143/425] mkfifo: replace unsafe libc::mkfifo with nix::unistd::mkfifo --- Cargo.lock | 1 + src/uu/mkfifo/Cargo.toml | 1 + src/uu/mkfifo/src/mkfifo.rs | 10 +++------- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e8d1969ef..29a11c199 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3559,6 +3559,7 @@ dependencies = [ "clap", "fluent", "libc", + "nix", "uucore", ] diff --git a/src/uu/mkfifo/Cargo.toml b/src/uu/mkfifo/Cargo.toml index ca0cc4dcb..900614344 100644 --- a/src/uu/mkfifo/Cargo.toml +++ b/src/uu/mkfifo/Cargo.toml @@ -20,6 +20,7 @@ path = "src/mkfifo.rs" [dependencies] clap = { workspace = true } libc = { workspace = true } +nix = { workspace = true, features = ["fs"] } uucore = { workspace = true, features = ["fs", "mode"] } fluent = { workspace = true } diff --git a/src/uu/mkfifo/src/mkfifo.rs b/src/uu/mkfifo/src/mkfifo.rs index 351e8fba1..3586eb7c3 100644 --- a/src/uu/mkfifo/src/mkfifo.rs +++ b/src/uu/mkfifo/src/mkfifo.rs @@ -4,8 +4,8 @@ // file that was distributed with this source code. use clap::{Arg, ArgAction, Command, value_parser}; -use libc::mkfifo; -use std::ffi::CString; +use nix::sys::stat::Mode; +use nix::unistd::mkfifo; use std::fs; use std::os::unix::fs::PermissionsExt; use uucore::display::Quotable; @@ -39,11 +39,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { }; for f in fifos { - let err = unsafe { - let name = CString::new(f.as_bytes()).unwrap(); - mkfifo(name.as_ptr(), 0o666) - }; - if err == -1 { + if mkfifo(f.as_str(), Mode::from_bits_truncate(0o666)).is_err() { show!(USimpleError::new( 1, translate!("mkfifo-error-cannot-create-fifo", "path" => f.quote()), From 19d3386dd951396ff3ee60b0e2b45937ed46dc97 Mon Sep 17 00:00:00 2001 From: mattsu <35655889+mattsu2020@users.noreply.github.com> Date: Fri, 9 Jan 2026 17:44:19 +0900 Subject: [PATCH 144/425] timeout: use nix kill/setpgid to reduce unsafe (#10120) * refactor(timeout): replace unsafe libc calls with nix crate equivalents Replace unsafe `libc::kill` and `libc::setpgid` calls in the timeout utility with safer nix crate wrappers (`kill`, `getpid`, `setpgid`) to reduce unsafe code usage and improve overall code safety. This maintains functionality while leveraging Rust's type safety for signal and process group operations. * refactor(timeout): reorder imports for consistency Reorder nix imports to alphabetical order in timeout.rs for better code organization and readability. No functional changes. --- src/uu/timeout/src/timeout.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/uu/timeout/src/timeout.rs b/src/uu/timeout/src/timeout.rs index 3e1a35c45..de20bec83 100644 --- a/src/uu/timeout/src/timeout.rs +++ b/src/uu/timeout/src/timeout.rs @@ -27,6 +27,9 @@ use uucore::{ signals::{signal_by_name_or_value, signal_name_by_value}, }; +use nix::sys::signal::{Signal, kill}; +use nix::unistd::{Pid, getpid, setpgid}; + pub mod options { pub static FOREGROUND: &str = "foreground"; pub static KILL_AFTER: &str = "kill-after"; @@ -293,8 +296,8 @@ fn preserve_signal_info(signal: libc::c_int) -> libc::c_int { // The easiest way to preserve the latter seems to be to kill // ourselves with whatever signal our child exited with, which is // what the following is intended to accomplish. - unsafe { - libc::kill(libc::getpid(), signal); + if let Ok(sig) = Signal::try_from(signal) { + let _ = kill(getpid(), Some(sig)); } signal } @@ -315,7 +318,7 @@ fn timeout( verbose: bool, ) -> UResult<()> { if !foreground { - unsafe { libc::setpgid(0, 0) }; + let _ = setpgid(Pid::from_raw(0), Pid::from_raw(0)); } #[cfg(unix)] enable_pipe_errors()?; From ebc08af9c34138f474b32ea0ef34bed3b086a3ed Mon Sep 17 00:00:00 2001 From: cerdelen <95369756+cerdelen@users.noreply.github.com> Date: Fri, 9 Jan 2026 11:31:24 +0100 Subject: [PATCH 145/425] Chgrp correct exit code (#10035) --- src/uucore/src/lib/features/perms.rs | 4 ++-- tests/by-util/test_chgrp.rs | 23 +++++++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/uucore/src/lib/features/perms.rs b/src/uucore/src/lib/features/perms.rs index 2823b35b1..d1e351ee3 100644 --- a/src/uucore/src/lib/features/perms.rs +++ b/src/uucore/src/lib/features/perms.rs @@ -619,7 +619,6 @@ impl ChownExecutor { ); continue; } - ret = match wrap_chown( path, &meta, @@ -632,7 +631,8 @@ impl ChownExecutor { if !n.is_empty() { show_error!("{n}"); } - 0 + // retain previous errors + ret.max(0) } Err(e) => { if self.verbosity.level != VerbosityLevel::Silent { diff --git a/tests/by-util/test_chgrp.rs b/tests/by-util/test_chgrp.rs index cc0727dd3..aa2c9192c 100644 --- a/tests/by-util/test_chgrp.rs +++ b/tests/by-util/test_chgrp.rs @@ -640,3 +640,26 @@ fn test_chgrp_recursive_on_file() { current_gid ); } + +#[test] +fn test_chgrp_exit_code_not_being_overwritten_by_last_file() { + use std::os::unix::prelude::PermissionsExt; + + let current_gid = getegid(); + let (at, mut ucmd) = at_and_ucmd!(); + at.mkdir("dir"); + at.mkdir("dir/a"); + at.mkdir("dir/b"); + at.touch("dir/b/file"); + at.touch("dir/a/file"); + std::fs::set_permissions(at.plus("dir/a"), PermissionsExt::from_mode(0o0000)).unwrap(); + + // chgrp walks the dir alphabetically. Dir a does not have permissions so it fails, dir b does have + // permissions so it succeeds. We check that the overall command does fail although the + // last step succeeded. + + ucmd.arg("-R") + .arg(current_gid.to_string()) + .arg("dir") + .fails(); +} From 25c088154a69b4cabdc6400c186fa96ab0ea7a9b Mon Sep 17 00:00:00 2001 From: oech3 <> Date: Fri, 9 Jan 2026 19:47:30 +0900 Subject: [PATCH 146/425] cp: Avoid other error at cp stream /dev/full --- src/uu/cp/src/platform/linux.rs | 2 +- tests/by-util/test_cp.rs | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/uu/cp/src/platform/linux.rs b/src/uu/cp/src/platform/linux.rs index 793890192..427593ae2 100644 --- a/src/uu/cp/src/platform/linux.rs +++ b/src/uu/cp/src/platform/linux.rs @@ -251,7 +251,7 @@ where } let num_bytes_copied = buf_copy::copy_stream(&mut src_file, &mut dst_file) - .map_err(|_| std::io::Error::from(std::io::ErrorKind::Other))?; + .map_err(|e| std::io::Error::other(format!("{e}")))?; Ok(num_bytes_copied) } diff --git a/tests/by-util/test_cp.rs b/tests/by-util/test_cp.rs index dfd07ec4b..2d252c560 100644 --- a/tests/by-util/test_cp.rs +++ b/tests/by-util/test_cp.rs @@ -88,6 +88,16 @@ macro_rules! assert_metadata_eq { }}; } +#[test] +#[cfg(target_os = "linux")] +fn test_cp_stream_to_full() { + let (_, mut ucmd) = at_and_ucmd!(); + ucmd.arg("/dev/zero") + .arg("/dev/full") + .fails() + .stderr_contains("No space"); +} + #[test] fn test_cp_cp() { let (at, mut ucmd) = at_and_ucmd!(); From 060559b35ceff4d92b05fb8d3444a02deb40a00d Mon Sep 17 00:00:00 2001 From: Dorian Peron Date: Fri, 9 Jan 2026 12:37:02 +0100 Subject: [PATCH 147/425] Revert "hashsum, cksum: Move --ckeck confliction to clap" This reverts commit cbf2e6bb7a115f82f07d513b4c1336e34457d6ee. --- src/uu/cksum/src/cksum.rs | 11 ++++++--- src/uu/hashsum/src/hashsum.rs | 26 ++++++++++----------- src/uucore/src/lib/features/checksum/mod.rs | 2 ++ tests/by-util/test_cksum.rs | 4 ++-- 4 files changed, 25 insertions(+), 18 deletions(-) diff --git a/src/uu/cksum/src/cksum.rs b/src/uu/cksum/src/cksum.rs index 72c7984f0..447e90954 100644 --- a/src/uu/cksum/src/cksum.rs +++ b/src/uu/cksum/src/cksum.rs @@ -132,6 +132,14 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { return Err(ChecksumError::AlgorithmNotSupportedWithCheck.into()); } + let text_flag = matches.get_flag(options::TEXT); + let binary_flag = matches.get_flag(options::BINARY); + let tag = matches.get_flag(options::TAG); + + if tag || binary_flag || text_flag { + return Err(ChecksumError::BinaryTextConflict.into()); + } + // Execute the checksum validation based on the presence of files or the use of stdin let verbose = ChecksumVerbose::new(status, quiet, warn); @@ -243,9 +251,6 @@ pub fn uu_app() -> Command { .short('c') .long(options::CHECK) .help(translate!("cksum-help-check")) - .conflicts_with(options::TAG) - .conflicts_with(options::BINARY) - .conflicts_with(options::TEXT) .action(ArgAction::SetTrue), ) .arg( diff --git a/src/uu/hashsum/src/hashsum.rs b/src/uu/hashsum/src/hashsum.rs index a13ec4684..3bc6dcff5 100644 --- a/src/uu/hashsum/src/hashsum.rs +++ b/src/uu/hashsum/src/hashsum.rs @@ -170,7 +170,18 @@ pub fn uumain(mut args: impl uucore::Args) -> UResult<()> { .map(|s| s.as_os_str()); if check { - // No reason to allow --check with --binary/--text on Cygwin. It want to be same with Linux and --text was broken for a long time. + // on Windows, allow --binary/--text to be used with --check + // and keep the behavior of defaulting to binary + #[cfg(not(windows))] + { + let text_flag = matches.get_flag("text"); + let binary_flag = matches.get_flag("binary"); + + if binary_flag || text_flag { + return Err(ChecksumError::BinaryTextConflict.into()); + } + } + let verbose = ChecksumVerbose::new(status, quiet, warn); let opts = ChecksumValidateOptions { @@ -220,10 +231,6 @@ mod options { } pub fn uu_app_common() -> Command { - // --text --arg-deps-check should be error by Arg::new(options::CHECK)...conflicts_with(options::TEXT) - // https://github.com/clap-rs/clap/issues/4520 ? - // Let --{warn,strict,quiet,status,ignore-missing} reject --text and remove them later. - // Bad error message, but not a lie... Command::new(uucore::util_name()) .version(uucore::crate_version!()) .help_template(uucore::localized_help_template(uucore::util_name())) @@ -253,9 +260,7 @@ pub fn uu_app_common() -> Command { .long("check") .help(translate!("hashsum-help-check")) .action(ArgAction::SetTrue) - .conflicts_with(options::BINARY) - .conflicts_with(options::TEXT) - .conflicts_with(options::TAG), + .conflicts_with("tag"), ) .arg( Arg::new(options::TAG) @@ -288,7 +293,6 @@ pub fn uu_app_common() -> Command { .help(translate!("hashsum-help-quiet")) .action(ArgAction::SetTrue) .overrides_with_all([options::STATUS, options::WARN]) - .conflicts_with("text") .requires(options::CHECK), ) .arg( @@ -298,7 +302,6 @@ pub fn uu_app_common() -> Command { .help(translate!("hashsum-help-status")) .action(ArgAction::SetTrue) .overrides_with_all([options::QUIET, options::WARN]) - .conflicts_with("text") .requires(options::CHECK), ) .arg( @@ -306,7 +309,6 @@ pub fn uu_app_common() -> Command { .long("strict") .help(translate!("hashsum-help-strict")) .action(ArgAction::SetTrue) - .conflicts_with("text") .requires(options::CHECK), ) .arg( @@ -314,7 +316,6 @@ pub fn uu_app_common() -> Command { .long("ignore-missing") .help(translate!("hashsum-help-ignore-missing")) .action(ArgAction::SetTrue) - .conflicts_with("text") .requires(options::CHECK), ) .arg( @@ -324,7 +325,6 @@ pub fn uu_app_common() -> Command { .help(translate!("hashsum-help-warn")) .action(ArgAction::SetTrue) .overrides_with_all([options::QUIET, options::STATUS]) - .conflicts_with("text") .requires(options::CHECK), ) .arg( diff --git a/src/uucore/src/lib/features/checksum/mod.rs b/src/uucore/src/lib/features/checksum/mod.rs index e272cdea6..7cf7fe129 100644 --- a/src/uucore/src/lib/features/checksum/mod.rs +++ b/src/uucore/src/lib/features/checksum/mod.rs @@ -390,6 +390,8 @@ pub enum ChecksumError { #[error("--length is only supported with --algorithm blake2b, sha2, or sha3")] LengthOnlyForBlake2bSha2Sha3, + #[error("the --binary and --text options are meaningless when verifying checksums")] + BinaryTextConflict, #[error("--text mode is only supported with --untagged")] TextWithoutUntagged, #[error("--check is not supported with --algorithm={{bsd,sysv,crc,crc32b}}")] diff --git a/tests/by-util/test_cksum.rs b/tests/by-util/test_cksum.rs index 40f49fc70..d1abe3409 100644 --- a/tests/by-util/test_cksum.rs +++ b/tests/by-util/test_cksum.rs @@ -1216,7 +1216,7 @@ fn test_conflicting_options() { .fails_with_code(1) .no_stdout() .stderr_contains( - "cannot be used with", //clap generated error + "cksum: the --binary and --text options are meaningless when verifying checksums", ); scene @@ -1228,7 +1228,7 @@ fn test_conflicting_options() { .fails_with_code(1) .no_stdout() .stderr_contains( - "cannot be used with", //clap generated error + "cksum: the --binary and --text options are meaningless when verifying checksums", ); } From e5f79b0ce08f3c2336a5ff585ee940479df74faf Mon Sep 17 00:00:00 2001 From: Dorian Peron Date: Fri, 9 Jan 2026 12:37:02 +0100 Subject: [PATCH 148/425] Revert "cksum,hashsum: Drop a message replaced by clap" This reverts commit 5e5c58ea93db180a6c4eba191f040f0a2a475214. --- src/uucore/src/lib/features/checksum/mod.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/uucore/src/lib/features/checksum/mod.rs b/src/uucore/src/lib/features/checksum/mod.rs index 7cf7fe129..2f3d28b41 100644 --- a/src/uucore/src/lib/features/checksum/mod.rs +++ b/src/uucore/src/lib/features/checksum/mod.rs @@ -374,6 +374,9 @@ pub enum ChecksumError { #[error("the --raw option is not supported with multiple files")] RawMultipleFiles, + #[error("the --{0} option is meaningful only when verifying checksums")] + CheckOnlyFlag(String), + // --length sanitization errors #[error("--length required for {}", .0.quote())] LengthRequired(String), From b3a6de8f99bdca03b17c7620f1ab324c47b7e23c Mon Sep 17 00:00:00 2001 From: Dorian Peron Date: Fri, 9 Jan 2026 12:37:02 +0100 Subject: [PATCH 149/425] Revert "cksum.rs: Simple default tag variable" This reverts commit ce00c0b154f93c5eb6f3ea4224f8d16ad3990ece. --- src/uu/cksum/src/cksum.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/uu/cksum/src/cksum.rs b/src/uu/cksum/src/cksum.rs index 447e90954..ff0e0b681 100644 --- a/src/uu/cksum/src/cksum.rs +++ b/src/uu/cksum/src/cksum.rs @@ -162,7 +162,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { // Set the default algorithm to CRC when not '--check'ing. let algo_kind = algo_cli.unwrap_or(AlgoKind::Crc); - let tag = !matches.get_flag(options::UNTAGGED); // Making TAG default at clap blocks --untagged + let tag = matches.get_flag(options::TAG) || !matches.get_flag(options::UNTAGGED); let binary = matches.get_flag(options::BINARY); let algo = SizedAlgoKind::from_unsized(algo_kind, length)?; From 6e87b83a8d64ca09ab783f09947748cc6cbc1f04 Mon Sep 17 00:00:00 2001 From: Dorian Peron Date: Fri, 9 Jan 2026 12:37:02 +0100 Subject: [PATCH 150/425] Revert "Merge pull request #9999 from oech3/cksum-text-clap-untagged" This reverts commit 8cb4f3094b4e659cfac4532342f4ddaab1840217. --- src/uu/cksum/src/cksum.rs | 48 ++++++++++++++++++++++++++++++------- tests/by-util/test_cksum.rs | 2 +- 2 files changed, 41 insertions(+), 9 deletions(-) diff --git a/src/uu/cksum/src/cksum.rs b/src/uu/cksum/src/cksum.rs index ff0e0b681..00753febf 100644 --- a/src/uu/cksum/src/cksum.rs +++ b/src/uu/cksum/src/cksum.rs @@ -7,7 +7,7 @@ use clap::builder::ValueParser; use clap::{Arg, ArgAction, Command}; -use std::ffi::OsString; +use std::ffi::{OsStr, OsString}; use uucore::checksum::compute::{ ChecksumComputeOptions, figure_out_output_format, perform_checksum_computation, }; @@ -73,6 +73,42 @@ mod options { /// Returns a pair of boolean. The first one indicates if we should use tagged /// output format, the second one indicates if we should use the binary flag in /// the untagged case. +fn handle_tag_text_binary_flags>( + args: impl Iterator, +) -> UResult<(bool, bool)> { + let mut tag = true; + let mut binary = false; + let mut text = false; + + // --binary, --tag and --untagged are tight together: none of them + // conflicts with each other but --tag will reset "binary" and "text" and + // set "tag". + + for arg in args { + let arg = arg.as_ref(); + if arg == "-b" || arg == "--binary" { + text = false; + binary = true; + } else if arg == "--text" { + text = true; + binary = false; + } else if arg == "--tag" { + tag = true; + binary = false; + text = false; + } else if arg == "--untagged" { + tag = false; + } + } + + // Specifying --text without ever mentioning --untagged fails. + if text && tag { + return Err(ChecksumError::TextWithoutUntagged.into()); + } + + Ok((tag, binary)) +} + /// Sanitize the `--length` argument depending on `--algorithm` and `--length`. fn maybe_sanitize_length( algo_cli: Option, @@ -162,8 +198,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { // Set the default algorithm to CRC when not '--check'ing. let algo_kind = algo_cli.unwrap_or(AlgoKind::Crc); - let tag = matches.get_flag(options::TAG) || !matches.get_flag(options::UNTAGGED); - let binary = matches.get_flag(options::BINARY); + let (tag, binary) = handle_tag_text_binary_flags(std::env::args_os())?; let algo = SizedAlgoKind::from_unsized(algo_kind, length)?; let line_ending = LineEnding::from_zero_flag(matches.get_flag(options::ZERO)); @@ -222,9 +257,7 @@ pub fn uu_app() -> Command { .long(options::TAG) .help(translate!("cksum-help-tag")) .action(ArgAction::SetTrue) - .overrides_with(options::UNTAGGED) - .overrides_with(options::BINARY) - .overrides_with(options::TEXT), + .overrides_with(options::UNTAGGED), ) .arg( Arg::new(options::LENGTH) @@ -268,8 +301,7 @@ pub fn uu_app() -> Command { .short('t') .hide(true) .overrides_with(options::BINARY) - .action(ArgAction::SetTrue) - .requires(options::UNTAGGED), + .action(ArgAction::SetTrue), ) .arg( Arg::new(options::BINARY) diff --git a/tests/by-util/test_cksum.rs b/tests/by-util/test_cksum.rs index d1abe3409..d4685d619 100644 --- a/tests/by-util/test_cksum.rs +++ b/tests/by-util/test_cksum.rs @@ -1066,7 +1066,7 @@ mod output_format { .args(&["-a", "md5"]) .arg(at.subdir.join("f")) .fails_with_code(1) - .stderr_contains("the following required arguments were not provided"); //clap does not change the meaning + .stderr_contains("--text mode is only supported with --untagged"); } #[test] From ced5dc92f46e8291edb9d08996a3a044f8b21f1d Mon Sep 17 00:00:00 2001 From: Dorian Peron Date: Fri, 9 Jan 2026 12:37:02 +0100 Subject: [PATCH 151/425] Revert "cksum: Move --ckeck's deps by clap" This reverts commit 1d63bdd163f6cc5d01a8442a430d9561e36d440b. --- src/uu/cksum/src/cksum.rs | 33 ++++++++++++++++++--------------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/src/uu/cksum/src/cksum.rs b/src/uu/cksum/src/cksum.rs index 00753febf..7d3228407 100644 --- a/src/uu/cksum/src/cksum.rs +++ b/src/uu/cksum/src/cksum.rs @@ -139,11 +139,19 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { let check = matches.get_flag(options::CHECK); - let ignore_missing = matches.get_flag(options::IGNORE_MISSING); - let warn = matches.get_flag(options::WARN); - let quiet = matches.get_flag(options::QUIET); - let strict = matches.get_flag(options::STRICT); - let status = matches.get_flag(options::STATUS); + let check_flag = |flag| match (check, matches.get_flag(flag)) { + (_, false) => Ok(false), + (true, true) => Ok(true), + (false, true) => Err(ChecksumError::CheckOnlyFlag(flag.into())), + }; + + // Each of the following flags are only expected in --check mode. + // If we encounter them otherwise, end with an error. + let ignore_missing = check_flag(options::IGNORE_MISSING)?; + let warn = check_flag(options::WARN)?; + let quiet = check_flag(options::QUIET)?; + let strict = check_flag(options::STRICT)?; + let status = check_flag(options::STATUS)?; let algo_cli = matches .get_one::(options::ALGORITHM) @@ -276,8 +284,7 @@ pub fn uu_app() -> Command { Arg::new(options::STRICT) .long(options::STRICT) .help(translate!("cksum-help-strict")) - .action(ArgAction::SetTrue) - .requires(options::CHECK), + .action(ArgAction::SetTrue), ) .arg( Arg::new(options::CHECK) @@ -317,31 +324,27 @@ pub fn uu_app() -> Command { .long("warn") .help(translate!("cksum-help-warn")) .action(ArgAction::SetTrue) - .overrides_with_all([options::STATUS, options::QUIET]) - .requires(options::CHECK), + .overrides_with_all([options::STATUS, options::QUIET]), ) .arg( Arg::new(options::STATUS) .long("status") .help(translate!("cksum-help-status")) .action(ArgAction::SetTrue) - .overrides_with_all([options::WARN, options::QUIET]) - .requires(options::CHECK), + .overrides_with_all([options::WARN, options::QUIET]), ) .arg( Arg::new(options::QUIET) .long(options::QUIET) .help(translate!("cksum-help-quiet")) .action(ArgAction::SetTrue) - .overrides_with_all([options::WARN, options::STATUS]) - .requires(options::CHECK), + .overrides_with_all([options::WARN, options::STATUS]), ) .arg( Arg::new(options::IGNORE_MISSING) .long(options::IGNORE_MISSING) .help(translate!("cksum-help-ignore-missing")) - .action(ArgAction::SetTrue) - .requires(options::CHECK), + .action(ArgAction::SetTrue), ) .arg( Arg::new(options::ZERO) From 0de3871dbcef4fdd00b30126746b15fecb0bd69c Mon Sep 17 00:00:00 2001 From: Dorian Peron Date: Fri, 9 Jan 2026 12:37:02 +0100 Subject: [PATCH 152/425] Revert "build-gnu.sh: Let md5sum.pl clap compatible" This reverts commit 29c777cfca914649f0ac7fd41dec08fec2c2f684. --- util/build-gnu.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/util/build-gnu.sh b/util/build-gnu.sh index 6075c8637..7102acd07 100755 --- a/util/build-gnu.sh +++ b/util/build-gnu.sh @@ -320,9 +320,11 @@ echo "n_stat1 = \$n_stat1"\n\ echo "n_stat2 = \$n_stat2"\n\ test \$n_stat1 -ge \$n_stat2 \\' tests/ls/stat-free-color.sh -# clap changes the error message. Check exit code only. +# 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 '/check-ignore-missing-4/,/EXIT/c \ ['\''check-ignore-missing-4'\'', '\''--ignore-missing'\'', {IN=> {f=> '\'''\''}}, {ERR_SUBST=>"s/.*//s"}, {EXIT=> 1}],' tests/cksum/md5sum.pl +# clap changes the error message + "${SED}" -i '/check-ignore-missing-4/,/EXIT=> 1/ { /ERR=>/,/try_help/d }' 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. From b1edc49ef6680ec105fd9557d4509cf8b0e843e9 Mon Sep 17 00:00:00 2001 From: Dorian Peron Date: Fri, 9 Jan 2026 12:37:02 +0100 Subject: [PATCH 153/425] Revert "hashsum: Move --ckeck's deps to clap" This reverts commit c01e83eb76cc59e0a78eb2d5fa0ec99d80c4c44f. --- src/uu/hashsum/src/hashsum.rs | 33 ++++++++++++++++++--------------- tests/by-util/test_hashsum.rs | 6 +++--- util/build-gnu.sh | 2 -- 3 files changed, 21 insertions(+), 20 deletions(-) diff --git a/src/uu/hashsum/src/hashsum.rs b/src/uu/hashsum/src/hashsum.rs index 3bc6dcff5..1bad36355 100644 --- a/src/uu/hashsum/src/hashsum.rs +++ b/src/uu/hashsum/src/hashsum.rs @@ -157,11 +157,19 @@ pub fn uumain(mut args: impl uucore::Args) -> UResult<()> { }; let check = matches.get_flag("check"); - let ignore_missing = matches.get_flag("ignore-missing"); - let warn = matches.get_flag("warn"); - let quiet = matches.get_flag("quiet"); - let strict = matches.get_flag("strict"); - let status = matches.get_flag("status"); + let check_flag = |flag| match (check, matches.get_flag(flag)) { + (_, false) => Ok(false), + (true, true) => Ok(true), + (false, true) => Err(ChecksumError::CheckOnlyFlag(flag.into())), + }; + + // Each of the following flags are only expected in --check mode. + // If we encounter them otherwise, end with an error. + let ignore_missing = check_flag("ignore-missing")?; + let warn = check_flag("warn")?; + let quiet = check_flag("quiet")?; + let strict = check_flag("strict")?; + let status = check_flag("status")?; // clap provides the default value -. So we unwrap() safety. let files = matches @@ -292,8 +300,7 @@ pub fn uu_app_common() -> Command { .long(options::QUIET) .help(translate!("hashsum-help-quiet")) .action(ArgAction::SetTrue) - .overrides_with_all([options::STATUS, options::WARN]) - .requires(options::CHECK), + .overrides_with_all([options::STATUS, options::WARN]), ) .arg( Arg::new(options::STATUS) @@ -301,22 +308,19 @@ pub fn uu_app_common() -> Command { .long("status") .help(translate!("hashsum-help-status")) .action(ArgAction::SetTrue) - .overrides_with_all([options::QUIET, options::WARN]) - .requires(options::CHECK), + .overrides_with_all([options::QUIET, options::WARN]), ) .arg( Arg::new(options::STRICT) .long("strict") .help(translate!("hashsum-help-strict")) - .action(ArgAction::SetTrue) - .requires(options::CHECK), + .action(ArgAction::SetTrue), ) .arg( Arg::new("ignore-missing") .long("ignore-missing") .help(translate!("hashsum-help-ignore-missing")) - .action(ArgAction::SetTrue) - .requires(options::CHECK), + .action(ArgAction::SetTrue), ) .arg( Arg::new(options::WARN) @@ -324,8 +328,7 @@ pub fn uu_app_common() -> Command { .long("warn") .help(translate!("hashsum-help-warn")) .action(ArgAction::SetTrue) - .overrides_with_all([options::QUIET, options::STATUS]) - .requires(options::CHECK), + .overrides_with_all([options::QUIET, options::STATUS]), ) .arg( Arg::new("zero") diff --git a/tests/by-util/test_hashsum.rs b/tests/by-util/test_hashsum.rs index 891cb9d4d..2f1719b0e 100644 --- a/tests/by-util/test_hashsum.rs +++ b/tests/by-util/test_hashsum.rs @@ -268,7 +268,7 @@ fn test_check_md5_ignore_missing() { .arg("--ignore-missing") .arg(at.subdir.join("testf.sha1")) .fails() - .stderr_contains("the following required arguments were not provided"); //clap generated error + .stderr_contains("the --ignore-missing option is meaningful only when verifying checksums"); } #[test] @@ -1021,13 +1021,13 @@ fn test_check_quiet() { .arg("--quiet") .arg(at.subdir.join("in.md5")) .fails() - .stderr_contains("the following required arguments were not provided"); //clap generated error + .stderr_contains("md5sum: the --quiet option is meaningful only when verifying checksums"); scene .ccmd("md5sum") .arg("--strict") .arg(at.subdir.join("in.md5")) .fails() - .stderr_contains("the following required arguments were not provided"); //clap generated error + .stderr_contains("md5sum: the --strict option is meaningful only when verifying checksums"); } #[test] diff --git a/util/build-gnu.sh b/util/build-gnu.sh index 7102acd07..65abcc4fd 100755 --- a/util/build-gnu.sh +++ b/util/build-gnu.sh @@ -322,8 +322,6 @@ 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 -# clap changes the error message - "${SED}" -i '/check-ignore-missing-4/,/EXIT=> 1/ { /ERR=>/,/try_help/d }' 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 From b5a49f33b5fed82f6cd786f18e5bffc7dc46b2c7 Mon Sep 17 00:00:00 2001 From: Chris Dryden Date: Fri, 9 Jan 2026 14:31:46 -0500 Subject: [PATCH 154/425] tac: use temp file for stdin to respect TMPDIR and handle disk-full errors (#10094) --- .../cspell.dictionaries/jargon.wordlist.txt | 1 + Cargo.lock | 1 + fuzz/Cargo.lock | 89 ++++++++----------- src/uu/tac/Cargo.toml | 1 + src/uu/tac/src/tac.rs | 47 ++++++++-- tests/by-util/test_tac.rs | 12 +++ util/fetch-gnu.sh | 3 + 7 files changed, 94 insertions(+), 60 deletions(-) diff --git a/.vscode/cspell.dictionaries/jargon.wordlist.txt b/.vscode/cspell.dictionaries/jargon.wordlist.txt index a1bda0e76..88478fa4e 100644 --- a/.vscode/cspell.dictionaries/jargon.wordlist.txt +++ b/.vscode/cspell.dictionaries/jargon.wordlist.txt @@ -215,3 +215,4 @@ TUNABLES tunables VMULL vmull +tmpfs diff --git a/Cargo.lock b/Cargo.lock index 29a11c199..30192f4cb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3968,6 +3968,7 @@ dependencies = [ "memchr", "memmap2", "regex", + "tempfile", "thiserror 2.0.17", "uucore", ] diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index 2554b8183..fd3fdab4d 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -103,9 +103,9 @@ dependencies = [ [[package]] name = "bigdecimal" -version = "0.4.9" +version = "0.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "560f42649de9fa436b73517378a147ec21f6c997a546581df4b4b31677828934" +checksum = "4d6867f1565b3aad85681f1015055b087fcfd840d6aeee6eee7f2da317603695" dependencies = [ "autocfg", "libm", @@ -184,9 +184,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.19.0" +version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" [[package]] name = "bytecount" @@ -196,9 +196,9 @@ checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" [[package]] name = "cc" -version = "1.2.48" +version = "1.2.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c481bdbf0ed3b892f6f806287d72acd515b352a4ec27a208489b8c1bc839633a" +checksum = "7a0aeaff4ff1a90589618835a598e545176939b97874f7abc7851caa0618f203" dependencies = [ "find-msvc-tools", "jobserver", @@ -231,18 +231,18 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.53" +version = "4.5.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8" +checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394" dependencies = [ "clap_builder", ] [[package]] name = "clap_builder" -version = "4.5.53" +version = "4.5.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00" +checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00" dependencies = [ "anstream", "anstyle", @@ -323,30 +323,13 @@ dependencies = [ "libc", ] -[[package]] -name = "crc" -version = "3.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" -dependencies = [ - "crc-catalog", -] - -[[package]] -name = "crc-catalog" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" - [[package]] name = "crc-fast" -version = "1.8.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2f7c8d397a6353ef0c1d6217ab91b3ddb5431daf57fd013f506b967dcf44458" +checksum = "e75b2483e97a5a7da73ac68a05b629f9c53cff58d8ed1c77866079e18b00dba5" dependencies = [ - "crc", "digest", - "rustversion", "spin", ] @@ -515,9 +498,9 @@ checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] name = "find-msvc-tools" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" +checksum = "645cbb3a84e60b7531617d5ae4e57f7e27308f6445f5abf653209ea76dec8dff" [[package]] name = "flate2" @@ -753,9 +736,9 @@ checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" [[package]] name = "icu_properties" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" dependencies = [ "icu_collections", "icu_locale_core", @@ -767,9 +750,9 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" [[package]] name = "icu_provider" @@ -850,9 +833,9 @@ dependencies = [ [[package]] name = "jiff-tzdb" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1283705eb0a21404d2bfd6eef2a7593d240bc42a0bdb39db0ad6fa2ec026524" +checksum = "68971ebff725b9e2ca27a601c5eb38a4c5d64422c4cbab0c535f248087eda5c2" [[package]] name = "jiff-tzdb-platform" @@ -1119,9 +1102,9 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" [[package]] name = "portable-atomic" -version = "1.11.1" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" +checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950" [[package]] name = "portable-atomic-util" @@ -1154,9 +1137,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.103" +version = "1.0.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7" dependencies = [ "unicode-ident", ] @@ -1187,9 +1170,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.42" +version = "1.0.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" dependencies = [ "proc-macro2", ] @@ -1273,9 +1256,9 @@ checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" [[package]] name = "rustix" -version = "1.1.2" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" dependencies = [ "bitflags", "errno", @@ -1292,9 +1275,9 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "self_cell" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16c2f82143577edb4921b71ede051dac62ca3c16084e918bf7b40c96ae10eb33" +checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" [[package]] name = "serde" @@ -1366,9 +1349,9 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "simd-adler32" -version = "0.3.7" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" [[package]] name = "similar" @@ -1417,9 +1400,9 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "syn" -version = "2.0.111" +version = "2.0.113" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +checksum = "678faa00651c9eb72dd2020cbdf275d92eccb2400d568e419efdd64838145cb4" dependencies = [ "proc-macro2", "quote", @@ -1439,9 +1422,9 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.23.0" +version = "3.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" dependencies = [ "fastrand", "getrandom 0.3.4", diff --git a/src/uu/tac/Cargo.toml b/src/uu/tac/Cargo.toml index 79e6b6610..bd1652935 100644 --- a/src/uu/tac/Cargo.toml +++ b/src/uu/tac/Cargo.toml @@ -27,6 +27,7 @@ clap = { workspace = true } uucore = { workspace = true } thiserror = { workspace = true } fluent = { workspace = true } +tempfile = { workspace = true } [[bin]] name = "tac" diff --git a/src/uu/tac/src/tac.rs b/src/uu/tac/src/tac.rs index f38661d03..15e34baf8 100644 --- a/src/uu/tac/src/tac.rs +++ b/src/uu/tac/src/tac.rs @@ -13,6 +13,7 @@ use std::ffi::OsString; use std::io::{BufWriter, Read, Write, stdin, stdout}; use std::{ fs::{File, read}, + io::copy, path::Path, }; use uucore::error::UError; @@ -241,14 +242,22 @@ fn tac(filenames: &[OsString], before: bool, regex: bool, separator: &str) -> UR mmap = mmap1; &mmap } else { - let mut buf1 = Vec::new(); - if let Err(e) = stdin().read_to_end(&mut buf1) { - let e: Box = TacError::ReadError(OsString::from("stdin"), e).into(); - show!(e); - continue; + // Copy stdin to a temp file (respects TMPDIR), then mmap it. + // Falls back to Vec buffer if temp file creation fails (e.g., bad TMPDIR). + match buffer_stdin() { + Ok(StdinData::Mmap(mmap1)) => { + mmap = mmap1; + &mmap + } + Ok(StdinData::Vec(buf1)) => { + buf = buf1; + &buf + } + Err(e) => { + show!(TacError::ReadError(OsString::from("stdin"), e)); + continue; + } } - buf = buf1; - &buf } } else { let path = Path::new(filename); @@ -304,6 +313,30 @@ fn try_mmap_stdin() -> Option { unsafe { Mmap::map(&stdin()).ok() } } +enum StdinData { + Mmap(Mmap), + Vec(Vec), +} + +/// Copy stdin to a temp file, then memory-map it. +/// Falls back to reading directly into memory if temp file creation fails. +fn buffer_stdin() -> std::io::Result { + // Try to create a temp file (respects TMPDIR) + if let Ok(mut tmp) = tempfile::tempfile() { + // Temp file created - copy stdin to it, then read back + copy(&mut stdin(), &mut tmp)?; + // SAFETY: If the file is truncated while we map it, SIGBUS will be raised + // and our process will be terminated, thus preventing access of invalid memory. + let mmap = unsafe { Mmap::map(&tmp)? }; + Ok(StdinData::Mmap(mmap)) + } else { + // Fall back to reading directly into memory (e.g., bad TMPDIR) + let mut buf = Vec::new(); + stdin().read_to_end(&mut buf)?; + Ok(StdinData::Vec(buf)) + } +} + fn try_mmap_path(path: &Path) -> Option { let file = File::open(path).ok()?; diff --git a/tests/by-util/test_tac.rs b/tests/by-util/test_tac.rs index feb79f581..be2b89cae 100644 --- a/tests/by-util/test_tac.rs +++ b/tests/by-util/test_tac.rs @@ -335,3 +335,15 @@ fn test_failed_write_is_reported() { .fails() .stderr_is("tac: failed to write to stdout: No space left on device (os error 28)\n"); } + +#[cfg(target_os = "linux")] +#[test] +fn test_stdin_bad_tmpdir_fallback() { + // When TMPDIR is invalid, tac falls back to reading stdin directly into memory + new_ucmd!() + .env("TMPDIR", "/nonexistent/dir") + .arg("-") + .pipe_in("a\nb\nc\n") + .succeeds() + .stdout_is("c\nb\na\n"); +} diff --git a/util/fetch-gnu.sh b/util/fetch-gnu.sh index caecdec7d..b4103bda3 100755 --- a/util/fetch-gnu.sh +++ b/util/fetch-gnu.sh @@ -12,3 +12,6 @@ curl -L ${repo}/raw/refs/heads/master/tests/csplit/csplit-io-err.sh > tests/cspl curl -L ${repo}/raw/refs/heads/master/tests/stty/bad-speed.sh > tests/stty/bad-speed.sh # Avoid incorrect PASS 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 From 27fd41125bd79dd48fd1da28a3fabe198a1354e7 Mon Sep 17 00:00:00 2001 From: Chris Dryden Date: Fri, 9 Jan 2026 15:03:09 -0500 Subject: [PATCH 155/425] cp: reduce memory usage for cp -al by skipping unnecessary tracking (#9805) Co-authored-by: Sylvestre Ledru --- src/uu/cp/src/copydir.rs | 12 +++++++++++- src/uu/cp/src/cp.rs | 16 +++++++++++----- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/src/uu/cp/src/copydir.rs b/src/uu/cp/src/copydir.rs index 6ac1ae090..db2f4ff19 100644 --- a/src/uu/cp/src/copydir.rs +++ b/src/uu/cp/src/copydir.rs @@ -27,7 +27,8 @@ use uucore::uio_error; use walkdir::{DirEntry, WalkDir}; use crate::{ - CopyResult, CpError, Options, aligned_ancestors, context_for, copy_attributes, copy_file, + CopyMode, CopyResult, CpError, Options, aligned_ancestors, context_for, copy_attributes, + copy_file, }; /// Ensure a Windows path starts with a `\\?`. @@ -468,6 +469,15 @@ pub(crate) fn copy_directory( let is_dir_for_permissions = entry_is_dir_no_follow || (options.dereference && direntry_path.is_dir()); if is_dir_for_permissions { + // For --link mode, copy attributes immediately to avoid O(n) memory + if options.copy_mode == CopyMode::Link { + copy_attributes( + &entry.source_absolute, + &entry.local_to_target, + &options.attributes, + )?; + continue; + } // Add this directory to our list for permission fixing later dirs_needing_permissions .push((entry.source_absolute.clone(), entry.local_to_target.clone())); diff --git a/src/uu/cp/src/cp.rs b/src/uu/cp/src/cp.rs index c3d75b225..b8745d649 100644 --- a/src/uu/cp/src/cp.rs +++ b/src/uu/cp/src/cp.rs @@ -2448,7 +2448,9 @@ fn copy_file( return Err(translate!("cp-error-cannot-change-attribute", "dest" => dest.quote()).into()); } - if options.preserve_hard_links() { + // When using --link mode, hard link structure is automatically preserved + // because we link to source files (which share inodes). + if options.preserve_hard_links() && options.copy_mode != CopyMode::Link { // if we encounter a matching device/inode pair in the source tree // we can arrange to create a hard link between the corresponding names // in the destination tree. @@ -2565,10 +2567,14 @@ fn copy_file( } } - copied_files.insert( - FileInformation::from_path(source, options.dereference(source_in_command_line))?, - dest.to_path_buf(), - ); + // Skip tracking copied files when using --link mode since hard link + // structure is automatically preserved + if options.copy_mode != CopyMode::Link { + copied_files.insert( + FileInformation::from_path(source, options.dereference(source_in_command_line))?, + dest.to_path_buf(), + ); + } if let Some(progress_bar) = progress_bar { progress_bar.inc(source_metadata.len()); From 1f8da8d400ec816f391ba5b5e889eb72f4d179ba Mon Sep 17 00:00:00 2001 From: Rostyslav Toch Date: Fri, 9 Jan 2026 20:04:02 +0000 Subject: [PATCH 156/425] ptx: fix panic when reference length exceeds line width (#9816) Co-authored-by: Sylvestre Ledru --- src/uu/ptx/src/ptx.rs | 4 +++- tests/by-util/test_ptx.rs | 9 +++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/uu/ptx/src/ptx.rs b/src/uu/ptx/src/ptx.rs index 9f8977f70..ebaac28ff 100644 --- a/src/uu/ptx/src/ptx.rs +++ b/src/uu/ptx/src/ptx.rs @@ -788,7 +788,9 @@ fn write_traditional_output( } else { 0 }; - config.line_width -= max_ref_len; + + // Use saturating_sub to prevent panic if the reference is wider than the line width. + config.line_width = config.line_width.saturating_sub(max_ref_len); } for word_ref in words { diff --git a/tests/by-util/test_ptx.rs b/tests/by-util/test_ptx.rs index acad875bb..8facc8ab2 100644 --- a/tests/by-util/test_ptx.rs +++ b/tests/by-util/test_ptx.rs @@ -338,3 +338,12 @@ fn test_unicode_truncation_alignment() { .succeeds() .stdout_only(" / bar\n föö/\n"); } + +#[test] +fn test_narrow_width_with_long_reference_no_panic() { + new_ucmd!() + .args(&["-w", "1", "-A"]) + .pipe_in("content") + .succeeds() + .stdout_only(":1 content\n"); +} From fc60a33f963d6cbdc195a3bdb4c99d8251ee7a0f Mon Sep 17 00:00:00 2001 From: Christopher Dryden Date: Fri, 9 Jan 2026 20:54:51 +0000 Subject: [PATCH 157/425] ci: add df/skip-rootfs test to SMACK/ROOTFS CI --- .github/workflows/GnuTests.yml | 36 ++++++------ .../cspell.dictionaries/jargon.wordlist.txt | 1 + util/run-gnu-tests-smack-ci.sh | 58 +++++++++---------- 3 files changed, 48 insertions(+), 47 deletions(-) diff --git a/.github/workflows/GnuTests.yml b/.github/workflows/GnuTests.yml index 3a7bb002d..39251c584 100644 --- a/.github/workflows/GnuTests.yml +++ b/.github/workflows/GnuTests.yml @@ -31,7 +31,7 @@ env: TEST_STTY_FULL_SUMMARY_FILE: 'gnu-stty-full-result.json' TEST_SELINUX_FULL_SUMMARY_FILE: 'selinux-gnu-full-result.json' TEST_SELINUX_ROOT_FULL_SUMMARY_FILE: 'selinux-root-gnu-full-result.json' - TEST_SMACK_FULL_SUMMARY_FILE: 'smack-gnu-full-result.json' + TEST_QEMU_FULL_SUMMARY_FILE: 'qemu-gnu-full-result.json' jobs: native: @@ -317,8 +317,8 @@ jobs: gnu/tests-selinux/*.log gnu/tests-selinux/*/*.log.gz - smack: - name: Run GNU tests (SMACK) + qemu: + name: Run GNU tests (SMACK/ROOTFS) runs-on: ubuntu-24.04 steps: - name: Checkout code (uutils) @@ -338,30 +338,30 @@ jobs: run: | sudo apt-get update sudo apt-get install -y qemu-system-x86 zstd cpio - - name: Run GNU SMACK tests + - name: Run GNU SMACK/ROOTFS tests run: | cd uutils - bash util/run-gnu-tests-smack-ci.sh "$GITHUB_WORKSPACE/gnu" "$GITHUB_WORKSPACE/gnu/tests-smack" + bash util/run-gnu-tests-smack-ci.sh "$GITHUB_WORKSPACE/gnu" "$GITHUB_WORKSPACE/gnu/tests-qemu" - name: Extract testing info into JSON run: | - python3 uutils/util/gnu-json-result.py gnu/tests-smack > ${{ env.TEST_SMACK_FULL_SUMMARY_FILE }} - - name: Upload SMACK json results + python3 uutils/util/gnu-json-result.py gnu/tests-qemu > ${{ env.TEST_QEMU_FULL_SUMMARY_FILE }} + - name: Upload SMACK/ROOTFS json results uses: actions/upload-artifact@v6 with: - name: smack-gnu-full-result - path: ${{ env.TEST_SMACK_FULL_SUMMARY_FILE }} - - name: Compress SMACK test logs - run: gzip gnu/tests-smack/*/*.log 2>/dev/null || true - - name: Upload SMACK test logs + name: qemu-gnu-full-result + path: ${{ env.TEST_QEMU_FULL_SUMMARY_FILE }} + - name: Compress SMACK/ROOTFS test logs + run: gzip gnu/tests-qemu/*/*.log 2>/dev/null || true + - name: Upload SMACK/ROOTFS test logs uses: actions/upload-artifact@v6 with: - name: smack-test-logs + name: qemu-test-logs path: | - gnu/tests-smack/*.log - gnu/tests-smack/*/*.log.gz + gnu/tests-qemu/*.log + gnu/tests-qemu/*/*.log.gz aggregate: - needs: [native, selinux, smack] + needs: [native, selinux, qemu] permissions: actions: read # for dawidd6/action-download-artifact to query and download artifacts contents: read # for actions/checkout to fetch code @@ -426,10 +426,10 @@ jobs: name: selinux-root-gnu-full-result path: results merge-multiple: true - - name: Download smack json results + - name: Download SMACK/ROOTFS json results uses: actions/download-artifact@v7 with: - name: smack-gnu-full-result + name: qemu-gnu-full-result path: results merge-multiple: true - name: Extract/summarize testing info diff --git a/.vscode/cspell.dictionaries/jargon.wordlist.txt b/.vscode/cspell.dictionaries/jargon.wordlist.txt index 88478fa4e..b0b1cbe6f 100644 --- a/.vscode/cspell.dictionaries/jargon.wordlist.txt +++ b/.vscode/cspell.dictionaries/jargon.wordlist.txt @@ -126,6 +126,7 @@ pseudoprime pseudoprimes quantiles readonly +ROOTFS reparse rposition seedable diff --git a/util/run-gnu-tests-smack-ci.sh b/util/run-gnu-tests-smack-ci.sh index 5dc47eb87..9fb9e81fb 100755 --- a/util/run-gnu-tests-smack-ci.sh +++ b/util/run-gnu-tests-smack-ci.sh @@ -1,5 +1,5 @@ #!/bin/bash -# Run GNU SMACK tests in QEMU with SMACK-enabled kernel +# Run GNU SMACK/ROOTFS tests in QEMU with SMACK-enabled kernel # Usage: run-gnu-tests-smack-ci.sh [GNU_DIR] [OUTPUT_DIR] # spell-checker:ignore rootfs zstd unzstd cpio newc nographic smackfs devtmpfs tmpfs poweroff libm libgcc libpthread libdl librt sysfs rwxat setuidgid set -e @@ -7,12 +7,12 @@ set -e SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" REPO_DIR="$(dirname "$SCRIPT_DIR")" GNU_DIR="${1:-$REPO_DIR/../gnu}" -OUTPUT_DIR="${2:-$REPO_DIR/target/smack-test-results}" -SMACK_DIR="$REPO_DIR/target/smack-test" +OUTPUT_DIR="${2:-$REPO_DIR/target/qemu-test-results}" +QEMU_DIR="$REPO_DIR/target/qemu-test" -echo "Setting up SMACK test environment..." -rm -rf "$SMACK_DIR" -mkdir -p "$SMACK_DIR"/{rootfs/{bin,lib64,proc,sys,dev,tmp,etc,gnu},kernel} +echo "Setting up SMACK/ROOTFS test environment..." +rm -rf "$QEMU_DIR" +mkdir -p "$QEMU_DIR"/{rootfs/{bin,lib64,proc,sys,dev,tmp,etc,gnu},kernel} # Download Arch Linux kernel (has SMACK built-in) if [ ! -f /tmp/arch-vmlinuz ]; then @@ -24,31 +24,31 @@ if [ ! -f /tmp/arch-vmlinuz ]; then mv "/tmp/$VMLINUZ_PATH" /tmp/arch-vmlinuz rm -rf /tmp/usr /tmp/arch-kernel.pkg.tar /tmp/arch-kernel.pkg.tar.zst fi -cp /tmp/arch-vmlinuz "$SMACK_DIR/kernel/vmlinuz" +cp /tmp/arch-vmlinuz "$QEMU_DIR/kernel/vmlinuz" # Setup busybox BUSYBOX=/tmp/busybox [ -f "$BUSYBOX" ] || curl -sL -o "$BUSYBOX" https://busybox.net/downloads/binaries/1.35.0-x86_64-linux-musl/busybox chmod +x "$BUSYBOX" -cp "$BUSYBOX" "$SMACK_DIR/rootfs/bin/" -(cd "$SMACK_DIR/rootfs/bin" && "$BUSYBOX" --list | xargs -I{} ln -sf busybox {} 2>/dev/null) +cp "$BUSYBOX" "$QEMU_DIR/rootfs/bin/" +(cd "$QEMU_DIR/rootfs/bin" && "$BUSYBOX" --list | xargs -I{} ln -sf busybox {} 2>/dev/null) # Copy required libraries for lib in ld-linux-x86-64.so.2 libc.so.6 libm.so.6 libgcc_s.so.1 libpthread.so.0 libdl.so.2 librt.so.1; do path=$(ldconfig -p | grep "$lib" | head -1 | awk '{print $NF}') - [ -n "$path" ] && [ -f "$path" ] && cp -L "$path" "$SMACK_DIR/rootfs/lib64/" 2>/dev/null || true + [ -n "$path" ] && [ -f "$path" ] && cp -L "$path" "$QEMU_DIR/rootfs/lib64/" 2>/dev/null || true done # Create minimal config files -echo -e "root:x:0:0:root:/root:/bin/sh\nnobody:x:65534:65534:nobody:/nonexistent:/bin/sh" > "$SMACK_DIR/rootfs/etc/passwd" -echo -e "root:x:0:\nnobody:x:65534:" > "$SMACK_DIR/rootfs/etc/group" -touch "$SMACK_DIR/rootfs/etc/mtab" +echo -e "root:x:0:0:root:/root:/bin/sh\nnobody:x:65534:65534:nobody:/nonexistent:/bin/sh" > "$QEMU_DIR/rootfs/etc/passwd" +echo -e "root:x:0:\nnobody:x:65534:" > "$QEMU_DIR/rootfs/etc/group" +touch "$QEMU_DIR/rootfs/etc/mtab" # Copy GNU tests -cp -r "$GNU_DIR/tests" "$SMACK_DIR/rootfs/gnu/" +cp -r "$GNU_DIR/tests" "$QEMU_DIR/rootfs/gnu/" # Create init script -cat > "$SMACK_DIR/rootfs/init" << 'INIT' +cat > "$QEMU_DIR/rootfs/init" << 'INIT' #!/bin/sh mount -t proc proc /proc mount -t sysfs sys /sys @@ -73,24 +73,24 @@ fi echo "EXIT:$?" poweroff -f INIT -chmod +x "$SMACK_DIR/rootfs/init" +chmod +x "$QEMU_DIR/rootfs/init" -# Build utilities with SMACK support -echo "Building utilities with SMACK support..." -cargo build --release --manifest-path="$REPO_DIR/Cargo.toml" --package uu_id --features uu_id/smack --package uu_ls --features uu_ls/smack --package uu_mkdir --features uu_mkdir/smack --package uu_mkfifo --features uu_mkfifo/smack --package uu_mknod --features uu_mknod/smack +# Build utilities for SMACK/ROOTFS tests +echo "Building utilities for SMACK/ROOTFS tests..." +cargo build --release --manifest-path="$REPO_DIR/Cargo.toml" --package uu_id --features uu_id/smack --package uu_ls --features uu_ls/smack --package uu_mkdir --features uu_mkdir/smack --package uu_mkfifo --features uu_mkfifo/smack --package uu_mknod --features uu_mknod/smack --package uu_df -# Find SMACK tests -SMACK_TESTS=$(grep -l 'require_smack_' -r "$GNU_DIR/tests/" 2>/dev/null || true) -[ -z "$SMACK_TESTS" ] && { echo "No SMACK tests found"; exit 0; } +# Find SMACK tests and tests requiring rootfs in mtab (only available in QEMU environment) +QEMU_TESTS=$(grep -l -E 'require_smack_|rootfs in mtab' -r "$GNU_DIR/tests/" 2>/dev/null | sort -u || true) +[ -z "$QEMU_TESTS" ] && { echo "No SMACK/ROOTFS tests found"; exit 0; } -echo "Found $(echo "$SMACK_TESTS" | wc -l) SMACK tests" +echo "Found $(echo "$QEMU_TESTS" | wc -l) SMACK/ROOTFS tests" # Create output directory rm -rf "$OUTPUT_DIR" mkdir -p "$OUTPUT_DIR" # Run each test -for TEST_PATH in $SMACK_TESTS; do +for TEST_PATH in $QEMU_TESTS; do TEST_REL="${TEST_PATH#"$GNU_DIR"/tests/}" TEST_DIR=$(dirname "$TEST_REL") TEST_NAME=$(basename "$TEST_REL" .sh) @@ -104,12 +104,12 @@ for TEST_PATH in $SMACK_TESTS; do fi # Create working copy - WORK="/tmp/smack-test-$$" + WORK="/tmp/qemu-test-$$" rm -rf "$WORK" "$WORK.gz" - cp -a "$SMACK_DIR/rootfs" "$WORK" + cp -a "$QEMU_DIR/rootfs" "$WORK" - # Copy built utilities with SMACK support - for U in id ls mkdir mkfifo mknod; do + # Copy built utilities for SMACK/ROOTFS tests + for U in id ls mkdir mkfifo mknod df; do rm -f "$WORK/bin/$U" cp "$REPO_DIR/target/release/$U" "$WORK/bin/$U" done @@ -126,7 +126,7 @@ for TEST_PATH in $SMACK_TESTS; do (cd "$WORK" && find . | cpio -o -H newc 2>/dev/null | gzip > "$WORK.gz") OUTPUT=$(timeout 120 qemu-system-x86_64 \ - -kernel "$SMACK_DIR/kernel/vmlinuz" \ + -kernel "$QEMU_DIR/kernel/vmlinuz" \ -initrd "$WORK.gz" \ -append "console=ttyS0 quiet panic=-1 security=smack lsm=smack" \ -nographic -m 256M -no-reboot 2>&1) || true From b684d1606d6f942fbf81aea5b08c38c194254d4e Mon Sep 17 00:00:00 2001 From: Chris Dryden Date: Fri, 9 Jan 2026 17:20:59 -0500 Subject: [PATCH 158/425] seq: add SIGPIPE handling for GNU compatibility (#9657) --- .../cspell.dictionaries/jargon.wordlist.txt | 1 + src/uu/seq/Cargo.toml | 1 + src/uu/seq/src/seq.rs | 21 +++ src/uucore/src/lib/features/signals.rs | 62 ++++++++ tests/by-util/test_seq.rs | 146 ++++++++---------- 5 files changed, 151 insertions(+), 80 deletions(-) diff --git a/.vscode/cspell.dictionaries/jargon.wordlist.txt b/.vscode/cspell.dictionaries/jargon.wordlist.txt index 88478fa4e..2e7d09131 100644 --- a/.vscode/cspell.dictionaries/jargon.wordlist.txt +++ b/.vscode/cspell.dictionaries/jargon.wordlist.txt @@ -138,6 +138,7 @@ setfattr setlocale shortcode shortcodes +sigaction siginfo sigusr strcasecmp diff --git a/src/uu/seq/Cargo.toml b/src/uu/seq/Cargo.toml index 6f74ce37a..534b675e1 100644 --- a/src/uu/seq/Cargo.toml +++ b/src/uu/seq/Cargo.toml @@ -30,6 +30,7 @@ uucore = { workspace = true, features = [ "format", "parser", "quoting-style", + "signals", ] } fluent = { workspace = true } diff --git a/src/uu/seq/src/seq.rs b/src/uu/seq/src/seq.rs index 7b56c26f5..6d8f00258 100644 --- a/src/uu/seq/src/seq.rs +++ b/src/uu/seq/src/seq.rs @@ -28,6 +28,8 @@ mod numberparse; use crate::error::SeqError; use crate::number::PreciseNumber; +#[cfg(unix)] +use uucore::signals; use uucore::translate; const OPT_SEPARATOR: &str = "separator"; @@ -90,8 +92,22 @@ fn select_precision( } } +// Initialize SIGPIPE state capture at process startup (Unix only) +#[cfg(unix)] +uucore::init_sigpipe_capture!(); + #[uucore::main] pub fn uumain(args: impl uucore::Args) -> UResult<()> { + // Restore SIGPIPE to default if it wasn't explicitly ignored by parent. + // The Rust runtime ignores SIGPIPE, but we need to respect the parent's + // signal disposition for proper pipeline behavior (GNU compatibility). + #[cfg(unix)] + if !signals::sigpipe_was_ignored() { + // Ignore the return value: if setting signal handler fails, we continue anyway. + // The worst case is we don't get proper SIGPIPE behavior, but seq will still work. + let _ = signals::enable_pipe_errors(); + } + let matches = uucore::clap_localization::handle_clap_result(uu_app(), split_short_args_with_value(args))?; @@ -213,8 +229,13 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { Ok(()) => Ok(()), Err(err) if err.kind() == std::io::ErrorKind::BrokenPipe => { // GNU seq prints the Broken pipe message but still exits with status 0 + // unless SIGPIPE was explicitly ignored, in which case it should fail. let err = err.map_err_context(|| "write error".into()); uucore::show_error!("{err}"); + #[cfg(unix)] + if signals::sigpipe_was_ignored() { + uucore::error::set_exit_code(1); + } Ok(()) } Err(err) => Err(err.map_err_context(|| "write error".into())), diff --git a/src/uucore/src/lib/features/signals.rs b/src/uucore/src/lib/features/signals.rs index 0bccb2173..81b414a86 100644 --- a/src/uucore/src/lib/features/signals.rs +++ b/src/uucore/src/lib/features/signals.rs @@ -426,6 +426,68 @@ pub fn ignore_interrupts() -> Result<(), Errno> { unsafe { signal(SIGINT, SigIgn) }.map(|_| ()) } +// SIGPIPE state capture - captures whether SIGPIPE was ignored at process startup +#[cfg(unix)] +use std::sync::atomic::{AtomicBool, Ordering}; + +#[cfg(unix)] +static SIGPIPE_WAS_IGNORED: AtomicBool = AtomicBool::new(false); + +/// Captures SIGPIPE state at process initialization, before main() runs. +/// +/// # Safety +/// Called from `.init_array` before main(). Only reads current SIGPIPE handler state. +#[cfg(unix)] +pub unsafe extern "C" fn capture_sigpipe_state() { + use nix::libc; + use std::mem::MaybeUninit; + use std::ptr; + + let mut current = MaybeUninit::::uninit(); + // SAFETY: sigaction with null new-action just queries current state + if unsafe { libc::sigaction(libc::SIGPIPE, ptr::null(), current.as_mut_ptr()) } == 0 { + // SAFETY: sigaction succeeded, so current is initialized + let ignored = unsafe { current.assume_init() }.sa_sigaction == libc::SIG_IGN; + SIGPIPE_WAS_IGNORED.store(ignored, Ordering::Release); + } +} + +/// Initializes SIGPIPE state capture. Call once at crate root level. +#[macro_export] +#[cfg(unix)] +macro_rules! init_sigpipe_capture { + () => { + #[cfg(all(unix, 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"))] + #[used] + #[unsafe(link_section = "__DATA,__mod_init_func")] + static CAPTURE_SIGPIPE_STATE: unsafe extern "C" fn() = + $crate::signals::capture_sigpipe_state; + }; +} + +#[macro_export] +#[cfg(not(unix))] +macro_rules! init_sigpipe_capture { + () => {}; +} + +/// Returns whether SIGPIPE was ignored at process startup. +#[cfg(unix)] +pub fn sigpipe_was_ignored() -> bool { + SIGPIPE_WAS_IGNORED.load(Ordering::Acquire) +} + +#[cfg(not(unix))] +pub const fn sigpipe_was_ignored() -> bool { + false +} + #[test] fn signal_by_value() { assert_eq!(signal_by_name_or_value("0"), Some(0)); diff --git a/tests/by-util/test_seq.rs b/tests/by-util/test_seq.rs index d5dd526aa..f94a9a983 100644 --- a/tests/by-util/test_seq.rs +++ b/tests/by-util/test_seq.rs @@ -3,34 +3,18 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. // spell-checker:ignore lmnop xlmnop +use rstest::rstest; use uutests::new_ucmd; +#[cfg(unix)] +use uutests::util::TestScenario; +#[cfg(unix)] +use uutests::util_name; #[test] fn test_invalid_arg() { new_ucmd!().arg("--definitely-invalid").fails_with_code(1); } -#[test] -#[cfg(unix)] -fn test_broken_pipe_still_exits_success() { - use std::process::Stdio; - - let mut child = new_ucmd!() - // Use an infinite sequence so a burst of output happens immediately after spawn. - // With small output the process can finish before stdout is closed and the Broken pipe never occurs. - .args(&["inf"]) - .set_stdout(Stdio::piped()) - .run_no_wait(); - - // Trigger a Broken pipe by writing to a pipe whose reader closed first. - child.close_stdout(); - let result = child.wait().unwrap(); - - result - .code_is(0) - .stderr_contains("write error: Broken pipe"); -} - #[test] fn test_no_args() { new_ucmd!() @@ -203,6 +187,24 @@ fn test_width_invalid_float() { .usage_error("invalid floating point argument: '1e2.3'"); } +#[test] +#[cfg(unix)] +fn test_sigpipe_ignored_reports_write_error() { + let scene = TestScenario::new(util_name!()); + let seq_bin = scene.bin_path.clone().into_os_string(); + let script = "trap '' PIPE; { \"$SEQ_BIN\" seq inf 2>err; echo $? >code; } | head -n1"; + let result = scene.cmd_shell(script).env("SEQ_BIN", &seq_bin).succeeds(); + + assert_eq!(result.stdout_str(), "1\n"); + + let err_contents = scene.fixtures.read("err"); + assert!( + err_contents.contains("seq: write error: Broken pipe"), + "stderr missing write error message: {err_contents:?}" + ); + assert_eq!(scene.fixtures.read("code"), "1\n"); +} + // ---- Tests for the big integer based path ---- #[test] @@ -648,52 +650,49 @@ fn test_width_floats() { .stdout_only("09.0\n10.0\n"); } -#[test] -fn test_neg_inf() { - new_ucmd!() - .args(&["--", "-inf", "0"]) - .run_stdout_starts_with(b"-inf\n-inf\n-inf\n") - .success(); -} - -#[test] -fn test_neg_infinity() { - new_ucmd!() - .args(&["--", "-infinity", "0"]) - .run_stdout_starts_with(b"-inf\n-inf\n-inf\n") - .success(); -} - -#[test] -fn test_inf() { - new_ucmd!() - .args(&["inf"]) - .run_stdout_starts_with(b"1\n2\n3\n") - .success(); -} - -#[test] -fn test_infinity() { - new_ucmd!() - .args(&["infinity"]) - .run_stdout_starts_with(b"1\n2\n3\n") - .success(); -} - -#[test] -fn test_inf_width() { - new_ucmd!() - .args(&["-w", "1.000", "inf", "inf"]) - .run_stdout_starts_with(b"1.000\n inf\n inf\n inf\n") - .success(); -} - -#[test] -fn test_neg_inf_width() { - new_ucmd!() - .args(&["-w", "1.000", "-inf", "-inf"]) - .run_stdout_starts_with(b"1.000\n -inf\n -inf\n -inf\n") - .success(); +/// Test infinite sequences - these produce endless output, so we check they start correctly +/// and terminate with SIGPIPE on Unix (or succeed on non-Unix where pipe behavior differs). +#[rstest] +#[case::neg_inf( + &["--", "-inf", "0"], + b"-inf\n-inf\n-inf\n" +)] +#[case::neg_infinity( + &["--", "-infinity", "0"], + b"-inf\n-inf\n-inf\n" +)] +#[case::inf( + &["inf"], + b"1\n2\n3\n" +)] +#[case::infinity( + &["infinity"], + b"1\n2\n3\n" +)] +#[case::inf_width( + &["-w", "1.000", "inf", "inf"], + b"1.000\n inf\n inf\n inf\n" +)] +#[case::neg_inf_width( + &["-w", "1.000", "-inf", "-inf"], + b"1.000\n -inf\n -inf\n -inf\n" +)] +#[case::precision_inf( + &["1", "1.2", "inf"], + b"1.0\n2.2\n3.4\n" +)] +#[case::equalize_width_inf( + &["-w", "1", "1.2", "inf"], + b"1.0\n2.2\n3.4\n" +)] +fn test_infinite_sequence(#[case] args: &[&str], #[case] expected_start: &[u8]) { + let result = new_ucmd!() + .args(args) + .run_stdout_starts_with(expected_start); + #[cfg(unix)] + result.signal_name_is("PIPE"); + #[cfg(not(unix))] + result.success(); } #[test] @@ -1073,12 +1072,6 @@ fn test_precision_corner_cases() { .args(&["1", "1.20", "3.000000"]) .succeeds() .stdout_is("1.00\n2.20\n"); - - // Infinity is ignored - new_ucmd!() - .args(&["1", "1.2", "inf"]) - .run_stdout_starts_with(b"1.0\n2.2\n3.4\n") - .success(); } // GNU `seq` manual only makes guarantees about `-w` working if the @@ -1135,11 +1128,4 @@ fn test_equalize_widths_corner_cases() { .args(&["-w", "0x1.1", "1.00002", "3"]) .succeeds() .stdout_is("1.0625\n2.06252\n"); - - // We can't really pad with infinite number of zeros, so `-w` is ignored. - // (there is another test with infinity as an increment above) - new_ucmd!() - .args(&["-w", "1", "1.2", "inf"]) - .run_stdout_starts_with(b"1.0\n2.2\n3.4\n") - .success(); } From c5c3a8e01e0941255ebbc9daabb2db40069c55ef Mon Sep 17 00:00:00 2001 From: Chris Dryden Date: Fri, 9 Jan 2026 17:30:56 -0500 Subject: [PATCH 159/425] Merge pull request #10079 from ChrisDryden/fix-numfmt-non-utf8 numfmt: support non-UTF8 delimiters for locales like GB18030 --- src/uu/numfmt/src/format.rs | 56 +++++++++++-------- src/uu/numfmt/src/numfmt.rs | 103 +++++++++++++++++++++++------------ src/uu/numfmt/src/options.rs | 2 +- tests/by-util/test_numfmt.rs | 19 +++++++ 4 files changed, 121 insertions(+), 59 deletions(-) diff --git a/src/uu/numfmt/src/format.rs b/src/uu/numfmt/src/format.rs index 33dc58bc2..f27926d87 100644 --- a/src/uu/numfmt/src/format.rs +++ b/src/uu/numfmt/src/format.rs @@ -358,32 +358,56 @@ fn format_string( )) } -fn format_and_print_delimited(s: &str, options: &NumfmtOptions) -> Result<()> { - let delimiter = options.delimiter.as_ref().unwrap(); - let mut output = String::new(); +fn split_bytes<'a>(input: &'a [u8], delim: &'a [u8]) -> impl Iterator { + let mut remainder = Some(input); + std::iter::from_fn(move || { + let input = remainder.take()?; + match input.windows(delim.len()).position(|w| w == delim) { + Some(pos) => { + remainder = Some(&input[pos + delim.len()..]); + Some(&input[..pos]) + } + None => Some(input), + } + }) +} - for (n, field) in (1..).zip(s.split(delimiter)) { +pub fn format_and_print_delimited(input: &[u8], options: &NumfmtOptions) -> Result<()> { + let delimiter = options.delimiter.as_ref().unwrap(); + let mut output: Vec = Vec::new(); + let eol = if options.zero_terminated { + b'\0' + } else { + b'\n' + }; + + for (n, field) in (1..).zip(split_bytes(input, delimiter)) { let field_selected = uucore::ranges::contain(&options.fields, n); // add delimiter before second and subsequent fields if n > 1 { - output.push_str(delimiter); + output.extend_from_slice(delimiter); } if field_selected { - output.push_str(&format_string(field.trim_start(), options, None)?); + // Field must be valid UTF-8 for numeric conversion + let field_str = std::str::from_utf8(field) + .map_err(|_| translate!("numfmt-error-invalid-number", "input" => String::from_utf8_lossy(field).into_owned().quote()))? + .trim_start(); + let formatted = format_string(field_str, options, None)?; + output.extend_from_slice(formatted.as_bytes()); } else { // add unselected field without conversion - output.push_str(field); + output.extend_from_slice(field); } } - println!("{output}"); + output.push(eol); + std::io::Write::write_all(&mut std::io::stdout(), &output).map_err(|e| e.to_string())?; Ok(()) } - -fn format_and_print_whitespace(s: &str, options: &NumfmtOptions) -> Result<()> { +pub fn format_and_print_whitespace(s: &str, options: &NumfmtOptions) -> Result<()> { let mut output = String::new(); for (n, (prefix, field)) in (1..).zip(WhitespaceSplitter { s: Some(s) }) { @@ -428,18 +452,6 @@ fn format_and_print_whitespace(s: &str, options: &NumfmtOptions) -> Result<()> { Ok(()) } -/// Format a line of text according to the selected options. -/// -/// Given a line of text `s`, split the line into fields, transform and format -/// any selected numeric fields, and print the result to stdout. Fields not -/// selected for conversion are passed through unmodified. -pub fn format_and_print(s: &str, options: &NumfmtOptions) -> Result<()> { - match &options.delimiter { - Some(_) => format_and_print_delimited(s, options), - None => format_and_print_whitespace(s, options), - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/uu/numfmt/src/numfmt.rs b/src/uu/numfmt/src/numfmt.rs index 81e8ab9cd..d5ce81138 100644 --- a/src/uu/numfmt/src/numfmt.rs +++ b/src/uu/numfmt/src/numfmt.rs @@ -4,10 +4,11 @@ // file that was distributed with this source code. use crate::errors::*; -use crate::format::format_and_print; +use crate::format::{format_and_print_delimited, format_and_print_whitespace}; use crate::options::*; use crate::units::{Result, Unit}; -use clap::{Arg, ArgAction, ArgMatches, Command, parser::ValueSource}; +use clap::{Arg, ArgAction, ArgMatches, Command, builder::ValueParser, parser::ValueSource}; +use std::ffi::OsString; use std::io::{BufRead, Error, Write}; use std::result::Result as StdResult; use std::str::FromStr; @@ -15,6 +16,7 @@ use std::str::FromStr; use units::{IEC_BASES, SI_BASES}; use uucore::display::Quotable; use uucore::error::UResult; +use uucore::os_str_as_bytes; use uucore::translate; use uucore::parser::shortcut_value_parser::ShortcutValueParser; @@ -26,7 +28,7 @@ pub mod format; pub mod options; mod units; -fn handle_args<'a>(args: impl Iterator, options: &NumfmtOptions) -> UResult<()> { +fn handle_args<'a>(args: impl Iterator, options: &NumfmtOptions) -> UResult<()> { for l in args { format_and_handle_validation(l, options)?; } @@ -37,40 +39,45 @@ fn handle_buffer(input: R, options: &NumfmtOptions) -> UResult<()> where R: BufRead, { - if options.zero_terminated { - handle_buffer_iterator( - input - .split(0) - // FIXME: This panics on UTF8 decoding, but this util in general doesn't handle - // invalid UTF8 - .map(|bytes| Ok(String::from_utf8(bytes?).unwrap())), - options, - ) - } else { - handle_buffer_iterator(input.lines(), options) - } + let terminator = if options.zero_terminated { 0u8 } else { b'\n' }; + handle_buffer_iterator(input.split(terminator), options, terminator) } fn handle_buffer_iterator( - iter: impl Iterator>, + iter: impl Iterator, Error>>, options: &NumfmtOptions, + terminator: u8, ) -> UResult<()> { - let eol = if options.zero_terminated { '\0' } else { '\n' }; for (idx, line_result) in iter.enumerate() { match line_result { Ok(line) if idx < options.header => { - print!("{line}{eol}"); + std::io::stdout().write_all(&line)?; + std::io::stdout().write_all(&[terminator])?; Ok(()) } - Ok(line) => format_and_handle_validation(line.as_ref(), options), + Ok(line) => format_and_handle_validation(&line, options), Err(err) => return Err(Box::new(NumfmtError::IoError(err.to_string()))), }?; } Ok(()) } -fn format_and_handle_validation(input_line: &str, options: &NumfmtOptions) -> UResult<()> { - let handled_line = format_and_print(input_line, options); +fn format_and_handle_validation(input_line: &[u8], options: &NumfmtOptions) -> UResult<()> { + let eol = if options.zero_terminated { + b'\0' + } else { + b'\n' + }; + + let handled_line = if options.delimiter.is_some() { + format_and_print_delimited(input_line, options) + } else { + // Whitespace mode requires valid UTF-8 + match std::str::from_utf8(input_line) { + Ok(s) => format_and_print_whitespace(s, options), + Err(_) => Err(translate!("numfmt-error-invalid-input")), + } + }; if let Err(error_message) = handled_line { match options.invalid { @@ -85,7 +92,8 @@ fn format_and_handle_validation(input_line: &str, options: &NumfmtOptions) -> UR } InvalidModes::Ignore => {} } - println!("{input_line}"); + std::io::stdout().write_all(input_line)?; + std::io::stdout().write_all(&[eol])?; } Ok(()) @@ -150,6 +158,22 @@ fn parse_unit_size_suffix(s: &str) -> Option { None } +/// Parse delimiter argument, ensuring it's a single character. +/// For non-UTF8 locales, we allow up to 4 bytes (max UTF-8 char length). +fn parse_delimiter(arg: &OsString) -> Result> { + let bytes = os_str_as_bytes(arg).map_err(|e| e.to_string())?; + // TODO: Cut, NL and here need to find a better way to do locale specific character count + if arg.to_str().is_some_and(|s| s.chars().count() > 1) + || (arg.to_str().is_none() && bytes.len() > 4) + { + Err(translate!( + "numfmt-error-delimiter-must-be-single-character" + )) + } else { + Ok(bytes.to_vec()) + } +} + fn parse_options(args: &ArgMatches) -> Result { let from = parse_unit(args.get_one::(FROM).unwrap())?; let to = parse_unit(args.get_one::(TO).unwrap())?; @@ -212,15 +236,10 @@ fn parse_options(args: &ArgMatches) -> Result { )); } - let delimiter = args.get_one::(DELIMITER).map_or(Ok(None), |arg| { - if arg.len() == 1 { - Ok(Some(arg.to_owned())) - } else { - Err(translate!( - "numfmt-error-delimiter-must-be-single-character" - )) - } - })?; + let delimiter = args + .get_one::(DELIMITER) + .map(parse_delimiter) + .transpose()?; // unwrap is fine because the argument has a default value let round = match args.get_one::(ROUND).unwrap().as_str() { @@ -264,8 +283,14 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { let options = parse_options(&matches).map_err(NumfmtError::IllegalArgument)?; - let result = match matches.get_many::(NUMBER) { - Some(values) => handle_args(values.map(|s| s.as_str()), &options), + let result = match matches.get_many::(NUMBER) { + Some(values) => { + let byte_args: Vec<&[u8]> = values + .map(|s| os_str_as_bytes(s).map_err(|e| e.to_string())) + .collect::, _>>() + .map_err(NumfmtError::IllegalArgument)?; + handle_args(byte_args.into_iter(), &options) + } None => { let stdin = std::io::stdin(); let mut locked_stdin = stdin.lock(); @@ -296,6 +321,7 @@ pub fn uu_app() -> Command { .short('d') .long(DELIMITER) .value_name("X") + .value_parser(ValueParser::os_string()) .help(translate!("numfmt-help-delimiter")), ) .arg( @@ -397,7 +423,12 @@ pub fn uu_app() -> Command { .help(translate!("numfmt-help-zero-terminated")) .action(ArgAction::SetTrue), ) - .arg(Arg::new(NUMBER).hide(true).action(ArgAction::Append)) + .arg( + Arg::new(NUMBER) + .hide(true) + .action(ArgAction::Append) + .value_parser(ValueParser::os_string()), + ) } #[cfg(test)] @@ -528,7 +559,7 @@ mod tests { #[test] fn args_fail_returns_status_2_for_invalid_input() { - let input_value = ["5", "4Q"].into_iter(); + let input_value = [b"5".as_slice(), b"4Q"].into_iter(); let mut options = get_valid_options(); options.invalid = InvalidModes::Fail; handle_args(input_value, &options).unwrap(); @@ -541,7 +572,7 @@ mod tests { #[test] fn args_warn_returns_status_0_for_invalid_input() { - let input_value = ["5", "4Q"].into_iter(); + let input_value = [b"5".as_slice(), b"4Q"].into_iter(); let mut options = get_valid_options(); options.invalid = InvalidModes::Warn; let result = handle_args(input_value, &options); diff --git a/src/uu/numfmt/src/options.rs b/src/uu/numfmt/src/options.rs index eaf0d8b8b..a8d16bda9 100644 --- a/src/uu/numfmt/src/options.rs +++ b/src/uu/numfmt/src/options.rs @@ -50,7 +50,7 @@ pub struct NumfmtOptions { pub padding: isize, pub header: usize, pub fields: Vec, - pub delimiter: Option, + pub delimiter: Option>, pub round: RoundMethod, pub suffix: Option, pub unit_separator: String, diff --git a/tests/by-util/test_numfmt.rs b/tests/by-util/test_numfmt.rs index 610e84adb..241286d07 100644 --- a/tests/by-util/test_numfmt.rs +++ b/tests/by-util/test_numfmt.rs @@ -1116,6 +1116,25 @@ fn test_zero_terminated_embedded_newline() { .stdout_is("1000 2000\x003000 4000\x00"); } +#[cfg(unix)] +#[test] +fn test_non_utf8_delimiter() { + use std::ffi::OsStr; + use std::os::unix::ffi::OsStrExt; + + // Single-byte non-UTF8 (0xFF) and multi-byte (0xA2E3, e.g. GB18030) + for delim in [&[0xFFu8][..], &[0xA2, 0xE3]] { + let input: Vec = [b"1", delim, b"2K"].concat(); + let expected: Vec = [b"1", delim, b"2000\n"].concat(); + new_ucmd!() + .args(&["--from=si", "--field=2", "-d"]) + .arg(OsStr::from_bytes(delim)) + .arg(OsStr::from_bytes(&input)) + .succeeds() + .stdout_is_bytes(expected); + } +} + #[test] fn test_unit_separator() { for (args, expected) in [ From e143680056ab31344f3654334d168576f75aeae2 Mon Sep 17 00:00:00 2001 From: Chris Dryden Date: Fri, 9 Jan 2026 17:32:13 -0500 Subject: [PATCH 160/425] Adding dd to SELinux to enable cp-a-selinux (#10148) --- util/build-gnu.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/util/build-gnu.sh b/util/build-gnu.sh index 6075c8637..ff0773845 100755 --- a/util/build-gnu.sh +++ b/util/build-gnu.sh @@ -93,7 +93,7 @@ export CARGOFLAGS # tell to make ln -vf "${UU_BUILD_DIR}/install" "${UU_BUILD_DIR}/ginstall" # The GNU tests use renamed install to ginstall if [ "${SELINUX_ENABLED}" = 1 ];then # Build few utils for SELinux for faster build. MULTICALL=y fails... - "${MAKE}" UTILS="cat chcon chmod cp cut echo env groups id ln ls mkdir mkfifo mknod mktemp mv printf rm rmdir runcon stat test touch tr true uname wc whoami" + "${MAKE}" UTILS="cat chcon chmod cp cut dd echo env groups id ln ls mkdir mkfifo mknod mktemp mv printf rm rmdir runcon stat test touch tr true uname wc whoami" else # Use MULTICALL=y for faster build "${MAKE}" MULTICALL=y SKIP_UTILS="install more seq" From d737450383f72c1512773af46f471981bb2b6ac7 Mon Sep 17 00:00:00 2001 From: Chris Dryden Date: Fri, 9 Jan 2026 17:37:34 -0500 Subject: [PATCH 161/425] Stty: Implemented input and output baud rate setting for stty (#9517) --- src/uu/stty/src/flags.rs | 12 +++- src/uu/stty/src/stty.rs | 117 +++++++++++++++++++------------------ tests/by-util/test_stty.rs | 65 +++++++++++++++++++++ 3 files changed, 135 insertions(+), 59 deletions(-) diff --git a/src/uu/stty/src/flags.rs b/src/uu/stty/src/flags.rs index c2a82198a..c346cbe7c 100644 --- a/src/uu/stty/src/flags.rs +++ b/src/uu/stty/src/flags.rs @@ -27,6 +27,14 @@ use nix::sys::termios::{ SpecialCharacterIndices as S, }; +#[derive(Debug)] +#[cfg_attr(test, derive(PartialEq))] +pub enum BaudType { + Input, + Output, + Both, +} + #[derive(Debug)] #[cfg_attr(test, derive(PartialEq))] pub enum AllFlags<'a> { @@ -38,7 +46,7 @@ pub enum AllFlags<'a> { target_os = "netbsd", target_os = "openbsd" ))] - Baud(u32), + Baud(u32, BaudType), #[cfg(not(any( target_os = "freebsd", target_os = "dragonfly", @@ -47,7 +55,7 @@ pub enum AllFlags<'a> { target_os = "netbsd", target_os = "openbsd" )))] - Baud(BaudRate), + Baud(BaudRate, BaudType), ControlFlags((&'a Flag, bool)), InputFlags((&'a Flag, bool)), LocalFlags((&'a Flag, bool)), diff --git a/src/uu/stty/src/stty.rs b/src/uu/stty/src/stty.rs index 8808857b6..f34f9b498 100644 --- a/src/uu/stty/src/stty.rs +++ b/src/uu/stty/src/stty.rs @@ -10,7 +10,7 @@ // spell-checker:ignore isig icanon iexten echoe crterase echok echonl noflsh xcase tostop echoprt prterase echoctl ctlecho echoke crtkill flusho extproc // spell-checker:ignore lnext rprnt susp swtch vdiscard veof veol verase vintr vkill vlnext vquit vreprint vstart vstop vsusp vswtc vwerase werase // spell-checker:ignore sigquit sigtstp -// spell-checker:ignore cbreak decctlq evenp litout oddp tcsadrain exta extb NCCS +// spell-checker:ignore cbreak decctlq evenp litout oddp tcsadrain exta extb NCCS cfsetispeed // spell-checker:ignore notaflag notacombo notabaud mod flags; @@ -21,7 +21,7 @@ use clap::{Arg, ArgAction, ArgMatches, Command}; use nix::libc::{O_NONBLOCK, TIOCGWINSZ, TIOCSWINSZ, c_ushort}; use nix::sys::termios::{ ControlFlags, InputFlags, LocalFlags, OutputFlags, SetArg, SpecialCharacterIndices as S, - Termios, cfgetospeed, cfsetospeed, tcgetattr, tcsetattr, + Termios, cfgetospeed, cfsetispeed, cfsetospeed, tcgetattr, tcsetattr, }; use nix::{ioctl_read_bad, ioctl_write_ptr_bad}; use std::cmp::Ordering; @@ -274,19 +274,24 @@ fn stty(opts: &Options) -> UResult<()> { let mut args_iter = args.iter(); while let Some(&arg) = args_iter.next() { match arg { - "ispeed" | "ospeed" => match args_iter.next() { + "ispeed" => match args_iter.next() { Some(speed) => { - if let Some(baud_flag) = string_to_baud(speed) { + if let Some(baud_flag) = string_to_baud(speed, flags::BaudType::Input) { valid_args.push(ArgOptions::Flags(baud_flag)); } else { - return Err(USimpleError::new( - 1, - translate!( - "stty-error-invalid-speed", - "arg" => *arg, - "speed" => *speed, - ), - )); + return invalid_speed(arg, speed); + } + } + None => { + return missing_arg(arg); + } + }, + "ospeed" => match args_iter.next() { + Some(speed) => { + if let Some(baud_flag) = string_to_baud(speed, flags::BaudType::Output) { + valid_args.push(ArgOptions::Flags(baud_flag)); + } else { + return invalid_speed(arg, speed); } } None => { @@ -383,12 +388,12 @@ fn stty(opts: &Options) -> UResult<()> { return missing_arg(arg); } // baud rate - } else if let Some(baud_flag) = string_to_baud(arg) { + } else if let Some(baud_flag) = string_to_baud(arg, flags::BaudType::Both) { valid_args.push(ArgOptions::Flags(baud_flag)); // non control char flag } else if let Some(flag) = string_to_flag(arg) { let remove_group = match flag { - AllFlags::Baud(_) => false, + AllFlags::Baud(_, _) => false, AllFlags::ControlFlags((flag, remove)) => { check_flag_group(flag, remove) } @@ -417,7 +422,7 @@ fn stty(opts: &Options) -> UResult<()> { for arg in &valid_args { match arg { ArgOptions::Mapping(mapping) => apply_char_mapping(&mut termios, mapping), - ArgOptions::Flags(flag) => apply_setting(&mut termios, flag), + ArgOptions::Flags(flag) => apply_setting(&mut termios, flag)?, ArgOptions::Special(setting) => { apply_special_setting(&mut termios, setting, opts.file.as_raw_fd())?; } @@ -468,6 +473,17 @@ fn invalid_integer_arg(arg: &str) -> Result> { )) } +fn invalid_speed(arg: &str, speed: &str) -> Result> { + Err(UUsageError::new( + 1, + translate!( + "stty-error-invalid-speed", + "arg" => arg, + "speed" => speed, + ), + )) +} + /// GNU uses different error messages if values overflow or underflow a u8, /// this function returns the appropriate error message in the case of overflow or underflow, or u8 on success fn parse_u8_or_err(arg: &str) -> Result { @@ -719,7 +735,7 @@ fn parse_baud_with_rounding(normalized: &str) -> Option { Some(value) } -fn string_to_baud(arg: &str) -> Option> { +fn string_to_baud(arg: &str, baud_type: flags::BaudType) -> Option> { // Reject invalid formats if arg != arg.trim_end() || arg.trim().starts_with('-') @@ -744,7 +760,7 @@ fn string_to_baud(arg: &str) -> Option> { target_os = "netbsd", target_os = "openbsd" ))] - return Some(AllFlags::Baud(value)); + return Some(AllFlags::Baud(value, baud_type)); #[cfg(not(any( target_os = "freebsd", @@ -757,7 +773,7 @@ fn string_to_baud(arg: &str) -> Option> { { for (text, baud_rate) in BAUD_RATES { if text.parse::().ok() == Some(value) { - return Some(AllFlags::Baud(*baud_rate)); + return Some(AllFlags::Baud(*baud_rate, baud_type)); } } None @@ -940,9 +956,9 @@ fn print_flags( } /// Apply a single setting -fn apply_setting(termios: &mut Termios, setting: &AllFlags) { +fn apply_setting(termios: &mut Termios, setting: &AllFlags) -> nix::Result<()> { match setting { - AllFlags::Baud(_) => apply_baud_rate_flag(termios, setting), + AllFlags::Baud(_, _) => apply_baud_rate_flag(termios, setting)?, AllFlags::ControlFlags((setting, disable)) => { setting.flag.apply(termios, !disable); } @@ -956,34 +972,21 @@ fn apply_setting(termios: &mut Termios, setting: &AllFlags) { setting.flag.apply(termios, !disable); } } + Ok(()) } -fn apply_baud_rate_flag(termios: &mut Termios, input: &AllFlags) { - // BSDs use a u32 for the baud rate, so any decimal number applies. - #[cfg(any( - target_os = "freebsd", - target_os = "dragonfly", - target_os = "ios", - target_os = "macos", - target_os = "netbsd", - target_os = "openbsd" - ))] - if let AllFlags::Baud(n) = input { - cfsetospeed(termios, *n).expect("Failed to set baud rate"); - } - - // Other platforms use an enum. - #[cfg(not(any( - target_os = "freebsd", - target_os = "dragonfly", - target_os = "ios", - target_os = "macos", - target_os = "netbsd", - target_os = "openbsd" - )))] - if let AllFlags::Baud(br) = input { - cfsetospeed(termios, *br).expect("Failed to set baud rate"); +fn apply_baud_rate_flag(termios: &mut Termios, input: &AllFlags) -> nix::Result<()> { + if let AllFlags::Baud(rate, baud_type) = input { + match baud_type { + flags::BaudType::Input => cfsetispeed(termios, *rate)?, + flags::BaudType::Output => cfsetospeed(termios, *rate)?, + flags::BaudType::Both => { + cfsetispeed(termios, *rate)?; + cfsetospeed(termios, *rate)?; + } + } } + Ok(()) } fn apply_char_mapping(termios: &mut Termios, mapping: &(S, u8)) { @@ -1446,10 +1449,10 @@ mod tests { target_os = "openbsd" )))] { - assert!(string_to_baud("9600").is_some()); - assert!(string_to_baud("115200").is_some()); - assert!(string_to_baud("38400").is_some()); - assert!(string_to_baud("19200").is_some()); + assert!(string_to_baud("9600", flags::BaudType::Both).is_some()); + assert!(string_to_baud("115200", flags::BaudType::Both).is_some()); + assert!(string_to_baud("38400", flags::BaudType::Both).is_some()); + assert!(string_to_baud("19200", flags::BaudType::Both).is_some()); } #[cfg(any( @@ -1461,10 +1464,10 @@ mod tests { target_os = "openbsd" ))] { - assert!(string_to_baud("9600").is_some()); - assert!(string_to_baud("115200").is_some()); - assert!(string_to_baud("1000000").is_some()); - assert!(string_to_baud("0").is_some()); + assert!(string_to_baud("9600", flags::BaudType::Both).is_some()); + assert!(string_to_baud("115200", flags::BaudType::Both).is_some()); + assert!(string_to_baud("1000000", flags::BaudType::Both).is_some()); + assert!(string_to_baud("0", flags::BaudType::Both).is_some()); } } @@ -1479,10 +1482,10 @@ mod tests { target_os = "openbsd" )))] { - assert_eq!(string_to_baud("995"), None); - assert_eq!(string_to_baud("invalid"), None); - assert_eq!(string_to_baud(""), None); - assert_eq!(string_to_baud("abc"), None); + assert_eq!(string_to_baud("995", flags::BaudType::Both), None); + assert_eq!(string_to_baud("invalid", flags::BaudType::Both), None); + assert_eq!(string_to_baud("", flags::BaudType::Both), None); + assert_eq!(string_to_baud("abc", flags::BaudType::Both), None); } } diff --git a/tests/by-util/test_stty.rs b/tests/by-util/test_stty.rs index ae64eb6ae..c2b8a77e5 100644 --- a/tests/by-util/test_stty.rs +++ b/tests/by-util/test_stty.rs @@ -1627,6 +1627,71 @@ fn test_stty_uses_stdin() { .stdout_contains("columns 100"); } +#[test] +#[cfg(unix)] +fn test_ispeed_ospeed_valid_speeds() { + let (path, _controller, _replica) = pty_path(); + let (_at, ts) = at_and_ts!(); + + // Test various valid baud rates for both ispeed and ospeed + let test_cases = [ + ("ispeed", "50"), + ("ispeed", "9600"), + ("ispeed", "19200"), + ("ospeed", "1200"), + ("ospeed", "9600"), + ("ospeed", "38400"), + ]; + + for (arg, speed) in test_cases { + let result = ts.ucmd().args(&["--file", &path, arg, speed]).run(); + let exp_result = unwrap_or_return!(expected_result(&ts, &["--file", &path, arg, speed])); + let normalized_stderr = normalize_stderr(result.stderr_str()); + + result + .stdout_is(exp_result.stdout_str()) + .code_is(exp_result.code()); + assert_eq!(normalized_stderr, exp_result.stderr_str()); + } +} + +#[test] +#[cfg(all( + unix, + not(any( + target_os = "freebsd", + target_os = "dragonfly", + target_os = "ios", + target_os = "macos", + target_os = "netbsd", + target_os = "openbsd" + )) +))] +#[ignore = "Issue: #9547"] +fn test_ispeed_ospeed_invalid_speeds() { + let (path, _controller, _replica) = pty_path(); + let (_at, ts) = at_and_ts!(); + + // Test invalid speed values (non-standard baud rates) + let test_cases = [ + ("ispeed", "12345"), + ("ospeed", "99999"), + ("ispeed", "abc"), + ("ospeed", "xyz"), + ]; + + for (arg, speed) in test_cases { + let result = ts.ucmd().args(&["--file", &path, arg, speed]).run(); + let exp_result = unwrap_or_return!(expected_result(&ts, &["--file", &path, arg, speed])); + let normalized_stderr = normalize_stderr(result.stderr_str()); + + result + .stdout_is(exp_result.stdout_str()) + .code_is(exp_result.code()); + assert_eq!(normalized_stderr, exp_result.stderr_str()); + } +} + #[test] #[cfg(unix)] fn test_columns_env_wrapping() { From e96a1706759836ce9c6458285675c87223cd68ca Mon Sep 17 00:00:00 2001 From: Christopher Dryden Date: Sat, 10 Jan 2026 06:01:50 +0000 Subject: [PATCH 162/425] tail: fix pipe-f test by detecting broken stdout pipe --- Cargo.lock | 1 - src/uu/tail/src/follow/watch.rs | 25 ++++---------- src/uu/tail/src/tail.rs | 1 - src/uu/tee/Cargo.toml | 1 - src/uu/tee/src/tee.rs | 46 ++------------------------ src/uucore/Cargo.toml | 1 + src/uucore/src/lib/features/signals.rs | 44 +++++++++++++++++++++++- tests/by-util/test_tail.rs | 26 +++++++++++++++ 8 files changed, 79 insertions(+), 66 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 30192f4cb..ec4b6825a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3995,7 +3995,6 @@ version = "0.5.0" dependencies = [ "clap", "fluent", - "nix", "uucore", ] diff --git a/src/uu/tail/src/follow/watch.rs b/src/uu/tail/src/follow/watch.rs index b4b4d00ac..a5dd53897 100644 --- a/src/uu/tail/src/follow/watch.rs +++ b/src/uu/tail/src/follow/watch.rs @@ -15,6 +15,8 @@ use std::path::{Path, PathBuf}; use std::sync::mpsc::{self, Receiver, channel}; use uucore::display::Quotable; use uucore::error::{UResult, USimpleError, set_exit_code}; +#[cfg(target_os = "linux")] +use uucore::signals::ensure_stdout_not_broken; use uucore::translate; use uucore::show_error; @@ -160,24 +162,6 @@ impl Observer { Ok(()) } - pub fn add_stdin( - &mut self, - display_name: &str, - reader: Option>, - update_last: bool, - ) -> UResult<()> { - if self.follow == Some(FollowMode::Descriptor) { - return self.add_path( - &PathBuf::from(text::DEV_STDIN), - display_name, - reader, - update_last, - ); - } - - Ok(()) - } - pub fn add_bad_path( &mut self, path: &Path, @@ -619,6 +603,11 @@ pub fn follow(mut observer: Observer, settings: &Settings) -> UResult<()> { } Err(mpsc::RecvTimeoutError::Timeout) => { timeout_counter += 1; + // Check if stdout pipe is still open + #[cfg(target_os = "linux")] + if let Ok(false) = ensure_stdout_not_broken() { + return Ok(()); + } } Err(e) => { return Err(USimpleError::new( diff --git a/src/uu/tail/src/tail.rs b/src/uu/tail/src/tail.rs index 56bf15504..ec094ae0a 100644 --- a/src/uu/tail/src/tail.rs +++ b/src/uu/tail/src/tail.rs @@ -265,7 +265,6 @@ fn tail_stdin( } else { let mut reader = BufReader::new(stdin()); unbounded_tail(&mut reader, settings)?; - observer.add_stdin(input.display_name.as_str(), Some(Box::new(reader)), true)?; } } } diff --git a/src/uu/tee/Cargo.toml b/src/uu/tee/Cargo.toml index 397f6efbb..38a946edf 100644 --- a/src/uu/tee/Cargo.toml +++ b/src/uu/tee/Cargo.toml @@ -19,7 +19,6 @@ path = "src/tee.rs" [dependencies] clap = { workspace = true } -nix = { workspace = true, features = ["poll", "fs"] } uucore = { workspace = true, features = ["libc", "parser", "signals"] } fluent = { workspace = true } diff --git a/src/uu/tee/src/tee.rs b/src/uu/tee/src/tee.rs index 77859fa8f..1325b0465 100644 --- a/src/uu/tee/src/tee.rs +++ b/src/uu/tee/src/tee.rs @@ -3,8 +3,6 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// cSpell:ignore POLLERR POLLRDBAND pfds revents - use clap::{Arg, ArgAction, Command, builder::PossibleValue}; use std::ffi::OsString; use std::fs::OpenOptions; @@ -18,6 +16,8 @@ use uucore::{format_usage, show_error}; // spell-checker:ignore nopipe +#[cfg(target_os = "linux")] +use uucore::signals::ensure_stdout_not_broken; #[cfg(unix)] use uucore::signals::{enable_pipe_errors, ignore_interrupts}; @@ -422,45 +422,3 @@ impl Read for NamedReader { } } } - -/// Check that if stdout is a pipe, it is not broken. -#[cfg(target_os = "linux")] -pub fn ensure_stdout_not_broken() -> Result { - use nix::{ - poll::{PollFd, PollFlags, PollTimeout}, - sys::stat::{SFlag, fstat}, - }; - use std::os::fd::AsFd; - - let out = stdout(); - - // First, check that stdout is a fifo and return true if it's not the case - let stat = fstat(out.as_fd())?; - if !SFlag::from_bits_truncate(stat.st_mode).contains(SFlag::S_IFIFO) { - return Ok(true); - } - - // POLLRDBAND is the flag used by GNU tee. - let mut pfds = [PollFd::new(out.as_fd(), PollFlags::POLLRDBAND)]; - - // Then, ensure that the pipe is not broken. - // Use ZERO timeout to return immediately - we just want to check the current state. - let res = nix::poll::poll(&mut pfds, PollTimeout::ZERO)?; - - if res > 0 { - // poll returned with events ready - check if POLLERR is set (pipe broken) - let error = pfds.iter().any(|pfd| { - if let Some(revents) = pfd.revents() { - revents.contains(PollFlags::POLLERR) - } else { - true - } - }); - return Ok(!error); - } - - // res == 0 means no events ready (timeout reached immediately with ZERO timeout). - // This means the pipe is healthy (not broken). - // res < 0 would be an error, but nix returns Err in that case. - Ok(true) -} diff --git a/src/uucore/Cargo.toml b/src/uucore/Cargo.toml index d58d2ccca..80742d2d5 100644 --- a/src/uucore/Cargo.toml +++ b/src/uucore/Cargo.toml @@ -93,6 +93,7 @@ nix = { workspace = true, features = [ "signal", "dir", "user", + "poll", ] } xattr = { workspace = true, optional = true } diff --git a/src/uucore/src/lib/features/signals.rs b/src/uucore/src/lib/features/signals.rs index 81b414a86..1c4d684a7 100644 --- a/src/uucore/src/lib/features/signals.rs +++ b/src/uucore/src/lib/features/signals.rs @@ -3,7 +3,7 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore (vars/api) fcntl setrlimit setitimer rubout pollable sysconf pgrp +// spell-checker:ignore (vars/api) fcntl setrlimit setitimer rubout pollable sysconf pgrp pfds revents POLLRDBAND POLLERR // spell-checker:ignore (vars/signals) ABRT ALRM CHLD SEGV SIGABRT SIGALRM SIGBUS SIGCHLD SIGCONT SIGDANGER SIGEMT SIGFPE SIGHUP SIGILL SIGINFO SIGINT SIGIO SIGIOT SIGKILL SIGMIGRATE SIGMSG SIGPIPE SIGPRE SIGPROF SIGPWR SIGQUIT SIGSEGV SIGSTOP SIGSYS SIGTALRM SIGTERM SIGTRAP SIGTSTP SIGTHR SIGTTIN SIGTTOU SIGURG SIGUSR SIGVIRT SIGVTALRM SIGWINCH SIGXCPU SIGXFSZ STKFLT PWR THR TSTP TTIN TTOU VIRT VTALRM XCPU XFSZ SIGCLD SIGPOLL SIGWAITING SIGAIOCANCEL SIGLWP SIGFREEZE SIGTHAW SIGCANCEL SIGLOST SIGXRES SIGJVM SIGRTMIN SIGRT SIGRTMAX TALRM AIOCANCEL XRES RTMIN RTMAX LTOSTOP //! This module provides a way to handle signals in a platform-independent way. @@ -488,6 +488,48 @@ pub const fn sigpipe_was_ignored() -> bool { false } +#[cfg(target_os = "linux")] +pub fn ensure_stdout_not_broken() -> std::io::Result { + use nix::{ + poll::{PollFd, PollFlags, PollTimeout, poll}, + sys::stat::{SFlag, fstat}, + }; + use std::io::stdout; + use std::os::fd::AsFd; + + let out = stdout(); + + // First, check that stdout is a fifo and return true if it's not the case + let stat = fstat(out.as_fd())?; + if !SFlag::from_bits_truncate(stat.st_mode).contains(SFlag::S_IFIFO) { + return Ok(true); + } + + // POLLRDBAND is the flag used by GNU tee. + let mut pfds = [PollFd::new(out.as_fd(), PollFlags::POLLRDBAND)]; + + // Then, ensure that the pipe is not broken. + // Use ZERO timeout to return immediately - we just want to check the current state. + let res = poll(&mut pfds, PollTimeout::ZERO)?; + + if res > 0 { + // poll returned with events ready - check if POLLERR is set (pipe broken) + let error = pfds.iter().any(|pfd| { + if let Some(revents) = pfd.revents() { + revents.contains(PollFlags::POLLERR) + } else { + true + } + }); + return Ok(!error); + } + + // res == 0 means no events ready (timeout reached immediately with ZERO timeout). + // This means the pipe is healthy (not broken). + // res < 0 would be an error, but nix returns Err in that case. + Ok(true) +} + #[test] fn signal_by_value() { assert_eq!(signal_by_name_or_value("0"), Some(0)); diff --git a/tests/by-util/test_tail.rs b/tests/by-util/test_tail.rs index 50b404c91..36a0a9a53 100644 --- a/tests/by-util/test_tail.rs +++ b/tests/by-util/test_tail.rs @@ -4961,3 +4961,29 @@ fn tail_n_lines_with_emoji() { .succeeds() .stdout_only("💐\n"); } + +#[test] +#[cfg(target_os = "linux")] +fn test_follow_pipe_f() { + new_ucmd!() + .args(&["-f", "-c3", "-s.1", "--max-unchanged-stats=1"]) + .pipe_in("foo\n") + .succeeds() + .stdout_only("oo\n"); +} + +#[test] +#[cfg(target_os = "linux")] +fn test_follow_stdout_pipe_close() { + let (at, mut ucmd) = at_and_ucmd!(); + at.write("f", "line1\nline2\n"); + + let mut child = ucmd + .args(&["-f", "-s.1", "--max-unchanged-stats=1", "f"]) + .set_stdout(Stdio::piped()) + .run_no_wait(); + + child.stdout_exact_bytes(6); // read "line1\n" + child.close_stdout(); + child.delay(2000).make_assertion().is_not_alive(); +} From a17e814aea8688bdbdb08dff5eb63d04ddc968bb Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Sat, 10 Jan 2026 16:37:19 +0900 Subject: [PATCH 163/425] run-gnu-tests-smack-ci.sh: Use release-small profile for faster build (#10144) Co-authored-by: Sylvestre Ledru --- util/run-gnu-tests-smack-ci.sh | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/util/run-gnu-tests-smack-ci.sh b/util/run-gnu-tests-smack-ci.sh index 9fb9e81fb..fdcd14897 100755 --- a/util/run-gnu-tests-smack-ci.sh +++ b/util/run-gnu-tests-smack-ci.sh @@ -4,6 +4,7 @@ # spell-checker:ignore rootfs zstd unzstd cpio newc nographic smackfs devtmpfs tmpfs poweroff libm libgcc libpthread libdl librt sysfs rwxat setuidgid set -e +: ${PROFILE:=release-small} SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" REPO_DIR="$(dirname "$SCRIPT_DIR")" GNU_DIR="${1:-$REPO_DIR/../gnu}" @@ -77,7 +78,7 @@ chmod +x "$QEMU_DIR/rootfs/init" # Build utilities for SMACK/ROOTFS tests echo "Building utilities for SMACK/ROOTFS tests..." -cargo build --release --manifest-path="$REPO_DIR/Cargo.toml" --package uu_id --features uu_id/smack --package uu_ls --features uu_ls/smack --package uu_mkdir --features uu_mkdir/smack --package uu_mkfifo --features uu_mkfifo/smack --package uu_mknod --features uu_mknod/smack --package uu_df +cargo build --profile="${PROFILE}" --manifest-path="$REPO_DIR/Cargo.toml" --package uu_id --features uu_id/smack --package uu_ls --features uu_ls/smack --package uu_mkdir --features uu_mkdir/smack --package uu_mkfifo --features uu_mkfifo/smack --package uu_mknod --features uu_mknod/smack --package uu_df # Find SMACK tests and tests requiring rootfs in mtab (only available in QEMU environment) QEMU_TESTS=$(grep -l -E 'require_smack_|rootfs in mtab' -r "$GNU_DIR/tests/" 2>/dev/null | sort -u || true) @@ -111,7 +112,7 @@ for TEST_PATH in $QEMU_TESTS; do # Copy built utilities for SMACK/ROOTFS tests for U in id ls mkdir mkfifo mknod df; do rm -f "$WORK/bin/$U" - cp "$REPO_DIR/target/release/$U" "$WORK/bin/$U" + cp "$REPO_DIR/target/${PROFILE}/$U" "$WORK/bin/$U" done # Set test script path and user From 67cedcdeb92ea3a2b347987e65e136485a8c2c2b Mon Sep 17 00:00:00 2001 From: oech3 <> Date: Sat, 10 Jan 2026 16:22:15 +0900 Subject: [PATCH 164/425] nproc: Avoid > /dev/full panic --- src/uu/nproc/src/nproc.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/uu/nproc/src/nproc.rs b/src/uu/nproc/src/nproc.rs index b75c4c8db..615a70594 100644 --- a/src/uu/nproc/src/nproc.rs +++ b/src/uu/nproc/src/nproc.rs @@ -6,6 +6,7 @@ // spell-checker:ignore (ToDO) NPROCESSORS nprocs numstr sysconf use clap::{Arg, ArgAction, Command}; +use std::io::{Write, stdout}; use std::{env, thread}; use uucore::display::Quotable; use uucore::error::{UResult, USimpleError}; @@ -85,7 +86,11 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { } else { cores -= ignore; } - println!("{cores}"); + //discard error about stdout flush + stdout() + .lock() + .write_all(format!("{cores}\n").as_bytes()) + .map_err(|e| USimpleError::new(1, e.to_string()))?; Ok(()) } From 7bf12ee7d96fe41b473209b412dcfff41951ebf8 Mon Sep 17 00:00:00 2001 From: Christopher Dryden Date: Sat, 10 Jan 2026 08:24:13 +0000 Subject: [PATCH 165/425] df: add rootfs to is_dummy_filesystem --- src/uucore/src/lib/features/fsext.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/uucore/src/lib/features/fsext.rs b/src/uucore/src/lib/features/fsext.rs index 0b6e59acb..c31b82725 100644 --- a/src/uucore/src/lib/features/fsext.rs +++ b/src/uucore/src/lib/features/fsext.rs @@ -393,6 +393,8 @@ fn is_dummy_filesystem(fs_type: &str, mount_option: &str) -> bool { | "kernfs" // for Irix 6.5 | "ignore" + // Linux initial root filesystem + | "rootfs" // Binary format support pseudo-filesystem | "binfmt_misc" => true, _ => fs_type == "none" From bb88fb2de76db11bffe822d41799b51365bab365 Mon Sep 17 00:00:00 2001 From: David CARLIER Date: Sat, 10 Jan 2026 08:51:33 +0000 Subject: [PATCH 166/425] more: file status made more idiomatic. (#10151) as checking for presence and unwrapping looks inefficient. --- src/uu/more/src/more.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/uu/more/src/more.rs b/src/uu/more/src/more.rs index e882f4028..6c93dce69 100644 --- a/src/uu/more/src/more.rs +++ b/src/uu/more/src/more.rs @@ -837,8 +837,11 @@ impl<'a> Pager<'a> { // Determine progress information to display // - Show next file name when at EOF and there is a next file // - Otherwise show percentage of the file read (if available) - let progress_info = if self.eof_reached && self.next_file.is_some() { - format!(" (Next file: {})", self.next_file.unwrap()) + let progress_info = if self.eof_reached { + self.next_file + .as_ref() + .map(|next_file| format!(" (Next file: {next_file})")) + .unwrap_or_default() } else if let Some(file_size) = self.file_size { // For files, show percentage or END let position = self From b2ad639ec2075f5bc86811502bdc60549493e6be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beat=20K=C3=BCng?= Date: Sat, 10 Jan 2026 10:19:50 +0100 Subject: [PATCH 167/425] fsxattr: disable "security.capability" check for bsd The check was failing with: thread 'features::fsxattr::tests::test_file_has_acl' (134643) panicked at src/uucore/src/lib/features/fsxattr.rs:278:55: called `Result::unwrap()` on an `Err` value: Custom { kind: InvalidInput, error: "no matching namespace" } --- src/uucore/src/lib/features/fsxattr.rs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/uucore/src/lib/features/fsxattr.rs b/src/uucore/src/lib/features/fsxattr.rs index 5e7861106..e9ae84c5f 100644 --- a/src/uucore/src/lib/features/fsxattr.rs +++ b/src/uucore/src/lib/features/fsxattr.rs @@ -273,10 +273,17 @@ mod tests { assert!(has_acl(&file_path)); assert!(!has_security_cap_acl(&file_path)); - let test_attr = "security.capability"; - let test_value = b""; - xattr::set(&file_path, test_attr, test_value).unwrap(); + // FreeBSD/NetBSD's xattr library does not support the "security" namespace + // (https://github.com/Stebalien/xattr/blob/master/src/sys/bsd.rs#L148). + // However, individual file systems might still implement additional namespaces according to + // https://man.freebsd.org/cgi/man.cgi?query=extattr&sektion=9&manpath=FreeBSD+14.3-RELEASE+and+Ports + #[cfg(not(any(target_os = "freebsd", target_os = "netbsd")))] + { + let test_attr = "security.capability"; + let test_value = b""; + xattr::set(&file_path, test_attr, test_value).unwrap(); - assert!(has_security_cap_acl(&file_path)); + assert!(has_security_cap_acl(&file_path)); + } } } From 3441de92f27c693d4cb9fd7630e49fce19ba0e34 Mon Sep 17 00:00:00 2001 From: Chris Dryden Date: Sat, 10 Jan 2026 06:39:14 -0500 Subject: [PATCH 168/425] tail: fix --pid with FIFO by using non-blocking open (#9663) * tail: fix --pid with FIFO by using non-blocking open * Address review comments: propagate fcntl errors and remove Windows stub --------- Co-authored-by: Sylvestre Ledru Co-authored-by: Sylvestre Ledru --- .../cspell.dictionaries/jargon.wordlist.txt | 1 + Cargo.lock | 1 + src/uu/tail/Cargo.toml | 3 ++ src/uu/tail/src/tail.rs | 44 ++++++++++++++++++- tests/by-util/test_tail.rs | 39 ++++++++++++++++ 5 files changed, 87 insertions(+), 1 deletion(-) diff --git a/.vscode/cspell.dictionaries/jargon.wordlist.txt b/.vscode/cspell.dictionaries/jargon.wordlist.txt index 47d435b87..33f495948 100644 --- a/.vscode/cspell.dictionaries/jargon.wordlist.txt +++ b/.vscode/cspell.dictionaries/jargon.wordlist.txt @@ -217,4 +217,5 @@ TUNABLES tunables VMULL vmull +SETFL tmpfs diff --git a/Cargo.lock b/Cargo.lock index 30192f4cb..ec89861a8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3981,6 +3981,7 @@ dependencies = [ "fluent", "libc", "memchr", + "nix", "notify", "rstest", "same-file", diff --git a/src/uu/tail/Cargo.toml b/src/uu/tail/Cargo.toml index 732278732..055b62400 100644 --- a/src/uu/tail/Cargo.toml +++ b/src/uu/tail/Cargo.toml @@ -27,6 +27,9 @@ uucore = { workspace = true, features = ["fs", "parser-size", "signals"] } same-file = { workspace = true } fluent = { workspace = true } +[target.'cfg(unix)'.dependencies] +nix = { workspace = true, features = ["fs"] } + [target.'cfg(windows)'.dependencies] windows-sys = { workspace = true, features = [ "Win32_System_Threading", diff --git a/src/uu/tail/src/tail.rs b/src/uu/tail/src/tail.rs index 56bf15504..a35e542d8 100644 --- a/src/uu/tail/src/tail.rs +++ b/src/uu/tail/src/tail.rs @@ -152,7 +152,12 @@ fn tail_file( } observer.add_bad_path(path, input.display_name.as_str(), false)?; } else { - match File::open(path) { + #[cfg(unix)] + let open_result = open_file(path, settings.pid != 0); + #[cfg(not(unix))] + let open_result = File::open(path); + + match open_result { Ok(mut file) => { let st = file.metadata()?; let blksize_limit = uucore::fs::sane_blksize::sane_blksize_from_metadata(&st); @@ -197,6 +202,43 @@ fn tail_file( Ok(()) } +/// Opens a file, using non-blocking mode for FIFOs when `use_nonblock_for_fifo` is true. +/// +/// When opening a FIFO with `--pid`, we need to use O_NONBLOCK so that: +/// 1. The open() call doesn't block waiting for a writer +/// 2. We can periodically check if the monitored process is still alive +/// +/// After opening, we clear O_NONBLOCK so subsequent reads block normally. +/// Without `--pid`, FIFOs block on open() until a writer connects (GNU behavior). +#[cfg(unix)] +fn open_file(path: &Path, use_nonblock_for_fifo: bool) -> std::io::Result { + use nix::fcntl::{FcntlArg, OFlag, fcntl}; + use std::fs::OpenOptions; + use std::os::fd::AsFd; + use std::os::unix::fs::{FileTypeExt, OpenOptionsExt}; + + let is_fifo = path + .metadata() + .ok() + .is_some_and(|m| m.file_type().is_fifo()); + + if is_fifo && use_nonblock_for_fifo { + let file = OpenOptions::new() + .read(true) + .custom_flags(libc::O_NONBLOCK) + .open(path)?; + + // Clear O_NONBLOCK so reads block normally + let flags = fcntl(file.as_fd(), FcntlArg::F_GETFL)?; + let new_flags = OFlag::from_bits_truncate(flags) & !OFlag::O_NONBLOCK; + fcntl(file.as_fd(), FcntlArg::F_SETFL(new_flags))?; + + Ok(file) + } else { + File::open(path) + } +} + fn tail_stdin( settings: &Settings, header_printer: &mut HeaderPrinter, diff --git a/tests/by-util/test_tail.rs b/tests/by-util/test_tail.rs index 50b404c91..231274ea7 100644 --- a/tests/by-util/test_tail.rs +++ b/tests/by-util/test_tail.rs @@ -2659,6 +2659,45 @@ fn test_fifo() { } } +/// Test that tail with --pid exits when the monitored process dies, even with a FIFO. +/// Without non-blocking FIFO open, tail would block forever waiting for a writer. +#[test] +#[cfg(all( + not(target_vendor = "apple"), + not(target_os = "windows"), + not(target_os = "android"), + not(target_os = "freebsd"), + not(target_os = "openbsd") +))] +fn test_fifo_with_pid() { + use std::process::Command; + + let (at, mut ucmd) = at_and_ucmd!(); + at.mkfifo("FIFO"); + + let mut dummy = Command::new("sh").spawn().unwrap(); + let pid = dummy.id(); + + let mut child = ucmd + .arg("-f") + .arg(format!("--pid={pid}")) + .arg("FIFO") + .run_no_wait(); + + child.make_assertion_with_delay(500).is_alive(); + + kill(Pid::from_raw(i32::try_from(pid).unwrap()), Signal::SIGUSR1).unwrap(); + let _ = dummy.wait(); + + child + .make_assertion_with_delay(DEFAULT_SLEEP_INTERVAL_MILLIS) + .is_not_alive() + .with_all_output() + .no_stderr() + .no_stdout() + .success(); +} + #[test] #[cfg(unix)] #[ignore = "disabled until fixed"] From cb6d3396f64827362729f493ce47f6eb618bd998 Mon Sep 17 00:00:00 2001 From: mattsu <35655889+mattsu2020@users.noreply.github.com> Date: Sat, 10 Jan 2026 22:22:07 +0900 Subject: [PATCH 169/425] refactor(head): replace unsafe raw fd usage with safe AsFd API (#10161) Use std::os::fd::AsFd trait and try_clone_to_owned to safely handle stdin file descriptor, eliminating unsafe code for better reliability and adherence to modern Rust standards. --- src/uu/head/src/head.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/uu/head/src/head.rs b/src/uu/head/src/head.rs index 7bb076c7f..428d62443 100644 --- a/src/uu/head/src/head.rs +++ b/src/uu/head/src/head.rs @@ -12,7 +12,7 @@ use std::fs::File; use std::io::{self, BufWriter, Read, Seek, SeekFrom, Write}; use std::num::TryFromIntError; #[cfg(unix)] -use std::os::fd::{AsRawFd, FromRawFd}; +use std::os::fd::AsFd; use std::path::PathBuf; use thiserror::Error; use uucore::display::{Quotable, print_verbatim}; @@ -479,8 +479,8 @@ fn uu_head(options: &HeadOptions) -> UResult<()> { #[cfg(unix)] { - let stdin_raw_fd = stdin.as_raw_fd(); - let mut stdin_file = unsafe { File::from_raw_fd(stdin_raw_fd) }; + let stdin_owned_fd = stdin.as_fd().try_clone_to_owned()?; + let mut stdin_file = File::from(stdin_owned_fd); let current_pos = stdin_file.stream_position(); if let Ok(current_pos) = current_pos { // We have a seekable file. Ensure we set the input stream to the From 04c81ce8cf094911f67110bb25650c869883a0ca Mon Sep 17 00:00:00 2001 From: Chris Dryden Date: Sat, 10 Jan 2026 08:25:18 -0500 Subject: [PATCH 170/425] tail: fix big number handling and error message quoting (#10155) * tail: fix big number handling and error message quoting * fix clippy: remove needless borrows in test_tail --- src/uu/tail/src/args.rs | 12 ++---- src/uu/tail/src/parse.rs | 12 +----- tests/by-util/test_tail.rs | 79 ++++++++++++++++++++++++++++---------- 3 files changed, 64 insertions(+), 39 deletions(-) diff --git a/src/uu/tail/src/args.rs b/src/uu/tail/src/args.rs index 5f3404fbf..d5159ab7a 100644 --- a/src/uu/tail/src/args.rs +++ b/src/uu/tail/src/args.rs @@ -13,7 +13,7 @@ use std::ffi::OsString; use std::io::IsTerminal; use std::time::Duration; use uucore::error::{UResult, USimpleError, UUsageError}; -use uucore::parser::parse_signed_num::{SignPrefix, parse_signed_num}; +use uucore::parser::parse_signed_num::{SignPrefix, parse_signed_num_max}; use uucore::parser::parse_size::ParseSizeError; use uucore::parser::parse_time; use uucore::parser::shortcut_value_parser::ShortcutValueParser; @@ -78,7 +78,7 @@ impl FilterMode { Err(e) => { return Err(USimpleError::new( 1, - translate!("tail-error-invalid-number-of-bytes", "arg" => format!("'{e}'")), + translate!("tail-error-invalid-number-of-bytes", "arg" => e.to_string()), )); } } @@ -366,12 +366,6 @@ pub fn parse_obsolete(arg: &OsString, input: Option<&OsString>) -> UResult { - translate!("tail-error-invalid-number-out-of-range", "arg" => arg.quote()) - } - parse::ParseError::Overflow => { - translate!("tail-error-invalid-number-overflow", "arg" => arg.quote()) - } // this ensures compatibility to GNU's error message (as tested in misc/tail) parse::ParseError::Context => { translate!( @@ -389,7 +383,7 @@ pub fn parse_obsolete(arg: &OsString, input: Option<&OsString>) -> UResult Result { - let result = parse_signed_num(src)?; + let result = parse_signed_num_max(src)?; // tail: '+' means "starting from line/byte N", default/'-' means "last N" let is_plus = result.sign == Some(SignPrefix::Plus); diff --git a/src/uu/tail/src/parse.rs b/src/uu/tail/src/parse.rs index 2e768d1c9..846ba49b8 100644 --- a/src/uu/tail/src/parse.rs +++ b/src/uu/tail/src/parse.rs @@ -26,8 +26,6 @@ impl Default for ObsoleteArgs { #[derive(PartialEq, Eq, Debug)] pub enum ParseError { - OutOfRange, - Overflow, Context, InvalidEncoding, } @@ -52,11 +50,7 @@ pub fn parse_obsolete(src: &OsString) -> Option .unwrap_or(rest.len()); let has_num = !rest[..end_num].is_empty(); let num: u64 = if has_num { - if let Ok(num) = rest[..end_num].parse() { - num - } else { - return Some(Err(ParseError::OutOfRange)); - } + rest[..end_num].parse().unwrap_or(u64::MAX) } else { 10 }; @@ -85,9 +79,7 @@ pub fn parse_obsolete(src: &OsString) -> Option } let multiplier = if mode == 'b' { 512 } else { 1 }; - let Some(num) = num.checked_mul(multiplier) else { - return Some(Err(ParseError::Overflow)); - }; + let num = num.saturating_mul(multiplier); Some(Ok(ObsoleteArgs { num, diff --git a/tests/by-util/test_tail.rs b/tests/by-util/test_tail.rs index 231274ea7..37b7239c2 100644 --- a/tests/by-util/test_tail.rs +++ b/tests/by-util/test_tail.rs @@ -1155,16 +1155,17 @@ fn test_invalid_num() { .fails() .stderr_str() .starts_with("tail: invalid number of lines: '1024R'"); + // 1Y overflows to u64::MAX (like GNU tail 9.9.x), so it succeeds new_ucmd!() - .args(&["-c", "1Y", "emptyfile.txt"]) - .fails() - .stderr_str() - .starts_with("tail: invalid number of bytes: '1Y': Value too large for defined data type"); + .args(&["-c", "1Y"]) + .pipe_in("x") + .succeeds() + .stdout_is("x"); new_ucmd!() - .args(&["-n", "1Y", "emptyfile.txt"]) - .fails() - .stderr_str() - .starts_with("tail: invalid number of lines: '1Y': Value too large for defined data type"); + .args(&["-n", "1Y"]) + .pipe_in("x\n") + .succeeds() + .stdout_is("x\n"); new_ucmd!() .args(&["-c", "-³"]) .fails() @@ -1172,6 +1173,45 @@ fn test_invalid_num() { .starts_with("tail: invalid number of bytes: '³'"); } +#[test] +fn test_oversized_num() { + const BIG: &str = "99999999999999999999999999999"; + const DATA: &str = "abcd"; + // -c and -n : output all (request more than available) + new_ucmd!() + .args(&["-c", BIG]) + .pipe_in(DATA) + .succeeds() + .stdout_is(DATA); + new_ucmd!() + .args(&["-n", BIG]) + .pipe_in("a\nb\n") + .succeeds() + .stdout_is("a\nb\n"); + // +: skip beyond input (empty output) + new_ucmd!() + .args(&["-c", &format!("+{BIG}")]) + .pipe_in(DATA) + .succeeds() + .no_stdout(); + new_ucmd!() + .args(&["-n", &format!("+{BIG}")]) + .pipe_in("a\nb\n") + .succeeds() + .no_stdout(); + // Obsolete syntax + new_ucmd!() + .arg(format!("+{BIG}c")) + .pipe_in(DATA) + .succeeds() + .no_stdout(); + new_ucmd!() + .arg(format!("-{BIG}c")) + .pipe_in(DATA) + .succeeds() + .stdout_is(DATA); +} + #[test] fn test_num_with_undocumented_sign_bytes() { // tail: '-' is not documented (8.32 man pages) @@ -4767,13 +4807,13 @@ fn test_gnu_args_err() { .fails_with_code(1) .no_stdout() .stderr_is("tail: option used in invalid context -- 2\n"); - // err-5 + // err-5: large numbers now clamp to u64::MAX scene .ucmd() .arg("-c99999999999999999999") - .fails_with_code(1) - .no_stdout() - .stderr_is("tail: invalid number of bytes: '99999999999999999999'\n"); + .pipe_in("x") + .succeeds() + .stdout_is("x"); // err-6 scene .ucmd() @@ -4787,20 +4827,19 @@ fn test_gnu_args_err() { .fails_with_code(1) .no_stdout() .stderr_is("tail: option used in invalid context -- 5\n"); + // Large obsolete-syntax numbers clamp to u64::MAX scene .ucmd() .arg("-9999999999999999999b") - .fails_with_code(1) - .no_stdout() - .stderr_is("tail: invalid number: '-9999999999999999999b'\n"); + .pipe_in("x") + .succeeds() + .stdout_is("x"); scene .ucmd() .arg("-999999999999999999999b") - .fails_with_code(1) - .no_stdout() - .stderr_is( - "tail: invalid number: '-999999999999999999999b': Numerical result out of range\n", - ); + .pipe_in("x") + .succeeds() + .stdout_is("x"); } #[test] From 6d3df54a7e157785473c91161588dd872dea6e54 Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Sat, 10 Jan 2026 21:39:13 +0900 Subject: [PATCH 171/425] run-gnu-tests-smack-ci.sh: Use multi-call binary for faster build --- util/run-gnu-tests-smack-ci.sh | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/util/run-gnu-tests-smack-ci.sh b/util/run-gnu-tests-smack-ci.sh index fdcd14897..3a788b008 100755 --- a/util/run-gnu-tests-smack-ci.sh +++ b/util/run-gnu-tests-smack-ci.sh @@ -78,7 +78,7 @@ chmod +x "$QEMU_DIR/rootfs/init" # Build utilities for SMACK/ROOTFS tests echo "Building utilities for SMACK/ROOTFS tests..." -cargo build --profile="${PROFILE}" --manifest-path="$REPO_DIR/Cargo.toml" --package uu_id --features uu_id/smack --package uu_ls --features uu_ls/smack --package uu_mkdir --features uu_mkdir/smack --package uu_mkfifo --features uu_mkfifo/smack --package uu_mknod --features uu_mknod/smack --package uu_df +cargo build --profile="${PROFILE}" --features=feat_smack,id,ls,mkdir,mkfifo,mknod,df --no-default-features # Find SMACK tests and tests requiring rootfs in mtab (only available in QEMU environment) QEMU_TESTS=$(grep -l -E 'require_smack_|rootfs in mtab' -r "$GNU_DIR/tests/" 2>/dev/null | sort -u || true) @@ -109,10 +109,9 @@ for TEST_PATH in $QEMU_TESTS; do rm -rf "$WORK" "$WORK.gz" cp -a "$QEMU_DIR/rootfs" "$WORK" - # Copy built utilities for SMACK/ROOTFS tests - for U in id ls mkdir mkfifo mknod df; do - rm -f "$WORK/bin/$U" - cp "$REPO_DIR/target/${PROFILE}/$U" "$WORK/bin/$U" + # Hardlink utilities for SMACK/ROOTFS tests + for U in $("$REPO_DIR/target/${PROFILE}/coreutils" --list); do + ln -vf "$REPO_DIR/target/${PROFILE}/coreutils" "$WORK/bin/$U" done # Set test script path and user From dff11d059fb65579e7495b2241f3630811ebdc7b Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 10 Jan 2026 15:35:57 +0000 Subject: [PATCH 172/425] chore(deps): update rust crate data-encoding-macro to v0.1.19 --- Cargo.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 621e519eb..121524db7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -837,15 +837,15 @@ dependencies = [ [[package]] name = "data-encoding" -version = "2.9.0" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" [[package]] name = "data-encoding-macro" -version = "0.1.18" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47ce6c96ea0102f01122a185683611bd5ac8d99e62bc59dd12e6bda344ee673d" +checksum = "8142a83c17aa9461d637e649271eae18bf2edd00e91f2e105df36c3c16355bdb" dependencies = [ "data-encoding", "data-encoding-macro-internal", @@ -853,9 +853,9 @@ dependencies = [ [[package]] name = "data-encoding-macro-internal" -version = "0.1.16" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d162beedaa69905488a8da94f5ac3edb4dd4788b732fadb7bd120b2625c1976" +checksum = "7ab67060fc6b8ef687992d439ca0fa36e7ed17e9a0b16b25b601e8757df720de" dependencies = [ "data-encoding", "syn", From 224be498afc64fc46cd367aa4e51e23dc66de7ca Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Thu, 8 Jan 2026 14:28:54 +0100 Subject: [PATCH 173/425] ls: remove redundant HashMap lookup in append_raw_style_code_for_indicator --- src/uu/ls/src/colors.rs | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/uu/ls/src/colors.rs b/src/uu/ls/src/colors.rs index 8eb4b7097..fc481eab0 100644 --- a/src/uu/ls/src/colors.rs +++ b/src/uu/ls/src/colors.rs @@ -129,14 +129,11 @@ impl<'a> StyleManager<'a> { indicator: Indicator, style_code: &mut String, ) { - if !self.indicator_codes.contains_key(&indicator) { - return; - } - style_code.push_str(self.reset(!self.initial_reset_is_done)); - style_code.push_str(ANSI_CSI); - if let Some(raw) = self.indicator_codes.get(&indicator) { + if let Some(raw) = self.indicator_codes.get(&indicator).cloned() { debug_assert!(!raw.is_empty()); - style_code.push_str(raw); + style_code.push_str(self.reset(!self.initial_reset_is_done)); + style_code.push_str(ANSI_CSI); + style_code.push_str(&raw); style_code.push_str(ANSI_SGR_END); } } From 71c3eb603ba42eb6048750de8e04e1045b17f703 Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Thu, 8 Jan 2026 14:29:23 +0100 Subject: [PATCH 174/425] ls: replace magic numbers with named constants --- src/uu/ls/src/colors.rs | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/uu/ls/src/colors.rs b/src/uu/ls/src/colors.rs index fc481eab0..39955fc3c 100644 --- a/src/uu/ls/src/colors.rs +++ b/src/uu/ls/src/colors.rs @@ -18,6 +18,14 @@ const ANSI_RESET: &str = "\x1b[0m"; const ANSI_CLEAR_EOL: &str = "\x1b[K"; const EMPTY_STYLE: &str = "\x1b[m"; +// Unix file mode bits +const MODE_SETUID: u32 = 0o4000; +const MODE_SETGID: u32 = 0o2000; +const MODE_EXECUTABLE: u32 = 0o0111; +const MODE_STICKY_OTHER_WRITABLE: u32 = 0o1002; +const MODE_OTHER_WRITABLE: u32 = 0o0002; +const MODE_STICKY: u32 = 0o1000; + enum RawIndicatorStyle { Empty, Code(Indicator), @@ -382,13 +390,13 @@ impl<'a> StyleManager<'a> { if self.needs_file_metadata() { if let Some(metadata) = path.metadata() { let mode = metadata.mode(); - if self.has_indicator_style(Indicator::Setuid) && mode & 0o4000 != 0 { + if self.has_indicator_style(Indicator::Setuid) && mode & MODE_SETUID != 0 { return Some(Indicator::Setuid); } - if self.has_indicator_style(Indicator::Setgid) && mode & 0o2000 != 0 { + if self.has_indicator_style(Indicator::Setgid) && mode & MODE_SETGID != 0 { return Some(Indicator::Setgid); } - if self.has_indicator_style(Indicator::ExecutableFile) && mode & 0o0111 != 0 { + if self.has_indicator_style(Indicator::ExecutableFile) && mode & MODE_EXECUTABLE != 0 { return Some(Indicator::ExecutableFile); } if self.has_indicator_style(Indicator::MultipleHardLinks) @@ -408,14 +416,14 @@ impl<'a> StyleManager<'a> { if let Some(metadata) = path.metadata() { let mode = metadata.mode(); if self.has_indicator_style(Indicator::StickyAndOtherWritable) - && mode & 0o1002 == 0o1002 + && mode & MODE_STICKY_OTHER_WRITABLE == MODE_STICKY_OTHER_WRITABLE { return Some(Indicator::StickyAndOtherWritable); } - if self.has_indicator_style(Indicator::OtherWritable) && mode & 0o0002 != 0 { + if self.has_indicator_style(Indicator::OtherWritable) && mode & MODE_OTHER_WRITABLE != 0 { return Some(Indicator::OtherWritable); } - if self.has_indicator_style(Indicator::Sticky) && mode & 0o1000 != 0 { + if self.has_indicator_style(Indicator::Sticky) && mode & MODE_STICKY != 0 { return Some(Indicator::Sticky); } } From c463561c8e361051a812fe1fe1ca4b1a3b58f449 Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Thu, 8 Jan 2026 14:30:07 +0100 Subject: [PATCH 175/425] ls: refactor complex indicator_for_raw_code method into smaller helpers --- src/uu/ls/src/colors.rs | 175 +++++++++++++++++++++++----------------- 1 file changed, 99 insertions(+), 76 deletions(-) diff --git a/src/uu/ls/src/colors.rs b/src/uu/ls/src/colors.rs index 39955fc3c..d97e5c42a 100644 --- a/src/uu/ls/src/colors.rs +++ b/src/uu/ls/src/colors.rs @@ -360,25 +360,7 @@ impl<'a> StyleManager<'a> { }; if file_type.is_symlink() { - let orphan_enabled = self.has_indicator_style(Indicator::OrphanedSymbolicLink); - let missing_enabled = self.has_indicator_style(Indicator::MissingFile); - let needs_target_state = self.ln_color_from_target || orphan_enabled; - let target_missing = needs_target_state && !entry_exists(); - - if target_missing { - let orphan_raw = self.indicator_codes.get(&Indicator::OrphanedSymbolicLink); - let orphan_raw_is_empty = orphan_raw.is_some_and(|value| value.is_empty()); - if orphan_enabled && (!orphan_raw_is_empty || self.ln_color_from_target) { - return Some(Indicator::OrphanedSymbolicLink); - } - if self.ln_color_from_target && missing_enabled { - return Some(Indicator::MissingFile); - } - } - if self.has_indicator_style(Indicator::SymbolicLink) { - return Some(Indicator::SymbolicLink); - } - return None; + return self.indicator_for_symlink(path, &mut entry_exists); } if self.has_indicator_style(Indicator::MissingFile) && !entry_exists() { @@ -386,72 +368,113 @@ impl<'a> StyleManager<'a> { } if file_type.is_file() { - #[cfg(unix)] - if self.needs_file_metadata() { - if let Some(metadata) = path.metadata() { - let mode = metadata.mode(); - if self.has_indicator_style(Indicator::Setuid) && mode & MODE_SETUID != 0 { - return Some(Indicator::Setuid); - } - if self.has_indicator_style(Indicator::Setgid) && mode & MODE_SETGID != 0 { - return Some(Indicator::Setgid); - } - if self.has_indicator_style(Indicator::ExecutableFile) && mode & MODE_EXECUTABLE != 0 { - return Some(Indicator::ExecutableFile); - } - if self.has_indicator_style(Indicator::MultipleHardLinks) - && metadata.nlink() > 1 - { - return Some(Indicator::MultipleHardLinks); - } - } - } - - if self.has_indicator_style(Indicator::RegularFile) { - return Some(Indicator::RegularFile); - } + self.indicator_for_file(path) } else if file_type.is_dir() { - #[cfg(unix)] - if self.needs_dir_metadata() { - if let Some(metadata) = path.metadata() { - let mode = metadata.mode(); - if self.has_indicator_style(Indicator::StickyAndOtherWritable) - && mode & MODE_STICKY_OTHER_WRITABLE == MODE_STICKY_OTHER_WRITABLE - { - return Some(Indicator::StickyAndOtherWritable); - } - if self.has_indicator_style(Indicator::OtherWritable) && mode & MODE_OTHER_WRITABLE != 0 { - return Some(Indicator::OtherWritable); - } - if self.has_indicator_style(Indicator::Sticky) && mode & MODE_STICKY != 0 { - return Some(Indicator::Sticky); - } - } - } - - if self.has_indicator_style(Indicator::Directory) { - return Some(Indicator::Directory); - } + self.indicator_for_directory(path) } else { - #[cfg(unix)] - { - if file_type.is_fifo() && self.has_indicator_style(Indicator::FIFO) { - return Some(Indicator::FIFO); + self.indicator_for_special_file(file_type) + } + } + + fn indicator_for_symlink( + &self, + _path: &PathData, + entry_exists: &mut dyn FnMut() -> bool, + ) -> Option { + let orphan_enabled = self.has_indicator_style(Indicator::OrphanedSymbolicLink); + let missing_enabled = self.has_indicator_style(Indicator::MissingFile); + let needs_target_state = self.ln_color_from_target || orphan_enabled; + let target_missing = needs_target_state && !entry_exists(); + + if target_missing { + let orphan_raw = self.indicator_codes.get(&Indicator::OrphanedSymbolicLink); + let orphan_raw_is_empty = orphan_raw.is_some_and(|value| value.is_empty()); + if orphan_enabled && (!orphan_raw_is_empty || self.ln_color_from_target) { + return Some(Indicator::OrphanedSymbolicLink); + } + if self.ln_color_from_target && missing_enabled { + return Some(Indicator::MissingFile); + } + } + if self.has_indicator_style(Indicator::SymbolicLink) { + return Some(Indicator::SymbolicLink); + } + None + } + + fn indicator_for_file(&self, path: &PathData) -> Option { + #[cfg(unix)] + if self.needs_file_metadata() { + if let Some(metadata) = path.metadata() { + let mode = metadata.mode(); + if self.has_indicator_style(Indicator::Setuid) && mode & MODE_SETUID != 0 { + return Some(Indicator::Setuid); } - if file_type.is_socket() && self.has_indicator_style(Indicator::Socket) { - return Some(Indicator::Socket); + if self.has_indicator_style(Indicator::Setgid) && mode & MODE_SETGID != 0 { + return Some(Indicator::Setgid); } - if file_type.is_block_device() && self.has_indicator_style(Indicator::BlockDevice) { - return Some(Indicator::BlockDevice); - } - if file_type.is_char_device() - && self.has_indicator_style(Indicator::CharacterDevice) + if self.has_indicator_style(Indicator::ExecutableFile) + && mode & MODE_EXECUTABLE != 0 { - return Some(Indicator::CharacterDevice); + return Some(Indicator::ExecutableFile); + } + if self.has_indicator_style(Indicator::MultipleHardLinks) && metadata.nlink() > 1 { + return Some(Indicator::MultipleHardLinks); } } } + if self.has_indicator_style(Indicator::RegularFile) { + Some(Indicator::RegularFile) + } else { + None + } + } + + fn indicator_for_directory(&self, path: &PathData) -> Option { + #[cfg(unix)] + if self.needs_dir_metadata() { + if let Some(metadata) = path.metadata() { + let mode = metadata.mode(); + if self.has_indicator_style(Indicator::StickyAndOtherWritable) + && mode & MODE_STICKY_OTHER_WRITABLE == MODE_STICKY_OTHER_WRITABLE + { + return Some(Indicator::StickyAndOtherWritable); + } + if self.has_indicator_style(Indicator::OtherWritable) + && mode & MODE_OTHER_WRITABLE != 0 + { + return Some(Indicator::OtherWritable); + } + if self.has_indicator_style(Indicator::Sticky) && mode & MODE_STICKY != 0 { + return Some(Indicator::Sticky); + } + } + } + + if self.has_indicator_style(Indicator::Directory) { + Some(Indicator::Directory) + } else { + None + } + } + + fn indicator_for_special_file(&self, file_type: &std::fs::FileType) -> Option { + #[cfg(unix)] + { + if file_type.is_fifo() && self.has_indicator_style(Indicator::FIFO) { + return Some(Indicator::FIFO); + } + if file_type.is_socket() && self.has_indicator_style(Indicator::Socket) { + return Some(Indicator::Socket); + } + if file_type.is_block_device() && self.has_indicator_style(Indicator::BlockDevice) { + return Some(Indicator::BlockDevice); + } + if file_type.is_char_device() && self.has_indicator_style(Indicator::CharacterDevice) { + return Some(Indicator::CharacterDevice); + } + } None } From 1f247d156da1121b155d9d462418c771d3a5471e Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Thu, 8 Jan 2026 14:31:09 +0100 Subject: [PATCH 176/425] ls: optimize canonicalize_indicator_value to avoid unnecessary allocations --- src/uu/ls/src/colors.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/uu/ls/src/colors.rs b/src/uu/ls/src/colors.rs index d97e5c42a..5f6f6a1df 100644 --- a/src/uu/ls/src/colors.rs +++ b/src/uu/ls/src/colors.rs @@ -4,6 +4,7 @@ // file that was distributed with this source code. use super::PathData; use lscolors::{Indicator, LsColors, Style}; +use std::borrow::Cow; use std::collections::HashMap; use std::env; use std::ffi::OsString; @@ -758,7 +759,7 @@ fn parse_indicator_codes() -> (HashMap, bool) { } continue; } - indicator_codes.insert(indicator, canonicalize_indicator_value(value)); + indicator_codes.insert(indicator, canonicalize_indicator_value(value).into_owned()); } } } @@ -766,14 +767,14 @@ fn parse_indicator_codes() -> (HashMap, bool) { (indicator_codes, ln_color_from_target) } -fn canonicalize_indicator_value(value: &str) -> String { +fn canonicalize_indicator_value(value: &str) -> Cow<'_, str> { if value.len() == 1 && value.chars().all(|c| c.is_ascii_digit()) { let mut canonical = String::with_capacity(2); canonical.push('0'); canonical.push_str(value); - canonical + Cow::Owned(canonical) } else { - value.to_string() + Cow::Borrowed(value) } } From f07f5b9f2031a3d2b55e0938a38d94e0cf30fc20 Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Fri, 9 Jan 2026 20:21:59 +0100 Subject: [PATCH 177/425] ls: fix Windows compilation by properly handling platform-specific code --- src/uu/ls/src/colors.rs | 92 +++++++++++++++++++++++++---------------- 1 file changed, 56 insertions(+), 36 deletions(-) diff --git a/src/uu/ls/src/colors.rs b/src/uu/ls/src/colors.rs index 5f6f6a1df..6b858ba65 100644 --- a/src/uu/ls/src/colors.rs +++ b/src/uu/ls/src/colors.rs @@ -19,13 +19,16 @@ const ANSI_RESET: &str = "\x1b[0m"; const ANSI_CLEAR_EOL: &str = "\x1b[K"; const EMPTY_STYLE: &str = "\x1b[m"; -// Unix file mode bits -const MODE_SETUID: u32 = 0o4000; -const MODE_SETGID: u32 = 0o2000; -const MODE_EXECUTABLE: u32 = 0o0111; -const MODE_STICKY_OTHER_WRITABLE: u32 = 0o1002; -const MODE_OTHER_WRITABLE: u32 = 0o0002; -const MODE_STICKY: u32 = 0o1000; +#[cfg(unix)] +mod mode { + // Unix file mode bits + pub const SETUID: u32 = 0o4000; + pub const SETGID: u32 = 0o2000; + pub const EXECUTABLE: u32 = 0o0111; + pub const STICKY_OTHER_WRITABLE: u32 = 0o1002; + pub const OTHER_WRITABLE: u32 = 0o0002; + pub const STICKY: u32 = 0o1000; +} enum RawIndicatorStyle { Empty, @@ -361,7 +364,7 @@ impl<'a> StyleManager<'a> { }; if file_type.is_symlink() { - return self.indicator_for_symlink(path, &mut entry_exists); + return self.indicator_for_symlink(&mut entry_exists); } if self.has_indicator_style(Indicator::MissingFile) && !entry_exists() { @@ -377,11 +380,7 @@ impl<'a> StyleManager<'a> { } } - fn indicator_for_symlink( - &self, - _path: &PathData, - entry_exists: &mut dyn FnMut() -> bool, - ) -> Option { + fn indicator_for_symlink(&self, entry_exists: &mut dyn FnMut() -> bool) -> Option { let orphan_enabled = self.has_indicator_style(Indicator::OrphanedSymbolicLink); let missing_enabled = self.has_indicator_style(Indicator::MissingFile); let needs_target_state = self.ln_color_from_target || orphan_enabled; @@ -403,19 +402,19 @@ impl<'a> StyleManager<'a> { None } + #[cfg(unix)] fn indicator_for_file(&self, path: &PathData) -> Option { - #[cfg(unix)] if self.needs_file_metadata() { if let Some(metadata) = path.metadata() { let mode = metadata.mode(); - if self.has_indicator_style(Indicator::Setuid) && mode & MODE_SETUID != 0 { + if self.has_indicator_style(Indicator::Setuid) && mode & mode::SETUID != 0 { return Some(Indicator::Setuid); } - if self.has_indicator_style(Indicator::Setgid) && mode & MODE_SETGID != 0 { + if self.has_indicator_style(Indicator::Setgid) && mode & mode::SETGID != 0 { return Some(Indicator::Setgid); } if self.has_indicator_style(Indicator::ExecutableFile) - && mode & MODE_EXECUTABLE != 0 + && mode & mode::EXECUTABLE != 0 { return Some(Indicator::ExecutableFile); } @@ -432,22 +431,31 @@ impl<'a> StyleManager<'a> { } } + #[cfg(not(unix))] + fn indicator_for_file(&self, _path: &PathData) -> Option { + if self.has_indicator_style(Indicator::RegularFile) { + Some(Indicator::RegularFile) + } else { + None + } + } + + #[cfg(unix)] fn indicator_for_directory(&self, path: &PathData) -> Option { - #[cfg(unix)] if self.needs_dir_metadata() { if let Some(metadata) = path.metadata() { let mode = metadata.mode(); if self.has_indicator_style(Indicator::StickyAndOtherWritable) - && mode & MODE_STICKY_OTHER_WRITABLE == MODE_STICKY_OTHER_WRITABLE + && mode & mode::STICKY_OTHER_WRITABLE == mode::STICKY_OTHER_WRITABLE { return Some(Indicator::StickyAndOtherWritable); } if self.has_indicator_style(Indicator::OtherWritable) - && mode & MODE_OTHER_WRITABLE != 0 + && mode & mode::OTHER_WRITABLE != 0 { return Some(Indicator::OtherWritable); } - if self.has_indicator_style(Indicator::Sticky) && mode & MODE_STICKY != 0 { + if self.has_indicator_style(Indicator::Sticky) && mode & mode::STICKY != 0 { return Some(Indicator::Sticky); } } @@ -460,22 +468,34 @@ impl<'a> StyleManager<'a> { } } - fn indicator_for_special_file(&self, file_type: &std::fs::FileType) -> Option { - #[cfg(unix)] - { - if file_type.is_fifo() && self.has_indicator_style(Indicator::FIFO) { - return Some(Indicator::FIFO); - } - if file_type.is_socket() && self.has_indicator_style(Indicator::Socket) { - return Some(Indicator::Socket); - } - if file_type.is_block_device() && self.has_indicator_style(Indicator::BlockDevice) { - return Some(Indicator::BlockDevice); - } - if file_type.is_char_device() && self.has_indicator_style(Indicator::CharacterDevice) { - return Some(Indicator::CharacterDevice); - } + #[cfg(not(unix))] + fn indicator_for_directory(&self, _path: &PathData) -> Option { + if self.has_indicator_style(Indicator::Directory) { + Some(Indicator::Directory) + } else { + None } + } + + #[cfg(unix)] + fn indicator_for_special_file(&self, file_type: &std::fs::FileType) -> Option { + if file_type.is_fifo() && self.has_indicator_style(Indicator::FIFO) { + return Some(Indicator::FIFO); + } + if file_type.is_socket() && self.has_indicator_style(Indicator::Socket) { + return Some(Indicator::Socket); + } + if file_type.is_block_device() && self.has_indicator_style(Indicator::BlockDevice) { + return Some(Indicator::BlockDevice); + } + if file_type.is_char_device() && self.has_indicator_style(Indicator::CharacterDevice) { + return Some(Indicator::CharacterDevice); + } + None + } + + #[cfg(not(unix))] + fn indicator_for_special_file(&self, _file_type: &std::fs::FileType) -> Option { None } From b5ea1a728abacd25ccf776a0acf80aed1f17894c Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Sat, 10 Jan 2026 17:26:39 +0100 Subject: [PATCH 178/425] Disable tsort_input_parsing_heavy for being too intermittent (#10109) * Disable tsort_input_parsing_heavy for being too intermittent * also ignore generate_input_parsing_heavy Comment out the generate_input_parsing_heavy function for future use. * Document why it has been disabled --- src/uu/tsort/benches/tsort_bench.rs | 41 ++++++++++++++++------------- 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/src/uu/tsort/benches/tsort_bench.rs b/src/uu/tsort/benches/tsort_bench.rs index 45ce47edd..18d121d66 100644 --- a/src/uu/tsort/benches/tsort_bench.rs +++ b/src/uu/tsort/benches/tsort_bench.rs @@ -116,24 +116,6 @@ fn generate_wide_dag(num_nodes: usize) -> Vec { data } -/// Generate DAG data for input parsing stress tests -fn generate_input_parsing_heavy(num_edges: usize) -> Vec { - // Create a scenario with many edges but relatively few unique nodes - // This stresses the input parsing and graph construction optimizations - let num_unique_nodes = (num_edges as f64).sqrt() as usize; - let mut data = Vec::new(); - - for i in 0..num_edges { - let from = i % num_unique_nodes; - let to = (i / num_unique_nodes) % num_unique_nodes; - if from != to { - data.extend_from_slice(format!("n{from} n{to}\n").as_bytes()); - } - } - - data -} - /// Benchmark linear chain graphs of different sizes /// This tests the performance improvements mentioned in PR #8694 #[divan::bench(args = [1_000_000])] @@ -184,6 +166,28 @@ fn tsort_wide_dag(bencher: Bencher, num_nodes: usize) { }); } +/* +/// silent for now because too much variance + + +/// Generate DAG data for input parsing stress tests +fn generate_input_parsing_heavy(num_edges: usize) -> Vec { + // Create a scenario with many edges but relatively few unique nodes + // This stresses the input parsing and graph construction optimizations + let num_unique_nodes = (num_edges as f64).sqrt() as usize; + let mut data = Vec::new(); + + for i in 0..num_edges { + let from = i % num_unique_nodes; + let to = (i / num_unique_nodes) % num_unique_nodes; + if from != to { + data.extend_from_slice(format!("n{from} n{to}\n").as_bytes()); + } + } + + data +} + /// Benchmark input parsing vs computation by using files with different edge densities #[divan::bench(args = [5_000])] fn tsort_input_parsing_heavy(bencher: Bencher, num_edges: usize) { @@ -195,6 +199,7 @@ fn tsort_input_parsing_heavy(bencher: Bencher, num_edges: usize) { black_box(run_util_function(uumain, &[file_path_str])); }); } +*/ fn main() { divan::main(); From f7d3584ee9afa67a56e5d50f17a9bf4b96416b68 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 10 Jan 2026 21:11:38 +0000 Subject: [PATCH 179/425] chore(deps): update rust crate blake2b_simd to v1.0.4 --- Cargo.lock | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 121524db7..fd211c70f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -211,13 +211,13 @@ dependencies = [ [[package]] name = "blake2b_simd" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06e903a20b159e944f91ec8499fe1e55651480c541ea0a584f5d967c49ad9d99" +checksum = "b79834656f71332577234b50bfc009996f7449e0c056884e6a02492ded0ca2f3" dependencies = [ "arrayref", "arrayvec", - "constant_time_eq 0.3.1", + "constant_time_eq", ] [[package]] @@ -230,7 +230,7 @@ dependencies = [ "arrayvec", "cc", "cfg-if", - "constant_time_eq 0.4.2", + "constant_time_eq", "cpufeatures", ] @@ -492,12 +492,6 @@ dependencies = [ "tiny-keccak", ] -[[package]] -name = "constant_time_eq" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" - [[package]] name = "constant_time_eq" version = "0.4.2" From d2c5ec406d90e9a5177a175471b4fea502eb29e4 Mon Sep 17 00:00:00 2001 From: Anton Kesy Date: Sun, 11 Jan 2026 01:27:07 +0100 Subject: [PATCH 180/425] uu: fix typo --- src/uu/seq/src/seq.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/uu/seq/src/seq.rs b/src/uu/seq/src/seq.rs index 6d8f00258..1e94a9901 100644 --- a/src/uu/seq/src/seq.rs +++ b/src/uu/seq/src/seq.rs @@ -288,7 +288,7 @@ pub fn uu_app() -> Command { } /// Integer print, default format, positive increment: fast code path -/// that avoids reformating digit at all iterations. +/// that avoids reformatting digit at all iterations. fn fast_print_seq( mut stdout: impl Write, first: &BigUint, From dc433bcacb85e234681b86789b0065342bcb5ae4 Mon Sep 17 00:00:00 2001 From: oech3 <> Date: Sun, 11 Jan 2026 13:14:54 +0900 Subject: [PATCH 181/425] printf 1 > /dev/full is not silent --- src/uucore/src/lib/features/format/mod.rs | 2 +- tests/by-util/test_printf.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/uucore/src/lib/features/format/mod.rs b/src/uucore/src/lib/features/format/mod.rs index 2863407a0..66e4d8bde 100644 --- a/src/uucore/src/lib/features/format/mod.rs +++ b/src/uucore/src/lib/features/format/mod.rs @@ -113,7 +113,7 @@ impl Display for FormatError { Self::InvalidPrecision(precision) => write!(f, "invalid precision: '{precision}'"), // TODO: Error message below needs some work Self::WrongSpecType => write!(f, "wrong % directive type was given"), - Self::IoError(_) => write!(f, "write error"), + Self::IoError(e) => write!(f, "write error: {e}"), Self::NoMoreArguments => write!(f, "no more arguments"), Self::InvalidArgument(_) => write!(f, "invalid argument"), Self::MissingHex => write!(f, "missing hexadecimal number in escape"), diff --git a/tests/by-util/test_printf.rs b/tests/by-util/test_printf.rs index 21e638f7c..6afe0330c 100644 --- a/tests/by-util/test_printf.rs +++ b/tests/by-util/test_printf.rs @@ -1490,5 +1490,5 @@ fn test_extreme_field_width_overflow() { new_ucmd!() .args(&["%999999999999999999999999d", "1"]) .fails_with_code(1) - .stderr_only("printf: write error\n"); + .stderr_contains("printf: write error"); //could contains additional message like "formatting width too large" not in GNU, thats fine. } From 20219097cc0e8b2f9e0d7344a89a9f21a632607b Mon Sep 17 00:00:00 2001 From: oech3 <> Date: Sun, 11 Jan 2026 16:07:08 +0900 Subject: [PATCH 182/425] Avoid hostid > /dev/full panic --- src/uu/hostid/src/hostid.rs | 27 ++++++++++++--------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/src/uu/hostid/src/hostid.rs b/src/uu/hostid/src/hostid.rs index 8c139c831..529813135 100644 --- a/src/uu/hostid/src/hostid.rs +++ b/src/uu/hostid/src/hostid.rs @@ -7,6 +7,7 @@ use clap::Command; use libc::{c_long, gethostid}; +use std::io::{Write, stdout}; use uucore::{error::UResult, format_usage}; use uucore::translate; @@ -14,20 +15,6 @@ use uucore::translate; #[uucore::main] pub fn uumain(args: impl uucore::Args) -> UResult<()> { uucore::clap_localization::handle_clap_result(uu_app(), args)?; - hostid(); - Ok(()) -} - -pub fn uu_app() -> Command { - Command::new(uucore::util_name()) - .version(uucore::crate_version!()) - .help_template(uucore::localized_help_template(uucore::util_name())) - .about(translate!("hostid-about")) - .override_usage(format_usage(&translate!("hostid-usage"))) - .infer_long_args(true) -} - -fn hostid() { /* * POSIX says gethostid returns a "32-bit identifier" but is silent * whether it's sign-extended. Turn off any sign-extension. This @@ -43,5 +30,15 @@ fn hostid() { let mask = 0xffff_ffff; result &= mask; - println!("{result:0>8x}"); + writeln!(stdout().lock(), "{result:0>8x}")?; + Ok(()) +} + +pub fn uu_app() -> Command { + Command::new(uucore::util_name()) + .version(uucore::crate_version!()) + .help_template(uucore::localized_help_template(uucore::util_name())) + .about(translate!("hostid-about")) + .override_usage(format_usage(&translate!("hostid-usage"))) + .infer_long_args(true) } From 749cc47ba2e3888bf180a97a200f275532b20e20 Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Sat, 10 Jan 2026 10:56:51 +0900 Subject: [PATCH 183/425] build-gnu.sh: Build seq as multicall-binary --- util/build-gnu.sh | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/util/build-gnu.sh b/util/build-gnu.sh index ea059c432..0937bcc63 100755 --- a/util/build-gnu.sh +++ b/util/build-gnu.sh @@ -88,15 +88,14 @@ fi cd - export CARGOFLAGS # tell to make -# bug: seq with MULTICALL=y breaks env-signal-handler.sh - "${MAKE}" UTILS="install seq" + "${MAKE}" UTILS=install ln -vf "${UU_BUILD_DIR}/install" "${UU_BUILD_DIR}/ginstall" # The GNU tests use renamed install to ginstall if [ "${SELINUX_ENABLED}" = 1 ];then # Build few utils for SELinux for faster build. MULTICALL=y fails... - "${MAKE}" UTILS="cat chcon chmod cp cut dd echo env groups id ln ls mkdir mkfifo mknod mktemp mv printf rm rmdir runcon stat test touch tr true uname wc whoami" + "${MAKE}" UTILS="cat chcon chmod cp cut dd echo env groups id ln ls mkdir mkfifo mknod mktemp mv printf rm rmdir runcon seq stat test touch tr true uname wc whoami" else # Use MULTICALL=y for faster build - "${MAKE}" MULTICALL=y SKIP_UTILS="install more seq" + "${MAKE}" MULTICALL=y SKIP_UTILS="install more" for binary in $("${UU_BUILD_DIR}"/coreutils --list) do ln -vf "${UU_BUILD_DIR}/coreutils" "${UU_BUILD_DIR}/${binary}" done From 91edf285b21cc0b0ea144b93e206499dc05a8987 Mon Sep 17 00:00:00 2001 From: Ivan-Shaml <72102779+Ivan-Shaml@users.noreply.github.com> Date: Sun, 11 Jan 2026 11:20:46 +0200 Subject: [PATCH 184/425] uptime: Add -p, --pretty argument (#10143) * uptime: Add -p, --pretty argument Rebase of PR https://github.com/uutils/coreutils/pull/7910 All credits go to GitHub User: https://github.com/irbeam256 * Add localization for "up" --- docs/src/extensions.md | 4 +- src/uu/uptime/locales/en-US.ftl | 14 ++++++ src/uu/uptime/locales/fr-FR.ftl | 14 ++++++ src/uu/uptime/src/uptime.rs | 23 ++++++++- src/uucore/src/lib/features/uptime.rs | 70 +++++++++++++++++++++++---- tests/by-util/test_uptime.rs | 9 ++++ 6 files changed, 123 insertions(+), 11 deletions(-) diff --git a/docs/src/extensions.md b/docs/src/extensions.md index 80f89e060..458b9c41e 100644 --- a/docs/src/extensions.md +++ b/docs/src/extensions.md @@ -184,7 +184,9 @@ also provides a `-v`/`--verbose` flag. ## `uptime` -Similar to the proc-ps implementation and unlike GNU/Coreutils, `uptime` provides `-s`/`--since` to show since when the system is up. +Similar to the proc-ps implementation and unlike GNU/Coreutils, `uptime` provides: + * `-s`/`--since` to show since when the system is up + * `-p`/`--pretty` to display uptime in a pretty-printed format ## `base32/base64/basenc` diff --git a/src/uu/uptime/locales/en-US.ftl b/src/uu/uptime/locales/en-US.ftl index a9dd66667..84704c951 100644 --- a/src/uu/uptime/locales/en-US.ftl +++ b/src/uu/uptime/locales/en-US.ftl @@ -9,6 +9,7 @@ uptime-about-musl-warning = Warning: When built with musl libc, the `uptime` uti # Help messages uptime-help-since = system up since uptime-help-path = file to search boot time from +uptime-help-pretty = show uptime in pretty format # Error messages uptime-error-io = couldn't get boot time: { $error } @@ -18,6 +19,7 @@ uptime-error-couldnt-get-boot-time = couldn't get boot time # Output messages uptime-output-unknown-uptime = up ???? days ??:??, +uptime-output-up-text = up uptime-user-count = { $count -> [one] 1 user @@ -36,6 +38,18 @@ uptime-format = { $days -> [one] { $days } day, { $time } *[other] { $days } days { $time } } +uptime-format-pretty-min = { $min -> + [one] { $min } minute + *[other] { $min } minutes +} +uptime-format-pretty-hour = { $hour -> + [one] { $hour } hour + *[other] { $hour } hours +} +uptime-format-pretty-day = { $day -> + [one] { $day } day + *[other] { $day } days +} # Load average formatting uptime-lib-format-loadavg = load average: { $avg1 }, { $avg5 }, { $avg15 } diff --git a/src/uu/uptime/locales/fr-FR.ftl b/src/uu/uptime/locales/fr-FR.ftl index 623e3b0d7..895ec0892 100644 --- a/src/uu/uptime/locales/fr-FR.ftl +++ b/src/uu/uptime/locales/fr-FR.ftl @@ -9,6 +9,7 @@ uptime-about-musl-warning = Avertissement : Lorsque compilé avec musl libc, l'u # Messages d'aide uptime-help-since = système actif depuis uptime-help-path = fichier pour rechercher l'heure de démarrage +uptime-help-pretty = afficher le temps de disponibilité dans un format agréable # Messages d'erreur uptime-error-io = impossible d'obtenir l'heure de démarrage : { $error } @@ -18,6 +19,7 @@ uptime-error-couldnt-get-boot-time = impossible d'obtenir l'heure de démarrage # Messages de sortie uptime-output-unknown-uptime = actif ???? jours ??:??, +uptime-output-up-text = actif uptime-user-count = { $count -> [one] 1 utilisateur @@ -36,6 +38,18 @@ uptime-format = { $days -> [one] { $days } jour, { $time } *[other] { $days } jours { $time } } +uptime-format-pretty-min = { $min -> + [one] { $min } minute + *[other] { $min } minutes +} +uptime-format-pretty-hour = { $hour -> + [one] { $hour } heure + *[other] { $hour } heures +} +uptime-format-pretty-day = { $day -> + [one] { $day } jour + *[other] { $day } jours +} # Formatage de la charge moyenne uptime-lib-format-loadavg = charge moyenne : { $avg1 }, { $avg5 }, { $avg15 } diff --git a/src/uu/uptime/src/uptime.rs b/src/uu/uptime/src/uptime.rs index 89dc55d31..d8da60654 100644 --- a/src/uu/uptime/src/uptime.rs +++ b/src/uu/uptime/src/uptime.rs @@ -27,6 +27,7 @@ use uucore::utmpx::*; pub mod options { pub static SINCE: &str = "since"; pub static PATH: &str = "path"; + pub static PRETTY: &str = "pretty"; } #[derive(Debug, Error)] @@ -57,6 +58,8 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { if matches.get_flag(options::SINCE) { uptime_since() + } else if matches.get_flag(options::PRETTY) { + pretty_print_uptime() } else if let Some(path) = file_path { uptime_with_file(path) } else { @@ -92,6 +95,13 @@ pub fn uu_app() -> Command { .value_parser(ValueParser::os_string()) .value_hint(ValueHint::AnyPath), ) + .arg( + Arg::new(options::PRETTY) + .short('p') + .long(options::PRETTY) + .help(translate!("uptime-help-pretty")) + .action(ArgAction::SetTrue), + ) } #[cfg(unix)] @@ -266,6 +276,17 @@ fn print_time() { } fn print_uptime(boot_time: Option) -> UResult<()> { - print!("up {}, ", get_formatted_uptime(boot_time)?); + let localized_text = translate!("uptime-output-up-text"); + let uptime_message = get_formatted_uptime(boot_time, OutputFormat::HumanReadable)?; + + print!("{localized_text} {uptime_message}, "); + Ok(()) +} + +fn pretty_print_uptime() -> UResult<()> { + let localized_text = translate!("uptime-output-up-text"); + let uptime_message = get_formatted_uptime(None, OutputFormat::PrettyPrint)?; + + println!("{localized_text} {uptime_message}"); Ok(()) } diff --git a/src/uucore/src/lib/features/uptime.rs b/src/uucore/src/lib/features/uptime.rs index 10b073ad5..6b869ef05 100644 --- a/src/uucore/src/lib/features/uptime.rs +++ b/src/uucore/src/lib/features/uptime.rs @@ -205,6 +205,56 @@ pub fn get_uptime(boot_time: Option) -> UResult { Err(UptimeError::SystemUptime)? } +/// The format used to display a FormattedUptime. +pub enum OutputFormat { + /// Typical `uptime` output (e.g. 2 days, 3:04). + HumanReadable, + + /// Pretty printed output (e.g. 2 days, 3 hours, 04 minutes). + PrettyPrint, +} + +struct FormattedUptime { + up_days: i64, + up_hours: i64, + up_mins: i64, +} + +impl FormattedUptime { + fn new(up_secs: i64) -> Self { + let up_days = up_secs / 86400; + let up_hours = (up_secs - (up_days * 86400)) / 3600; + let up_mins = (up_secs - (up_days * 86400) - (up_hours * 3600)) / 60; + + Self { + up_days, + up_hours, + up_mins, + } + } + + fn get_human_readable_uptime(&self) -> String { + translate!( + "uptime-format", + "days" => self.up_days, + "time" => format!("{:02}:{:02}", self.up_hours, self.up_mins)) + } + + fn get_pretty_print_uptime(&self) -> String { + let mut parts = Vec::new(); + if self.up_days > 0 { + parts.push(translate!("uptime-format-pretty-day", "day" => self.up_days)); + } + if self.up_hours > 0 { + parts.push(translate!("uptime-format-pretty-hour", "hour" => self.up_hours)); + } + if self.up_mins > 0 || parts.is_empty() { + parts.push(translate!("uptime-format-pretty-min", "min" => self.up_mins)); + } + parts.join(", ") + } +} + /// Get the system uptime /// /// # Arguments @@ -227,26 +277,28 @@ pub fn get_uptime(_boot_time: Option) -> UResult { /// # Arguments /// /// boot_time: Option - Manually specify the boot time, or None to try to get it from the system. +/// output_format: OutputFormat - Selects the format of the output string. /// /// # Returns /// /// Returns a UResult with the uptime in a human-readable format(e.g. "1 day, 3:45") if successful, otherwise an UptimeError. #[inline] -pub fn get_formatted_uptime(boot_time: Option) -> UResult { +pub fn get_formatted_uptime( + boot_time: Option, + output_format: OutputFormat, +) -> UResult { let up_secs = get_uptime(boot_time)?; if up_secs < 0 { Err(UptimeError::SystemUptime)?; } - let up_days = up_secs / 86400; - let up_hours = (up_secs - (up_days * 86400)) / 3600; - let up_mins = (up_secs - (up_days * 86400) - (up_hours * 3600)) / 60; - Ok(translate!( - "uptime-format", - "days" => up_days, - "time" => format!("{up_hours:02}:{up_mins:02}") - )) + let formatted_uptime = FormattedUptime::new(up_secs); + + match output_format { + OutputFormat::HumanReadable => Ok(formatted_uptime.get_human_readable_uptime()), + OutputFormat::PrettyPrint => Ok(formatted_uptime.get_pretty_print_uptime()), + } } /// Get the number of users currently logged in diff --git a/tests/by-util/test_uptime.rs b/tests/by-util/test_uptime.rs index 5c1d5d15f..e1d813cde 100644 --- a/tests/by-util/test_uptime.rs +++ b/tests/by-util/test_uptime.rs @@ -267,6 +267,15 @@ fn test_uptime_since() { new_ucmd!().arg("--since").succeeds().stdout_matches(&re); } +#[test] +fn test_uptime_pretty_print() { + new_ucmd!() + .arg("-p") + .succeeds() + .stdout_contains("up") + .stdout_contains("minute"); +} + /// Test uptime reliability on macOS with sysctl kern.boottime fallback. /// This addresses intermittent failures from issue #3621 by ensuring /// the command consistently succeeds when utmpx data is unavailable. From ade95d6c18962a8230978207637f84e43f1d7572 Mon Sep 17 00:00:00 2001 From: Daniel Hofstetter Date: Sun, 11 Jan 2026 12:31:27 +0100 Subject: [PATCH 185/425] deny.toml: remove constant_time_eq from skip list (#10176) --- deny.toml | 2 -- 1 file changed, 2 deletions(-) diff --git a/deny.toml b/deny.toml index f2925b1ae..eb0e02300 100644 --- a/deny.toml +++ b/deny.toml @@ -109,8 +109,6 @@ skip = [ { name = "linux-raw-sys", version = "0.11.0" }, # crossterm { name = "signal-hook", version = "0.3.18" }, - # blake2b_simd - { name = "constant_time_eq", version = "0.3.1" }, ] # spell-checker: enable From 0d403a4932977b6eec29f114f3090f1d0156d115 Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Mon, 12 Jan 2026 00:28:07 +0900 Subject: [PATCH 186/425] test_mv.rs: Remove ignore from test_mv_broken_symlink_to_another_fs (#9978) --- tests/by-util/test_mv.rs | 43 ++++------------------------------------ 1 file changed, 4 insertions(+), 39 deletions(-) diff --git a/tests/by-util/test_mv.rs b/tests/by-util/test_mv.rs index 3c69d65a7..a3d196562 100644 --- a/tests/by-util/test_mv.rs +++ b/tests/by-util/test_mv.rs @@ -623,56 +623,21 @@ fn test_mv_symlink_into_target() { ucmd.arg("dir-link").arg("dir").succeeds(); } -#[cfg(all(unix, not(target_os = "android")))] -#[ignore = "requires sudo"] +#[cfg(target_os = "linux")] #[test] fn test_mv_broken_symlink_to_another_fs() { let scene = TestScenario::new(util_name!()); scene.fixtures.mkdir("foo"); - - let output = scene - .cmd("sudo") - .env("PATH", env!("PATH")) - .args(&["-E", "--non-interactive", "ls"]) - .run(); - println!("test output: {output:?}"); - - let mount = scene - .cmd("sudo") - .env("PATH", env!("PATH")) - .args(&[ - "-E", - "--non-interactive", - "mount", - "none", - "-t", - "tmpfs", - "foo", - ]) - .run(); - - if !mount.succeeded() { - print!("Test skipped; requires root user"); - return; - } - - scene.fixtures.mkdir("bar"); - scene.fixtures.symlink_file("nonexistent", "bar/baz"); - + scene.fixtures.symlink_file("missing", "foo/dangling"); + let dest = "/dev/shm/foo"; scene .ucmd() - .arg("bar") .arg("foo") + .arg(dest) .succeeds() .no_stderr() .no_stdout(); - - scene - .cmd("sudo") - .env("PATH", env!("PATH")) - .args(&["-E", "--non-interactive", "umount", "foo"]) - .succeeds(); } #[test] From a0af03c07a9f955994f58031337d8a641c4a3301 Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Sun, 11 Jan 2026 17:23:08 +0100 Subject: [PATCH 187/425] make build-gnu.sh reentrant --- util/build-gnu.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/util/build-gnu.sh b/util/build-gnu.sh index 0937bcc63..dfefa6d26 100755 --- a/util/build-gnu.sh +++ b/util/build-gnu.sh @@ -89,7 +89,7 @@ cd - export CARGOFLAGS # tell to make "${MAKE}" UTILS=install -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 renamed install to ginstall if [ "${SELINUX_ENABLED}" = 1 ];then # Build few utils for SELinux for faster build. MULTICALL=y fails... "${MAKE}" UTILS="cat chcon chmod cp cut dd echo env groups id ln ls mkdir mkfifo mknod mktemp mv printf rm rmdir runcon seq stat test touch tr true uname wc whoami" @@ -97,7 +97,7 @@ else # Use MULTICALL=y for faster build "${MAKE}" MULTICALL=y SKIP_UTILS="install more" for binary in $("${UU_BUILD_DIR}"/coreutils --list) - do ln -vf "${UU_BUILD_DIR}/coreutils" "${UU_BUILD_DIR}/${binary}" + do [ -e "${UU_BUILD_DIR}/${binary}" ] || ln -vf "${UU_BUILD_DIR}/coreutils" "${UU_BUILD_DIR}/${binary}" done fi From 8e461a0eb74b07b4424594f755ff16ebac534195 Mon Sep 17 00:00:00 2001 From: Christopher Dryden Date: Sun, 11 Jan 2026 22:24:04 +0000 Subject: [PATCH 188/425] fetch-gnu: backport timeout tests from master --- util/fetch-gnu.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/util/fetch-gnu.sh b/util/fetch-gnu.sh index b4103bda3..54a893df5 100755 --- a/util/fetch-gnu.sh +++ b/util/fetch-gnu.sh @@ -4,6 +4,8 @@ repo=https://github.com/coreutils/coreutils curl -L "${repo}/releases/download/v${ver}/coreutils-${ver}.tar.xz" | tar --strip-components=1 -xJf - # TODO stop backporting tests from master at GNU coreutils > 9.9 +curl -L ${repo}/raw/refs/heads/master/tests/timeout/timeout.sh > tests/timeout/timeout.sh +curl -L ${repo}/raw/refs/heads/master/tests/timeout/timeout-group.sh > tests/timeout/timeout-group.sh curl -L ${repo}/raw/refs/heads/master/tests/mv/hardlink-case.sh > tests/mv/hardlink-case.sh curl -L ${repo}/raw/refs/heads/master/tests/mkdir/writable-under-readonly.sh > tests/mkdir/writable-under-readonly.sh curl -L ${repo}/raw/refs/heads/master/tests/cp/cp-mv-enotsup-xattr.sh > tests/cp/cp-mv-enotsup-xattr.sh #spell-checker:disable-line From 28e9439feff984126681bfc7f497b3d439f01d32 Mon Sep 17 00:00:00 2001 From: oech3 <> Date: Mon, 12 Jan 2026 15:18:00 +0900 Subject: [PATCH 189/425] mknod: Avoid major/minor No overflow --- src/uu/mknod/src/mknod.rs | 8 ++++---- tests/by-util/test_mknod.rs | 13 +++++++++++++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/uu/mknod/src/mknod.rs b/src/uu/mknod/src/mknod.rs index 56474e4b6..558717a8d 100644 --- a/src/uu/mknod/src/mknod.rs +++ b/src/uu/mknod/src/mknod.rs @@ -143,8 +143,8 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { let dev = match ( file_type, - matches.get_one::(options::MAJOR), - matches.get_one::(options::MINOR), + matches.get_one::(options::MAJOR), + matches.get_one::(options::MINOR), ) { (FileType::Fifo, None, None) => 0, (FileType::Fifo, _, _) => { @@ -208,13 +208,13 @@ pub fn uu_app() -> Command { Arg::new(options::MAJOR) .value_name(options::MAJOR) .help(translate!("mknod-help-major")) - .value_parser(value_parser!(u64)), + .value_parser(value_parser!(u32)), ) .arg( Arg::new(options::MINOR) .value_name(options::MINOR) .help(translate!("mknod-help-minor")) - .value_parser(value_parser!(u64)), + .value_parser(value_parser!(u32)), ) .arg( Arg::new(options::SECURITY_CONTEXT) diff --git a/tests/by-util/test_mknod.rs b/tests/by-util/test_mknod.rs index 5d2b08aec..304f2b4a8 100644 --- a/tests/by-util/test_mknod.rs +++ b/tests/by-util/test_mknod.rs @@ -14,6 +14,19 @@ use uutests::util::TestScenario; use uutests::util::run_ucmd_as_root; use uutests::util_name; +//Reject 2^32+1 major/minor device number +#[test] +fn test_mknod_overflow_major_minor() { + new_ucmd!() + .arg("lg32") + .arg("c") + .arg("4294967296") + .arg("1") + .fails_with_code(1) + .no_stdout() + .stderr_contains("invalid value '4294967296'"); //clap generated message, thats fine. +} + #[test] fn test_mknod_invalid_arg() { new_ucmd!() From 97f8a08ddd82fa0dc7539a7bd2dbbc3518926503 Mon Sep 17 00:00:00 2001 From: oech3 <> Date: Sun, 11 Jan 2026 00:43:26 +0900 Subject: [PATCH 190/425] users: Avoid > /dev/full panic --- src/uu/users/src/users.rs | 3 ++- tests/by-util/test_users.rs | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/uu/users/src/users.rs b/src/uu/users/src/users.rs index 3fb48a9b6..6586c879f 100644 --- a/src/uu/users/src/users.rs +++ b/src/uu/users/src/users.rs @@ -6,6 +6,7 @@ // spell-checker:ignore (paths) wtmp use std::ffi::OsString; +use std::io::{Write, stdout}; use std::path::Path; use clap::builder::ValueParser; @@ -73,7 +74,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { if !users.is_empty() { users.sort(); - println!("{}", users.join(" ")); + writeln!(stdout().lock(), "{}", users.join(" "))?; } Ok(()) diff --git a/tests/by-util/test_users.rs b/tests/by-util/test_users.rs index 0d3d7772b..dd1e043da 100644 --- a/tests/by-util/test_users.rs +++ b/tests/by-util/test_users.rs @@ -6,6 +6,21 @@ use uutests::new_ucmd; #[cfg(any(target_vendor = "apple", target_os = "linux"))] use uutests::{util::TestScenario, util_name}; +#[ignore = "does not work as same as users > /dev/full"] +#[test] +#[cfg(target_os = "linux")] +fn test_full_panic() { + let full = std::fs::OpenOptions::new() + .write(true) + .open("/dev/full") + .unwrap(); + + new_ucmd!() + .set_stdout(full) + .fails() + .stderr_contains("No space"); +} + #[test] fn test_invalid_arg() { new_ucmd!().arg("--definitely-invalid").fails_with_code(1); From d51d2faa0eda814df6636cb999f46f46a7878e94 Mon Sep 17 00:00:00 2001 From: oech3 <> Date: Mon, 12 Jan 2026 20:15:22 +0900 Subject: [PATCH 191/425] touch: extent a test for many device files --- tests/by-util/test_touch.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/by-util/test_touch.rs b/tests/by-util/test_touch.rs index 25e6b301f..29425d6aa 100644 --- a/tests/by-util/test_touch.rs +++ b/tests/by-util/test_touch.rs @@ -1055,7 +1055,9 @@ fn test_touch_non_utf8_paths() { #[test] #[cfg(target_os = "linux")] -fn test_touch_dev_full() { +fn test_touch_device_files() { let (_, mut ucmd) = at_and_ucmd!(); - ucmd.args(&["/dev/full"]).succeeds().no_output(); + ucmd.args(&["/dev/null", "/dev/zero", "/dev/full", "/dev/random"]) + .succeeds() + .no_output(); } From 40e50ba710bb01cc410bd3dc22a31076475ac1b6 Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Mon, 12 Jan 2026 21:56:54 +0900 Subject: [PATCH 192/425] GNUmakefile: Support CARGO_BUILD_TARGET (#9223) Fixes #9206 . Removes `RUSTC_ARCH` variable. --- .github/workflows/CICD.yml | 2 +- GNUmakefile | 30 ++++++++++++++++-------------- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/.github/workflows/CICD.yml b/.github/workflows/CICD.yml index 0a6c9ab13..89f137c5f 100644 --- a/.github/workflows/CICD.yml +++ b/.github/workflows/CICD.yml @@ -298,7 +298,7 @@ jobs: fi # Check that we don't cross-build uudoc # also do not try to generate manpages for part of hashsum - make install-manpages PREFIX=/tmp/usr UTILS=true RUSTC_ARCH="--target aarch64-unknown-linux-gnu" + env CARGO_BUILD_TARGET=aarch64-unknown-linux-gnu make install-manpages PREFIX=/tmp/usr UTILS=true # build (host) make build echo "Check that target directory will be ignored by backup tools" diff --git a/GNUmakefile b/GNUmakefile index d3430e7e2..3382fa74d 100644 --- a/GNUmakefile +++ b/GNUmakefile @@ -25,7 +25,6 @@ endif # Binaries CARGO ?= cargo CARGOFLAGS ?= -RUSTC_ARCH ?= # should be empty except for cross-build, not --target $(shell rustc --print host-tuple) #prefix prepended to all binaries and library dir PROG_PREFIX ?= @@ -46,8 +45,11 @@ INSTALLDIR_BIN=$(DESTDIR)$(BINDIR) BASEDIR ?= $(shell pwd) ifdef CARGO_TARGET_DIR BUILDDIR := $(CARGO_TARGET_DIR)/${PROFILE} +BUILDDIR_UUDOC := $(CARGO_TARGET_DIR)/${PROFILE} else -BUILDDIR := $(BASEDIR)/target/${PROFILE} +BUILDDIR := $(BASEDIR)/target/$(CARGO_BUILD_TARGET)/${PROFILE} +# uudoc should not be cross build +BUILDDIR_UUDOC := $(BASEDIR)/target/$(PROFILE) endif PKG_BUILDDIR := $(BUILDDIR)/deps DOCSDIR := $(BASEDIR)/docs @@ -316,14 +318,14 @@ all: build build-pkgs: ifneq (${MULTICALL}, y) ifdef BUILD_SPEC_FEATURE - ${CARGO} build ${CARGOFLAGS} --features "$(BUILD_SPEC_FEATURE)" ${PROFILE_CMD} $(foreach pkg,$(EXES),-p uu_$(pkg)) $(RUSTC_ARCH) + ${CARGO} build ${CARGOFLAGS} --features "$(BUILD_SPEC_FEATURE)" ${PROFILE_CMD} $(foreach pkg,$(EXES),-p uu_$(pkg)) else - ${CARGO} build ${CARGOFLAGS} ${PROFILE_CMD} $(foreach pkg,$(EXES),-p uu_$(pkg)) $(RUSTC_ARCH) + ${CARGO} build ${CARGOFLAGS} ${PROFILE_CMD} $(foreach pkg,$(EXES),-p uu_$(pkg)) endif endif build-coreutils: - ${CARGO} build ${CARGOFLAGS} --features "${EXES} $(BUILD_SPEC_FEATURE)" ${PROFILE_CMD} --no-default-features $(RUSTC_ARCH) + ${CARGO} build ${CARGOFLAGS} --features "${EXES} $(BUILD_SPEC_FEATURE)" ${PROFILE_CMD} --no-default-features build: build-coreutils build-pkgs locales @@ -373,21 +375,21 @@ busytest: $(BUILDDIR)/busybox $(addprefix test_busybox_,$(filter-out $(SKIP_UTIL endif clean: - cargo clean $(RUSTC_ARCH) - cd $(DOCSDIR) && $(MAKE) clean $(RUSTC_ARCH) + cargo clean + cd $(DOCSDIR) && $(MAKE) clean distclean: clean - $(CARGO) clean $(CARGOFLAGS) $(RUSTC_ARCH) && $(CARGO) update $(CARGOFLAGS) $(RUSTC_ARCH) + $(CARGO) clean $(CARGOFLAGS) && $(CARGO) update $(CARGOFLAGS) ifeq ($(MANPAGES),y) +# Do not cross-build uudoc build-uudoc: - # Use same PROFILE with coreutils to share crates (if not cross-build) - ${CARGO} build ${CARGOFLAGS} --bin uudoc --features "uudoc ${EXES}" ${PROFILE_CMD} --no-default-features + @unset CARGO_BUILD_TARGET && ${CARGO} build ${CARGOFLAGS} --bin uudoc --features "uudoc ${EXES}" ${PROFILE_CMD} --no-default-features install-manpages: build-uudoc mkdir -p $(DESTDIR)$(DATAROOTDIR)/man/man1 $(foreach prog, $(INSTALLEES) $(HASHSUM_PROGS), \ - $(BUILDDIR)/uudoc manpage $(prog) > $(DESTDIR)$(DATAROOTDIR)/man/man1/$(PROG_PREFIX)$(prog).1 $(newline) \ + $(BUILDDIR_UUDOC)/uudoc manpage $(prog) > $(DESTDIR)$(DATAROOTDIR)/man/man1/$(PROG_PREFIX)$(prog).1 $(newline) \ ) else install-manpages: @@ -400,9 +402,9 @@ install-completions: build-uudoc mkdir -p $(DESTDIR)$(DATAROOTDIR)/bash-completion/completions mkdir -p $(DESTDIR)$(DATAROOTDIR)/fish/vendor_completions.d $(foreach prog, $(INSTALLEES) $(HASHSUM_PROGS) , \ - $(BUILDDIR)/uudoc completion $(prog) zsh > $(DESTDIR)$(DATAROOTDIR)/zsh/site-functions/_$(PROG_PREFIX)$(prog) $(newline) \ - $(BUILDDIR)/uudoc completion $(prog) bash > $(DESTDIR)$(DATAROOTDIR)/bash-completion/completions/$(PROG_PREFIX)$(prog).bash $(newline) \ - $(BUILDDIR)/uudoc completion $(prog) fish > $(DESTDIR)$(DATAROOTDIR)/fish/vendor_completions.d/$(PROG_PREFIX)$(prog).fish $(newline) \ + $(BUILDDIR_UUDOC)/uudoc completion $(prog) zsh > $(DESTDIR)$(DATAROOTDIR)/zsh/site-functions/_$(PROG_PREFIX)$(prog) $(newline) \ + $(BUILDDIR_UUDOC)/uudoc completion $(prog) bash > $(DESTDIR)$(DATAROOTDIR)/bash-completion/completions/$(PROG_PREFIX)$(prog).bash $(newline) \ + $(BUILDDIR_UUDOC)/uudoc completion $(prog) fish > $(DESTDIR)$(DATAROOTDIR)/fish/vendor_completions.d/$(PROG_PREFIX)$(prog).fish $(newline) \ ) else install-completions: From b5bee7cb222f60bccec88e7bb81d9bb3623dab14 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 12 Jan 2026 15:46:34 +0000 Subject: [PATCH 193/425] chore(deps): update rust crate rand_core to v0.9.4 --- Cargo.lock | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fd211c70f..70de649ee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2271,7 +2271,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ "rand_chacha 0.9.0", - "rand_core 0.9.3", + "rand_core 0.9.4", ] [[package]] @@ -2291,7 +2291,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core 0.9.3", + "rand_core 0.9.4", ] [[package]] @@ -2305,9 +2305,9 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.9.3" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +checksum = "4f1b3bc831f92381018fd9c6350b917c7b21f1eed35a65a51900e0e55a3d7afa" dependencies = [ "getrandom 0.3.3", ] @@ -3842,7 +3842,7 @@ dependencies = [ "codspeed-divan-compat", "fluent", "rand 0.9.2", - "rand_core 0.9.3", + "rand_core 0.9.4", "tempfile", "uucore", ] From 476f0458f56c931990fef0c12f1003fc3fec9a90 Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Tue, 13 Jan 2026 17:18:14 +0900 Subject: [PATCH 194/425] CICD.yml: Upload binaries from latest commit too --- .github/workflows/CICD.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/workflows/CICD.yml b/.github/workflows/CICD.yml index 89f137c5f..113f10909 100644 --- a/.github/workflows/CICD.yml +++ b/.github/workflows/CICD.yml @@ -902,6 +902,18 @@ jobs: ${{ steps.vars.outputs.STAGING }}/${{ steps.vars.outputs.PKG_NAME }} 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 + with: + tag_name: latest-commit + force_update: true + draft: false + prerelease: true + files: | + ${{ steps.vars.outputs.STAGING }}/${{ steps.vars.outputs.PKG_NAME }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} test_busybox: name: Tests/BusyBox test suite From 4a6483bf2477ef8a3048cadff535366b6db95009 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 13 Jan 2026 10:59:14 +0000 Subject: [PATCH 195/425] chore(deps): update rust crate rand_core to v0.9.5 --- Cargo.lock | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 70de649ee..dabc16931 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2271,7 +2271,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ "rand_chacha 0.9.0", - "rand_core 0.9.4", + "rand_core 0.9.5", ] [[package]] @@ -2291,7 +2291,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core 0.9.4", + "rand_core 0.9.5", ] [[package]] @@ -2305,9 +2305,9 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.9.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f1b3bc831f92381018fd9c6350b917c7b21f1eed35a65a51900e0e55a3d7afa" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ "getrandom 0.3.3", ] @@ -3842,7 +3842,7 @@ dependencies = [ "codspeed-divan-compat", "fluent", "rand 0.9.2", - "rand_core 0.9.4", + "rand_core 0.9.5", "tempfile", "uucore", ] From ab10959010561af419ba6472e723d8ad0feb0032 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 13 Jan 2026 10:59:21 +0000 Subject: [PATCH 196/425] chore(deps): update rust crate time to v0.3.45 --- Cargo.lock | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 70de649ee..e27cc26e8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2845,9 +2845,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.44" +version = "0.3.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +checksum = "f9e442fc33d7fdb45aa9bfeb312c095964abdf596f7567261062b2a7107aaabd" dependencies = [ "deranged", "itoa", @@ -2855,22 +2855,22 @@ dependencies = [ "num-conv", "num_threads", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" +checksum = "8b36ee98fd31ec7426d599183e8fe26932a8dc1fb76ddb6214d05493377d34ca" [[package]] name = "time-macros" -version = "0.2.24" +version = "0.2.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +checksum = "71e552d1249bf61ac2a52db88179fd0673def1e1ad8243a00d9ec9ed71fee3dd" dependencies = [ "num-conv", "time-core", From ac36d2f347e8a763c9fa921d3c7d3159d0cbe01f Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Tue, 13 Jan 2026 01:28:25 +0900 Subject: [PATCH 197/425] freebsd.yml: Avoid no space left on device --- .github/workflows/freebsd.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index 84f6b55b2..ccbf7be9b 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -133,6 +133,8 @@ jobs: - uses: actions/checkout@v6 with: persist-credentials: false + - name: Avoid no space left on device (Ubuntu runner) + 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 @@ -192,6 +194,7 @@ jobs: set +e cd "${WORKSPACE}" unset FAULT + export RUSTFLAGS="-C strip=symbols" # for disk space cargo build || FAULT=1 export PATH=~/.cargo/bin:${PATH} export RUST_BACKTRACE=1 From 5fc9f8e7e0e47c9d7a6e29a63f5c6bbd3e5cf558 Mon Sep 17 00:00:00 2001 From: Chris Dryden Date: Tue, 13 Jan 2026 12:59:21 -0500 Subject: [PATCH 198/425] dd: use seek for stdin skip when possible (#9821) * dd: use seek for stdin skip when possible * Add Rust tests for skip with seekable stdin * Address review comments: fix return value, add comment, refactor tests * dd: use ibs-sized buffer for ESPIPE fallback in skip --- src/uu/dd/src/dd.rs | 40 +++++++++++++++++++++++++++++++++------- tests/by-util/test_dd.rs | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 7 deletions(-) diff --git a/src/uu/dd/src/dd.rs b/src/uu/dd/src/dd.rs index e2344952a..f5f4f9365 100644 --- a/src/uu/dd/src/dd.rs +++ b/src/uu/dd/src/dd.rs @@ -272,14 +272,40 @@ impl Source { return Ok(len); } } - let m = read_and_discard(f, n, ibs)?; - if m < n { - show_error!( - "{}", - translate!("dd-error-cannot-skip-offset", "file" => "standard input") - ); + // Get file length before seeking to avoid race condition + let file_len = f.metadata().map(|m| m.len()).unwrap_or(u64::MAX); + // Try seek first; fall back to read if not seekable + match n.try_into().ok().map(|n| f.seek(SeekFrom::Current(n))) { + Some(Ok(pos)) => { + if pos > file_len { + show_error!( + "{}", + translate!("dd-error-cannot-skip-offset", "file" => "standard input") + ); + } + Ok(n) + } + // ESPIPE means the file descriptor is not seekable (e.g., a pipe), + // so fall back to reading and discarding bytes using ibs-sized buffer + Some(Err(e)) if e.raw_os_error() == Some(libc::ESPIPE) => { + let m = read_and_discard(f, n, ibs)?; + if m < n { + show_error!( + "{}", + translate!("dd-error-cannot-skip-offset", "file" => "standard input") + ); + } + Ok(m) + } + _ => { + show_error!( + "{}", + translate!("dd-error-cannot-skip-invalid", "file" => "standard input") + ); + set_exit_code(1); + Ok(0) + } } - Ok(m) } Self::File(f) => f.seek(SeekFrom::Current(n.try_into().unwrap())), #[cfg(unix)] diff --git a/tests/by-util/test_dd.rs b/tests/by-util/test_dd.rs index 35a1561e4..3e53f9a59 100644 --- a/tests/by-util/test_dd.rs +++ b/tests/by-util/test_dd.rs @@ -669,6 +669,39 @@ fn test_skip_beyond_file() { ); } +#[test] +#[cfg(unix)] +fn test_skip_beyond_file_seekable_stdin() { + // When stdin is a seekable file, dd should use seek to skip bytes. + // This tests that skipping beyond the file size issues a warning. + use std::process::Stdio; + + // Test cases: (bs, skip) pairs that skip beyond a 4-byte file + let test_cases = [ + ("bs=1", "skip=5"), // skip 5 bytes + ("bs=3", "skip=2"), // skip 6 bytes + ]; + + for (bs, skip) in test_cases { + let (at, mut ucmd) = at_and_ucmd!(); + at.write("in", "abcd"); + + let stdin = OwnedFileDescriptorOrHandle::open_file( + OpenOptions::new().read(true), + at.plus("in").as_path(), + ) + .unwrap(); + + ucmd.args(&[bs, skip, "count=0", "status=noxfer"]) + .set_stdin(Stdio::from(stdin)) + .succeeds() + .no_stdout() + .stderr_contains( + "'standard input': cannot skip to specified offset\n0+0 records in\n0+0 records out\n", + ); + } +} + #[test] fn test_seek_do_not_overwrite() { let (at, mut ucmd) = at_and_ucmd!(); From 574f6ba2ef95af91e5c6e36f6491fa358701e555 Mon Sep 17 00:00:00 2001 From: mattsu <35655889+mattsu2020@users.noreply.github.com> Date: Wed, 14 Jan 2026 06:17:38 +0900 Subject: [PATCH 199/425] fmt : handle invalid UTF-8 input by replacing malformed sequences (#9329) --------- Co-authored-by: Sylvestre Ledru --- src/uu/fmt/src/fmt.rs | 2 +- src/uu/fmt/src/linebreak.rs | 30 ++-- src/uu/fmt/src/parasplit.rs | 306 +++++++++++++++++++++++++----------- tests/by-util/test_fmt.rs | 18 ++- 4 files changed, 247 insertions(+), 109 deletions(-) diff --git a/src/uu/fmt/src/fmt.rs b/src/uu/fmt/src/fmt.rs index 882c0834a..c06c5702b 100644 --- a/src/uu/fmt/src/fmt.rs +++ b/src/uu/fmt/src/fmt.rs @@ -234,7 +234,7 @@ fn process_file( match para_result { Err(s) => { ostream - .write_all(s.as_bytes()) + .write_all(&s) .map_err_context(|| translate!("fmt-error-failed-to-write-output"))?; ostream .write_all(b"\n") diff --git a/src/uu/fmt/src/linebreak.rs b/src/uu/fmt/src/linebreak.rs index 653e7c3e0..a64728aeb 100644 --- a/src/uu/fmt/src/linebreak.rs +++ b/src/uu/fmt/src/linebreak.rs @@ -14,7 +14,7 @@ use crate::parasplit::{ParaWords, Paragraph, WordInfo}; struct BreakArgs<'a> { opts: &'a FmtOptions, init_len: usize, - indent_str: &'a str, + indent: &'a [u8], indent_len: usize, uniform: bool, ostream: &'a mut BufWriter, @@ -59,11 +59,11 @@ pub fn break_lines( let p_init_len = winfo.word_nchars + if opts.crown || opts.tagged { // handle "init" portion - ostream.write_all(para.init_str.as_bytes())?; + ostream.write_all(¶.init_str)?; para.init_len } else if !para.mail_header { // for non-(crown, tagged) that's the same as a normal indent - ostream.write_all(p_indent.as_bytes())?; + ostream.write_all(p_indent)?; p_indent_len } else { // except that mail headers get no indent at all @@ -71,7 +71,7 @@ pub fn break_lines( }; // write first word after writing init - ostream.write_all(winfo.word.as_bytes())?; + ostream.write_all(winfo.word)?; // does this paragraph require uniform spacing? let uniform = para.mail_header || opts.uniform; @@ -79,7 +79,7 @@ pub fn break_lines( let mut break_args = BreakArgs { opts, init_len: p_init_len, - indent_str: p_indent, + indent: p_indent, indent_len: p_indent_len, uniform, ostream, @@ -121,7 +121,7 @@ fn accum_words_simple<'a>( ); if l + wlen + slen > args.opts.width { - write_newline(args.indent_str, args.ostream)?; + write_newline(args.indent, args.ostream)?; write_with_spaces(&winfo.word[winfo.word_start..], 0, args.ostream)?; Ok((args.indent_len + winfo.word_nchars, winfo.ends_punct)) } else { @@ -146,7 +146,7 @@ fn break_knuth_plass<'a, T: Clone + Iterator>>( (false, false), |(mut prev_punct, mut fresh), &(next_break, break_before)| { if fresh { - write_newline(args.indent_str, args.ostream)?; + write_newline(args.indent, args.ostream)?; } // at each breakpoint, keep emitting words until we find the word matching this breakpoint for winfo in &mut iter { @@ -167,7 +167,7 @@ fn break_knuth_plass<'a, T: Clone + Iterator>>( if std::ptr::eq(winfo, next_break) { // OK, we found the matching word if break_before { - write_newline(args.indent_str, args.ostream)?; + write_newline(args.indent, args.ostream)?; write_with_spaces(&winfo.word[winfo.word_start..], 0, args.ostream)?; } else { // breaking after this word, so that means "fresh" is true for the next iteration @@ -186,7 +186,7 @@ fn break_knuth_plass<'a, T: Clone + Iterator>>( // after the last linebreak, write out the rest of the final line. for winfo in iter { if fresh { - write_newline(args.indent_str, args.ostream)?; + write_newline(args.indent, args.ostream)?; } let (slen, word) = slice_if_fresh( fresh, @@ -474,13 +474,13 @@ fn compute_slen(uniform: bool, newline: bool, start: bool, punct: bool) -> usize /// Otherwise, compute `slen` and leave whitespace alone. fn slice_if_fresh( fresh: bool, - word: &str, + word: &[u8], start: usize, uniform: bool, newline: bool, sstart: bool, punct: bool, -) -> (usize, &str) { +) -> (usize, &[u8]) { if fresh { (0, &word[start..]) } else { @@ -489,14 +489,14 @@ fn slice_if_fresh( } /// Write a newline and add the indent. -fn write_newline(indent: &str, ostream: &mut BufWriter) -> std::io::Result<()> { +fn write_newline(indent: &[u8], ostream: &mut BufWriter) -> std::io::Result<()> { ostream.write_all(b"\n")?; - ostream.write_all(indent.as_bytes()) + ostream.write_all(indent) } /// Write the word, along with slen spaces. fn write_with_spaces( - word: &str, + word: &[u8], slen: usize, ostream: &mut BufWriter, ) -> std::io::Result<()> { @@ -505,5 +505,5 @@ fn write_with_spaces( } else if slen == 1 { ostream.write_all(b" ")?; } - ostream.write_all(word.as_bytes()) + ostream.write_all(word) } diff --git a/src/uu/fmt/src/parasplit.rs b/src/uu/fmt/src/parasplit.rs index 3be410b8a..ab402eac9 100644 --- a/src/uu/fmt/src/parasplit.rs +++ b/src/uu/fmt/src/parasplit.rs @@ -5,7 +5,7 @@ // spell-checker:ignore (ToDO) INFTY MULT PSKIP accum aftertab beforetab breakwords fmt's formatline linebreak linebreaking linebreaks linelen maxlength minlength nchars noformat noformatline ostream overlen parasplit plass pmatch poffset posn powf prefixindent punct signum slen sstart tabwidth tlen underlen winfo wlen wordlen wordsplits xanti xprefix -use std::io::{BufRead, Lines}; +use std::io::BufRead; use std::iter::Peekable; use std::slice::Iter; use unicode_width::UnicodeWidthChar; @@ -26,6 +26,90 @@ fn char_width(c: char) -> usize { } } +/// Return the UTF-8 sequence length implied by a leading byte, or `None` if invalid. +fn utf8_char_width(byte: u8) -> Option { + // UTF-8 leading-byte ranges per Unicode Standard, Ch. 3, Table 3-7 and RFC 3629. + // 00..7F => 1 byte; C2..DF => 2 bytes; E0..EF => 3 bytes; F0..F4 => 4 bytes. + // Disallowed bytes include C0..C1 and F5..FF. + const ASCII_MAX: u8 = 0x7F; + const TWO_BYTE_START: u8 = 0xC2; + const TWO_BYTE_END: u8 = 0xDF; + const THREE_BYTE_START: u8 = 0xE0; + const THREE_BYTE_END: u8 = 0xEF; + const FOUR_BYTE_START: u8 = 0xF0; + const FOUR_BYTE_END: u8 = 0xF4; // up to U+10FFFF + + if byte <= ASCII_MAX { + return Some(1); + } + if (TWO_BYTE_START..=TWO_BYTE_END).contains(&byte) { + return Some(2); + } + if (THREE_BYTE_START..=THREE_BYTE_END).contains(&byte) { + return Some(3); + } + if (FOUR_BYTE_START..=FOUR_BYTE_END).contains(&byte) { + return Some(4); + } + None +} + +/// Decode a UTF-8 character starting at `start`, returning the char and bytes consumed. +fn decode_char(bytes: &[u8], start: usize) -> (Option, usize) { + let Some(&first) = bytes.get(start) else { + return (None, 1); + }; + if first < 0x80 { + return (Some(first as char), 1); + } + + let Some(width) = utf8_char_width(first) else { + return (None, 1); + }; + + if start + width > bytes.len() { + return (None, 1); + } + + match std::str::from_utf8(&bytes[start..start + width]) { + Ok(s) => (s.chars().next(), width), + Err(_) => (None, 1), + } +} + +struct DecodedCharInfo { + ch: Option, + consumed: usize, + width: usize, + is_ascii: bool, +} + +fn decode_char_info(bytes: &[u8], start: usize) -> DecodedCharInfo { + let (ch, consumed) = decode_char(bytes, start); + let (width, is_ascii) = match ch { + Some(c) => (char_width(c), c.is_ascii()), + None => (1, false), + }; + DecodedCharInfo { + ch, + consumed, + width, + is_ascii, + } +} + +/// Compute display width for a UTF-8 byte slice, treating invalid bytes as width 1. +fn byte_display_width(bytes: &[u8]) -> usize { + let mut width = 0; + let mut idx = 0; + while idx < bytes.len() { + let info = decode_char_info(bytes, idx); + width += info.width; + idx += info.consumed; + } + width +} + /// GNU fmt has a more restrictive definition of whitespace than Unicode. /// It only considers ASCII whitespace characters (space, tab, newline, etc.) /// and excludes many Unicode whitespace characters like non-breaking spaces. @@ -34,12 +118,16 @@ fn is_fmt_whitespace(c: char) -> bool { matches!(c, ' ' | '\t' | '\n' | '\r' | '\x0B' | '\x0C') } +fn is_fmt_whitespace_byte(b: u8) -> bool { + matches!(b, b' ' | b'\t' | b'\n' | b'\r' | 0x0B | 0x0C) +} + // lines with PSKIP, lacking PREFIX, or which are entirely blank are // NoFormatLines; otherwise, they are FormatLines #[derive(Debug)] pub enum Line { FormatLine(FileLine), - NoFormatLine(String, bool), + NoFormatLine(Vec, bool), } impl Line { @@ -52,7 +140,7 @@ impl Line { } /// when we know that it's a [`Line::NoFormatLine`], as in the [`ParagraphStream`] iterator - fn get_noformatline(self) -> (String, bool) { + fn get_noformatline(self) -> (Vec, bool) { match self { Self::NoFormatLine(s, b) => (s, b), Self::FormatLine(..) => panic!("Found FormatLine when expecting NoFormatLine"), @@ -64,7 +152,7 @@ impl Line { /// the next line or not #[derive(Debug)] pub struct FileLine { - line: String, + line: Vec, /// The end of the indent, always the start of the text indent_end: usize, /// The end of the PREFIX's indent, that is, the spaces before the prefix @@ -78,75 +166,86 @@ pub struct FileLine { /// Iterator that produces a stream of Lines from a file pub struct FileLines<'a> { opts: &'a FmtOptions, - lines: Lines<&'a mut FileOrStdReader>, + reader: &'a mut FileOrStdReader, } impl FileLines<'_> { - fn new<'b>(opts: &'b FmtOptions, lines: Lines<&'b mut FileOrStdReader>) -> FileLines<'b> { - FileLines { opts, lines } + fn new<'b>(opts: &'b FmtOptions, reader: &'b mut FileOrStdReader) -> FileLines<'b> { + FileLines { opts, reader } } /// returns true if this line should be formatted - fn match_prefix(&self, line: &str) -> (bool, usize) { + fn match_prefix(&self, line: &[u8]) -> (bool, usize) { let Some(prefix) = &self.opts.prefix else { return (true, 0); }; - FileLines::match_prefix_generic(prefix, line, self.opts.xprefix) + FileLines::match_prefix_generic(prefix.as_bytes(), line, self.opts.xprefix) } /// returns true if this line should be formatted - fn match_anti_prefix(&self, line: &str) -> bool { + fn match_anti_prefix(&self, line: &[u8]) -> bool { let Some(anti_prefix) = &self.opts.anti_prefix else { return true; }; - match FileLines::match_prefix_generic(anti_prefix, line, self.opts.xanti_prefix) { + match FileLines::match_prefix_generic(anti_prefix.as_bytes(), line, self.opts.xanti_prefix) + { (true, _) => false, (_, _) => true, } } - fn match_prefix_generic(pfx: &str, line: &str, exact: bool) -> (bool, usize) { + fn match_prefix_generic(pfx: &[u8], line: &[u8], exact: bool) -> (bool, usize) { if line.starts_with(pfx) { return (true, 0); } if !exact { - // we do it this way rather than byte indexing to support unicode whitespace chars - for (i, char) in line.char_indices() { + let mut i = 0; + while i < line.len() { if line[i..].starts_with(pfx) { return (true, i); - } else if !is_fmt_whitespace(char) { + } else if !is_fmt_whitespace_byte(line[i]) { break; } + i += 1; } } (false, 0) } - fn compute_indent(&self, string: &str, prefix_end: usize) -> (usize, usize, usize) { + fn compute_indent(&self, bytes: &[u8], prefix_end: usize) -> (usize, usize, usize) { let mut prefix_len = 0; let mut indent_len = 0; - let mut indent_end = 0; - for (os, c) in string.char_indices() { - if os == prefix_end { + let mut indent_end = bytes.len(); + let mut idx = 0; + while idx < bytes.len() { + if idx == prefix_end { // we found the end of the prefix, so this is the printed length of the prefix here prefix_len = indent_len; } - if (os >= prefix_end) && !is_fmt_whitespace(c) { - // found first non-whitespace after prefix, this is indent_end - indent_end = os; + let byte = bytes[idx]; + if idx >= prefix_end && !is_fmt_whitespace_byte(byte) { + indent_end = idx; break; - } else if c == '\t' { - // compute tab length - indent_len = (indent_len / self.opts.tabwidth + 1) * self.opts.tabwidth; - } else { - // non-tab character - indent_len += char_width(c); } + + if byte == b'\t' { + indent_len = (indent_len / self.opts.tabwidth + 1) * self.opts.tabwidth; + idx += 1; + continue; + } + + let info = decode_char_info(bytes, idx); + indent_len += info.width; + idx += info.consumed; + continue; + } + if indent_end == bytes.len() { + indent_end = idx; } (indent_end, prefix_len, indent_len) } @@ -156,14 +255,26 @@ impl Iterator for FileLines<'_> { type Item = Line; fn next(&mut self) -> Option { - let n = self.lines.next()?.ok()?; + let mut buf = Vec::new(); + match self.reader.read_until(b'\n', &mut buf) { + Ok(0) => return None, + Ok(_) => {} + Err(_) => return None, + } + if buf.ends_with(b"\n") { + buf.pop(); + if buf.ends_with(b"\r") { + buf.pop(); + } + } + let n = buf; // if this line is entirely whitespace, // emit a blank line // Err(true) indicates that this was a linebreak, // which is important to know when detecting mail headers - if n.chars().all(is_fmt_whitespace) { - return Some(Line::NoFormatLine(String::new(), true)); + if n.iter().all(|&b| is_fmt_whitespace_byte(b)) { + return Some(Line::NoFormatLine(Vec::new(), true)); } let (pmatch, poffset) = self.match_prefix(&n[..]); @@ -181,8 +292,8 @@ impl Iterator for FileLines<'_> { // following line) if pmatch && n[poffset + self.opts.prefix.as_ref().map_or(0, |s| s.len())..] - .chars() - .all(is_fmt_whitespace) + .iter() + .all(|&b| is_fmt_whitespace_byte(b)) { return Some(Line::NoFormatLine(n, false)); } @@ -210,20 +321,20 @@ impl Iterator for FileLines<'_> { /// A paragraph : a collection of [`FileLines`] that are to be formatted /// plus info about the paragraph's indentation /// -/// We only retain the String from the [`FileLine`]; the other info +/// We retain the raw bytes from the [`FileLine`]; the other info /// is only there to help us in deciding how to merge lines into Paragraphs #[derive(Debug)] pub struct Paragraph { /// the lines of the file - lines: Vec, + lines: Vec>, /// string representing the init, that is, the first line's indent - pub init_str: String, + pub init_str: Vec, /// printable length of the init string considering TABWIDTH pub init_len: usize, - /// byte location of end of init in first line String + /// byte location of end of init in first line buffer init_end: usize, /// string representing indent - pub indent_str: String, + pub indent_str: Vec, /// length of above pub indent_len: usize, /// byte location of end of indent (in crown and tagged mode, only applies to 2nd line and onward) @@ -242,7 +353,7 @@ pub struct ParagraphStream<'a> { impl ParagraphStream<'_> { pub fn new<'b>(opts: &'b FmtOptions, reader: &'b mut FileOrStdReader) -> ParagraphStream<'b> { - let lines = FileLines::new(opts, reader.lines()).peekable(); + let lines = FileLines::new(opts, reader).peekable(); // at the beginning of the file, we might find mail headers ParagraphStream { lines, @@ -260,10 +371,10 @@ impl ParagraphStream<'_> { false } else { let l_slice = &line.line[..]; - if l_slice.starts_with("From ") { + if l_slice.starts_with(b"From ") { true } else { - let Some(colon_posn) = l_slice.find(':') else { + let Some(colon_posn) = l_slice.iter().position(|&b| b == b':') else { return false; }; @@ -273,18 +384,18 @@ impl ParagraphStream<'_> { } l_slice[..colon_posn] - .chars() - .all(|x| !matches!(x as usize, y if !(33..=126).contains(&y))) + .iter() + .all(|&b| (33..=126).contains(&(b as usize)) && b != b':') } } } } impl Iterator for ParagraphStream<'_> { - type Item = Result; + type Item = Result>; #[allow(clippy::cognitive_complexity)] - fn next(&mut self) -> Option> { + fn next(&mut self) -> Option>> { // return a NoFormatLine in an Err; it should immediately be output let noformat = match self.lines.peek()? { Line::FormatLine(_) => false, @@ -299,10 +410,10 @@ impl Iterator for ParagraphStream<'_> { } // found a FormatLine, now build a paragraph - let mut init_str = String::new(); + let mut init_str = Vec::new(); let mut init_end = 0; let mut init_len = 0; - let mut indent_str = String::new(); + let mut indent_str = Vec::new(); let mut indent_end = 0; let mut indent_len = 0; let mut prefix_len = 0; @@ -326,11 +437,11 @@ impl Iterator for ParagraphStream<'_> { // there can't be any indent or prefixindent because otherwise is_mail_header // would fail since there cannot be any whitespace before the colon in a // valid header field - indent_str.push_str(" "); + indent_str.extend_from_slice(b" "); indent_len = 2; } else { if self.opts.crown || self.opts.tagged { - init_str.push_str(&fl.line[..fl.indent_end]); + init_str.extend_from_slice(&fl.line[..fl.indent_end]); init_len = fl.indent_len; init_end = fl.indent_end; } else { @@ -340,7 +451,7 @@ impl Iterator for ParagraphStream<'_> { // these will be overwritten in the 2nd line of crown or tagged mode, but // we are not guaranteed to get to the 2nd line, e.g., if the next line // is a NoFormatLine or None. Thus, we set sane defaults the 1st time around - indent_str.push_str(&fl.line[..fl.indent_end]); + indent_str.extend_from_slice(&fl.line[..fl.indent_end]); indent_len = fl.indent_len; indent_end = fl.indent_end; @@ -354,7 +465,7 @@ impl Iterator for ParagraphStream<'_> { // pretty arbitrary. // Perhaps a better default would be 1 TABWIDTH? But ugh that's so big. if self.opts.tagged { - indent_str.push_str(" "); + indent_str.extend_from_slice(b" "); indent_len += 4; } } @@ -381,7 +492,7 @@ impl Iterator for ParagraphStream<'_> { // this is part of the same paragraph, get the indent info from this line indent_str.clear(); - indent_str.push_str(&fl.line[..fl.indent_end]); + indent_str.extend_from_slice(&fl.line[..fl.indent_end]); indent_len = fl.indent_len; indent_end = fl.indent_end; @@ -449,11 +560,14 @@ impl<'a> ParaWords<'a> { self.para .lines .iter() - .flat_map(|x| x.split_whitespace()) + .flat_map(|x| { + x.split(|b| is_fmt_whitespace_byte(*b)) + .filter(|segment| !segment.is_empty()) + }) .map(|x| WordInfo { word: x, word_start: 0, - word_nchars: x.len(), // OK for mail headers; only ASCII allowed (unicode is escaped) + word_nchars: byte_display_width(x), before_tab: None, after_tab: 0, sentence_start: false, @@ -492,24 +606,22 @@ impl<'a> ParaWords<'a> { struct WordSplit<'a> { opts: &'a FmtOptions, - string: &'a str, + bytes: &'a [u8], length: usize, position: usize, prev_punct: bool, } impl WordSplit<'_> { - fn analyze_tabs(&self, string: &str) -> (Option, usize, Option) { - // given a string, determine (length before tab) and (printed length after first tab) - // if there are no tabs, beforetab = -1 and aftertab is the printed length + fn analyze_tabs(&self, bytes: &[u8]) -> (Option, usize, Option) { let mut beforetab = None; let mut aftertab = 0; let mut word_start = None; - for (os, c) in string.char_indices() { - if !is_fmt_whitespace(c) { - word_start = Some(os); + for (idx, b) in bytes.iter().enumerate() { + if !is_fmt_whitespace_byte(*b) { + word_start = Some(idx); break; - } else if c == '\t' { + } else if *b == b'\t' { if beforetab.is_none() { beforetab = Some(aftertab); aftertab = 0; @@ -522,28 +634,50 @@ impl WordSplit<'_> { } (beforetab, aftertab, word_start) } -} -impl WordSplit<'_> { - fn new<'b>(opts: &'b FmtOptions, string: &'b str) -> WordSplit<'b> { - // wordsplits *must* start at a non-whitespace character - let trim_string = string.trim_start_matches(is_fmt_whitespace); + fn new<'b>(opts: &'b FmtOptions, bytes: &'b [u8]) -> WordSplit<'b> { + let start = bytes + .iter() + .position(|&b| !is_fmt_whitespace_byte(b)) + .unwrap_or(bytes.len()); + let trimmed = &bytes[start..]; WordSplit { opts, - string: trim_string, - length: string.len(), + bytes: trimmed, + length: trimmed.len(), position: 0, prev_punct: false, } } - fn is_punctuation(c: char) -> bool { - matches!(c, '!' | '.' | '?') + fn is_punctuation_byte(b: u8) -> bool { + matches!(b, b'!' | b'.' | b'?') + } + + fn scan_word_end(&self, word_start: usize) -> (usize, usize, Option) { + let mut word_nchars = 0; + let mut idx = word_start; + let mut last_ascii = None; + while idx < self.length { + let info = decode_char_info(self.bytes, idx); + let is_whitespace = info.is_ascii && info.ch.is_some_and(is_fmt_whitespace); + if is_whitespace { + break; + } + word_nchars += info.width; + if info.is_ascii { + last_ascii = info.ch.map(|c| c as u8); + } else { + last_ascii = None; + } + idx += info.consumed; + } + (idx, word_nchars, last_ascii) } } pub struct WordInfo<'a> { - pub word: &'a str, + pub word: &'a [u8], pub word_start: usize, pub word_nchars: usize, pub before_tab: Option, @@ -567,7 +701,7 @@ impl<'a> Iterator for WordSplit<'a> { // find the start of the next word, and record if we find a tab character let (before_tab, after_tab, word_start) = - if let (b, a, Some(s)) = self.analyze_tabs(&self.string[old_position..]) { + if let (b, a, Some(s)) = self.analyze_tabs(&self.bytes[old_position..]) { (b, a, s + old_position) } else { self.position = self.length; @@ -577,18 +711,8 @@ impl<'a> Iterator for WordSplit<'a> { // find the beginning of the next whitespace // note that this preserves the invariant that self.position // points to whitespace character OR end of string - let mut word_nchars = 0; - self.position = match self.string[word_start..].find(|x: char| { - if is_fmt_whitespace(x) { - true - } else { - word_nchars += char_width(x); - false - } - }) { - None => self.length, - Some(s) => s + word_start, - }; + let (next_position, word_nchars, last_ascii) = self.scan_word_end(word_start); + self.position = next_position; let word_start_relative = word_start - old_position; // if the previous sentence was punctuation and this sentence has >2 whitespace or one tab, is a new sentence. @@ -596,16 +720,14 @@ impl<'a> Iterator for WordSplit<'a> { self.prev_punct && (before_tab.is_some() || word_start_relative > 1); // now record whether this word ends in punctuation - self.prev_punct = match self.string[..self.position].chars().next_back() { - Some(ch) => WordSplit::is_punctuation(ch), - _ => panic!("fatal: expected word not to be empty"), - }; + let ends_punct = last_ascii.is_some_and(WordSplit::is_punctuation_byte); + self.prev_punct = ends_punct; let (word, word_start_relative, before_tab, after_tab) = if self.opts.uniform { - (&self.string[word_start..self.position], 0, None, 0) + (&self.bytes[word_start..self.position], 0, None, 0) } else { ( - &self.string[old_position..self.position], + &self.bytes[old_position..self.position], word_start_relative, before_tab, after_tab, @@ -619,7 +741,7 @@ impl<'a> Iterator for WordSplit<'a> { before_tab, after_tab, sentence_start: is_start_of_sentence, - ends_punct: self.prev_punct, + ends_punct, new_line, }) } diff --git a/tests/by-util/test_fmt.rs b/tests/by-util/test_fmt.rs index 5959569de..66d08817e 100644 --- a/tests/by-util/test_fmt.rs +++ b/tests/by-util/test_fmt.rs @@ -3,7 +3,7 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore plass samp +// spell-checker:ignore plass samp FFFD #[cfg(target_os = "linux")] use std::os::unix::ffi::OsStringExt; use uutests::new_ucmd; @@ -323,6 +323,8 @@ fn test_fmt_unicode_whitespace_handling() { ("non-breaking space", non_breaking_space), ("figure space", figure_space), ("narrow no-break space", narrow_no_break_space), + ("word joiner", "\u{2060}"), + ("cyrillic kha", "\u{0445}"), ] { let input = format!("={char}="); let result = new_ucmd!() @@ -397,3 +399,17 @@ fn fmt_reflow_unicode() { .succeeds() .stdout_is("漢字漢字\n💐\n日本語の文字\n"); } + +#[test] +fn test_fmt_invalid_utf8() { + // Regression test for handling invalid UTF-8 input (e.g. ISO-8859-1) + // fmt should not drop lines with invalid UTF-8. + // \xA0 is non-breaking space in ISO-8859-1, but invalid in UTF-8. + // We expect GNU-compatible passthrough of the raw byte, not lossy replacement. + let input = b"=\xA0="; + new_ucmd!() + .args(&["-s", "-w1"]) + .pipe_in(input) + .succeeds() + .stdout_is_bytes(b"=\xA0=\n"); +} From 4ba5db0903ece9541ad55c0ca2ab4f13f927c3d5 Mon Sep 17 00:00:00 2001 From: mattsu <35655889+mattsu2020@users.noreply.github.com> Date: Wed, 14 Jan 2026 06:28:40 +0900 Subject: [PATCH 200/425] feat(sort): add warning messages for obsolescent keys and options (#9900) * feat(sort): add warning messages for obsolescent keys and options Added localized warning messages in en-US.ftl for various sort command issues, including obsolescent key formats, ignored options, and locale-related warnings. Implemented LegacyKeyWarning struct and GlobalOptionFlags in sort.rs to detect deprecated key syntax (e.g., +field -field) and suggest modern -k replacements, improving user guidance and compatibility. * refactor(sort): remove stable and unique flags from GlobalOptionFlags Removes the unused `stable` and `unique` boolean fields from the GlobalOptionFlags struct and their initialization in the impl block, simplifying the code by eliminating redundant options. * feat(sort): allow multiple sort modes by removing mutual conflicts Previously, sort mode flags were mutually exclusive, preventing users from specifying more than one mode. This change removes the conflicts to enable combining sort options for more flexible sorting behavior. * refactor(sort): separate arg_index and key_index in legacy key warnings - Update LegacyKeyWarning struct to include arg_index and make key_index optional - Modify preprocess_legacy_args to set arg_index instead of key_index during parsing - Add index_legacy_warnings function to compute key_index after arg processing - Adjust emit_debug_warnings to match updated key_index type - This ensures accurate indexing for legacy key warnings in sort utility * feat(sort): add legacy sort key detection for '+' prefixed arguments - Introduce `starts_with_plus` function to check for arguments starting with '+' in a platform-specific manner - Modify `parse_sort_arguments` to detect and process legacy sort keys, enabling proper handling of deprecated options with warnings - This ensures backward compatibility for users relying on old sort syntax while guiding migration to modern flags * refactor(sort): simplify legacy args preprocessing Remove intermediate vector creation in preprocess_legacy_args and streamline the handling of legacy '+' prefixed arguments to improve efficiency and readability without altering functionality. Additionally, refactor uumain to directly index legacy warnings when present. * refactor(sort/benches): optimize locale UTF8 benchmarks by predefining args Move output file creation and argument setup outside benchmark loops in sort_locale_utf8_bench.rs to avoid measuring initialization time in each iteration, ensuring accurate performance measurements for sorting operations. --------- Co-authored-by: Sylvestre Ledru --- src/uu/sort/benches/sort_locale_utf8_bench.rs | 41 +- src/uu/sort/locales/en-US.ftl | 16 + src/uu/sort/src/sort.rs | 433 ++++++++++++++++-- 3 files changed, 436 insertions(+), 54 deletions(-) diff --git a/src/uu/sort/benches/sort_locale_utf8_bench.rs b/src/uu/sort/benches/sort_locale_utf8_bench.rs index b0ebb340d..6f61dc322 100644 --- a/src/uu/sort/benches/sort_locale_utf8_bench.rs +++ b/src/uu/sort/benches/sort_locale_utf8_bench.rs @@ -21,11 +21,10 @@ fn sort_ascii_utf8_locale(bencher: Bencher) { let output_file = NamedTempFile::new().unwrap(); let output_path = output_file.path().to_str().unwrap().to_string(); + let args = ["-o", &output_path, file_path.to_str().unwrap()]; + black_box(run_util_function(uumain, &args)); bencher.bench(|| { - black_box(run_util_function( - uumain, - &["-o", &output_path, file_path.to_str().unwrap()], - )); + black_box(run_util_function(uumain, &args)); }); } @@ -37,11 +36,10 @@ fn sort_mixed_utf8_locale(bencher: Bencher) { let output_file = NamedTempFile::new().unwrap(); let output_path = output_file.path().to_str().unwrap().to_string(); + let args = ["-o", &output_path, file_path.to_str().unwrap()]; + black_box(run_util_function(uumain, &args)); bencher.bench(|| { - black_box(run_util_function( - uumain, - &["-o", &output_path, file_path.to_str().unwrap()], - )); + black_box(run_util_function(uumain, &args)); }); } @@ -54,12 +52,13 @@ fn sort_numeric_utf8_locale(bencher: Bencher) { data.extend_from_slice(line.as_bytes()); } let file_path = setup_test_file(&data); + let output_file = NamedTempFile::new().unwrap(); + let output_path = output_file.path().to_str().unwrap().to_string(); + let args = ["-n", "-o", &output_path, file_path.to_str().unwrap()]; + black_box(run_util_function(uumain, &args)); bencher.bench(|| { - black_box(run_util_function( - uumain, - &["-n", file_path.to_str().unwrap()], - )); + black_box(run_util_function(uumain, &args)); }); } @@ -68,12 +67,13 @@ fn sort_numeric_utf8_locale(bencher: Bencher) { fn sort_reverse_utf8_locale(bencher: Bencher) { let data = text_data::generate_mixed_locale_data(50_000); let file_path = setup_test_file(&data); + let output_file = NamedTempFile::new().unwrap(); + let output_path = output_file.path().to_str().unwrap().to_string(); + let args = ["-r", "-o", &output_path, file_path.to_str().unwrap()]; + black_box(run_util_function(uumain, &args)); bencher.bench(|| { - black_box(run_util_function( - uumain, - &["-r", file_path.to_str().unwrap()], - )); + black_box(run_util_function(uumain, &args)); }); } @@ -82,12 +82,13 @@ fn sort_reverse_utf8_locale(bencher: Bencher) { fn sort_unique_utf8_locale(bencher: Bencher) { let data = text_data::generate_mixed_locale_data(50_000); let file_path = setup_test_file(&data); + let output_file = NamedTempFile::new().unwrap(); + let output_path = output_file.path().to_str().unwrap().to_string(); + let args = ["-u", "-o", &output_path, file_path.to_str().unwrap()]; + black_box(run_util_function(uumain, &args)); bencher.bench(|| { - black_box(run_util_function( - uumain, - &["-u", file_path.to_str().unwrap()], - )); + black_box(run_util_function(uumain, &args)); }); } diff --git a/src/uu/sort/locales/en-US.ftl b/src/uu/sort/locales/en-US.ftl index a5c5d01b6..f571c5631 100644 --- a/src/uu/sort/locales/en-US.ftl +++ b/src/uu/sort/locales/en-US.ftl @@ -51,6 +51,22 @@ sort-error-write-failed = write failed: {$output} sort-failed-to-delete-temporary-directory = failed to delete temporary directory: {$error} sort-failed-to-set-up-signal-handler = failed to set up signal handler: {$error} +# Warning messages +sort-warning-failed-to-set-locale = failed to set locale +sort-warning-simple-byte-comparison = text ordering performed using simple byte comparison +sort-warning-key-zero-width = key {$key} has zero width and will be ignored +sort-warning-key-numeric-spans-fields = key {$key} is numeric and spans multiple fields +sort-warning-leading-blanks-significant = leading blanks are significant in key {$key}; consider also specifying 'b' +sort-warning-numbers-use-decimal-point = numbers use '.' as a decimal point in this locale +sort-warning-options-ignored = options '-{$options}' are ignored +sort-warning-option-ignored = option '-{$option}' is ignored +sort-warning-option-reverse-last-resort = option '-r' only applies to last-resort comparison +sort-warning-obsolescent-key = obsolescent key '{$key}' used; consider '-k {$replacement}' instead +sort-warning-separator-grouping = field separator '{$sep}' is treated as a group separator in numbers +sort-warning-separator-decimal = field separator '{$sep}' is treated as a decimal point in numbers +sort-warning-separator-minus = field separator '{$sep}' is treated as a minus sign in numbers +sort-warning-separator-plus = field separator '{$sep}' is treated as a plus sign in numbers + # Help messages sort-help-help = Print help information. sort-help-version = Print version information. diff --git a/src/uu/sort/src/sort.rs b/src/uu/sort/src/sort.rs index 071163c5a..eb2fa0ff5 100644 --- a/src/uu/sort/src/sort.rs +++ b/src/uu/sort/src/sort.rs @@ -21,7 +21,7 @@ mod tmp_dir; use bigdecimal::BigDecimal; use chunks::LineData; use clap::builder::ValueParser; -use clap::{Arg, ArgAction, Command}; +use clap::{Arg, ArgAction, ArgMatches, Command}; use custom_str_cmp::custom_str_cmp; use ext_sort::ext_sort; use fnv::FnvHasher; @@ -36,6 +36,8 @@ use std::hash::{Hash, Hasher}; use std::io::{BufRead, BufReader, BufWriter, Read, Write, stdin, stdout}; use std::num::IntErrorKind; use std::ops::Range; +#[cfg(unix)] +use std::os::unix::ffi::OsStrExt; use std::path::Path; use std::path::PathBuf; use std::str::Utf8Error; @@ -1067,18 +1069,13 @@ impl FieldSelector { } } -/// Creates an `Arg` that conflicts with all other sort modes. +/// Creates an `Arg` for a sort mode flag. fn make_sort_mode_arg(mode: &'static str, short: char, help: String) -> Arg { Arg::new(mode) .short(short) .long(mode) .help(help) .action(ArgAction::SetTrue) - .conflicts_with_all( - options::modes::ALL_SORT_MODES - .iter() - .filter(|&&m| m != mode), - ) } #[cfg(target_os = "linux")] @@ -1119,6 +1116,80 @@ struct LegacyKeyPart { opts: String, } +#[derive(Debug, Clone)] +struct LegacyKeyWarning { + arg_index: usize, + key_index: Option, + from_field: usize, + to_field: Option, + to_char: Option, +} + +impl LegacyKeyWarning { + fn legacy_key_display(&self) -> String { + match self.to_field { + Some(to) => format!("+{} -{}", self.from_field, to), + None => format!("+{}", self.from_field), + } + } + + fn replacement_key_display(&self) -> String { + let start_field = self.from_field.saturating_add(1); + match self.to_field { + Some(to_field) => { + let end_field = match self.to_char { + Some(0) | None => to_field.max(1), + Some(_) => to_field.saturating_add(1), + }; + format!("{start_field},{end_field}") + } + None => start_field.to_string(), + } + } +} + +#[derive(Default)] +struct GlobalOptionFlags { + keys_specified: bool, + ignore_leading_blanks: bool, + dictionary_order: bool, + ignore_case: bool, + ignore_non_printing: bool, + reverse: bool, + mode_numeric: bool, + mode_general: bool, + mode_human: bool, + mode_month: bool, + mode_random: bool, + mode_version: bool, +} + +impl GlobalOptionFlags { + fn from_matches(matches: &ArgMatches) -> Self { + let sort_value = matches + .get_one::(options::modes::SORT) + .map(|s| s.as_str()); + Self { + keys_specified: matches.contains_id(options::KEY), + ignore_leading_blanks: matches.get_flag(options::IGNORE_LEADING_BLANKS), + dictionary_order: matches.get_flag(options::DICTIONARY_ORDER), + ignore_case: matches.get_flag(options::IGNORE_CASE), + ignore_non_printing: matches.get_flag(options::IGNORE_NONPRINTING), + reverse: matches.get_flag(options::REVERSE), + mode_human: matches.get_flag(options::modes::HUMAN_NUMERIC) + || sort_value == Some("human-numeric"), + mode_month: matches.get_flag(options::modes::MONTH) || sort_value == Some("month"), + mode_general: matches.get_flag(options::modes::GENERAL_NUMERIC) + || sort_value == Some("general-numeric"), + mode_numeric: matches.get_flag(options::modes::NUMERIC) + || sort_value == Some("numeric"), + mode_version: matches.get_flag(options::modes::VERSION) + || sort_value == Some("version"), + mode_random: matches.get_flag(options::modes::RANDOM) || sort_value == Some("random"), + } + } +} + fn parse_usize_or_max(num: &str) -> Option { match num.parse::() { Ok(v) => Some(v), @@ -1192,16 +1263,17 @@ fn legacy_key_to_k(from: &LegacyKeyPart, to: Option<&LegacyKeyPart>) -> String { /// Preprocess argv to handle legacy +POS1 [-POS2] syntax by converting it into -k forms /// before clap sees the arguments. -fn preprocess_legacy_args(args: I) -> Vec +fn preprocess_legacy_args(args: I) -> (Vec, Vec) where I: IntoIterator, I::Item: Into, { if !allows_traditional_usage() { - return args.into_iter().map(Into::into).collect(); + return (args.into_iter().map(Into::into).collect(), Vec::new()); } let mut processed = Vec::new(); + let mut legacy_warnings = Vec::new(); let mut iter = args.into_iter().map(Into::into).peekable(); while let Some(arg) = iter.next() { @@ -1211,38 +1283,110 @@ where break; } - let as_str = arg.to_string_lossy(); - if let Some(from_spec) = as_str.strip_prefix('+') { - if let Some(from) = parse_legacy_part(from_spec) { - let mut to_part = None; + if starts_with_plus(&arg) { + let as_str = arg.to_string_lossy(); + if let Some(from_spec) = as_str.strip_prefix('+') { + if let Some(from) = parse_legacy_part(from_spec) { + let mut to_part = None; - let next_candidate = iter.peek().map(|next| next.to_string_lossy().to_string()); + let next_candidate = iter.peek().map(|next| next.to_string_lossy().to_string()); - if let Some(next_str) = next_candidate { - if let Some(stripped) = next_str.strip_prefix('-') { - if stripped.starts_with(|c: char| c.is_ascii_digit()) { - let next_arg = iter.next().unwrap(); - if let Some(parsed) = parse_legacy_part(stripped) { - to_part = Some(parsed); - } else { - processed.push(arg); - processed.push(next_arg); - continue; + if let Some(next_str) = next_candidate { + if let Some(stripped) = next_str.strip_prefix('-') { + if stripped.starts_with(|c: char| c.is_ascii_digit()) { + let next_arg = iter.next().unwrap(); + if let Some(parsed) = parse_legacy_part(stripped) { + to_part = Some(parsed); + } else { + processed.push(arg); + processed.push(next_arg); + continue; + } } } } - } - let keydef = legacy_key_to_k(&from, to_part.as_ref()); - processed.push(OsString::from(format!("-k{keydef}"))); - continue; + let keydef = legacy_key_to_k(&from, to_part.as_ref()); + let arg_index = processed.len(); + legacy_warnings.push(LegacyKeyWarning { + arg_index, + key_index: None, + from_field: from.field, + to_field: to_part.as_ref().map(|p| p.field), + to_char: to_part.as_ref().map(|p| p.char_pos), + }); + processed.push(OsString::from(format!("-k{keydef}"))); + continue; + } } } processed.push(arg); } - processed + (processed, legacy_warnings) +} + +fn starts_with_plus(arg: &OsStr) -> bool { + #[cfg(unix)] + { + arg.as_bytes().first() == Some(&b'+') + } + #[cfg(not(unix))] + { + arg.to_string_lossy().starts_with('+') + } +} + +fn index_legacy_warnings(processed_args: &[OsString], legacy_warnings: &mut [LegacyKeyWarning]) { + if legacy_warnings.is_empty() { + return; + } + + let mut index_by_arg = std::collections::HashMap::new(); + for (warning_idx, warning) in legacy_warnings.iter().enumerate() { + index_by_arg.insert(warning.arg_index, warning_idx); + } + + let mut key_index = 0usize; + let mut i = 0usize; + while i < processed_args.len() { + let arg = &processed_args[i]; + if arg == OsStr::new("--") { + break; + } + + let mut matched_key = false; + if arg == OsStr::new("-k") || arg == OsStr::new("--key") { + if i + 1 < processed_args.len() { + key_index = key_index.saturating_add(1); + matched_key = true; + i += 2; + } else { + i += 1; + } + } else { + let as_str = arg.to_string_lossy(); + if let Some(spec) = as_str.strip_prefix("-k") { + if !spec.is_empty() { + key_index = key_index.saturating_add(1); + matched_key = true; + } + } else if let Some(spec) = as_str.strip_prefix("--key=") { + if !spec.is_empty() { + key_index = key_index.saturating_add(1); + matched_key = true; + } + } + i += 1; + } + + if matched_key { + if let Some(&warning_idx) = index_by_arg.get(&i.saturating_sub(1)) { + legacy_warnings[warning_idx].key_index = Some(key_index); + } + } + } } #[cfg(target_os = "linux")] @@ -1271,16 +1415,232 @@ fn default_merge_batch_size() -> usize { } } +fn locale_failed_to_set() -> bool { + matches!(env::var("LC_ALL").ok().as_deref(), Some("missing")) +} + +fn key_zero_width(selector: &FieldSelector) -> bool { + let Some(to) = &selector.to else { + return false; + }; + if to.field < selector.from.field { + return true; + } + if to.field == selector.from.field { + return to.char != 0 && to.char < selector.from.char; + } + false +} + +fn key_spans_multiple_fields(selector: &FieldSelector) -> bool { + if !matches!( + selector.settings.mode, + SortMode::Numeric | SortMode::HumanNumeric | SortMode::GeneralNumeric + ) { + return false; + } + match &selector.to { + None => true, + Some(to) => to.field > selector.from.field, + } +} + +fn key_leading_blanks_significant(selector: &FieldSelector) -> bool { + selector.settings.mode == SortMode::Default + && !selector.from.ignore_blanks + && !selector.settings.ignore_blanks +} + +fn emit_debug_warnings( + settings: &GlobalSettings, + flags: &GlobalOptionFlags, + legacy_warnings: &[LegacyKeyWarning], +) { + if locale_failed_to_set() { + show_error!("{}", translate!("sort-warning-failed-to-set-locale")); + } + + show_error!("{}", translate!("sort-warning-simple-byte-comparison")); + + for (idx, selector) in settings.selectors.iter().enumerate() { + let key_index = idx + 1; + if let Some(legacy) = legacy_warnings + .iter() + .find(|warning| warning.key_index == Some(key_index)) + { + show_error!( + "{}", + translate!( + "sort-warning-obsolescent-key", + "key" => legacy.legacy_key_display(), + "replacement" => legacy.replacement_key_display() + ) + ); + } + + if key_zero_width(selector) { + show_error!( + "{}", + translate!("sort-warning-key-zero-width", "key" => key_index) + ); + continue; + } + + if flags.keys_specified && key_spans_multiple_fields(selector) { + show_error!( + "{}", + translate!( + "sort-warning-key-numeric-spans-fields", + "key" => key_index + ) + ); + } else if flags.keys_specified && key_leading_blanks_significant(selector) { + show_error!( + "{}", + translate!( + "sort-warning-leading-blanks-significant", + "key" => key_index + ) + ); + } + } + + let numeric_used = settings.selectors.iter().any(|selector| { + matches!( + selector.settings.mode, + SortMode::Numeric | SortMode::HumanNumeric | SortMode::GeneralNumeric + ) + }); + + let mut suppress_decimal_warning = false; + if numeric_used { + if let Some(sep) = settings.separator { + match sep { + b'.' => { + show_error!( + "{}", + translate!("sort-warning-separator-decimal", "sep" => ".") + ); + suppress_decimal_warning = true; + } + b'-' => { + show_error!( + "{}", + translate!("sort-warning-separator-minus", "sep" => "-") + ); + } + b'+' => { + show_error!( + "{}", + translate!("sort-warning-separator-plus", "sep" => "+") + ); + } + _ => {} + } + } + + if !suppress_decimal_warning { + show_error!("{}", translate!("sort-warning-numbers-use-decimal-point")); + } + } + + let uses_reverse = settings + .selectors + .iter() + .any(|selector| selector.settings.reverse); + let uses_blanks = settings + .selectors + .iter() + .any(|selector| selector.settings.ignore_blanks || selector.from.ignore_blanks); + let uses_dictionary = settings + .selectors + .iter() + .any(|selector| selector.settings.dictionary_order); + let uses_case = settings + .selectors + .iter() + .any(|selector| selector.settings.ignore_case); + let uses_non_printing = settings + .selectors + .iter() + .any(|selector| selector.settings.ignore_non_printing); + + let uses_mode = |mode| { + settings + .selectors + .iter() + .any(|selector| selector.settings.mode == mode) + }; + + let reverse_unused = flags.reverse && !uses_reverse; + let last_resort_active = + settings.mode != SortMode::Random && !settings.stable && !settings.unique; + let reverse_ignored = reverse_unused && !last_resort_active; + let reverse_last_resort_warning = reverse_unused && last_resort_active; + + let mut ignored_opts = String::new(); + if flags.ignore_leading_blanks && !uses_blanks { + ignored_opts.push('b'); + } + if flags.dictionary_order && !uses_dictionary { + ignored_opts.push('d'); + } + if flags.ignore_case && !uses_case { + ignored_opts.push('f'); + } + if flags.ignore_non_printing && !uses_non_printing { + ignored_opts.push('i'); + } + if flags.mode_general && !uses_mode(SortMode::GeneralNumeric) { + ignored_opts.push('g'); + } + if flags.mode_human && !uses_mode(SortMode::HumanNumeric) { + ignored_opts.push('h'); + } + if flags.mode_month && !uses_mode(SortMode::Month) { + ignored_opts.push('M'); + } + if flags.mode_numeric && !uses_mode(SortMode::Numeric) { + ignored_opts.push('n'); + } + if flags.mode_random && !uses_mode(SortMode::Random) { + ignored_opts.push('R'); + } + if reverse_ignored { + ignored_opts.push('r'); + } + if flags.mode_version && !uses_mode(SortMode::Version) { + ignored_opts.push('V'); + } + + if ignored_opts.len() == 1 { + show_error!( + "{}", + translate!("sort-warning-option-ignored", "option" => ignored_opts) + ); + } else if ignored_opts.len() > 1 { + show_error!( + "{}", + translate!("sort-warning-options-ignored", "options" => ignored_opts) + ); + } + + if reverse_last_resort_warning { + show_error!("{}", translate!("sort-warning-option-reverse-last-resort")); + } +} + #[uucore::main] #[allow(clippy::cognitive_complexity)] pub fn uumain(args: impl uucore::Args) -> UResult<()> { let mut settings = GlobalSettings::default(); - let matches = uucore::clap_localization::handle_clap_result_with_exit_code( - uu_app(), - preprocess_legacy_args(args), - 2, - )?; + let (processed_args, mut legacy_warnings) = preprocess_legacy_args(args); + if !legacy_warnings.is_empty() { + index_legacy_warnings(&processed_args, &mut legacy_warnings); + } + let matches = + uucore::clap_localization::handle_clap_result_with_exit_code(uu_app(), processed_args, 2)?; // Prevent -o/--output to be specified multiple times if matches @@ -1569,6 +1929,11 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { let output = Output::new(matches.get_one::(options::OUTPUT))?; + if settings.debug { + let global_flags = GlobalOptionFlags::from_matches(&matches); + emit_debug_warnings(&settings, &global_flags, &legacy_warnings); + } + settings.init_precomputed(); let result = exec(&mut files, &settings, output, &mut tmp_dir); From 0b0ba60d41876ec1da55025e77e5cf4ed940a584 Mon Sep 17 00:00:00 2001 From: mattsu <35655889+mattsu2020@users.noreply.github.com> Date: Wed, 14 Jan 2026 06:39:22 +0900 Subject: [PATCH 201/425] sort: refine error handling and localize field parsing errors (#10068) * sort: refactor error handling and localize field parsing errors * style(test): fix formatting and spell-checker ignore in sort tests - Add 'dfgi' to spell-checker ignore list for accurate linting - Reformat tuple and method call in test_conflict_check_out for consistency and to resolve clippy warnings * refactor(sort): use Self::default() and remove unnecessary .into() in error handling - Changed `ModeFlags::default()` to `Self::default()` for better idiomatic Rust code. - Removed `.into()` call in `FieldSelector::from` method as it was redundant, simplifying error construction. --- src/uu/sort/locales/en-US.ftl | 9 + src/uu/sort/locales/fr-FR.ftl | 9 + src/uu/sort/src/sort.rs | 606 ++++++++++++++++++++-------------- tests/by-util/test_sort.rs | 106 +++++- 4 files changed, 466 insertions(+), 264 deletions(-) diff --git a/src/uu/sort/locales/en-US.ftl b/src/uu/sort/locales/en-US.ftl index f571c5631..3ae3b0616 100644 --- a/src/uu/sort/locales/en-US.ftl +++ b/src/uu/sort/locales/en-US.ftl @@ -32,6 +32,15 @@ sort-field-index-cannot-be-zero = field index can not be 0 sort-failed-parse-char-index = failed to parse character index {$char}: {$error} sort-invalid-option = invalid option: '{$option}' sort-invalid-char-index-zero-start = invalid character index 0 for the start position of a field +sort-invalid-field-spec = {$msg}: invalid field specification {$spec} +sort-invalid-count-at-start-of = invalid count at start of {$string} +sort-invalid-number-at-field-start = invalid number at field start +sort-invalid-number-after-dash = invalid number after '-' +sort-invalid-number-after-dot = invalid number after '.' +sort-invalid-number-after-comma = invalid number after ',' +sort-field-number-is-zero = field number is zero +sort-character-offset-is-zero = character offset is zero +sort-stray-character-field-spec = stray character in field spec sort-invalid-batch-size-arg = invalid --batch-size argument '{$arg}' sort-minimum-batch-size-two = minimum --batch-size argument is '2' sort-batch-size-too-large = --batch-size argument {$arg} too large diff --git a/src/uu/sort/locales/fr-FR.ftl b/src/uu/sort/locales/fr-FR.ftl index 4dbc05a49..1a01ebb6b 100644 --- a/src/uu/sort/locales/fr-FR.ftl +++ b/src/uu/sort/locales/fr-FR.ftl @@ -32,6 +32,15 @@ sort-field-index-cannot-be-zero = l'index de champ ne peut pas être 0 sort-failed-parse-char-index = échec d'analyse de l'index de caractère {$char} : {$error} sort-invalid-option = option invalide : '{$option}' sort-invalid-char-index-zero-start = index de caractère 0 invalide pour la position de début d'un champ +sort-invalid-field-spec = {$msg} : spécification de champ invalide {$spec} +sort-invalid-count-at-start-of = nombre invalide au début de {$string} +sort-invalid-number-at-field-start = nombre invalide au début du champ +sort-invalid-number-after-dash = nombre invalide après '-' +sort-invalid-number-after-dot = nombre invalide après '.' +sort-invalid-number-after-comma = nombre invalide après ',' +sort-field-number-is-zero = le numéro de champ est zéro +sort-character-offset-is-zero = le décalage de caractère est zéro +sort-stray-character-field-spec = caractère parasite dans la spécification de champ sort-invalid-batch-size-arg = argument --batch-size invalide '{$arg}' sort-minimum-batch-size-two = l'argument --batch-size minimum est '2' sort-batch-size-too-large = argument --batch-size {$arg} trop grand diff --git a/src/uu/sort/src/sort.rs b/src/uu/sort/src/sort.rs index eb2fa0ff5..670103c7b 100644 --- a/src/uu/sort/src/sort.rs +++ b/src/uu/sort/src/sort.rs @@ -69,15 +69,6 @@ mod options { pub const GENERAL_NUMERIC: &str = "general-numeric-sort"; pub const VERSION: &str = "version-sort"; pub const RANDOM: &str = "random-sort"; - - pub const ALL_SORT_MODES: [&str; 6] = [ - GENERAL_NUMERIC, - HUMAN_NUMERIC, - MONTH, - NUMERIC, - VERSION, - RANDOM, - ]; } pub mod check { @@ -141,9 +132,6 @@ pub enum SortError { error: std::io::Error, }, - #[error("{}", translate!("sort-parse-key-error", "key" => .key.quote(), "msg" => .msg.clone()))] - ParseKeyError { key: String, msg: String }, - #[error("{}", translate!("sort-cannot-read", "path" => format!("{}", .path.maybe_quote()), "error" => strip_errno(.error)))] ReadFailed { path: PathBuf, @@ -209,20 +197,6 @@ enum SortMode { Default, } -impl SortMode { - fn get_short_name(&self) -> Option { - match self { - Self::Numeric => Some('n'), - Self::HumanNumeric => Some('h'), - Self::GeneralNumeric => Some('g'), - Self::Month => Some('M'), - Self::Version => Some('V'), - Self::Random => Some('R'), - Self::Default => None, - } - } -} - /// Return the length of the byte slice while ignoring embedded NULs (used for debug underline alignment). fn count_non_null_bytes(bytes: &[u8]) -> usize { bytes.iter().filter(|&&c| c != b'\0').count() @@ -432,53 +406,7 @@ struct KeySettings { reverse: bool, } -impl KeySettings { - /// Checks if the supplied combination of `mode`, `ignore_non_printing` and `dictionary_order` is allowed. - fn check_compatibility( - mode: SortMode, - ignore_non_printing: bool, - dictionary_order: bool, - ) -> Result<(), String> { - if matches!( - mode, - SortMode::Numeric | SortMode::HumanNumeric | SortMode::GeneralNumeric | SortMode::Month - ) { - if dictionary_order { - return Err( - translate!("sort-options-incompatible", "opt1" => "d", "opt2" => mode.get_short_name().unwrap()), - ); - } else if ignore_non_printing { - return Err( - translate!("sort-options-incompatible", "opt1" => "i", "opt2" => mode.get_short_name().unwrap()), - ); - } - } - Ok(()) - } - - fn set_sort_mode(&mut self, mode: SortMode) -> Result<(), String> { - if self.mode != SortMode::Default && self.mode != mode { - return Err( - translate!("sort-options-incompatible", "opt1" => self.mode.get_short_name().unwrap(), "opt2" => mode.get_short_name().unwrap()), - ); - } - Self::check_compatibility(mode, self.ignore_non_printing, self.dictionary_order)?; - self.mode = mode; - Ok(()) - } - - fn set_dictionary_order(&mut self) -> Result<(), String> { - Self::check_compatibility(self.mode, self.ignore_non_printing, true)?; - self.dictionary_order = true; - Ok(()) - } - - fn set_ignore_non_printing(&mut self) -> Result<(), String> { - Self::check_compatibility(self.mode, true, self.dictionary_order)?; - self.ignore_non_printing = true; - Ok(()) - } -} +impl KeySettings {} impl From<&GlobalSettings> for KeySettings { fn from(settings: &GlobalSettings) -> Self { @@ -498,6 +426,122 @@ impl Default for KeySettings { Self::from(&GlobalSettings::default()) } } + +#[derive(Clone, Copy, Debug, Default)] +struct ModeFlags { + numeric: bool, + general_numeric: bool, + human_numeric: bool, + month: bool, + version: bool, + random: bool, +} + +impl ModeFlags { + fn from_mode(mode: SortMode) -> Self { + let mut flags = Self::default(); + match mode { + SortMode::Numeric => flags.numeric = true, + SortMode::GeneralNumeric => flags.general_numeric = true, + SortMode::HumanNumeric => flags.human_numeric = true, + SortMode::Month => flags.month = true, + SortMode::Version => flags.version = true, + SortMode::Random => flags.random = true, + SortMode::Default => {} + } + flags + } + + fn to_mode(self) -> SortMode { + if self.numeric { + SortMode::Numeric + } else if self.general_numeric { + SortMode::GeneralNumeric + } else if self.human_numeric { + SortMode::HumanNumeric + } else if self.month { + SortMode::Month + } else if self.random { + SortMode::Random + } else if self.version { + SortMode::Version + } else { + SortMode::Default + } + } +} + +fn ordering_opts_string( + flags: ModeFlags, + dictionary_order: bool, + ignore_non_printing: bool, + ignore_case: bool, +) -> String { + let mut opts = String::new(); + if dictionary_order { + opts.push('d'); + } + if ignore_case { + opts.push('f'); + } + if flags.general_numeric { + opts.push('g'); + } + if flags.human_numeric { + opts.push('h'); + } + if !dictionary_order && ignore_non_printing { + opts.push('i'); + } + if flags.month { + opts.push('M'); + } + if flags.numeric { + opts.push('n'); + } + if flags.random { + opts.push('R'); + } + if flags.version { + opts.push('V'); + } + opts +} + +fn ordering_incompatible( + flags: ModeFlags, + dictionary_order: bool, + ignore_non_printing: bool, +) -> bool { + let mut count = 0; + if flags.numeric { + count += 1; + } + if flags.general_numeric { + count += 1; + } + if flags.human_numeric { + count += 1; + } + if flags.month { + count += 1; + } + if flags.version || flags.random || dictionary_order || ignore_non_printing { + count += 1; + } + count > 1 +} + +fn incompatible_options_error(opts: &str) -> Box { + USimpleError::new( + 2, + translate!( + "sort-options-incompatible", + "opt1" => opts, + "opt2" => "" + ), + ) +} enum Selection<'a> { AsBigDecimal(GeneralBigDecimalParseResult), WithNumInfo(&'a [u8], NumInfo), @@ -774,42 +818,6 @@ struct KeyPosition { ignore_blanks: bool, } -impl KeyPosition { - fn new(key: &str, default_char_index: usize, ignore_blanks: bool) -> Result { - let mut field_and_char = key.split('.'); - - let field = field_and_char - .next() - .ok_or_else(|| translate!("sort-invalid-key", "key" => key.quote()))?; - let char = field_and_char.next(); - - let field = match field.parse::() { - Ok(f) => f, - Err(e) if *e.kind() == IntErrorKind::PosOverflow => usize::MAX, - Err(e) => { - return Err( - translate!("sort-failed-parse-field-index", "field" => field.quote(), "error" => e), - ); - } - }; - if field == 0 { - return Err(translate!("sort-field-index-cannot-be-zero")); - } - - let char = char.map_or(Ok(default_char_index), |char| { - char.parse().map_err(|e: std::num::ParseIntError| { - translate!("sort-failed-parse-char-index", "char" => char.quote(), "error" => e) - }) - })?; - - Ok(Self { - field, - char, - ignore_blanks, - }) - } -} - impl Default for KeyPosition { fn default() -> Self { Self { @@ -820,6 +828,88 @@ impl Default for KeyPosition { } } +fn bad_field_spec(spec: &str, msg_key: &str) -> Box { + USimpleError::new( + 2, + translate!( + "sort-invalid-field-spec", + "msg" => translate!(msg_key), + "spec" => spec.quote() + ), + ) +} + +fn invalid_count_error(msg_key: &str, input: &str) -> Box { + USimpleError::new( + 2, + format!( + "{}: {}", + translate!(msg_key), + translate!("sort-invalid-count-at-start-of", "string" => input.quote()) + ), + ) +} + +fn parse_field_count<'a>(input: &'a str, msg_key: &str) -> UResult<(usize, &'a str)> { + let bytes = input.as_bytes(); + let mut idx = 0; + while idx < bytes.len() && bytes[idx].is_ascii_digit() { + idx += 1; + } + if idx == 0 { + return Err(invalid_count_error(msg_key, input)); + } + let (num_str, rest) = input.split_at(idx); + let value = match num_str.parse::() { + Ok(v) => v, + Err(e) if *e.kind() == IntErrorKind::PosOverflow => usize::MAX, + Err(_) => return Err(invalid_count_error(msg_key, input)), + }; + Ok((value, rest)) +} + +fn is_ordering_option_char(byte: u8) -> bool { + matches!( + byte, + b'b' | b'd' | b'f' | b'g' | b'h' | b'i' | b'M' | b'n' | b'R' | b'r' | b'V' + ) +} + +fn parse_ordering_options<'a>( + input: &'a str, + settings: &mut KeySettings, + flags: &mut ModeFlags, +) -> (&'a str, bool) { + let mut ignore_blanks = false; + let bytes = input.as_bytes(); + let mut idx = 0; + while idx < bytes.len() { + match bytes[idx] { + b'b' => ignore_blanks = true, + b'd' => { + settings.dictionary_order = true; + settings.ignore_non_printing = false; + } + b'f' => settings.ignore_case = true, + b'g' => flags.general_numeric = true, + b'h' => flags.human_numeric = true, + b'i' => { + if !settings.dictionary_order { + settings.ignore_non_printing = true; + } + } + b'M' => flags.month = true, + b'n' => flags.numeric = true, + b'R' => flags.random = true, + b'r' => settings.reverse = true, + b'V' => flags.version = true, + _ => break, + } + idx += 1; + } + (&input[idx..], ignore_blanks) +} + #[derive(Clone, PartialEq, Debug, Default)] struct FieldSelector { from: KeyPosition, @@ -833,91 +923,106 @@ struct FieldSelector { } impl FieldSelector { - /// Splits this position into the actual position and the attached options. - fn split_key_options(position: &str) -> (&str, &str) { - if let Some((options_start, _)) = position.char_indices().find(|(_, c)| c.is_alphabetic()) { - position.split_at(options_start) - } else { - (position, "") - } - } - fn parse(key: &str, global_settings: &GlobalSettings) -> UResult { - let mut from_to = key.split(','); - let (from, from_options) = Self::split_key_options(from_to.next().unwrap()); - let to = from_to.next().map(Self::split_key_options); - let options_are_empty = from_options.is_empty() && matches!(to, None | Some((_, ""))); - - if options_are_empty { - // Inherit the global settings if there are no options attached to this key. - (|| { - // This would be ideal for a try block, I think. In the meantime this closure allows - // to use the `?` operator here. - Self::new( - KeyPosition::new(from, 1, global_settings.ignore_leading_blanks)?, - to.map(|(to, _)| { - KeyPosition::new(to, 0, global_settings.ignore_leading_blanks) - }) - .transpose()?, - KeySettings::from(global_settings), - ) - })() + let has_options = key.as_bytes().iter().copied().any(is_ordering_option_char); + let mut settings = if has_options { + KeySettings::default() } else { - // Do not inherit from `global_settings`, as there are options attached to this key. - Self::parse_with_options((from, from_options), to) - } - .map_err(|msg| { - SortError::ParseKeyError { - key: key.to_owned(), - msg, - } - .into() - }) - } - - fn parse_with_options( - (from, from_options): (&str, &str), - to: Option<(&str, &str)>, - ) -> Result { - /// Applies `options` to `key_settings`, returning if the 'b'-flag (ignore blanks) was present. - fn parse_key_settings( - options: &str, - key_settings: &mut KeySettings, - ) -> Result { - let mut ignore_blanks = false; - for option in options.chars() { - match option { - 'M' => key_settings.set_sort_mode(SortMode::Month)?, - 'b' => ignore_blanks = true, - 'd' => key_settings.set_dictionary_order()?, - 'f' => key_settings.ignore_case = true, - 'g' => key_settings.set_sort_mode(SortMode::GeneralNumeric)?, - 'h' => key_settings.set_sort_mode(SortMode::HumanNumeric)?, - 'i' => key_settings.set_ignore_non_printing()?, - 'n' => key_settings.set_sort_mode(SortMode::Numeric)?, - 'R' => key_settings.set_sort_mode(SortMode::Random)?, - 'r' => key_settings.reverse = true, - 'V' => key_settings.set_sort_mode(SortMode::Version)?, - c => { - return Err(translate!("sort-invalid-option", "option" => c)); - } - } - } - Ok(ignore_blanks) - } - - let mut key_settings = KeySettings::default(); - let from = parse_key_settings(from_options, &mut key_settings) - .map(|ignore_blanks| KeyPosition::new(from, 1, ignore_blanks))??; - let to = if let Some((to, to_options)) = to { - Some( - parse_key_settings(to_options, &mut key_settings) - .map(|ignore_blanks| KeyPosition::new(to, 0, ignore_blanks))??, - ) - } else { - None + KeySettings::from(global_settings) }; - Self::new(from, to, key_settings) + let mut flags = if has_options { + ModeFlags::default() + } else { + ModeFlags::from_mode(settings.mode) + }; + + let mut from_ignore_blanks = if has_options { + false + } else { + settings.ignore_blanks + }; + let mut to_ignore_blanks = if has_options { + false + } else { + settings.ignore_blanks + }; + + let (from_field, mut rest) = parse_field_count(key, "sort-invalid-number-at-field-start")?; + if from_field == 0 { + return Err(bad_field_spec(key, "sort-field-number-is-zero")); + } + + let mut from_char = 1; + if let Some(stripped) = rest.strip_prefix('.') { + let (char_idx, rest_after) = + parse_field_count(stripped, "sort-invalid-number-after-dot")?; + if char_idx == 0 { + return Err(bad_field_spec(key, "sort-character-offset-is-zero")); + } + from_char = char_idx; + rest = rest_after; + } + + let (rest_after_opts, ignore_blanks) = + parse_ordering_options(rest, &mut settings, &mut flags); + if ignore_blanks { + from_ignore_blanks = true; + } + + let mut to = None; + if let Some(rest_after_comma) = rest_after_opts.strip_prefix(',') { + let (to_field, mut rest) = + parse_field_count(rest_after_comma, "sort-invalid-number-after-comma")?; + if to_field == 0 { + return Err(bad_field_spec(key, "sort-field-number-is-zero")); + } + + let mut to_char = 0; + if let Some(stripped) = rest.strip_prefix('.') { + let (char_idx, rest_after) = + parse_field_count(stripped, "sort-invalid-number-after-dot")?; + to_char = char_idx; + rest = rest_after; + } + + let (rest, ignore_blanks_end) = parse_ordering_options(rest, &mut settings, &mut flags); + if ignore_blanks_end { + to_ignore_blanks = true; + } + if !rest.is_empty() { + return Err(bad_field_spec(key, "sort-stray-character-field-spec")); + } + to = Some(KeyPosition { + field: to_field, + char: to_char, + ignore_blanks: to_ignore_blanks, + }); + } else if !rest_after_opts.is_empty() { + return Err(bad_field_spec(key, "sort-stray-character-field-spec")); + } + + if ordering_incompatible( + flags, + settings.dictionary_order, + settings.ignore_non_printing, + ) { + let opts = ordering_opts_string( + flags, + settings.dictionary_order, + settings.ignore_non_printing, + settings.ignore_case, + ); + return Err(incompatible_options_error(&opts)); + } + + settings.mode = flags.to_mode(); + + let from = KeyPosition { + field: from_field, + char: from_char, + ignore_blanks: from_ignore_blanks, + }; + Self::new(from, to, settings).map_err(|msg| USimpleError::new(2, msg)) } fn new( @@ -1702,49 +1807,59 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { .unwrap_or_default() }; - settings.mode = if matches.get_flag(options::modes::HUMAN_NUMERIC) - || matches - .get_one::(options::modes::SORT) - .is_some_and(|s| s == "human-numeric") - { - SortMode::HumanNumeric - } else if matches.get_flag(options::modes::MONTH) - || matches - .get_one::(options::modes::SORT) - .is_some_and(|s| s == "month") - { - SortMode::Month - } else if matches.get_flag(options::modes::GENERAL_NUMERIC) - || matches - .get_one::(options::modes::SORT) - .is_some_and(|s| s == "general-numeric") - { - SortMode::GeneralNumeric - } else if matches.get_flag(options::modes::NUMERIC) - || matches - .get_one::(options::modes::SORT) - .is_some_and(|s| s == "numeric") - { - SortMode::Numeric - } else if matches.get_flag(options::modes::VERSION) - || matches - .get_one::(options::modes::SORT) - .is_some_and(|s| s == "version") - { - SortMode::Version - } else if matches.get_flag(options::modes::RANDOM) - || matches - .get_one::(options::modes::SORT) - .is_some_and(|s| s == "random") - { - settings.salt = Some(get_rand_string()); - SortMode::Random - } else { - SortMode::Default - }; + let mut mode_flags = ModeFlags::default(); + if matches.get_flag(options::modes::HUMAN_NUMERIC) { + mode_flags.human_numeric = true; + } + if matches.get_flag(options::modes::MONTH) { + mode_flags.month = true; + } + if matches.get_flag(options::modes::GENERAL_NUMERIC) { + mode_flags.general_numeric = true; + } + if matches.get_flag(options::modes::NUMERIC) { + mode_flags.numeric = true; + } + if matches.get_flag(options::modes::VERSION) { + mode_flags.version = true; + } + if matches.get_flag(options::modes::RANDOM) { + mode_flags.random = true; + } + if let Some(sort_arg) = matches.get_one::(options::modes::SORT) { + match sort_arg.as_str() { + "human-numeric" => mode_flags.human_numeric = true, + "month" => mode_flags.month = true, + "general-numeric" => mode_flags.general_numeric = true, + "numeric" => mode_flags.numeric = true, + "version" => mode_flags.version = true, + "random" => mode_flags.random = true, + _ => {} + } + } - settings.dictionary_order = matches.get_flag(options::DICTIONARY_ORDER); - settings.ignore_non_printing = matches.get_flag(options::IGNORE_NONPRINTING); + let dictionary_order = matches.get_flag(options::DICTIONARY_ORDER); + let ignore_non_printing = matches.get_flag(options::IGNORE_NONPRINTING); + let ignore_case = matches.get_flag(options::IGNORE_CASE); + + if ordering_incompatible(mode_flags, dictionary_order, ignore_non_printing) { + let opts = ordering_opts_string( + mode_flags, + dictionary_order, + ignore_non_printing, + ignore_case, + ); + return Err(incompatible_options_error(&opts)); + } + + settings.mode = mode_flags.to_mode(); + if mode_flags.random { + settings.salt = Some(get_rand_string()); + } + + settings.dictionary_order = dictionary_order; + settings.ignore_non_printing = ignore_non_printing; + settings.ignore_case = ignore_case; if matches.contains_id(options::PARALLEL) { // "0" is default - threads = num of cores settings.threads = matches @@ -1840,6 +1955,9 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { settings.merge = matches.get_flag(options::MERGE); settings.check = matches.contains_id(options::check::CHECK); + if settings.check && matches.get_flag(options::check::CHECK_SILENT) { + return Err(incompatible_options_error("cC")); + } if matches.get_flag(options::check::CHECK_SILENT) || matches!( matches @@ -1852,7 +1970,10 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { settings.check = true; } - settings.ignore_case = matches.get_flag(options::IGNORE_CASE); + if matches.contains_id(options::OUTPUT) && settings.check { + let opts = if settings.check_silent { "Co" } else { "co" }; + return Err(incompatible_options_error(opts)); + } settings.ignore_leading_blanks = matches.get_flag(options::IGNORE_LEADING_BLANKS); @@ -1977,8 +2098,7 @@ pub fn uu_app() -> Command { "numeric", "version", "random", - ])) - .conflicts_with_all(options::modes::ALL_SORT_MODES), + ])), ) .arg(make_sort_mode_arg( options::modes::HUMAN_NUMERIC, @@ -2015,12 +2135,6 @@ pub fn uu_app() -> Command { .short('d') .long(options::DICTIONARY_ORDER) .help(translate!("sort-help-dictionary-order")) - .conflicts_with_all([ - options::modes::NUMERIC, - options::modes::GENERAL_NUMERIC, - options::modes::HUMAN_NUMERIC, - options::modes::MONTH, - ]) .action(ArgAction::SetTrue), ) .arg( @@ -2041,14 +2155,12 @@ pub fn uu_app() -> Command { options::check::QUIET, options::check::DIAGNOSE_FIRST, ])) - .conflicts_with_all([options::OUTPUT, options::check::CHECK_SILENT]) .help(translate!("sort-help-check")), ) .arg( Arg::new(options::check::CHECK_SILENT) .short('C') .long(options::check::CHECK_SILENT) - .conflicts_with_all([options::OUTPUT, options::check::CHECK]) .help(translate!("sort-help-check-silent")) .action(ArgAction::SetTrue), ) @@ -2064,12 +2176,6 @@ pub fn uu_app() -> Command { .short('i') .long(options::IGNORE_NONPRINTING) .help(translate!("sort-help-ignore-nonprinting")) - .conflicts_with_all([ - options::modes::NUMERIC, - options::modes::GENERAL_NUMERIC, - options::modes::HUMAN_NUMERIC, - options::modes::MONTH, - ]) .action(ArgAction::SetTrue), ) .arg( diff --git a/tests/by-util/test_sort.rs b/tests/by-util/test_sort.rs index 6330f759d..0ff93996d 100644 --- a/tests/by-util/test_sort.rs +++ b/tests/by-util/test_sort.rs @@ -3,7 +3,7 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore (words) ints (linux) NOFILE +// spell-checker:ignore (words) ints (linux) NOFILE dfgi #![allow(clippy::cast_possible_wrap)] use std::env; @@ -620,7 +620,7 @@ fn test_keys_invalid_field() { new_ucmd!() .args(&["-k", "1."]) .fails() - .stderr_only("sort: failed to parse key '1.': failed to parse character index '': cannot parse integer from empty string\n"); + .stderr_only("sort: invalid number after '.': invalid count at start of ''\n"); } #[test] @@ -628,7 +628,7 @@ fn test_keys_invalid_field_option() { new_ucmd!() .args(&["-k", "1.1x"]) .fails() - .stderr_only("sort: failed to parse key '1.1x': invalid option: 'x'\n"); + .stderr_only("sort: stray character in field spec: invalid field specification '1.1x'\n"); } #[test] @@ -636,7 +636,7 @@ fn test_keys_invalid_field_zero() { new_ucmd!() .args(&["-k", "0.1"]) .fails() - .stderr_only("sort: failed to parse key '0.1': field index can not be 0\n"); + .stderr_only("sort: field number is zero: invalid field specification '0.1'\n"); } #[test] @@ -644,7 +644,73 @@ fn test_keys_invalid_char_zero() { new_ucmd!() .args(&["-k", "1.0"]) .fails() - .stderr_only("sort: failed to parse key '1.0': invalid character index 0 for the start position of a field\n"); + .stderr_only("sort: character offset is zero: invalid field specification '1.0'\n"); +} + +#[test] +fn test_keys_invalid_number_formats() { + new_ucmd!() + .args(&["-k", "0"]) + .fails_with_code(2) + .stderr_only("sort: field number is zero: invalid field specification '0'\n"); + + new_ucmd!() + .args(&["-k", "2.,3"]) + .fails_with_code(2) + .stderr_only("sort: invalid number after '.': invalid count at start of ',3'\n"); + + new_ucmd!() + .args(&["-k", "2,"]) + .fails_with_code(2) + .stderr_only("sort: invalid number after ',': invalid count at start of ''\n"); + + new_ucmd!() + .args(&["-k", "1.1,-k0"]) + .fails_with_code(2) + .stderr_only("sort: invalid number after ',': invalid count at start of '-k0'\n"); +} + +#[test] +fn test_incompatible_options() { + new_ucmd!() + .arg("-hn") + .fails_with_code(2) + .stderr_only("sort: options '-hn' are incompatible\n"); + + new_ucmd!() + .arg("-in") + .fails_with_code(2) + .stderr_only("sort: options '-in' are incompatible\n"); + + new_ucmd!() + .arg("-nR") + .fails_with_code(2) + .stderr_only("sort: options '-nR' are incompatible\n"); + + new_ucmd!() + .arg("-dfgiMnR") + .fails_with_code(2) + .stderr_only("sort: options '-dfgMnR' are incompatible\n"); + + new_ucmd!() + .args(&["--sort=random", "-n"]) + .fails_with_code(2) + .stderr_only("sort: options '-nR' are incompatible\n"); + + new_ucmd!() + .args(&["-c", "-o", "out"]) + .fails_with_code(2) + .stderr_only("sort: options '-co' are incompatible\n"); + + new_ucmd!() + .args(&["-C", "-o", "out"]) + .fails_with_code(2) + .stderr_only("sort: options '-Co' are incompatible\n"); + + new_ucmd!() + .args(&["-c", "-C"]) + .fails_with_code(2) + .stderr_only("sort: options '-cC' are incompatible\n"); } #[test] @@ -1154,16 +1220,22 @@ fn test_sigpipe_panic() { #[test] fn test_conflict_check_out() { - let check_flags = ["-c=silent", "-c=quiet", "-c=diagnose-first", "-c", "-C"]; - for check_flag in &check_flags { + let cases = [ + ("-c=silent", "sort: options '-Co' are incompatible\n"), + ("-c=quiet", "sort: options '-Co' are incompatible\n"), + ( + "-c=diagnose-first", + "sort: options '-co' are incompatible\n", + ), + ("-c", "sort: options '-co' are incompatible\n"), + ("-C", "sort: options '-Co' are incompatible\n"), + ]; + for (check_flag, expected) in &cases { new_ucmd!() .arg(check_flag) .arg("-o=/dev/null") .fails() - .stderr_contains( - // the rest of the message might be subject to change - "error: the argument", - ); + .stderr_contains(expected); } } @@ -1204,7 +1276,7 @@ fn test_verifies_files_after_keys() { "nonexistent_dir/input_file", ]) .fails_with_code(2) - .stderr_contains("failed to parse key"); + .stderr_contains("invalid field specification '0'"); } #[test] @@ -1735,8 +1807,14 @@ fn test_clap_localization_missing_required_argument() { #[test] fn test_clap_localization_invalid_value() { let test_cases = vec![ - ("en_US.UTF-8", "sort: failed to parse key 'invalid'"), - ("fr_FR.UTF-8", "sort: échec d'analyse de la clé 'invalid'"), + ( + "en_US.UTF-8", + "sort: invalid number at field start: invalid count at start of 'invalid'", + ), + ( + "fr_FR.UTF-8", + "sort: nombre invalide au début du champ: nombre invalide au début de 'invalid'", + ), ]; for (locale, expected_message) in test_cases { From db1d57c18b1a73630cef9d4939f3d951b689b294 Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Tue, 13 Jan 2026 22:45:29 +0100 Subject: [PATCH 202/425] sort: remove empty impl block for KeySettings --- src/uu/sort/src/sort.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/uu/sort/src/sort.rs b/src/uu/sort/src/sort.rs index 670103c7b..18ceb546d 100644 --- a/src/uu/sort/src/sort.rs +++ b/src/uu/sort/src/sort.rs @@ -406,8 +406,6 @@ struct KeySettings { reverse: bool, } -impl KeySettings {} - impl From<&GlobalSettings> for KeySettings { fn from(settings: &GlobalSettings) -> Self { Self { From 6ed0f08cd2cd3129bfc44dfe11800439b628f39b Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Tue, 13 Jan 2026 22:46:08 +0100 Subject: [PATCH 203/425] sort: refactor ordering_incompatible to use early returns Simplify the function by computing mode_count directly and using early returns instead of accumulating a counter with nested if statements. --- src/uu/sort/src/sort.rs | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/src/uu/sort/src/sort.rs b/src/uu/sort/src/sort.rs index 18ceb546d..53c50f187 100644 --- a/src/uu/sort/src/sort.rs +++ b/src/uu/sort/src/sort.rs @@ -511,23 +511,22 @@ fn ordering_incompatible( dictionary_order: bool, ignore_non_printing: bool, ) -> bool { - let mut count = 0; - if flags.numeric { - count += 1; + let mode_count = u8::from(flags.numeric) + + u8::from(flags.general_numeric) + + u8::from(flags.human_numeric) + + u8::from(flags.month); + + // Multiple numeric/month modes are incompatible + if mode_count > 1 { + return true; } - if flags.general_numeric { - count += 1; + + // A numeric/month mode combined with version/random/dictionary/ignore_non_printing is incompatible + if mode_count == 1 { + return flags.version || flags.random || dictionary_order || ignore_non_printing; } - if flags.human_numeric { - count += 1; - } - if flags.month { - count += 1; - } - if flags.version || flags.random || dictionary_order || ignore_non_printing { - count += 1; - } - count > 1 + + false } fn incompatible_options_error(opts: &str) -> Box { From d806231866a38b484df7bacf6660369ab9393163 Mon Sep 17 00:00:00 2001 From: Chris Dryden Date: Wed, 14 Jan 2026 00:07:17 -0500 Subject: [PATCH 204/425] tail: add --debug flag to show follow implementation mode (#10105) * tail: add --debug flag to show follow implementation mode * tail: add tests for --debug flag output * fix formatting --- src/uu/tail/locales/en-US.ftl | 5 +++++ src/uu/tail/src/args.rs | 10 ++++++++++ src/uu/tail/src/tail.rs | 10 ++++++++++ tests/by-util/test_tail.rs | 36 +++++++++++++++++++++++++++++++++++ 4 files changed, 61 insertions(+) diff --git a/src/uu/tail/locales/en-US.ftl b/src/uu/tail/locales/en-US.ftl index 6f7383aa4..6d434ae98 100644 --- a/src/uu/tail/locales/en-US.ftl +++ b/src/uu/tail/locales/en-US.ftl @@ -6,6 +6,7 @@ tail-usage = tail [FLAG]... [FILE]... # Help messages tail-help-bytes = Number of bytes to print +tail-help-debug = indicate which --follow implementation is used tail-help-follow = Print the file as it grows tail-help-lines = Number of lines to print tail-help-pid = With -f, terminate after process ID, PID dies @@ -70,3 +71,7 @@ tail-giving-up-on-this-name = ; giving up on this name tail-stdin-header = standard input tail-no-files-remaining = no files remaining tail-become-inaccessible = has become inaccessible + +# Debug messages +tail-debug-using-notification-mode = using notification mode +tail-debug-using-polling-mode = using polling mode diff --git a/src/uu/tail/src/args.rs b/src/uu/tail/src/args.rs index d5159ab7a..63bd2b0da 100644 --- a/src/uu/tail/src/args.rs +++ b/src/uu/tail/src/args.rs @@ -38,6 +38,7 @@ pub mod options { pub const MAX_UNCHANGED_STATS: &str = "max-unchanged-stats"; pub const ARG_FILES: &str = "files"; pub const PRESUME_INPUT_PIPE: &str = "-presume-input-pipe"; // NOTE: three hyphens is correct + pub const DEBUG: &str = "debug"; } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -139,6 +140,7 @@ pub struct Settings { pub use_polling: bool, pub verbose: bool, pub presume_input_pipe: bool, + pub debug: bool, /// `FILE(s)` positional arguments pub inputs: Vec, } @@ -155,6 +157,7 @@ impl Default for Settings { use_polling: Default::default(), verbose: Default::default(), presume_input_pipe: Default::default(), + debug: Default::default(), inputs: Vec::default(), } } @@ -223,6 +226,7 @@ impl Settings { mode: FilterMode::from(matches)?, verbose: matches.get_flag(options::verbosity::VERBOSE), presume_input_pipe: matches.get_flag(options::PRESUME_INPUT_PIPE), + debug: matches.get_flag(options::DEBUG), ..Default::default() }; @@ -543,6 +547,12 @@ pub fn uu_app() -> Command { .overrides_with(options::FOLLOW_RETRY) .action(ArgAction::SetTrue), ) + .arg( + Arg::new(options::DEBUG) + .long(options::DEBUG) + .help(translate!("tail-help-debug")) + .action(ArgAction::SetTrue), + ) .arg( Arg::new(options::PRESUME_INPUT_PIPE) .long("presume-input-pipe") diff --git a/src/uu/tail/src/tail.rs b/src/uu/tail/src/tail.rs index 3a16f670b..8782db36f 100644 --- a/src/uu/tail/src/tail.rs +++ b/src/uu/tail/src/tail.rs @@ -72,6 +72,16 @@ fn uu_tail(settings: &Settings) -> UResult<()> { let mut observer = Observer::from(settings); observer.start(settings)?; + + // Print debug info about the follow implementation being used + if settings.debug && settings.follow.is_some() { + if observer.use_polling { + show_error!("{}", translate!("tail-debug-using-polling-mode")); + } else { + show_error!("{}", translate!("tail-debug-using-notification-mode")); + } + } + // Do an initial tail print of each path's content. // Add `path` and `reader` to `files` map if `--follow` is selected. for input in &settings.inputs.clone() { diff --git a/tests/by-util/test_tail.rs b/tests/by-util/test_tail.rs index 3dd0ee0d2..390633704 100644 --- a/tests/by-util/test_tail.rs +++ b/tests/by-util/test_tail.rs @@ -5065,3 +5065,39 @@ fn test_follow_stdout_pipe_close() { child.close_stdout(); child.delay(2000).make_assertion().is_not_alive(); } + +#[test] +fn test_debug_flag_with_polling() { + let ts = TestScenario::new(util_name!()); + let at = &ts.fixtures; + at.touch("f"); + + let mut child = ts + .ucmd() + .args(&["--debug", "-f", "--use-polling", "f"]) + .run_no_wait(); + + child.make_assertion_with_delay(500).is_alive(); + child + .kill() + .make_assertion() + .with_all_output() + .stderr_contains("tail: using polling mode"); +} + +#[test] +#[cfg(target_os = "linux")] +fn test_debug_flag_with_inotify() { + let ts = TestScenario::new(util_name!()); + let at = &ts.fixtures; + at.touch("f"); + + let mut child = ts.ucmd().args(&["--debug", "-f", "f"]).run_no_wait(); + + child.make_assertion_with_delay(500).is_alive(); + child + .kill() + .make_assertion() + .with_all_output() + .stderr_contains("tail: using notification mode"); +} From 6b49ff906139a20775ae2fdf1064715c35a3e3e9 Mon Sep 17 00:00:00 2001 From: mattsu <35655889+mattsu2020@users.noreply.github.com> Date: Wed, 14 Jan 2026 14:11:55 +0900 Subject: [PATCH 205/425] sort: gnu coreutils compatibility (sort float.sh) (#9839) * feat(sort): support international decimal separators in numeric sorting M --- fuzz/Cargo.lock | 34 ++++++++++++++++++++++++++++++++++ src/uu/sort/Cargo.toml | 7 ++++++- src/uu/sort/src/sort.rs | 34 +++++++++++++++++++++++++++------- tests/by-util/test_sort.rs | 26 ++++++++++++++++++++++++++ 4 files changed, 93 insertions(+), 8 deletions(-) diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index fd3fdab4d..df362fd02 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -502,6 +502,17 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "645cbb3a84e60b7531617d5ae4e57f7e27308f6445f5abf653209ea76dec8dff" +[[package]] +name = "fixed_decimal" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35eabf480f94d69182677e37571d3be065822acfafd12f2f085db44fbbcc8e57" +dependencies = [ + "displaydoc", + "smallvec", + "writeable", +] + [[package]] name = "flate2" version = "1.1.5" @@ -676,6 +687,27 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icu_decimal" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a38c52231bc348f9b982c1868a2af3195199623007ba2c7650f432038f5b3e8e" +dependencies = [ + "fixed_decimal", + "icu_decimal_data", + "icu_locale", + "icu_locale_core", + "icu_provider", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_decimal_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2905b4044eab2dd848fe84199f9195567b63ab3a93094711501363f63546fef7" + [[package]] name = "icu_locale" version = "2.1.1" @@ -1731,7 +1763,9 @@ dependencies = [ "glob", "hex", "icu_collator", + "icu_decimal", "icu_locale", + "icu_provider", "itertools", "libc", "md-5", diff --git a/src/uu/sort/Cargo.toml b/src/uu/sort/Cargo.toml index 8a9570eaa..476375516 100644 --- a/src/uu/sort/Cargo.toml +++ b/src/uu/sort/Cargo.toml @@ -34,7 +34,12 @@ self_cell = { workspace = true } tempfile = { workspace = true } thiserror = { workspace = true } unicode-width = { workspace = true } -uucore = { workspace = true, features = ["fs", "parser-size", "version-cmp"] } +uucore = { workspace = true, features = [ + "fs", + "parser-size", + "version-cmp", + "i18n-decimal", +] } fluent = { workspace = true } [target.'cfg(unix)'.dependencies] diff --git a/src/uu/sort/src/sort.rs b/src/uu/sort/src/sort.rs index 670103c7b..739bc1651 100644 --- a/src/uu/sort/src/sort.rs +++ b/src/uu/sort/src/sort.rs @@ -47,6 +47,7 @@ use uucore::error::{FromIo, strip_errno}; use uucore::error::{UError, UResult, USimpleError, UUsageError}; use uucore::extendedbigdecimal::ExtendedBigDecimal; use uucore::format_usage; +use uucore::i18n::decimal::locale_decimal_separator; use uucore::line_ending::LineEnding; use uucore::parser::num_parser::{ExtendedParser, ExtendedParserError}; use uucore::parser::parse_size::{ParseSizeError, Parser}; @@ -106,6 +107,14 @@ mod options { const DECIMAL_PT: u8 = b'.'; +fn locale_decimal_pt() -> u8 { + match locale_decimal_separator().as_bytes().first().copied() { + Some(b'.') => b'.', + Some(b',') => b',', + _ => DECIMAL_PT, + } +} + const NEGATIVE: &u8 = &b'-'; const POSITIVE: &u8 = &b'+'; @@ -683,8 +692,8 @@ impl<'a> Line<'a> { } SortMode::GeneralNumeric => { let initial_selection = &self.line[selection.clone()]; - - let leading = get_leading_gen(initial_selection); + let decimal_pt = locale_decimal_pt(); + let leading = get_leading_gen(initial_selection, decimal_pt); // Shorten selection to leading. selection.start += leading.start; @@ -1072,7 +1081,11 @@ impl FieldSelector { Selection::WithNumInfo(range_str, info) } else if self.settings.mode == SortMode::GeneralNumeric { // Parse this number as BigDecimal, as this is the requirement for general numeric sorting. - Selection::AsBigDecimal(general_bd_parse(&range_str[get_leading_gen(range_str)])) + let decimal_pt = locale_decimal_pt(); + Selection::AsBigDecimal(general_bd_parse( + &range_str[get_leading_gen(range_str, decimal_pt)], + decimal_pt, + )) } else { // This is not a numeric sort, so we don't need a NumCache. Selection::Str(range_str) @@ -2491,7 +2504,7 @@ fn ascii_case_insensitive_cmp(a: &[u8], b: &[u8]) -> Ordering { // scientific notation, so we strip those lines only after the end of the following numeric string. // For example, 5e10KFD would be 5e10 or 5x10^10 and +10000HFKJFK would become 10000. #[allow(clippy::cognitive_complexity)] -fn get_leading_gen(inp: &[u8]) -> Range { +fn get_leading_gen(inp: &[u8], decimal_pt: u8) -> Range { let trimmed = inp.trim_ascii_start(); let leading_whitespace_len = inp.len() - trimmed.len(); @@ -2529,7 +2542,7 @@ fn get_leading_gen(inp: &[u8]) -> Range { continue; } - if c == DECIMAL_PT && !had_decimal_pt && !had_e_notation { + if c == decimal_pt && !had_decimal_pt && !had_e_notation { had_decimal_pt = true; continue; } @@ -2572,9 +2585,16 @@ pub enum GeneralBigDecimalParseResult { /// Parse the beginning string into a [`GeneralBigDecimalParseResult`]. /// Using a [`GeneralBigDecimalParseResult`] instead of [`ExtendedBigDecimal`] is necessary to correctly order floats. #[inline(always)] -fn general_bd_parse(a: &[u8]) -> GeneralBigDecimalParseResult { +fn general_bd_parse(a: &[u8], decimal_pt: u8) -> GeneralBigDecimalParseResult { + let parsed_bytes = (decimal_pt != DECIMAL_PT).then(|| { + a.iter() + .map(|&b| if b == decimal_pt { DECIMAL_PT } else { b }) + .collect::>() + }); + let input = parsed_bytes.as_deref().unwrap_or(a); + // The string should be valid ASCII to be parsed. - let Ok(a) = std::str::from_utf8(a) else { + let Ok(a) = std::str::from_utf8(input) else { return GeneralBigDecimalParseResult::Invalid; }; diff --git a/tests/by-util/test_sort.rs b/tests/by-util/test_sort.rs index 0ff93996d..b478912dd 100644 --- a/tests/by-util/test_sort.rs +++ b/tests/by-util/test_sort.rs @@ -1631,6 +1631,32 @@ fn test_g_float() { .stdout_is(output); } +#[test] +fn test_g_float_locale_decimal_separator() { + let Ok(locale_fr_utf8) = env::var("LOCALE_FR_UTF8") else { + return; + }; + if locale_fr_utf8 == "none" { + return; + } + + let ts = TestScenario::new("sort"); + + ts.ucmd() + .env("LC_ALL", &locale_fr_utf8) + .args(&["-g", "--stable"]) + .pipe_in("1,9\n1,10\n") + .succeeds() + .stdout_is("1,10\n1,9\n"); + + ts.ucmd() + .env("LC_ALL", &locale_fr_utf8) + .args(&["-g", "--stable"]) + .pipe_in("1.9\n1.10\n") + .succeeds() + .stdout_is("1.10\n1.9\n"); +} + #[test] // Test misc numbers ("'a" is not interpreted as literal, trailing text is ignored...) fn test_g_misc() { From fd9157a3385464d09ef51d1ad32285b3cbfae9e4 Mon Sep 17 00:00:00 2001 From: kimono-koans <32370782+kimono-koans@users.noreply.github.com> Date: Wed, 14 Jan 2026 05:00:12 -0600 Subject: [PATCH 206/425] ls: Proper alignment for capabilities and ACLs notifications (#8793) Fixes: https://github.com/uutils/coreutils/issues/8792 --- src/uu/ls/src/ls.rs | 17 ++++++++++++++++- tests/by-util/test_ls.rs | 23 ++++++++++++++++++++--- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/src/uu/ls/src/ls.rs b/src/uu/ls/src/ls.rs index 84016a1af..3e98c1116 100644 --- a/src/uu/ls/src/ls.rs +++ b/src/uu/ls/src/ls.rs @@ -2968,8 +2968,10 @@ fn display_item_long( output_display.extend(b"."); } else if is_acl_set { output_display.extend(b"+"); + } else { + output_display.extend(b" "); } - output_display.extend(b" "); + output_display.extend_pad_left(&display_symlink_count(md), padding.link_count); if config.long.owner { @@ -3626,6 +3628,19 @@ fn calculate_padding_collection( if config.context { padding_collections.context = context_len.max(padding_collections.context); } + + // correctly align columns when some files have capabilities/ACLs and others do not + { + #[cfg(any(not(unix), target_os = "android", target_os = "macos"))] + // TODO: See how Mac should work here + let is_acl_set = false; + #[cfg(all(unix, not(any(target_os = "android", target_os = "macos"))))] + let is_acl_set = has_acl(item.display_name()); + if context_len > 1 || is_acl_set { + padding_collections.link_count += 1; + } + } + if items.len() == 1usize { padding_collections.size = 0usize; padding_collections.major = 0usize; diff --git a/tests/by-util/test_ls.rs b/tests/by-util/test_ls.rs index bc8643d03..571540d11 100644 --- a/tests/by-util/test_ls.rs +++ b/tests/by-util/test_ls.rs @@ -6332,7 +6332,9 @@ fn test_unknown_format_specifier() { fn test_acl_display_symlink() { use std::process::Command; - let (at, mut ucmd) = at_and_ucmd!(); + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + let dir_name = "dir"; let link_name = "link"; at.mkdir(dir_name); @@ -6357,11 +6359,26 @@ fn test_acl_display_symlink() { at.symlink_dir(dir_name, link_name); - let re_with_acl = Regex::new(r"[a-z-]*\+ .*link").unwrap(); - ucmd.arg("-lLd") + let re_with_acl = Regex::new(r"[a-z-]*\+\s\d+\s.*link").unwrap(); + + scene + .ucmd() + .arg("-lLd") .arg(link_name) .succeeds() .stdout_matches(&re_with_acl); + + let test2: uutests::util::CmdResult = scene.ucmd().arg("-l").succeeds(); + + let mut iter = test2 + .stdout() + .split(|b| b == &b'\n') + .skip(1) + .filter_map(|line: &[u8]| line.iter().position(|b: &u8| b.is_ascii_digit())); + + let first = iter.next().unwrap(); + + assert!(iter.all(|i| i == first)); } #[test] From d8a743ceb6a133ccc2d5e46e4dd82ba278a91683 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 14 Jan 2026 13:31:02 +0000 Subject: [PATCH 207/425] chore(deps): update rust crate zip to v7.1.0 --- Cargo.lock | 25 ++----------------------- 1 file changed, 2 insertions(+), 23 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c5637dba8..5cfe22c15 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -106,15 +106,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "arbitrary" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dde20b3d026af13f561bdd0f15edf01fc734f0dafcedbaf42bba506a9517f223" -dependencies = [ - "derive_arbitrary", -] - [[package]] name = "arrayref" version = "0.3.9" @@ -864,17 +855,6 @@ dependencies = [ "powerfmt", ] -[[package]] -name = "derive_arbitrary" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30542c1ad912e0e3d22a1935c290e12e8a29d704a420177a31faad4a601a0800" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "derive_more" version = "2.0.1" @@ -4867,11 +4847,10 @@ dependencies = [ [[package]] name = "zip" -version = "7.0.0" +version = "7.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bdd8a47718a4ee5fe78e07667cd36f3de80e7c2bfe727c7074245ffc7303c037" +checksum = "9013f1222db8a6d680f13a7ccdc60a781199cd09c2fa4eff58e728bb181757fc" dependencies = [ - "arbitrary", "crc32fast", "flate2", "indexmap", From 887a7ba7f04aef378d3ae5e4c5531ee24b7bd408 Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Wed, 14 Jan 2026 09:21:46 +0100 Subject: [PATCH 208/425] benchmarks: Add memory benchmarks --- .github/workflows/benchmarks.yml | 74 +++++++++++++++++--------------- 1 file changed, 40 insertions(+), 34 deletions(-) diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index c7c24890e..ec97e294e 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -18,36 +18,38 @@ concurrency: jobs: benchmarks: - name: Run benchmarks (CodSpeed) + name: Run ${{ matrix.type }} benchmarks for ${{ matrix.package }} (CodSpeed) runs-on: ubuntu-latest strategy: matrix: - benchmark-target: - - { package: uu_base64 } - - { package: uu_cksum } - - { package: uu_cp } - - { package: uu_cut } - - { package: uu_dd } - - { package: uu_df } - - { package: uu_du } - - { package: uu_expand } - - { package: uu_fold } - - { package: uu_join } - - { package: uu_ls } - - { package: uu_mv } - - { package: uu_nl } - - { package: uu_numfmt } - - { package: uu_rm } - - { package: uu_seq } - - { package: uu_shuf } - - { package: uu_sort } - - { package: uu_split } - - { package: uu_tsort } - - { package: uu_unexpand } - - { package: uu_uniq } - - { package: uu_wc } - - { package: uu_factor } - - { package: uu_date } + type: [performance, memory] + package: [ + uu_base64, + uu_cksum, + uu_cp, + uu_cut, + uu_dd, + uu_df, + uu_du, + uu_expand, + uu_fold, + uu_join, + uu_ls, + uu_mv, + uu_nl, + uu_numfmt, + uu_rm, + uu_seq, + uu_shuf, + uu_sort, + uu_split, + uu_tsort, + uu_unexpand, + uu_uniq, + uu_wc, + uu_factor, + uu_date + ] steps: - uses: actions/checkout@v6 with: @@ -64,19 +66,23 @@ jobs: shell: bash run: cargo install cargo-codspeed --locked - - name: Build benchmarks for ${{ matrix.benchmark-target.package }} + - name: Build benchmarks for ${{ matrix.package }} (${{ matrix.type }}) shell: bash run: | - echo "Building benchmarks for ${{ matrix.benchmark-target.package }}" - cargo codspeed build -p ${{ matrix.benchmark-target.package }} + echo "Building ${{ matrix.type }} benchmarks for ${{ matrix.package }}" + if [ "${{ matrix.type }}" = "memory" ]; then + cargo codspeed build -m analysis -p ${{ matrix.package }} + else + cargo codspeed build -p ${{ matrix.package }} + fi - - name: Run benchmarks for ${{ matrix.benchmark-target.package }} + - name: Run ${{ matrix.type }} benchmarks for ${{ matrix.package }} uses: CodSpeedHQ/action@v4 env: CODSPEED_LOG: debug with: - mode: simulation + mode: ${{ matrix.type == 'memory' && 'memory' || 'simulation' }} run: | - echo "Running benchmarks for ${{ matrix.benchmark-target.package }}" - cargo codspeed run -p ${{ matrix.benchmark-target.package }} > /dev/null + echo "Running ${{ matrix.type }} benchmarks for ${{ matrix.package }}" + cargo codspeed run -p ${{ matrix.package }} > /dev/null token: ${{ secrets.CODSPEED_TOKEN }} From f7b5f8f101ebd5e99a429932669028394a107e03 Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Thu, 15 Jan 2026 07:33:04 +0900 Subject: [PATCH 209/425] CICD.yml: Upload uudoc at least for Linux-x64-glibc (#10231) --- .github/workflows/CICD.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/CICD.yml b/.github/workflows/CICD.yml index 113f10909..fac09b36a 100644 --- a/.github/workflows/CICD.yml +++ b/.github/workflows/CICD.yml @@ -878,10 +878,14 @@ jobs: shell: bash run: | ## Package artifact(s) - # binary + # binaries cp 'target/${{ matrix.job.target }}/release/${{ env.PROJECT_NAME }}${{ steps.vars.outputs.EXE_suffix }}' '${{ steps.vars.outputs.STAGING }}/${{ steps.vars.outputs.PKG_BASENAME }}/' + cp 'target/${{ matrix.job.target }}/release/uudoc${{ steps.vars.outputs.EXE_suffix }}' '${{ steps.vars.outputs.STAGING }}/${{ steps.vars.outputs.PKG_BASENAME }}/' || : # `strip` binary (if needed) - if [ -n "${{ steps.vars.outputs.STRIP }}" ]; then "${{ steps.vars.outputs.STRIP }}" '${{ steps.vars.outputs.STAGING }}/${{ steps.vars.outputs.PKG_BASENAME }}/${{ env.PROJECT_NAME }}${{ steps.vars.outputs.EXE_suffix }}' ; fi + if [ -n "${{ steps.vars.outputs.STRIP }}" ]; then + "${{ steps.vars.outputs.STRIP }}" '${{ steps.vars.outputs.STAGING }}/${{ steps.vars.outputs.PKG_BASENAME }}/${{ env.PROJECT_NAME }}${{ steps.vars.outputs.EXE_suffix }}' + "${{ steps.vars.outputs.STRIP }}" '${{ steps.vars.outputs.STAGING }}/${{ steps.vars.outputs.PKG_BASENAME }}/uudoc' || : + fi # README and LICENSE # * spell-checker:ignore EADME ICENSE (shopt -s nullglob; for f in [R]"EADME"{,.*}; do cp $f '${{ steps.vars.outputs.STAGING }}/${{ steps.vars.outputs.PKG_BASENAME }}/' ; done) From 450e7cfee996d26ccf516e5bd11f9bd673a5b3c6 Mon Sep 17 00:00:00 2001 From: Chris Dryden Date: Wed, 14 Jan 2026 17:39:16 -0500 Subject: [PATCH 210/425] date: use PosixCustom formatting for GNU-compatible output (#10245) --- src/uu/date/src/date.rs | 28 +++++++++++++++------------- tests/by-util/test_date.rs | 29 ++++++++++++++++++++++++++--- 2 files changed, 41 insertions(+), 16 deletions(-) diff --git a/src/uu/date/src/date.rs b/src/uu/date/src/date.rs index 5baa75432..b63b04bf4 100644 --- a/src/uu/date/src/date.rs +++ b/src/uu/date/src/date.rs @@ -8,7 +8,7 @@ mod locale; use clap::{Arg, ArgAction, Command}; -use jiff::fmt::strtime; +use jiff::fmt::strtime::{self, BrokenDownTime, Config, PosixCustom}; use jiff::tz::{TimeZone, TimeZoneDatabase}; use jiff::{Timestamp, Zoned}; use std::collections::HashMap; @@ -431,21 +431,23 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { let mut stdout = BufWriter::new(std::io::stdout().lock()); // Format all the dates + let config = Config::new().custom(PosixCustom::new()).lenient(true); for date in dates { match date { - // TODO: Switch to lenient formatting. - Ok(date) => match strtime::format(format_string, &date) { - Ok(s) => writeln!(stdout, "{s}").map_err(|e| { - USimpleError::new(1, translate!("date-error-write", "error" => e)) - })?, - Err(e) => { - let _ = stdout.flush(); - return Err(USimpleError::new( - 1, - translate!("date-error-invalid-format", "format" => format_string, "error" => e), - )); + Ok(date) => { + match BrokenDownTime::from(&date).to_string_with_config(&config, format_string) { + Ok(s) => writeln!(stdout, "{s}").map_err(|e| { + USimpleError::new(1, translate!("date-error-write", "error" => e)) + })?, + Err(e) => { + let _ = stdout.flush(); + return Err(USimpleError::new( + 1, + translate!("date-error-invalid-format", "format" => format_string, "error" => e), + )); + } } - }, + } Err((input, _err)) => { let _ = stdout.flush(); show!(USimpleError::new( diff --git a/tests/by-util/test_date.rs b/tests/by-util/test_date.rs index 97b3d1056..744cffecb 100644 --- a/tests/by-util/test_date.rs +++ b/tests/by-util/test_date.rs @@ -480,9 +480,8 @@ fn test_date_set_valid_4() { #[test] fn test_invalid_format_string() { - let result = new_ucmd!().arg("+%!").fails(); - result.no_stdout(); - assert!(result.stderr_str().starts_with("date: invalid format ")); + // With lenient mode, invalid format sequences are output literally (like GNU date) + new_ucmd!().arg("+%!").succeeds().stdout_is("%!\n"); } #[test] @@ -1446,3 +1445,27 @@ fn test_date_locale_fr_french() { "Output should include timezone information, got: {stdout}" ); } + +#[test] +fn test_date_posix_format_specifiers() { + let cases = [ + // %r: 12-hour time with zero-padded hour (08:17:48 AM, not 8:17:48 AM) + ("%r", "08:17:48 AM"), + // %x: locale date in MM/DD/YY format + ("%x", "01/19/97"), + // %X: locale time in HH:MM:SS format + ("%X", "08:17:48"), + // %:8z: invalid format (width between : and z) should output literally (lenient mode) + ("%:8z", "%:8z"), + ]; + + for (format, expected) in cases { + new_ucmd!() + .env("TZ", "UTC") + .arg("-d") + .arg("1997-01-19 08:17:48") + .arg(format!("+{format}")) + .succeeds() + .stdout_is(format!("{expected}\n")); + } +} From df1e043ec08f81a9d0d7191bf883c21a253bc4cc Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Thu, 15 Jan 2026 20:09:45 +0900 Subject: [PATCH 211/425] CICD.yml: Drop checks for hashsum --- .github/workflows/CICD.yml | 23 ++++++++--------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/.github/workflows/CICD.yml b/.github/workflows/CICD.yml index fac09b36a..fc8b265b9 100644 --- a/.github/workflows/CICD.yml +++ b/.github/workflows/CICD.yml @@ -297,7 +297,6 @@ jobs: mv -T target target.cache fi # Check that we don't cross-build uudoc - # also do not try to generate manpages for part of hashsum env CARGO_BUILD_TARGET=aarch64-unknown-linux-gnu make install-manpages PREFIX=/tmp/usr UTILS=true # build (host) make build @@ -371,25 +370,19 @@ jobs: run: | set -x DESTDIR=/tmp/ make PROFILE=release MULTICALL=n install - # Check that the utils are present - test -f /tmp/usr/local/bin/hashsum - # Check that hashsum symlinks are present - test -h /tmp/usr/local/bin/b2sum - test -h /tmp/usr/local/bin/md5sum - test -h /tmp/usr/local/bin/sha1sum - test -h /tmp/usr/local/bin/sha224sum - test -h /tmp/usr/local/bin/sha256sum - test -h /tmp/usr/local/bin/sha384sum - test -h /tmp/usr/local/bin/sha512sum + # Check that *sum are present + for s in {md5,b2,sha1,sha224,sha256,sha384,sha512}sum + do test -e /tmp/usr/local/bin/${s} + done - name: "`make install MULTICALL=y LN=ln -svf`" shell: bash run: | set -x DESTDIR=/tmp/ make PROFILE=release MULTICALL=y LN="ln -svf" install - # Check that relative symlinks of hashsum are present - [ $(readlink /tmp/usr/local/bin/b2sum) = coreutils ] - [ $(readlink /tmp/usr/local/bin/md5sum) = coreutils ] - [ $(readlink /tmp/usr/local/bin/sha512sum) = coreutils ] + # Check that symlinks of *sum are present + for s in {md5,b2,sha1,sha224,sha256,sha384,sha512}sum + do test $(readlink /tmp/usr/local/bin/${s}) = coreutils + done - name: "`make UTILS=XXX`" shell: bash run: | From 5fd34598a005da9fc66fc9a8e7bf20dcac5eeba9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=BF=20Fleur=20de=20Blue?= <135421389+Xylphy@users.noreply.github.com> Date: Thu, 15 Jan 2026 23:45:18 +0800 Subject: [PATCH 212/425] mktemp: Fix template validation to require trailing consecutive Xs (#10224) * fix(mktemp): Validation of consecutive X's at template end * fix(mktemp): Updated integration tests to expect failure for templates that do not end with a sufficient X run * mktemp: document trailing-X template parsing * mktemp: match GNU behavior for template X run detection Change logic to only accept templates where the run of Xs containing the final 'X' is at least 3 characters long. This fixes cases like "XXX_XX", which should error with "too few X's in template", and allows "tempXXXlate", matching GNU mktemp * mktemp: Simplify search using rposition --- src/uu/mktemp/src/mktemp.rs | 19 +++++++++++++++--- tests/by-util/test_mktemp.rs | 37 ++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/src/uu/mktemp/src/mktemp.rs b/src/uu/mktemp/src/mktemp.rs index c285e9c90..5e3b8aa58 100644 --- a/src/uu/mktemp/src/mktemp.rs +++ b/src/uu/mktemp/src/mktemp.rs @@ -193,9 +193,22 @@ struct Params { /// assert_eq!(find_last_contiguous_block_of_xs("aXbXcX"), None); /// ``` fn find_last_contiguous_block_of_xs(s: &str) -> Option<(usize, usize)> { - let j = s.rfind("XXX")? + 3; - let i = s[..j].rfind(|c| c != 'X').map_or(0, |i| i + 1); - Some((i, j)) + let bytes = s.as_bytes(); + + // Find the index of the last 'X'. + let end = bytes.iter().rposition(|&b| b == b'X')?; + + // Walk left to find the start of the run of Xs that ends at `end`. + let mut start = end; + while start > 0 && bytes[start - 1] == b'X' { + start -= 1; + } + + if end + 1 - start >= 3 { + Some((start, end + 1)) + } else { + None + } } impl Params { diff --git a/tests/by-util/test_mktemp.rs b/tests/by-util/test_mktemp.rs index 405c7bfee..cedcb4927 100644 --- a/tests/by-util/test_mktemp.rs +++ b/tests/by-util/test_mktemp.rs @@ -30,6 +30,7 @@ static TEST_TEMPLATE7: &str = "XXXtemplate"; static TEST_TEMPLATE8: &str = "tempXXXl/ate"; #[cfg(windows)] static TEST_TEMPLATE8: &str = "tempXXXl\\ate"; +static TEST_TEMPLATE9: &str = "XXX_XX"; #[cfg(not(windows))] const TMPDIR: &str = "TMPDIR"; @@ -109,6 +110,11 @@ fn test_mktemp_mktemp() { .env(TMPDIR, &pathname) .arg(TEST_TEMPLATE8) .fails(); + scene + .ucmd() + .env(TMPDIR, &pathname) + .arg(TEST_TEMPLATE9) + .fails(); } #[test] @@ -168,6 +174,12 @@ fn test_mktemp_mktemp_t() { .no_stdout() .stderr_contains("invalid suffix") .stderr_contains("contains directory separator"); + scene + .ucmd() + .env(TMPDIR, &pathname) + .arg("-t") + .arg(TEST_TEMPLATE9) + .fails(); } #[test] @@ -224,6 +236,12 @@ fn test_mktemp_make_temp_dir() { .arg("-d") .arg(TEST_TEMPLATE8) .fails(); + scene + .ucmd() + .env(TMPDIR, &pathname) + .arg("-d") + .arg(TEST_TEMPLATE9) + .fails(); } #[test] @@ -280,6 +298,12 @@ fn test_mktemp_dry_run() { .arg("-u") .arg(TEST_TEMPLATE8) .fails(); + scene + .ucmd() + .env(TMPDIR, &pathname) + .arg("-u") + .arg(TEST_TEMPLATE9) + .fails(); } #[test] @@ -367,6 +391,13 @@ fn test_mktemp_suffix() { .arg("suf") .arg(TEST_TEMPLATE8) .fails(); + scene + .ucmd() + .env(TMPDIR, &pathname) + .arg("--suffix") + .arg("suf") + .arg(TEST_TEMPLATE9) + .fails(); } #[test] @@ -424,6 +455,12 @@ fn test_mktemp_tmpdir() { .arg(pathname) .arg(TEST_TEMPLATE8) .fails(); + scene + .ucmd() + .arg("-p") + .arg(pathname) + .arg(TEST_TEMPLATE9) + .fails(); } #[test] From ee3163ab2c56e0ea72c58ad25c6e3194ca2a1fd6 Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Thu, 15 Jan 2026 23:18:03 +0900 Subject: [PATCH 213/425] coreutils: Remove limitation for prefix --- README.md | 2 -- docs/src/extensions.md | 6 ++++++ src/bin/coreutils.rs | 28 +++++++++++----------------- util/build-gnu.sh | 8 +++----- 4 files changed, 20 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index e770bd543..59a2e2c6b 100644 --- a/README.md +++ b/README.md @@ -227,8 +227,6 @@ To install every program with a prefix (e.g. uu-echo uu-cat): make PROG_PREFIX=uu- install ``` -`PROG_PREFIX` requires separator `-`, `_`, or `=`. - To install the multicall binary: ```shell diff --git a/docs/src/extensions.md b/docs/src/extensions.md index 458b9c41e..fa92c54a0 100644 --- a/docs/src/extensions.md +++ b/docs/src/extensions.md @@ -25,6 +25,12 @@ $ ls -w=80 With GNU coreutils, `--help` usually prints the help message and `--version` prints the version. We also commonly provide short options: `-h` for help and `-V` for version. +## `coreutils` + +Our `coreutils` calls utility by `coreutils utility-name` and has `--list` to run against busybox test suite. +Our `coreutils` is called as `utility-name` if its binary name ends with `utility-name` to support prefixed names. +Longer name is prioritized e.g. `sum` with the prefix `ck` is called as `cksum`. + ## `env` GNU `env` allows the empty string to be used as an environment variable name. diff --git a/src/bin/coreutils.rs b/src/bin/coreutils.rs index 6a9141936..55c885237 100644 --- a/src/bin/coreutils.rs +++ b/src/bin/coreutils.rs @@ -52,24 +52,18 @@ fn main() { process::exit(0); }); - // binary name equals util name? - if let Some(&(uumain, _)) = utils.get(binary_as_util) { - validation::setup_localization_or_exit(binary_as_util); - process::exit(uumain(vec![binary.into()].into_iter().chain(args))); - } + // binary name ends with util name? + let matched_util = utils + .keys() + .filter(|&&u| binary_as_util.ends_with(u) && !binary_as_util.ends_with("coreutils")) + .max_by_key(|u| u.len()); //Prefer stty more than tty. coreutils is not ls - // binary name equals prefixed util name? - // * prefix/stem may be any string ending in a non-alphanumeric character - // For example, if the binary is named `uu_test`, it will match `test` as a utility. - let util_name = - if let Some(util) = validation::find_prefixed_util(binary_as_util, utils.keys().copied()) { - // prefixed util => replace 0th (aka, executable name) argument - Some(OsString::from(util)) - } else { - // unmatched binary name => regard as multi-binary container and advance argument list - uucore::set_utility_is_second_arg(); - args.next() - }; + let util_name = if let Some(&util) = matched_util { + Some(OsString::from(util)) + } else { + uucore::set_utility_is_second_arg(); + args.next() + }; // 0th argument equals util name? if let Some(util_os) = util_name { diff --git a/util/build-gnu.sh b/util/build-gnu.sh index dfefa6d26..48beaeee5 100755 --- a/util/build-gnu.sh +++ b/util/build-gnu.sh @@ -88,19 +88,17 @@ fi cd - export CARGOFLAGS # tell to make - "${MAKE}" UTILS=install -[ -e "${UU_BUILD_DIR}/ginstall" ] || ln -vf "${UU_BUILD_DIR}/install" "${UU_BUILD_DIR}/ginstall" # The GNU tests use renamed install to ginstall if [ "${SELINUX_ENABLED}" = 1 ];then # Build few utils for SELinux for faster build. MULTICALL=y fails... - "${MAKE}" UTILS="cat chcon chmod cp cut dd echo env groups id ln ls mkdir mkfifo mknod mktemp mv printf rm rmdir runcon seq stat test touch tr true uname wc whoami" + "${MAKE}" UTILS="cat chcon chmod cp cut dd echo env groups id 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="install 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 ## cd "${path_GNU}" && echo "[ pwd:'${PWD}' ]" From a1545ef2a96db060292a12415c8631c05b7d8b21 Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Fri, 16 Jan 2026 21:19:01 +0100 Subject: [PATCH 214/425] try to decrease the variance in the memory usage for benchmark (#10273) --- src/uu/du/benches/du_bench.rs | 14 +++++++++++--- src/uu/numfmt/benches/numfmt_bench.rs | 8 ++++++-- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/uu/du/benches/du_bench.rs b/src/uu/du/benches/du_bench.rs index 8a2d29246..5ea45ed9c 100644 --- a/src/uu/du/benches/du_bench.rs +++ b/src/uu/du/benches/du_bench.rs @@ -77,9 +77,17 @@ fn du_all_wide_tree(bencher: Bencher, (total_files, total_dirs): (usize, usize)) /// Benchmark du on deep directory structures #[divan::bench(args = [(100, 3)])] fn du_deep_tree(bencher: Bencher, (depth, files_per_level): (usize, usize)) { - let temp_dir = TempDir::new().unwrap(); - fs_tree::create_deep_tree(temp_dir.path(), depth, files_per_level); - bench_du_with_args(bencher, &temp_dir, &[]); + bencher + .with_inputs(|| { + let temp_dir = TempDir::new().unwrap(); + fs_tree::create_deep_tree(temp_dir.path(), depth, files_per_level); + temp_dir + }) + .bench_values(|temp_dir| { + let temp_path_str = temp_dir.path().to_str().unwrap(); + let args = vec![temp_path_str]; + black_box(run_util_function(uumain, &args)); + }); } /// Benchmark du -s (summarize) on balanced tree diff --git a/src/uu/numfmt/benches/numfmt_bench.rs b/src/uu/numfmt/benches/numfmt_bench.rs index d75bf4ad1..561b65093 100644 --- a/src/uu/numfmt/benches/numfmt_bench.rs +++ b/src/uu/numfmt/benches/numfmt_bench.rs @@ -63,8 +63,12 @@ fn numfmt_from_si(bencher: Bencher, count: usize) { /// Benchmark large numbers with SI formatting #[divan::bench(args = [10_000])] fn numfmt_large_numbers_si(bencher: Bencher, count: usize) { - // Generate larger numbers (millions to billions range) - let numbers: Vec = (1..=count).map(|n| (n * 1_000_000).to_string()).collect(); + // Generate numbers that all produce uniform SI output lengths (all in 1-9M range) + // This avoids variance from variable output string lengths + let numbers: Vec = (1..=count) + .map(|n| ((n % 9) + 1) * 1_000_000) + .map(|n| n.to_string()) + .collect(); let mut args = vec!["--to=si"]; let number_refs: Vec<&str> = numbers.iter().map(|s| s.as_str()).collect(); args.extend(number_refs); From 3efdb50543004ca2f39b0527590520f0a72f3abf Mon Sep 17 00:00:00 2001 From: Christopher Dryden Date: Fri, 16 Jan 2026 23:18:22 +0000 Subject: [PATCH 215/425] ls: fix symlink target coloring for chains and extensions --- src/uu/ls/src/ls.rs | 13 +++++++++---- tests/by-util/test_ls.rs | 30 ++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/src/uu/ls/src/ls.rs b/src/uu/ls/src/ls.rs index 3e98c1116..6694d7bca 100644 --- a/src/uu/ls/src/ls.rs +++ b/src/uu/ls/src/ls.rs @@ -3398,10 +3398,15 @@ fn display_item_name( } } - match fs::metadata(&absolute_target) { - Ok(_) => { - let target_data = - PathData::new(absolute_target, None, None, config, false); + match fs::canonicalize(&absolute_target) { + Ok(resolved_target) => { + let target_data = PathData::new( + resolved_target, + None, + target_path.file_name().map(|s| s.to_os_string()), + config, + false, + ); name.push(color_name( escaped_target, &target_data, diff --git a/tests/by-util/test_ls.rs b/tests/by-util/test_ls.rs index 571540d11..41b72af6b 100644 --- a/tests/by-util/test_ls.rs +++ b/tests/by-util/test_ls.rs @@ -4951,6 +4951,36 @@ fn test_dereference_symlink_file_color() { .stdout_is(out_exp); } +/// Symlink chain target should be colored by final target type, not as symlink (#8934). +#[test] +fn test_symlink_chain_target_color() { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("file"); + at.relative_symlink_file("file", "link1"); + at.relative_symlink_file("link1", "link2"); + let out = ucmd + .args(&["-l", "--color=always", "link2"]) + .succeeds() + .stdout_move_str(); + let target = out.split("->").nth(1).unwrap(); + assert!(!target.contains("36m")); // 36m = cyan (symlink color) +} + +/// Symlink target should be colored by extension (e.g., .tar.gz shows as archive color). +#[test] +fn test_symlink_target_extension_color() { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("archive.tar.gz"); + at.relative_symlink_file("archive.tar.gz", "link"); + let out = ucmd + .env("LS_COLORS", "*.tar.gz=31") + .args(&["-l", "--color=always", "link"]) + .succeeds() + .stdout_move_str(); + let target = out.split("->").nth(1).unwrap(); + assert!(target.contains("31m")); // 31 = red (our configured archive color) +} + #[test] fn test_tabsize_option() { let scene = TestScenario::new(util_name!()); From c66be8cd60565a42e6436cd5895aebd62b1aa718 Mon Sep 17 00:00:00 2001 From: Etienne Cordonnier Date: Sat, 17 Jan 2026 09:32:44 +0100 Subject: [PATCH 216/425] stdbuf: use exec instead of forking (#9495) --- src/uu/stdbuf/src/stdbuf.rs | 69 ++++++++++------------------------- tests/by-util/test_stdbuf.rs | 71 +++++++++++++++++++++++++++++++++++- 2 files changed, 89 insertions(+), 51 deletions(-) diff --git a/src/uu/stdbuf/src/stdbuf.rs b/src/uu/stdbuf/src/stdbuf.rs index f45dd2b97..b18e73d5d 100644 --- a/src/uu/stdbuf/src/stdbuf.rs +++ b/src/uu/stdbuf/src/stdbuf.rs @@ -7,12 +7,14 @@ use clap::{Arg, ArgAction, ArgMatches, Command}; use std::ffi::OsString; +#[cfg(unix)] +use std::os::unix::process::CommandExt; use std::path::PathBuf; use std::process; use tempfile::TempDir; use tempfile::tempdir; use thiserror::Error; -use uucore::error::{FromIo, UResult, USimpleError, UUsageError}; +use uucore::error::{UResult, USimpleError, UUsageError}; use uucore::format_usage; use uucore::parser::parse_size::parse_size_u64; use uucore::translate; @@ -208,55 +210,22 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { set_command_env(&mut command, "_STDBUF_E", &options.stderr); command.args(command_params); - let mut process = match command.spawn() { - Ok(p) => p, - Err(e) => { - return match e.kind() { - std::io::ErrorKind::PermissionDenied => Err(USimpleError::new( - 126, - translate!("stdbuf-error-permission-denied"), - )), - std::io::ErrorKind::NotFound => Err(USimpleError::new( - 127, - translate!("stdbuf-error-no-such-file"), - )), - _ => Err(USimpleError::new( - 1, - translate!("stdbuf-error-failed-to-execute", "error" => e), - )), - }; - } - }; - - let status = process.wait().map_err_context(String::new)?; - match status.code() { - Some(i) => { - if i == 0 { - Ok(()) - } else { - Err(i.into()) - } - } - None => { - #[cfg(unix)] - { - use std::os::unix::process::ExitStatusExt; - let signal_msg = status - .signal() - .map_or_else(|| "unknown".to_string(), |s| s.to_string()); - Err(USimpleError::new( - 1, - translate!("stdbuf-error-killed-by-signal", "signal" => signal_msg), - )) - } - #[cfg(not(unix))] - { - Err(USimpleError::new( - 1, - "process terminated abnormally".to_string(), - )) - } - } + // Replace the current process with the target program (no fork) using exec. + let e = command.exec(); + // exec() only returns if there was an error + match e.kind() { + std::io::ErrorKind::PermissionDenied => Err(USimpleError::new( + 126, + translate!("stdbuf-error-permission-denied"), + )), + std::io::ErrorKind::NotFound => Err(USimpleError::new( + 127, + translate!("stdbuf-error-no-such-file"), + )), + _ => Err(USimpleError::new( + 1, + translate!("stdbuf-error-failed-to-execute", "error" => e), + )), } } diff --git a/tests/by-util/test_stdbuf.rs b/tests/by-util/test_stdbuf.rs index c74ad54ec..00e117f47 100644 --- a/tests/by-util/test_stdbuf.rs +++ b/tests/by-util/test_stdbuf.rs @@ -2,7 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore dyld dylib setvbuf +// spell-checker:ignore cmdline dyld dylib PDEATHSIG setvbuf #[cfg(target_os = "linux")] use uutests::at_and_ucmd; use uutests::new_ucmd; @@ -276,3 +276,72 @@ fn test_stdbuf_non_utf8_paths() { .succeeds() .stdout_is("test content for stdbuf\n"); } + +#[test] +#[cfg(target_os = "linux")] +fn test_stdbuf_no_fork_regression() { + // Regression test for issue #9066: https://github.com/uutils/coreutils/issues/9066 + // The original stdbuf implementation used fork+spawn which broke signal handling + // and PR_SET_PDEATHSIG. This test verifies that stdbuf uses exec() instead. + // With fork: stdbuf process would remain visible in process list + // With exec: stdbuf process is replaced by target command (GNU compatible) + + use std::process::{Command, Stdio}; + use std::thread; + use std::time::Duration; + + let scene = TestScenario::new(util_name!()); + + // Start stdbuf with a long-running command + let mut child = Command::new(&scene.bin_path) + .args(["stdbuf", "-o0", "sleep", "3"]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("Failed to start stdbuf"); + + let child_pid = child.id(); + + // Poll until exec happens or timeout + let cmdline_path = format!("/proc/{child_pid}/cmdline"); + let timeout = Duration::from_secs(2); + let poll_interval = Duration::from_millis(10); + let start_time = std::time::Instant::now(); + + let command_name = loop { + if start_time.elapsed() > timeout { + child.kill().ok(); + panic!("TIMEOUT: Process {child_pid} did not respond within {timeout:?}"); + } + + if let Ok(cmdline) = std::fs::read_to_string(&cmdline_path) { + let cmd_parts: Vec<&str> = cmdline.split('\0').collect(); + let name = cmd_parts.first().map_or("", |v| v); + + // Wait for exec to complete (process name changes from original binary to target) + // Handle both multicall binary (coreutils) and individual utilities (stdbuf) + if !name.contains("coreutils") && !name.contains("stdbuf") && !name.is_empty() { + break name.to_string(); + } + } + + thread::sleep(poll_interval); + }; + + // The loop already waited for exec (no longer original binary), so this should always pass + // But keep the assertion as a safety check and clear documentation + assert!( + !command_name.contains("coreutils") && !command_name.contains("stdbuf"), + "REGRESSION: Process {child_pid} is still original binary (coreutils or stdbuf) - fork() used instead of exec()" + ); + + // Ensure we're running the expected target command + assert!( + command_name.contains("sleep"), + "Expected 'sleep' command at PID {child_pid}, got: {command_name}" + ); + + // Cleanup + child.kill().ok(); + child.wait().ok(); +} From 0b5826086c339dc3d7f59b7f47a214d9cff5371c Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Sat, 17 Jan 2026 17:56:13 +0900 Subject: [PATCH 217/425] CICD.yml: Upload manpages and completions (#10257) --- .github/workflows/CICD.yml | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/.github/workflows/CICD.yml b/.github/workflows/CICD.yml index fc8b265b9..87813c798 100644 --- a/.github/workflows/CICD.yml +++ b/.github/workflows/CICD.yml @@ -4,7 +4,7 @@ name: CICD # spell-checker:ignore (env/flags) Awarnings Ccodegen Coverflow Cpanic Dwarnings RUSTDOCFLAGS RUSTFLAGS Zpanic CARGOFLAGS # spell-checker:ignore (jargon) SHAs deps dequote softprops subshell toolchain fuzzers dedupe devel profdata # spell-checker:ignore (people) Peltoche rivy dtolnay Anson dawidd -# spell-checker:ignore (shell/tools) binutils choco clippy dmake esac fakeroot fdesc fdescfs gmake grcov halium lcov libclang libfuse libssl limactl mkdir nextest nocross pacman popd printf pushd redoxer rsync rustc rustfmt rustup shopt sccache utmpdump xargs +# spell-checker:ignore (shell/tools) binutils choco clippy dmake esac fakeroot fdesc fdescfs gmake grcov halium lcov libclang libfuse libssl limactl mkdir nextest nocross pacman popd printf pushd redoxer rsync rustc rustfmt rustup shopt sccache utmpdump xargs zstd # spell-checker:ignore (misc) aarch alnum armhf bindir busytest coreutils defconfig DESTDIR gecos getenforce gnueabihf issuecomment maint manpages msys multisize noconfirm nofeatures nullglob onexitbegin onexitend pell runtest Swatinem tempfile testsuite toybox uutils libsystemd codspeed env: @@ -890,6 +890,19 @@ jobs: *) tar czf '${{ steps.vars.outputs.PKG_NAME }}' '${{ steps.vars.outputs.PKG_BASENAME }}'/* ;; esac popd >/dev/null + - name: Package manpages and completions + if: matrix.job.target == 'x86_64-unknown-linux-gnu' && matrix.job.features == 'feat_os_unix,uudoc' + run: | + mkdir -p share/{man/man1,bash-completion/completions,fish/vendor_completions.d,zsh/site-functions,elvish/lib} + _uudoc=target/${{ matrix.job.target }}/release/uudoc + for bin in $('target/${{ matrix.job.target }}/release/coreutils' --list);do + ${_uudoc} manpage ${bin} > share/man/man1/${bin}.1 + ${_uudoc} completion ${bin} bash > share/bash-completion/completions/${bin}.bash + ${_uudoc} completion ${bin} fish > share/fish/vendor_completions.d/${bin}.fish + ${_uudoc} completion ${bin} zsh > share/zsh/site-functions/_${bin} + ${_uudoc} completion ${bin} elvish > share/elvish/lib/${bin}.elv + done + tar --zstd -cf docs.tar.zst share - name: Publish uses: softprops/action-gh-release@v2 if: steps.vars.outputs.DEPLOY && matrix.job.skip-publish != true @@ -897,6 +910,7 @@ jobs: draft: true files: | ${{ steps.vars.outputs.STAGING }}/${{ steps.vars.outputs.PKG_NAME }} + docs.tar.zst env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Publish latest commit @@ -909,6 +923,7 @@ jobs: prerelease: true files: | ${{ steps.vars.outputs.STAGING }}/${{ steps.vars.outputs.PKG_NAME }} + docs.tar.zst env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 1b3a66aef8f291f23a761541b498fa5879b35ea1 Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Sat, 17 Jan 2026 18:44:23 +0900 Subject: [PATCH 218/425] CICD.yml: upload binaries without version string (#10217) --- .github/workflows/CICD.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/CICD.yml b/.github/workflows/CICD.yml index 87813c798..788f4ed5e 100644 --- a/.github/workflows/CICD.yml +++ b/.github/workflows/CICD.yml @@ -685,7 +685,7 @@ jobs: outputs TARGET_ARCH TARGET_OS # package name PKG_suffix=".tar.gz" ; case '${{ matrix.job.target }}' in *-pc-windows-*) PKG_suffix=".zip" ;; esac; - PKG_BASENAME=${PROJECT_NAME}-${REF_TAG:-$REF_SHAS}-${{ matrix.job.target }} + PKG_BASENAME=${PROJECT_NAME}-${{ matrix.job.target }} PKG_NAME=${PKG_BASENAME}${PKG_suffix} outputs PKG_suffix PKG_BASENAME PKG_NAME # deployable tag? (ie, leading "vM" or "M"; M == version number) @@ -915,10 +915,9 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Publish latest commit uses: softprops/action-gh-release@v2 - if: steps.vars.outputs.DEPLOY && matrix.job.skip-publish != true + if: github.event_name == 'push' && github.ref == 'refs/heads/main' && matrix.job.skip-publish != true with: tag_name: latest-commit - force_update: true draft: false prerelease: true files: | From 8728685e56a5fc5a26a84537a98395aba5cbf3c6 Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Sat, 17 Jan 2026 18:48:36 +0900 Subject: [PATCH 219/425] CI: Purge disk space at background to avoid delaying (#10276) --- .github/workflows/CICD.yml | 8 ++++---- .github/workflows/freebsd.yml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/CICD.yml b/.github/workflows/CICD.yml index 788f4ed5e..bc42a6bc5 100644 --- a/.github/workflows/CICD.yml +++ b/.github/workflows/CICD.yml @@ -630,6 +630,8 @@ jobs: - uses: actions/checkout@v6 with: persist-credentials: false + - name: Avoid no space left on device + run: sudo rm -rf /usr/share/dotnet /usr/local/lib/android & - uses: dtolnay/rust-toolchain@master with: toolchain: ${{ env.RUST_MIN_SRV }} @@ -854,8 +856,6 @@ jobs: if: matrix.job.skip-tests != true shell: bash run: | - command -v sudo && sudo rm -rf /usr/local/lib/android /usr/share/dotnet # avoid no space left - df -h ||: ## Test individual utilities ${{ steps.vars.outputs.CARGO_CMD }} ${{ steps.vars.outputs.CARGO_CMD_OPTIONS }} test --target=${{ matrix.job.target }} \ ${{ matrix.job.cargo-options }} ${{ steps.dep_vars.outputs.CARGO_UTILITY_LIST_OPTIONS }} @@ -1271,13 +1271,13 @@ jobs: - uses: actions/checkout@v6 with: persist-credentials: false + - name: Avoid no space left on device + run: sudo rm -rf /usr/share/dotnet /usr/local/lib/android & - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - name: build and test all features individually shell: bash run: | - command -v sudo && sudo rm -rf /usr/local/lib/android /usr/share/dotnet # avoid no space left - df -h ||: CARGO_FEATURES_OPTION='--features=${{ matrix.job.features }}' ; for f in $(util/show-utils.sh ${CARGO_FEATURES_OPTION}) do diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index ccbf7be9b..549f2ba85 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -134,7 +134,7 @@ jobs: with: persist-credentials: false - name: Avoid no space left on device (Ubuntu runner) - run: sudo rm -rf /usr/share/dotnet /usr/local/lib/android + run: sudo rm -rf /usr/share/dotnet /usr/local/lib/android & - uses: Swatinem/rust-cache@v2 - name: Run sccache-cache uses: mozilla-actions/sccache-action@v0.0.9 From 1876f51568dacc92af81a13a285916e4655839fd Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Sat, 17 Jan 2026 18:49:14 +0900 Subject: [PATCH 220/425] GnuTests.yml: Dedup setenforce 1 (#10266) --- .github/workflows/GnuTests.yml | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/.github/workflows/GnuTests.yml b/.github/workflows/GnuTests.yml index 39251c584..0f8ed7fd1 100644 --- a/.github/workflows/GnuTests.yml +++ b/.github/workflows/GnuTests.yml @@ -233,15 +233,6 @@ jobs: lima ls -laZ /etc/selinux lima sudo sestatus - # Ensure we're running in enforcing mode - lima sudo setenforce 1 - lima getenforce - - # Create test files with SELinux contexts for testing - lima sudo mkdir -p /var/test_selinux - lima sudo touch /var/test_selinux/test_file - lima sudo chcon -t etc_t /var/test_selinux/test_file - lima ls -Z /var/test_selinux/test_file # Verify context - name: Install dependencies in VM run: | lima sudo dnf -y update @@ -267,8 +258,16 @@ jobs: lima bash -c "cd ~/work/uutils/ && echo 'Found SELinux tests:'; wc -l selinux-tests.txt" - name: Run GNU SELinux tests run: | + # Ensure we're running in enforcing mode lima sudo setenforce 1 lima getenforce + + # Create test files with SELinux contexts for testing + lima sudo mkdir -p /var/test_selinux + lima sudo touch /var/test_selinux/test_file + lima sudo chcon -t etc_t /var/test_selinux/test_file + lima ls -Z /var/test_selinux/test_file # Verify context + lima cat /proc/filesystems lima bash -c "cd ~/work/uutils/ && bash util/run-gnu-test.sh \$(cat selinux-tests.txt)" - name: Extract testing info from individual logs into JSON From 43eeaf93227c1d1bd1760381660c3c0ab90a97e9 Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Sat, 17 Jan 2026 18:57:25 +0900 Subject: [PATCH 221/425] build-gnu.sh: Replace ${SED} with gsed wrapper (#10201) --- util/build-gnu.sh | 139 +++++++++++++++++++++++----------------------- util/fetch-gnu.sh | 3 +- 2 files changed, 72 insertions(+), 70 deletions(-) diff --git a/util/build-gnu.sh b/util/build-gnu.sh index dfefa6d26..13b055ae0 100755 --- a/util/build-gnu.sh +++ b/util/build-gnu.sh @@ -9,15 +9,16 @@ set -e # Use GNU make, readlink and sed on *BSD and macOS -MAKE=$(command -v gmake||command -v make) -READLINK=$(command -v greadlink||command -v readlink) # Use our readlink to remove a dependency -SED=$(command -v gsed||command -v sed) +command -v gmake && make(){ gmake "$@";} +command -v greadlink && readlink(){ greadlink "$@";} # todo: use our readlink for less deps +command -v gsed && sed(){ gsed "$@";} +SED=$(command -v gsed||command -v sed) # for find...exec... SYSTEM_TIMEOUT=$(command -v timeout) SYSTEM_YES=$(command -v yes) ME="${0}" -ME_dir="$(dirname -- "$("${READLINK}" -fm -- "${ME}")")" +ME_dir="$(dirname -- "$(readlink -fm -- "${ME}")")" REPO_main_dir="$(dirname -- "${ME_dir}")" @@ -28,7 +29,7 @@ unset CARGOFLAGS ### * config (from environment with fallback defaults); note: GNU is expected to be a sibling repo directory path_UUTILS=${path_UUTILS:-${REPO_main_dir}} -path_GNU="$("${READLINK}" -fm -- "${path_GNU:-${path_UUTILS}/../gnu}")" +path_GNU="$(readlink -fm -- "${path_GNU:-${path_UUTILS}/../gnu}")" ### @@ -88,14 +89,14 @@ fi cd - export CARGOFLAGS # tell to make - "${MAKE}" UTILS=install + make UTILS=install [ -e "${UU_BUILD_DIR}/ginstall" ] || ln -vf "${UU_BUILD_DIR}/install" "${UU_BUILD_DIR}/ginstall" # The GNU tests use renamed install to ginstall if [ "${SELINUX_ENABLED}" = 1 ];then # Build few utils for SELinux for faster build. MULTICALL=y fails... - "${MAKE}" UTILS="cat chcon chmod cp cut dd echo env groups id ln ls mkdir mkfifo mknod mktemp mv printf rm rmdir runcon seq stat test touch tr true uname wc whoami" + make UTILS="cat chcon chmod cp cut dd echo env groups id ln ls mkdir mkfifo mknod mktemp mv printf rm rmdir runcon seq stat test touch tr true uname wc whoami" else # Use MULTICALL=y for faster build - "${MAKE}" MULTICALL=y SKIP_UTILS="install more" + make MULTICALL=y SKIP_UTILS="install more" for binary in $("${UU_BUILD_DIR}"/coreutils --list) do [ -e "${UU_BUILD_DIR}/${binary}" ] || ln -vf "${UU_BUILD_DIR}/coreutils" "${UU_BUILD_DIR}/${binary}" done @@ -114,7 +115,7 @@ done # Always update the PATH to test the uutils coreutils instead of the GNU coreutils # This ensures the correct path is used even if the repository was moved or rebuilt in a different location -"${SED}" -i "s/^[[:blank:]]*PATH=.*/ PATH='${UU_BUILD_DIR//\//\\/}\$(PATH_SEPARATOR)'\"\$\$PATH\" \\\/" tests/local.mk +sed -i "s/^[[:blank:]]*PATH=.*/ PATH='${UU_BUILD_DIR//\//\\/}\$(PATH_SEPARATOR)'\"\$\$PATH\" \\\/" tests/local.mk if test -f gnu-built; then echo "GNU build already found. Skip" @@ -122,7 +123,7 @@ if test -f gnu-built; then echo "Note: the customization of the tests will still happen" else # Disable useless checks - "${SED}" -i 's|check-texinfo: $(syntax_checks)|check-texinfo:|' doc/local.mk + sed -i 's|check-texinfo: $(syntax_checks)|check-texinfo:|' doc/local.mk # Stop manpage generation for cleaner log : > man/local.mk # Use CFLAGS for best build time since we discard GNU coreutils @@ -130,15 +131,15 @@ else --enable-single-binary=symlinks --enable-install-program="arch,kill,uptime,hostname" \ "$([ "${SELINUX_ENABLED}" = 1 ] && echo --with-selinux || echo --without-selinux)" #Add timeout to to protect against hangs - "${SED}" -i 's|^"\$@|'"${SYSTEM_TIMEOUT}"' 600 "\$@|' build-aux/test-driver + sed -i 's|^"\$@|'"${SYSTEM_TIMEOUT}"' 600 "\$@|' build-aux/test-driver # Use a better diff - "${SED}" -i 's|diff -c|diff -u|g' tests/Coreutils.pm + sed -i 's|diff -c|diff -u|g' tests/Coreutils.pm # Skip make if possible # Use GNU nproc for *BSD and macOS NPROC="$(command -v nproc||command -v gnproc)" test "${SELINUX_ENABLED}" = 1 && touch src/getlimits # SELinux tests does not use it - test -f src/getlimits || "${MAKE}" -j "$("${NPROC}")" + test -f src/getlimits || make -j "$("${NPROC}")" cp -f src/getlimits "${UU_BUILD_DIR}" # Handle generated factor tests @@ -153,12 +154,12 @@ else ) for i in ${seq}; do echo "strip t${i}.sh from Makefile" - "${SED}" -i -e "s/\$(tf)\/t${i}.sh//g" Makefile + sed -i -e "s/\$(tf)\/t${i}.sh//g" Makefile done # Remove tests checking for --version & --help # Not really interesting for us and logs are too big - "${SED}" -i -e '/tests\/help\/help-version.sh/ D' \ + sed -i -e '/tests\/help\/help-version.sh/ D' \ -e '/tests\/help\/help-version-getopt.sh/ D' \ Makefile touch gnu-built @@ -169,36 +170,36 @@ grep -rl 'path_prepend_' tests/* | xargs -r "${SED}" -i 's| path_prepend_ ./src| grep -rl '\$abs_path_dir_' tests/*/*.sh | xargs -r "${SED}" -i "s|\$abs_path_dir_|${UU_BUILD_DIR//\//\\/}|g" # We can't build runcon and chcon without libselinux. But GNU no longer builds dummies of them. So consider they are SELinux specific. -"${SED}" -i 's/^print_ver_.*/require_selinux_/' tests/runcon/runcon-compute.sh -"${SED}" -i 's/^print_ver_.*/require_selinux_/' tests/runcon/runcon-no-reorder.sh -"${SED}" -i 's/^print_ver_.*/require_selinux_/' tests/chcon/chcon-fail.sh +sed -i 's/^print_ver_.*/require_selinux_/' tests/runcon/runcon-compute.sh +sed -i 's/^print_ver_.*/require_selinux_/' tests/runcon/runcon-no-reorder.sh +sed -i 's/^print_ver_.*/require_selinux_/' tests/chcon/chcon-fail.sh # Mask mtab by unshare instead of LD_PRELOAD (able to merge this to GNU?) -"${SED}" -i -e 's|^export LD_PRELOAD=.*||' -e "s|.*maybe LD_PRELOAD.*|df() { unshare -rm bash -c \"mount -t tmpfs tmpfs /proc \&\& command df \\\\\"\\\\\$@\\\\\"\" -- \"\$@\"; }|" tests/df/no-mtab-status.sh +sed -i -e 's|^export LD_PRELOAD=.*||' -e "s|.*maybe LD_PRELOAD.*|df() { unshare -rm bash -c \"mount -t tmpfs tmpfs /proc \&\& command df \\\\\"\\\\\$@\\\\\"\" -- \"\$@\"; }|" tests/df/no-mtab-status.sh # We use coreutils yes -"${SED}" -i "s|--coreutils-prog=||g" tests/misc/coreutils.sh +sed -i "s|--coreutils-prog=||g" tests/misc/coreutils.sh # Different message -"${SED}" -i "s|coreutils: unknown program 'blah'|blah: function/utility not found|" tests/misc/coreutils.sh +sed -i "s|coreutils: unknown program 'blah'|blah: function/utility not found|" tests/misc/coreutils.sh # Use the system coreutils where the test fails due to error in a util that is not the one being tested -"${SED}" -i "s|grep '^#define HAVE_CAP 1' \$CONFIG_HEADER > /dev/null|true|" tests/ls/capability.sh +sed -i "s|grep '^#define HAVE_CAP 1' \$CONFIG_HEADER > /dev/null|true|" tests/ls/capability.sh # our messages are better -"${SED}" -i "s|cannot stat 'symlink': Permission denied|not writing through dangling symlink 'symlink'|" tests/cp/fail-perm.sh -"${SED}" -i "s|cp: target directory 'symlink': Permission denied|cp: 'symlink' is not a directory|" tests/cp/fail-perm.sh +sed -i "s|cannot stat 'symlink': Permission denied|not writing through dangling symlink 'symlink'|" tests/cp/fail-perm.sh +sed -i "s|cp: target directory 'symlink': Permission denied|cp: 'symlink' is not a directory|" tests/cp/fail-perm.sh # Our message is a bit better -"${SED}" -i "s|cannot create regular file 'no-such/': Not a directory|'no-such/' is not a directory|" tests/mv/trailing-slash.sh +sed -i "s|cannot create regular file 'no-such/': Not a directory|'no-such/' is not a directory|" tests/mv/trailing-slash.sh # Our message is better -"${SED}" -i "s|warning: unrecognized escape|warning: incomplete hex escape|" tests/stat/stat-printf.pl +sed -i "s|warning: unrecognized escape|warning: incomplete hex escape|" tests/stat/stat-printf.pl -"${SED}" -i 's|timeout |'"${SYSTEM_TIMEOUT}"' |' tests/tail/follow-stdin.sh +sed -i 's|timeout |'"${SYSTEM_TIMEOUT}"' |' tests/tail/follow-stdin.sh # trap_sigpipe_or_skip_ fails with uutils tools because of a bug in # timeout/yes (https://github.com/uutils/coreutils/issues/7252), so we use # system's yes/timeout to make sure the tests run (instead of being skipped). -"${SED}" -i 's|\(trap .* \)timeout\( .* \)yes|'"\1${SYSTEM_TIMEOUT}\2${SYSTEM_YES}"'|' init.cfg +sed -i 's|\(trap .* \)timeout\( .* \)yes|'"\1${SYSTEM_TIMEOUT}\2${SYSTEM_YES}"'|' init.cfg # Remove dup of /usr/bin/ and /usr/local/bin/ when executed several times grep -rlE '/usr/bin/\s?/usr/bin' init.cfg tests/* | xargs -r "${SED}" -Ei 's|/usr/bin/\s?/usr/bin/|/usr/bin/|g' @@ -209,101 +210,101 @@ grep -rlE '/usr/local/bin/\s?/usr/local/bin' init.cfg tests/* | xargs -r "${SED} # we should not regress our project just to match what GNU is going. # So, do some changes on the fly -"${SED}" -i -e "s|removed directory 'a/'|removed directory 'a'|g" tests/rm/v-slash.sh +sed -i -e "s|removed directory 'a/'|removed directory 'a'|g" tests/rm/v-slash.sh # 'rel' doesn't exist. Our implementation is giving a better message. -"${SED}" -i -e "s|rm: cannot remove 'rel': Permission denied|rm: cannot remove 'rel': No such file or directory|g" tests/rm/inaccessible.sh +sed -i -e "s|rm: cannot remove 'rel': Permission denied|rm: cannot remove 'rel': No such file or directory|g" tests/rm/inaccessible.sh # Our implementation shows "Directory not empty" for directories that can't be accessed due to lack of execute permissions # This is actually more accurate than "Permission denied" since the real issue is that we can't empty the directory -"${SED}" -i -e "s|rm: cannot remove 'a/1': Permission denied|rm: cannot remove 'a/1/2': Permission denied|g" -e "s|rm: cannot remove 'b': Permission denied|rm: cannot remove 'a': Directory not empty\nrm: cannot remove 'b/3': Permission denied|g" tests/rm/rm2.sh +sed -i -e "s|rm: cannot remove 'a/1': Permission denied|rm: cannot remove 'a/1/2': Permission denied|g" -e "s|rm: cannot remove 'b': Permission denied|rm: cannot remove 'a': Directory not empty\nrm: cannot remove 'b/3': Permission denied|g" tests/rm/rm2.sh # overlay-headers.sh test intends to check for inotify events, # however there's a bug because `---dis` is an alias for: `---disable-inotify` sed -i -e "s|---dis ||g" tests/tail/overlay-headers.sh # Do not FAIL, just do a regular ERROR -"${SED}" -i -e "s|framework_failure_ 'no inotify_add_watch';|fail=1;|" tests/tail/inotify-rotate-resources.sh +sed -i -e "s|framework_failure_ 'no inotify_add_watch';|fail=1;|" tests/tail/inotify-rotate-resources.sh # pr-tests.pl: Override the comparison function to suppress diff output # This prevents the test from overwhelming logs while still reporting failures -"${SED}" -i '/^my $fail = run_tests/i no warnings "redefine"; *Coreutils::_compare_files = sub { my ($p, $t, $io, $a, $e) = @_; my $d = File::Compare::compare($a, $e); warn "$p: test $t: mismatch\\n" if $d; return $d; };' tests/pr/pr-tests.pl +sed -i '/^my $fail = run_tests/i no warnings "redefine"; *Coreutils::_compare_files = sub { my ($p, $t, $io, $a, $e) = @_; my $d = File::Compare::compare($a, $e); warn "$p: test $t: mismatch\\n" if $d; return $d; };' tests/pr/pr-tests.pl # We don't have the same error message and no need to be that specific -"${SED}" -i -e "s|invalid suffix in --pages argument|invalid --pages argument|" \ +sed -i -e "s|invalid suffix in --pages argument|invalid --pages argument|" \ -e "s|--pages argument '\$too_big' too large|invalid --pages argument '\$too_big'|" \ -e "s|invalid page range|invalid --pages argument|" tests/misc/xstrtol.pl # When decoding an invalid base32/64 string, gnu writes everything it was able to decode until # it hit the decode error, while we don't write anything if the input is invalid. -"${SED}" -i "s/\(baddecode.*OUT=>\"\).*\"/\1\"/g" tests/basenc/base64.pl -"${SED}" -i "s/\(\(b2[ml]_[69]\|z85_8\|z85_35\).*OUT=>\)[^}]*\(.*\)/\1\"\"\3/g" tests/basenc/basenc.pl +sed -i "s/\(baddecode.*OUT=>\"\).*\"/\1\"/g" tests/basenc/base64.pl +sed -i "s/\(\(b2[ml]_[69]\|z85_8\|z85_35\).*OUT=>\)[^}]*\(.*\)/\1\"\"\3/g" tests/basenc/basenc.pl # add "error: " to the expected error message -"${SED}" -i "s/\$prog: invalid input/\$prog: error: invalid input/g" tests/basenc/basenc.pl +sed -i "s/\$prog: invalid input/\$prog: error: invalid input/g" tests/basenc/basenc.pl # basenc: swap out error message for unexpected arg -"${SED}" -i "s/ {ERR=>\"\$prog: foobar\\\\n\" \. \$try_help }/ {ERR=>\"error: unexpected argument '--foobar' found\n\n tip: to pass '--foobar' as a value, use '-- --foobar'\n\nUsage: basenc [OPTION]... [FILE]\n\nFor more information, try '--help'.\n\"}]/" tests/basenc/basenc.pl -"${SED}" -i "s/ {ERR_SUBST=>\"s\/(unrecognized|unknown) option \[-' \]\*foobar\[' \]\*\/foobar\/\"}],//" tests/basenc/basenc.pl +sed -i "s/ {ERR=>\"\$prog: foobar\\\\n\" \. \$try_help }/ {ERR=>\"error: unexpected argument '--foobar' found\n\n tip: to pass '--foobar' as a value, use '-- --foobar'\n\nUsage: basenc [OPTION]... [FILE]\n\nFor more information, try '--help'.\n\"}]/" tests/basenc/basenc.pl +sed -i "s/ {ERR_SUBST=>\"s\/(unrecognized|unknown) option \[-' \]\*foobar\[' \]\*\/foobar\/\"}],//" tests/basenc/basenc.pl # exit early for the selinux check. The first is enough for us. -"${SED}" -i "s|# Independent of whether SELinux|return 0\n #|g" init.cfg +sed -i "s|# Independent of whether SELinux|return 0\n #|g" init.cfg # Some tests are executed with the "nobody" user. # The check to verify if it works is based on the GNU coreutils version # making it too restrictive for us -"${SED}" -i "s|\$PACKAGE_VERSION|[0-9]*|g" tests/rm/fail-2eperm.sh tests/mv/sticky-to-xpart.sh init.cfg +sed -i "s|\$PACKAGE_VERSION|[0-9]*|g" tests/rm/fail-2eperm.sh tests/mv/sticky-to-xpart.sh init.cfg # usage_vs_getopt.sh is heavily modified as it runs all the binaries # with the option -/ is used, clap is returning a better error than GNU's. Adjust the GNU test -"${SED}" -i -e "s~ grep \" '\*/'\*\" err || framework_failure_~ grep \" '*-/'*\" err || framework_failure_~" tests/misc/usage_vs_getopt.sh -"${SED}" -i -e "s~ sed -n \"1s/'\\\/'/'OPT'/p\" < err >> pat || framework_failure_~ sed -n \"1s/'-\\\/'/'OPT'/p\" < err >> pat || framework_failure_~" tests/misc/usage_vs_getopt.sh +sed -i -e "s~ grep \" '\*/'\*\" err || framework_failure_~ grep \" '*-/'*\" err || framework_failure_~" tests/misc/usage_vs_getopt.sh +sed -i -e "s~ sed -n \"1s/'\\\/'/'OPT'/p\" < err >> pat || framework_failure_~ sed -n \"1s/'-\\\/'/'OPT'/p\" < err >> pat || framework_failure_~" tests/misc/usage_vs_getopt.sh # Ignore runcon, it needs some extra attention # For all other tools, we want drop-in compatibility, and that includes the exit code. -"${SED}" -i -e "s/rcexp=1$/rcexp=1\n case \"\$prg\" in runcon|stdbuf) return;; esac/" tests/misc/usage_vs_getopt.sh +sed -i -e "s/rcexp=1$/rcexp=1\n case \"\$prg\" in runcon|stdbuf) return;; esac/" tests/misc/usage_vs_getopt.sh # GNU has option=[SUFFIX], clap is -"${SED}" -i -e "s/cat opts/sed -i -e \"s| <.\*$||g\" opts/" tests/misc/usage_vs_getopt.sh +sed -i -e "s/cat opts/sed -i -e \"s| <.\*$||g\" opts/" tests/misc/usage_vs_getopt.sh # for some reasons, some stuff are duplicated, strip that -"${SED}" -i -e "s/provoked error./provoked error\ncat pat |sort -u > pat/" tests/misc/usage_vs_getopt.sh +sed -i -e "s/provoked error./provoked error\ncat pat |sort -u > pat/" tests/misc/usage_vs_getopt.sh # install verbose messages shows ginstall as command -"${SED}" -i -e "s/ginstall: creating directory/install: creating directory/g" tests/install/basic-1.sh +sed -i -e "s/ginstall: creating directory/install: creating directory/g" tests/install/basic-1.sh # GNU doesn't support padding < -LONG_MAX # disable this test case -"${SED}" -i -Ez "s/\n([^\n#]*pad-3\.2[^\n]*)\n([^\n]*)\n([^\n]*)/\n# uutils\/numfmt supports padding = LONG_MIN\n#\1\n#\2\n#\3/" tests/numfmt/numfmt.pl +sed -i -Ez "s/\n([^\n#]*pad-3\.2[^\n]*)\n([^\n]*)\n([^\n]*)/\n# uutils\/numfmt supports padding = LONG_MIN\n#\1\n#\2\n#\3/" tests/numfmt/numfmt.pl # Update the GNU error message to match the one generated by clap -"${SED}" -i -e "s/\$prog: multiple field specifications/error: the argument '--field ' cannot be used multiple times\n\nUsage: numfmt [OPTION]... [NUMBER]...\n\nFor more information, try '--help'./g" tests/numfmt/numfmt.pl -"${SED}" -i -e "s/Try 'mv --help' for more information/For more information, try '--help'/g" -e "s/mv: missing file operand/error: the following required arguments were not provided:\n ...\n\nUsage: mv [OPTION]... [-T] SOURCE DEST\n mv [OPTION]... SOURCE... DIRECTORY\n mv [OPTION]... -t DIRECTORY SOURCE...\n/g" -e "s/mv: missing destination file operand after 'no-file'/error: The argument '...' requires at least 2 values, but only 1 was provided\n\nUsage: mv [OPTION]... [-T] SOURCE DEST\n mv [OPTION]... SOURCE... DIRECTORY\n mv [OPTION]... -t DIRECTORY SOURCE...\n/g" tests/mv/diag.sh +sed -i -e "s/\$prog: multiple field specifications/error: the argument '--field ' cannot be used multiple times\n\nUsage: numfmt [OPTION]... [NUMBER]...\n\nFor more information, try '--help'./g" tests/numfmt/numfmt.pl +sed -i -e "s/Try 'mv --help' for more information/For more information, try '--help'/g" -e "s/mv: missing file operand/error: the following required arguments were not provided:\n ...\n\nUsage: mv [OPTION]... [-T] SOURCE DEST\n mv [OPTION]... SOURCE... DIRECTORY\n mv [OPTION]... -t DIRECTORY SOURCE...\n/g" -e "s/mv: missing destination file operand after 'no-file'/error: The argument '...' requires at least 2 values, but only 1 was provided\n\nUsage: mv [OPTION]... [-T] SOURCE DEST\n mv [OPTION]... SOURCE... DIRECTORY\n mv [OPTION]... -t DIRECTORY SOURCE...\n/g" tests/mv/diag.sh # our error message is better -"${SED}" -i -e "s|mv: cannot overwrite 'a/t': Directory not empty|mv: cannot move 'b/t' to 'a/t': Directory not empty|" tests/mv/dir2dir.sh +sed -i -e "s|mv: cannot overwrite 'a/t': Directory not empty|mv: cannot move 'b/t' to 'a/t': Directory not empty|" tests/mv/dir2dir.sh # GNU doesn't support width > INT_MAX # disable these test cases -"${SED}" -i -E "s|^([^#]*2_31.*)$|#\1|g" tests/printf/printf-cov.pl +sed -i -E "s|^([^#]*2_31.*)$|#\1|g" tests/printf/printf-cov.pl -"${SED}" -i -e "s/du: invalid -t argument/du: invalid --threshold argument/" -e "s/du: option requires an argument/error: a value is required for '--threshold ' but none was supplied/" -e "s/Try 'du --help' for more information./\nFor more information, try '--help'./" tests/du/threshold.sh +sed -i -e "s/du: invalid -t argument/du: invalid --threshold argument/" -e "s/du: option requires an argument/error: a value is required for '--threshold ' but none was supplied/" -e "s/Try 'du --help' for more information./\nFor more information, try '--help'./" tests/du/threshold.sh # Remove the extra output check -"${SED}" -i -e "s|Try '\$prog --help' for more information.\\\n||" tests/du/files0-from.pl -"${SED}" -i -e "s|-: No such file or directory|cannot access '-': No such file or directory|g" tests/du/files0-from.pl +sed -i -e "s|Try '\$prog --help' for more information.\\\n||" tests/du/files0-from.pl +sed -i -e "s|-: No such file or directory|cannot access '-': No such file or directory|g" tests/du/files0-from.pl # Skip the move-dir-while-traversing test - our implementation uses safe traversal with openat() # which avoids the TOCTOU race condition that this test tries to trigger. The test uses inotify # to detect when du opens a directory path and moves it to cause an error, but our openat-based # implementation doesn't trigger inotify events on the full path, preventing the race condition. # This is actually better behavior - we're immune to this class of filesystem race attacks. -"${SED}" -i '1s/^/exit 0 # Skip test - uutils du uses safe traversal that prevents this race condition\n/' tests/du/move-dir-while-traversing.sh +sed -i '1s/^/exit 0 # Skip test - uutils du uses safe traversal that prevents this race condition\n/' tests/du/move-dir-while-traversing.sh awk 'BEGIN {count=0} /compare exp out2/ && count < 6 {sub(/compare exp out2/, "grep -q \"cannot be used with\" out2"); count++} 1' tests/df/df-output.sh > tests/df/df-output.sh.tmp && mv tests/df/df-output.sh.tmp tests/df/df-output.sh # with ls --dired, in case of error, we have a slightly different error position -"${SED}" -i -e "s|44 45|48 49|" tests/ls/stat-failed.sh +sed -i -e "s|44 45|48 49|" tests/ls/stat-failed.sh # small difference in the error message -"${SED}" -i -e "s/ls: invalid argument 'XX' for 'time style'/ls: invalid --time-style argument 'XX'/" \ +sed -i -e "s/ls: invalid argument 'XX' for 'time style'/ls: invalid --time-style argument 'XX'/" \ -e "s/Valid arguments are:/Possible values are:/" \ -e "s/Try 'ls --help' for more information./\nFor more information try --help/" \ tests/ls/time-style-diag.sh @@ -311,29 +312,29 @@ awk 'BEGIN {count=0} /compare exp out2/ && count < 6 {sub(/compare exp out2/, "g # disable two kind of tests: # "hostid BEFORE --help" doesn't fail for GNU. we fail. we are probably doing better # "hostid BEFORE --help AFTER " same for this -"${SED}" -i -e "s/env \$prog \$BEFORE \$opt > out2/env \$prog \$BEFORE \$opt > out2 #/" -e "s/env \$prog \$BEFORE \$opt AFTER > out3/env \$prog \$BEFORE \$opt AFTER > out3 #/" -e "s/compare exp out2/compare exp out2 #/" -e "s/compare exp out3/compare exp out3 #/" tests/help/help-version-getopt.sh +sed -i -e "s/env \$prog \$BEFORE \$opt > out2/env \$prog \$BEFORE \$opt > out2 #/" -e "s/env \$prog \$BEFORE \$opt AFTER > out3/env \$prog \$BEFORE \$opt AFTER > out3 #/" -e "s/compare exp out2/compare exp out2 #/" -e "s/compare exp out3/compare exp out3 #/" tests/help/help-version-getopt.sh # Add debug info + we have less syscall then GNU's. Adjust our check. -"${SED}" -i -e '/test \$n_stat1 = \$n_stat2 \\/c\ +sed -i -e '/test \$n_stat1 = \$n_stat2 \\/c\ echo "n_stat1 = \$n_stat1"\n\ echo "n_stat2 = \$n_stat2"\n\ test \$n_stat1 -ge \$n_stat2 \\' tests/ls/stat-free-color.sh # no need to replicate this output with hashsum -"${SED}" -i -e "s|Try 'md5sum --help' for more information.\\\n||" tests/cksum/md5sum.pl +sed -i -e "s|Try 'md5sum --help' for more information.\\\n||" tests/cksum/md5sum.pl # Our ls command always outputs ANSI color codes prepended with a zero. However, # in the case of GNU, it seems inconsistent. Nevertheless, it looks like it # doesn't matter whether we prepend a zero or not. -"${SED}" -i -E 's/\^\[\[([1-9]m)/^[[0\1/g; s/\^\[\[m/^[[0m/g' tests/ls/color-norm.sh +sed -i -E 's/\^\[\[([1-9]m)/^[[0\1/g; s/\^\[\[m/^[[0m/g' tests/ls/color-norm.sh # It says in the test itself that having more than one reset is a bug, so we # don't need to replicate that behavior. -"${SED}" -i -E 's/(\^\[\[0m)+/\^\[\[0m/g' tests/ls/color-norm.sh +sed -i -E 's/(\^\[\[0m)+/\^\[\[0m/g' tests/ls/color-norm.sh # GNU's ls seems to output color codes in the order given in the environment # variable, but our ls seems to output them in a predefined order. Nevertheless, # the order doesn't matter, so it's okay. -"${SED}" -i 's/44;37/37;44/' tests/ls/multihardlink.sh +sed -i 's/44;37/37;44/' tests/ls/multihardlink.sh # Just like mentioned in the previous patch, GNU's ls output color codes in the # same way it is specified in the environment variable, but our ls emits them @@ -342,19 +343,19 @@ test \$n_stat1 -ge \$n_stat2 \\' tests/ls/stat-free-color.sh # individually, for example, ^[[31^[[42 instead of ^[[31;42, but we don't do # that anywhere in our implementation, and it looks like GNU's ls also doesn't # do that. So, it's okay to ignore the zero. -"${SED}" -i "s/color_code='0;31;42'/color_code='31;42'/" tests/ls/color-clear-to-eol.sh +sed -i "s/color_code='0;31;42'/color_code='31;42'/" tests/ls/color-clear-to-eol.sh # patching this because of the same reason as the last one. -"${SED}" -i "s/color_code='0;31;42'/color_code='31;42'/" tests/ls/quote-align.sh +sed -i "s/color_code='0;31;42'/color_code='31;42'/" tests/ls/quote-align.sh # Slightly different error message -"${SED}" -i 's/not supported/unexpected argument/' tests/mv/mv-exchange.sh +sed -i 's/not supported/unexpected argument/' tests/mv/mv-exchange.sh # upstream doesn't having the program name in the error message # but we do. We should keep it that way. -"${SED}" -i 's/echo "changing security context/echo "chcon: changing security context/' tests/chcon/chcon.sh +sed -i 's/echo "changing security context/echo "chcon: changing security context/' tests/chcon/chcon.sh # Disable this test, it is not relevant for us: # * the selinux crate is handling errors # * the test says "maybe we should not fail when no context available" -"${SED}" -i -e "s|returns_ 1||g" tests/cp/no-ctx.sh +sed -i -e "s|returns_ 1||g" tests/cp/no-ctx.sh diff --git a/util/fetch-gnu.sh b/util/fetch-gnu.sh index 54a893df5..8b2cebe6c 100755 --- a/util/fetch-gnu.sh +++ b/util/fetch-gnu.sh @@ -16,4 +16,5 @@ curl -L ${repo}/raw/refs/heads/master/tests/stty/bad-speed.sh > tests/stty/bad-s curl -L ${repo}/raw/refs/heads/master/tests/runcon/runcon-compute.sh > tests/runcon/runcon-compute.sh curl -L ${repo}/raw/refs/heads/master/tests/tac/tac-continue.sh > tests/tac/tac-continue.sh # Add tac-continue.sh to root tests (it requires root to mount tmpfs) -sed -i 's|tests/split/l-chunk-root.sh.*|tests/split/l-chunk-root.sh\t\t\t\\\n tests/tac/tac-continue.sh\t\t\t\\|' tests/local.mk +# Use sed -i.bak for macOS +sed -i.bak 's|tests/split/l-chunk-root.sh.*|tests/split/l-chunk-root.sh\t\t\t\\\n tests/tac/tac-continue.sh\t\t\t\\|' tests/local.mk From 6dac4d407483202a80e142d1e08d9bf1d4fc128b Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Sat, 17 Jan 2026 11:33:24 +0100 Subject: [PATCH 222/425] bench: try to remove more variances (#10277) --- src/uu/du/benches/du_bench.rs | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/src/uu/du/benches/du_bench.rs b/src/uu/du/benches/du_bench.rs index 5ea45ed9c..0b63ce9a2 100644 --- a/src/uu/du/benches/du_bench.rs +++ b/src/uu/du/benches/du_bench.rs @@ -61,17 +61,33 @@ fn du_human_balanced_tree( /// Benchmark du on wide directory structures (many files/dirs, shallow) #[divan::bench(args = [(5000, 500)])] fn du_wide_tree(bencher: Bencher, (total_files, total_dirs): (usize, usize)) { - let temp_dir = TempDir::new().unwrap(); - fs_tree::create_wide_tree(temp_dir.path(), total_files, total_dirs); - bench_du_with_args(bencher, &temp_dir, &[]); + bencher + .with_inputs(|| { + let temp_dir = TempDir::new().unwrap(); + fs_tree::create_wide_tree(temp_dir.path(), total_files, total_dirs); + temp_dir + }) + .bench_values(|temp_dir| { + let temp_path_str = temp_dir.path().to_str().unwrap(); + let args = vec![temp_path_str]; + black_box(run_util_function(uumain, &args)); + }); } /// Benchmark du -a on wide directory structures #[divan::bench(args = [(5000, 500)])] fn du_all_wide_tree(bencher: Bencher, (total_files, total_dirs): (usize, usize)) { - let temp_dir = TempDir::new().unwrap(); - fs_tree::create_wide_tree(temp_dir.path(), total_files, total_dirs); - bench_du_with_args(bencher, &temp_dir, &["-a"]); + bencher + .with_inputs(|| { + let temp_dir = TempDir::new().unwrap(); + fs_tree::create_wide_tree(temp_dir.path(), total_files, total_dirs); + temp_dir + }) + .bench_values(|temp_dir| { + let temp_path_str = temp_dir.path().to_str().unwrap(); + let args = vec![temp_path_str, "-a"]; + black_box(run_util_function(uumain, &args)); + }); } /// Benchmark du on deep directory structures From 038a08bff27ea5eddbf97a452e8f522e2ca157a7 Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Sat, 17 Jan 2026 12:13:51 +0100 Subject: [PATCH 223/425] contrib: add info about expectations and ping (#10275) --- CONTRIBUTING.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a7a006221..fcd0f1971 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -250,8 +250,8 @@ gitignore: add temporary files - It's up to you whether you want to use `git merge main` or `git rebase main`. - Feel free to ask for help with merge conflicts. -- You do not need to ping maintainers to request a review, but it's fine to do - so if you don't get a response within a few days. +- You do not need to ping maintainers to request a review immediately after submission. If you do not get a response to your patch within a few days, it is fine to request a review. + - If after a week your patch has still not been reviewed, we recommend that you ping the maintainers on our Discord channel in `#coreutils-chat`. ## Platforms From 2a044db3a2f02acbb5324a675f9a0bc6ccf84930 Mon Sep 17 00:00:00 2001 From: Max Ambaum Date: Sat, 17 Jan 2026 12:41:29 +0000 Subject: [PATCH 224/425] cp: Fixed POSIXLY_CORRECT test to actually check if file exists and its contents (#10247) cp: Added check of file contents in POSIXLY_CORRECT test cp: Fixed windows test by including path cp: Updated test --- tests/by-util/test_cp.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/by-util/test_cp.rs b/tests/by-util/test_cp.rs index 2d252c560..8cb844ce8 100644 --- a/tests/by-util/test_cp.rs +++ b/tests/by-util/test_cp.rs @@ -2991,11 +2991,15 @@ fn test_copy_through_dangling_symlink() { fn test_copy_through_dangling_symlink_posixly_correct() { let (at, mut ucmd) = at_and_ucmd!(); at.touch("file"); + at.write("file", "content"); at.symlink_file("nonexistent", "target"); ucmd.arg("file") .arg("target") .env("POSIXLY_CORRECT", "1") .succeeds(); + assert!(at.file_exists("nonexistent")); + let contents = at.read("nonexistent"); + assert_eq!(contents, "content"); } #[test] From 2cb20e02b2e4ed291808cc68d835093557d01b71 Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Sat, 17 Jan 2026 13:53:22 +0100 Subject: [PATCH 225/425] bench: reduce memory variance in cp and numfmt benchmarks --- src/uu/cp/benches/cp_bench.rs | 31 ++++++++++++++++----------- src/uu/numfmt/benches/numfmt_bench.rs | 29 ++++++++++++++----------- 2 files changed, 34 insertions(+), 26 deletions(-) diff --git a/src/uu/cp/benches/cp_bench.rs b/src/uu/cp/benches/cp_bench.rs index d673c14e4..84954f0bb 100644 --- a/src/uu/cp/benches/cp_bench.rs +++ b/src/uu/cp/benches/cp_bench.rs @@ -82,20 +82,25 @@ fn cp_preserve_metadata( #[divan::bench(args = [16])] fn cp_large_file(bencher: Bencher, size_mb: usize) { - let temp_dir = TempDir::new().unwrap(); - let source = temp_dir.path().join("source.bin"); - let dest = temp_dir.path().join("dest.bin"); + bencher + .with_inputs(|| { + let temp_dir = TempDir::new().unwrap(); + let source = temp_dir.path().join("source.bin"); + binary_data::create_file(&source, size_mb, b'x'); + (temp_dir, source) + }) + .counter(divan::counter::BytesCount::new(size_mb * 1024 * 1024)) + .bench_values(|(temp_dir, source)| { + // Use unique destination name to avoid filesystem allocation variance + let dest = temp_dir.path().join(format!( + "dest_{}.bin", + std::ptr::addr_of!(temp_dir) as usize + )); + let source_str = source.to_str().unwrap(); + let dest_str = dest.to_str().unwrap(); - binary_data::create_file(&source, size_mb, b'x'); - - let source_str = source.to_str().unwrap(); - let dest_str = dest.to_str().unwrap(); - - bencher.bench(|| { - fs_utils::remove_path(&dest); - - black_box(run_util_function(uumain, &[source_str, dest_str])); - }); + black_box(run_util_function(uumain, &[source_str, dest_str])); + }); } fn main() { diff --git a/src/uu/numfmt/benches/numfmt_bench.rs b/src/uu/numfmt/benches/numfmt_bench.rs index 561b65093..b3f86cce5 100644 --- a/src/uu/numfmt/benches/numfmt_bench.rs +++ b/src/uu/numfmt/benches/numfmt_bench.rs @@ -63,19 +63,22 @@ fn numfmt_from_si(bencher: Bencher, count: usize) { /// Benchmark large numbers with SI formatting #[divan::bench(args = [10_000])] fn numfmt_large_numbers_si(bencher: Bencher, count: usize) { - // Generate numbers that all produce uniform SI output lengths (all in 1-9M range) - // This avoids variance from variable output string lengths - let numbers: Vec = (1..=count) - .map(|n| ((n % 9) + 1) * 1_000_000) - .map(|n| n.to_string()) - .collect(); - let mut args = vec!["--to=si"]; - let number_refs: Vec<&str> = numbers.iter().map(|s| s.as_str()).collect(); - args.extend(number_refs); - - bencher.bench(|| { - black_box(run_util_function(uumain, &args)); - }); + bencher + .with_inputs(|| { + // Generate numbers that all produce uniform SI output lengths (all in 1-9M range) + // This avoids variance from variable output string lengths + let numbers: Vec = (1..=count) + .map(|n| ((n % 9) + 1) * 1_000_000) + .map(|n| n.to_string()) + .collect(); + let mut args: Vec = vec!["--to=si".to_string()]; + args.extend(numbers); + args + }) + .bench_values(|args| { + let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); + black_box(run_util_function(uumain, &arg_refs)); + }); } /// Benchmark different padding widths From 08c78f2e2df992b084190b28ae1ae34ad5474197 Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Sat, 17 Jan 2026 22:17:59 +0900 Subject: [PATCH 226/425] {README,CONTRIBUTIONS}.md: Add link to binaries from latest commit (#10280) * {README,CONTRIBUTIONS}.md: Add link to binaries from latest commit * Add the word "from main branch" at link to tag/latest-commit --- CONTRIBUTING.md | 4 ++-- README.md | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fcd0f1971..a8e463707 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -78,11 +78,11 @@ issues and writing documentation are just as important as writing code. We can't fix bugs we don't know about, so good issues are super helpful! Here are some tips for writing good issues: -- If you find a bug, make sure it's still a problem on the `main` branch. +- If you find a bug, make sure it's still a problem on the [`main` branch](https://github.com/uutils/coreutils/releases/tag/latest-commit). - Search through the existing issues to see whether it has already been reported. - Make sure to include all relevant information, such as: - - Which version of uutils did you check? + - Which version or commit hash of uutils did you check? - Which version of GNU coreutils are you comparing with? - What platform are you on? - Provide a way to reliably reproduce the issue. diff --git a/README.md b/README.md index e770bd543..f05bf77ea 100644 --- a/README.md +++ b/README.md @@ -29,8 +29,9 @@ options might be missing or different behavior might be experienced.
-We provide prebuilt binaries at https://github.com/uutils/coreutils/releases/latest . -It is recommended to install from main branch if you install from source. +We provide prebuilt binaries, manpages, and shell completions from main branch at https://github.com/uutils/coreutils/releases/tag/latest-commit . +The latest stable tag https://github.com/uutils/coreutils/releases/latest exists only for reproducible products and packagers. +You should use binary from latest commit generally.
From 7eb78ab8602748c8bd14554c0ac7797444973eb8 Mon Sep 17 00:00:00 2001 From: Andrus Suvalau Date: Sat, 17 Jan 2026 14:18:24 +0100 Subject: [PATCH 227/425] dd: get rid of line buffered stdout (#10235) * dd: get rid of line-buffered stdout Line-buffered stdout causes partial write and read operations in dd, which is an issue when writing binary data to stdout. Partial writes can lead to data loss and require passing iflag=fullblock to ensure that the exact number of bytes is read. * dd: Add test to check for dropped writes (cherry picked from commit 0f7c53111df8b945bdb06217396b332f24a42100) * Fix build on Windows * dd: use OwnedFileDescriptorOrHandle OwnedFileDescriptorOrHandle can be used to bypass the LineWriter that is used by default for Stdout. * Run test_no_dropped_writes only on unix --------- Co-authored-by: Adrian Kretz --- src/uu/dd/src/dd.rs | 7 ++++--- tests/by-util/test_dd.rs | 23 +++++++++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/uu/dd/src/dd.rs b/src/uu/dd/src/dd.rs index f5f4f9365..ebcc737fd 100644 --- a/src/uu/dd/src/dd.rs +++ b/src/uu/dd/src/dd.rs @@ -30,7 +30,7 @@ use std::cmp; use std::env; use std::ffi::OsString; use std::fs::{File, OpenOptions}; -use std::io::{self, Read, Seek, SeekFrom, Stdout, Write}; +use std::io::{self, Read, Seek, SeekFrom, Write}; #[cfg(any(target_os = "linux", target_os = "android"))] use std::os::fd::AsFd; #[cfg(any(target_os = "linux", target_os = "android"))] @@ -601,7 +601,7 @@ enum Density { /// Data destinations. enum Dest { /// Output to stdout. - Stdout(Stdout), + Stdout(File), /// Output to a file. /// @@ -829,7 +829,8 @@ struct Output<'a> { impl<'a> Output<'a> { /// Instantiate this struct with stdout as a destination. fn new_stdout(settings: &'a Settings) -> UResult { - let mut dst = Dest::Stdout(io::stdout()); + let fx = OwnedFileDescriptorOrHandle::from(io::stdout())?; + let mut dst = Dest::Stdout(fx.into_file()); dst.seek(settings.seek, settings.obs) .map_err_context(|| translate!("dd-error-write-error"))?; Ok(Self { dst, settings }) diff --git a/tests/by-util/test_dd.rs b/tests/by-util/test_dd.rs index 3e53f9a59..08ffa83d0 100644 --- a/tests/by-util/test_dd.rs +++ b/tests/by-util/test_dd.rs @@ -1814,6 +1814,29 @@ fn test_wrong_number_err_msg() { .stderr_contains("dd: invalid number: '1kBb555'\n"); } +#[test] +#[cfg(unix)] +fn test_no_dropped_writes() { + use std::process::Stdio; + + const BLK_SIZE: usize = 0x4000; + const COUNT: usize = 1000; + const NUM_BYTES: usize = BLK_SIZE * COUNT; + + let result = new_ucmd!() + .args(&[ + "if=/dev/urandom", + &format!("bs={BLK_SIZE}"), + &format!("count={COUNT}"), + ]) + .set_stdout(Stdio::piped()) + .set_stderr(Stdio::piped()) + .succeeds(); + + assert_eq!(result.stdout().len(), NUM_BYTES); + assert!(result.stderr_str().contains(&format!("{NUM_BYTES} bytes"))); +} + #[test] #[cfg(any(target_os = "linux", target_os = "android"))] fn test_oflag_direct_partial_block() { From 7707b72870b19c25cd3c076128f106ef4e47d704 Mon Sep 17 00:00:00 2001 From: Dhruv <62135445+dhr412@users.noreply.github.com> Date: Sat, 17 Jan 2026 18:56:15 +0530 Subject: [PATCH 228/425] Fix tail -c panics when requested bytes exceed file size (#10268) * Fix tail -c panics when requested bytes exceed file size * Add tail bytes exceed file size test * Update test to hit block size condition --- src/uu/tail/src/tail.rs | 4 +++- tests/by-util/test_tail.rs | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/uu/tail/src/tail.rs b/src/uu/tail/src/tail.rs index 8782db36f..2ffb537cf 100644 --- a/src/uu/tail/src/tail.rs +++ b/src/uu/tail/src/tail.rs @@ -473,7 +473,9 @@ fn bounded_tail(file: &mut File, settings: &Settings) { return; } FilterMode::Bytes(Signum::Negative(count)) => { - file.seek(SeekFrom::End(-(*count as i64))).unwrap(); + if file.seek(SeekFrom::End(-(*count as i64))).is_err() { + file.seek(SeekFrom::Start(0)).unwrap(); + } limit = Some(*count); } FilterMode::Bytes(Signum::Positive(count)) if count > &1 => { diff --git a/tests/by-util/test_tail.rs b/tests/by-util/test_tail.rs index 390633704..134cc78eb 100644 --- a/tests/by-util/test_tail.rs +++ b/tests/by-util/test_tail.rs @@ -5040,6 +5040,22 @@ fn tail_n_lines_with_emoji() { .stdout_only("💐\n"); } +#[test] +fn test_tail_bytes_exceeds_file_size() { + let ts = TestScenario::new(util_name!()); + let at = &ts.fixtures; + + // Should be > 4096 bytes (block size can vary): + at.write("test_file.txt", &"x".repeat(5000)); + + ts.ucmd() + .arg("-c") + .arg("1048576") + .arg("test_file.txt") + .succeeds() + .stdout_only("x".repeat(5000)); +} + #[test] #[cfg(target_os = "linux")] fn test_follow_pipe_f() { From 75f45e87e52ed95840494963ab9a28651165d56e Mon Sep 17 00:00:00 2001 From: Martin Kunkel <41590858+martinkunkel2@users.noreply.github.com> Date: Sat, 17 Jan 2026 14:28:04 +0100 Subject: [PATCH 229/425] comm: fix comparison when reading from pipes (#9545) * comm: fix comparison when reading from pipes Use case is that two files are piped into comm, i.e. in bash comm <(cat file1) <(cat file2) Before the fix, comm reads from the pipes twice. Once in "fn comm" and once in "fn are_files_identical". As such, part of the data is skipped in comparison which leads to wrong output. This is fixed by skipping the file comparison in case one of the files is not a regular file. * comm: add test for reading from pipes --- Cargo.toml | 1 + src/uu/comm/src/comm.rs | 5 +++++ tests/by-util/test_comm.rs | 39 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 45 insertions(+) diff --git a/Cargo.toml b/Cargo.toml index 77738518f..2a3625625 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -549,6 +549,7 @@ uutests.workspace = true uucore = { workspace = true, features = [ "mode", "entries", + "pipes", "process", "signals", "utmpx", diff --git a/src/uu/comm/src/comm.rs b/src/uu/comm/src/comm.rs index 80b20b53f..37ac4de1c 100644 --- a/src/uu/comm/src/comm.rs +++ b/src/uu/comm/src/comm.rs @@ -136,6 +136,11 @@ pub fn are_files_identical(path1: &Path, path2: &Path) -> io::Result { return Ok(false); } + // only proceed if both are regular files + if !metadata1.is_file() || !metadata2.is_file() { + return Ok(false); + } + let file1 = File::open(path1)?; let file2 = File::open(path2)?; diff --git a/tests/by-util/test_comm.rs b/tests/by-util/test_comm.rs index bf719d7fb..3194d270e 100644 --- a/tests/by-util/test_comm.rs +++ b/tests/by-util/test_comm.rs @@ -648,3 +648,42 @@ fn test_comm_eintr_handling() { .stdout_contains("line2") .stdout_contains("line3"); } + +#[test] +#[cfg(any(target_os = "linux", target_os = "android"))] +fn test_comm_anonymous_pipes() { + use std::{io::Write, os::fd::AsRawFd, process}; + use uucore::pipes::pipe; + + let scene = TestScenario::new(util_name!()); + + // Open two anonymous pipes + let (comm1_reader, mut comm1_writer) = pipe().unwrap(); + let (comm2_reader, mut comm2_writer) = pipe().unwrap(); + + // comm reads the data in chunks + // make content large enough, so that at least two chunks are read + // default buffer size is 8192, so with 6 characters (5 digits + \n) per line we need to write at least 1366 lines + + // write 1500 lines into comm1: 00000\n00001\n...01500\n + let mut content = String::new(); + for i in 0..1500 { + content.push_str(&format!("{i:05}\n")); + } + assert!(comm1_writer.write_all(content.as_bytes()).is_ok()); + drop(comm1_writer); + + // write into comm2: 00000\n00001\n...01500\n99999\n + content.push_str("99999\n"); + assert!(comm2_writer.write_all(content.as_bytes()).is_ok()); + drop(comm2_writer); + + // run comm, showing unique lines in second input + let comm1_fd = format!("/proc/{}/fd/{}", process::id(), comm1_reader.as_raw_fd()); + let comm2_fd = format!("/proc/{}/fd/{}", process::id(), comm2_reader.as_raw_fd()); + scene + .ucmd() + .args(&["-13", &comm1_fd, &comm2_fd]) + .succeeds() + .stdout_is("99999\n"); +} From 2b203791b7baaaa26d94a871cb06a097d4f58d97 Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Sat, 17 Jan 2026 16:07:38 +0100 Subject: [PATCH 230/425] bench: fix variance in remaining numfmt benchmarks --- src/uu/numfmt/benches/numfmt_bench.rs | 119 +++++++++++++++----------- 1 file changed, 68 insertions(+), 51 deletions(-) diff --git a/src/uu/numfmt/benches/numfmt_bench.rs b/src/uu/numfmt/benches/numfmt_bench.rs index b3f86cce5..aed3fb035 100644 --- a/src/uu/numfmt/benches/numfmt_bench.rs +++ b/src/uu/numfmt/benches/numfmt_bench.rs @@ -10,54 +10,66 @@ use uucore::benchmark::run_util_function; /// Benchmark SI formatting by passing numbers as command-line arguments #[divan::bench(args = [10_000])] fn numfmt_to_si(bencher: Bencher, count: usize) { - let numbers: Vec = (1..=count).map(|n| n.to_string()).collect(); - let mut args = vec!["--to=si"]; - let number_refs: Vec<&str> = numbers.iter().map(|s| s.as_str()).collect(); - args.extend(number_refs); - - bencher.bench(|| { - black_box(run_util_function(uumain, &args)); - }); + bencher + .with_inputs(|| { + let numbers: Vec = (1..=count).map(|n| n.to_string()).collect(); + let mut args: Vec = vec!["--to=si".to_string()]; + args.extend(numbers); + args + }) + .bench_values(|args| { + let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); + black_box(run_util_function(uumain, &arg_refs)); + }); } /// Benchmark SI formatting with precision format #[divan::bench(args = [10_000])] fn numfmt_to_si_precision(bencher: Bencher, count: usize) { - let numbers: Vec = (1..=count).map(|n| n.to_string()).collect(); - let mut args = vec!["--to=si", "--format=%.6f"]; - let number_refs: Vec<&str> = numbers.iter().map(|s| s.as_str()).collect(); - args.extend(number_refs); - - bencher.bench(|| { - black_box(run_util_function(uumain, &args)); - }); + bencher + .with_inputs(|| { + let numbers: Vec = (1..=count).map(|n| n.to_string()).collect(); + let mut args: Vec = vec!["--to=si".to_string(), "--format=%.6f".to_string()]; + args.extend(numbers); + args + }) + .bench_values(|args| { + let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); + black_box(run_util_function(uumain, &arg_refs)); + }); } /// Benchmark IEC (binary) formatting #[divan::bench(args = [10_000])] fn numfmt_to_iec(bencher: Bencher, count: usize) { - let numbers: Vec = (1..=count).map(|n| n.to_string()).collect(); - let mut args = vec!["--to=iec"]; - let number_refs: Vec<&str> = numbers.iter().map(|s| s.as_str()).collect(); - args.extend(number_refs); - - bencher.bench(|| { - black_box(run_util_function(uumain, &args)); - }); + bencher + .with_inputs(|| { + let numbers: Vec = (1..=count).map(|n| n.to_string()).collect(); + let mut args: Vec = vec!["--to=iec".to_string()]; + args.extend(numbers); + args + }) + .bench_values(|args| { + let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); + black_box(run_util_function(uumain, &arg_refs)); + }); } /// Benchmark parsing from SI format back to raw numbers #[divan::bench(args = [10_000])] fn numfmt_from_si(bencher: Bencher, count: usize) { - // Generate SI formatted data (e.g., "1K", "2K", etc.) - let numbers: Vec = (1..=count).map(|n| format!("{n}K")).collect(); - let mut args = vec!["--from=si"]; - let number_refs: Vec<&str> = numbers.iter().map(|s| s.as_str()).collect(); - args.extend(number_refs); - - bencher.bench(|| { - black_box(run_util_function(uumain, &args)); - }); + bencher + .with_inputs(|| { + // Generate SI formatted data (e.g., "1K", "2K", etc.) + let numbers: Vec = (1..=count).map(|n| format!("{n}K")).collect(); + let mut args: Vec = vec!["--from=si".to_string()]; + args.extend(numbers); + args + }) + .bench_values(|args| { + let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); + black_box(run_util_function(uumain, &arg_refs)); + }); } /// Benchmark large numbers with SI formatting @@ -84,29 +96,34 @@ fn numfmt_large_numbers_si(bencher: Bencher, count: usize) { /// Benchmark different padding widths #[divan::bench(args = [(10_000, 50)])] fn numfmt_padding(bencher: Bencher, (count, padding): (usize, usize)) { - let numbers: Vec = (1..=count).map(|n| n.to_string()).collect(); - let padding_arg = format!("--padding={padding}"); - let mut args = vec!["--to=si", &padding_arg]; - let number_refs: Vec<&str> = numbers.iter().map(|s| s.as_str()).collect(); - args.extend(number_refs); - - bencher.bench(|| { - black_box(run_util_function(uumain, &args)); - }); + bencher + .with_inputs(|| { + let numbers: Vec = (1..=count).map(|n| n.to_string()).collect(); + let mut args: Vec = vec!["--to=si".to_string(), format!("--padding={padding}")]; + args.extend(numbers); + args + }) + .bench_values(|args| { + let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); + black_box(run_util_function(uumain, &arg_refs)); + }); } /// Benchmark round modes with SI formatting #[divan::bench(args = [("up", 10_000), ("down", 10_000), ("towards-zero", 10_000)])] fn numfmt_round_modes(bencher: Bencher, (round_mode, count): (&str, usize)) { - let numbers: Vec = (1..=count).map(|n| n.to_string()).collect(); - let round_arg = format!("--round={round_mode}"); - let mut args = vec!["--to=si", &round_arg]; - let number_refs: Vec<&str> = numbers.iter().map(|s| s.as_str()).collect(); - args.extend(number_refs); - - bencher.bench(|| { - black_box(run_util_function(uumain, &args)); - }); + bencher + .with_inputs(|| { + let numbers: Vec = (1..=count).map(|n| n.to_string()).collect(); + let mut args: Vec = + vec!["--to=si".to_string(), format!("--round={round_mode}")]; + args.extend(numbers); + args + }) + .bench_values(|args| { + let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); + black_box(run_util_function(uumain, &arg_refs)); + }); } fn main() { From cd288fc40141bbe46689ce73977779da43252dec Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Sat, 17 Jan 2026 16:20:39 +0100 Subject: [PATCH 231/425] ci: ensure test failures are caught in coverage script and fix them (#10286) --- src/uucore/src/lib/features/proc_info.rs | 15 ++++++++++----- src/uucore/src/lib/features/process.rs | 12 +++++------- tests/by-util/test_dd.rs | 4 ++++ util/build-run-test-coverage-linux.sh | 9 ++++++++- 4 files changed, 27 insertions(+), 13 deletions(-) diff --git a/src/uucore/src/lib/features/proc_info.rs b/src/uucore/src/lib/features/proc_info.rs index 8345e7e09..d36f5d010 100644 --- a/src/uucore/src/lib/features/proc_info.rs +++ b/src/uucore/src/lib/features/proc_info.rs @@ -465,11 +465,16 @@ mod tests { .flat_map(Teletype::try_from) .collect::>(); - assert_eq!(result.len(), 1); - assert_eq!( - pid_entry.tty(), - Vec::from_iter(result.into_iter()).first().unwrap().clone() - ); + // In CI environments or when running without a terminal, there may be no TTY + if result.is_empty() { + assert_eq!(pid_entry.tty(), Teletype::Unknown); + } else { + assert_eq!(result.len(), 1); + assert_eq!( + pid_entry.tty(), + Vec::from_iter(result.into_iter()).first().unwrap().clone() + ); + } } #[test] diff --git a/src/uucore/src/lib/features/process.rs b/src/uucore/src/lib/features/process.rs index 55e8c3648..043d4850d 100644 --- a/src/uucore/src/lib/features/process.rs +++ b/src/uucore/src/lib/features/process.rs @@ -67,13 +67,11 @@ pub fn getpid() -> pid_t { /// so some system such as redox doesn't supported. #[cfg(not(target_os = "redox"))] pub fn getsid(pid: i32) -> Result { - unsafe { - let result = libc::getsid(pid); - if Errno::last() == Errno::UnknownErrno { - Ok(result) - } else { - Err(Errno::last()) - } + let result = unsafe { libc::getsid(pid) }; + if result == -1 { + Err(Errno::last()) + } else { + Ok(result) } } diff --git a/tests/by-util/test_dd.rs b/tests/by-util/test_dd.rs index 08ffa83d0..ce0eec3d1 100644 --- a/tests/by-util/test_dd.rs +++ b/tests/by-util/test_dd.rs @@ -1655,6 +1655,8 @@ fn test_reading_partial_blocks_from_fifo() { .stdout(Stdio::piped()) .stderr(Stdio::piped()) .env("LC_ALL", "C") + .env("LANG", "C") + .env("LANGUAGE", "C") .spawn() .unwrap(); @@ -1700,6 +1702,8 @@ fn test_reading_partial_blocks_from_fifo_unbuffered() { .stdout(Stdio::piped()) .stderr(Stdio::piped()) .env("LC_ALL", "C") + .env("LANG", "C") + .env("LANGUAGE", "C") .spawn() .unwrap(); diff --git a/util/build-run-test-coverage-linux.sh b/util/build-run-test-coverage-linux.sh index 9dcfefed2..8aba21530 100755 --- a/util/build-run-test-coverage-linux.sh +++ b/util/build-run-test-coverage-linux.sh @@ -28,6 +28,8 @@ set -e # Treat unset variables as errors set -u +# Ensure pipeline failures are caught (not just the last command's exit code) +set -o pipefail # Print expanded commands to stdout before running them set -x @@ -39,7 +41,12 @@ REPO_main_dir="$(dirname -- "${ME_dir}")" FEATURES_OPTION=${FEATURES_OPTION:-"--features=feat_os_unix"} COVERAGE_DIR=${COVERAGE_DIR:-"${REPO_main_dir}/coverage"} -LLVM_PROFDATA="$(find "$(rustc --print sysroot)" -name llvm-profdata)" +# Find llvm-profdata in the nightly toolchain (which is used for coverage builds) +LLVM_PROFDATA="$(find "$(RUSTUP_TOOLCHAIN=nightly-gnu rustc --print sysroot)" -name llvm-profdata)" +if [ -z "${LLVM_PROFDATA}" ]; then + echo "Error: llvm-profdata not found. Install it with: rustup +nightly-gnu component add llvm-tools" + exit 1 +fi PROFRAW_DIR="${COVERAGE_DIR}/traces" PROFDATA_DIR="${COVERAGE_DIR}/data" From f2bec7b9fe285a61e1fe2143ce480112abba75a9 Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Sat, 17 Jan 2026 16:36:28 +0100 Subject: [PATCH 232/425] date/test: %x format specifier respects locale settings (#10285) --- tests/by-util/test_date.rs | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/by-util/test_date.rs b/tests/by-util/test_date.rs index 744cffecb..336d07a1c 100644 --- a/tests/by-util/test_date.rs +++ b/tests/by-util/test_date.rs @@ -1469,3 +1469,31 @@ fn test_date_posix_format_specifiers() { .stdout_is(format!("{expected}\n")); } } + +/// Test that %x format specifier respects locale settings +/// This is a regression test for locale-aware date formatting +#[test] +#[ignore = "https://bugs.launchpad.net/ubuntu/+source/rust-coreutils/+bug/2137410"] +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +fn test_date_format_x_locale_aware() { + // With C locale, %x should output MM/DD/YY (US format) + new_ucmd!() + .env("TZ", "UTC") + .env("LC_ALL", "C") + .arg("-d") + .arg("1997-01-19 08:17:48") + .arg("+%x") + .succeeds() + .stdout_is("01/19/97\n"); + + // With French locale, %x should output DD/MM/YYYY (European format) + // GNU date outputs: 19/01/1997 + new_ucmd!() + .env("TZ", "UTC") + .env("LC_ALL", "fr_FR.UTF-8") + .arg("-d") + .arg("1997-01-19 08:17:48") + .arg("+%x") + .succeeds() + .stdout_is("19/01/1997\n"); +} From b3ad96f8a004e0c70bb7b90ff5aaabea5eb5c19d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=E1=BA=A3=20th=E1=BA=BF=20gi=E1=BB=9Bi=20l=C3=A0=20Rust?= <90588855+naoNao89@users.noreply.github.com> Date: Sat, 17 Jan 2026 23:36:28 +0700 Subject: [PATCH 233/425] refactor(dirname): implement pure string manipulation per POSIX (#8936) --- .../cspell.dictionaries/jargon.wordlist.txt | 1 + src/uu/dirname/src/dirname.rs | 138 +++++++++++------- tests/by-util/test_dirname.rs | 70 ++++++++- 3 files changed, 151 insertions(+), 58 deletions(-) diff --git a/.vscode/cspell.dictionaries/jargon.wordlist.txt b/.vscode/cspell.dictionaries/jargon.wordlist.txt index 33f495948..2f21572f7 100644 --- a/.vscode/cspell.dictionaries/jargon.wordlist.txt +++ b/.vscode/cspell.dictionaries/jargon.wordlist.txt @@ -202,6 +202,7 @@ nofield # * clippy uninlined nonminimal +rposition # * CPU/hardware features ASIMD diff --git a/src/uu/dirname/src/dirname.rs b/src/uu/dirname/src/dirname.rs index 3399b4a03..8659465cd 100644 --- a/src/uu/dirname/src/dirname.rs +++ b/src/uu/dirname/src/dirname.rs @@ -4,8 +4,9 @@ // file that was distributed with this source code. use clap::{Arg, ArgAction, Command}; +use std::borrow::Cow; use std::ffi::OsString; -use std::path::Path; +#[cfg(unix)] use uucore::display::print_verbatim; use uucore::error::{UResult, UUsageError}; use uucore::format_usage; @@ -18,51 +19,84 @@ mod options { pub const DIR: &str = "dir"; } -/// Handle the special case where a path ends with "/." +/// Perform dirname as pure string manipulation per POSIX/GNU behavior. +/// +/// dirname should NOT normalize paths. It does simple string manipulation: +/// 1. Strip trailing slashes (unless path is all slashes) +/// 2. If ends with `/.` (possibly `//.` or `///.`), strip the `/+.` pattern +/// 3. Otherwise, remove everything after the last `/` +/// 4. If no `/` found, return `.` +/// 5. Strip trailing slashes from result (unless result would be empty) +/// +/// Examples: +/// - `foo/.` → `foo` +/// - `foo/./bar` → `foo/.` +/// - `foo/bar` → `foo` +/// - `a/b/c` → `a/b` /// -/// This matches GNU/POSIX behavior where `dirname("/home/dos/.")` returns "/home/dos" -/// rather than "/home" (which would be the result of `Path::parent()` due to normalization). /// Per POSIX.1-2017 dirname specification and GNU coreutils manual: /// - POSIX: /// - GNU: /// -/// dirname should do simple string manipulation without path normalization. /// See issue #8910 and similar fix in basename (#8373, commit c5268a897). -/// -/// Returns `Some(())` if the special case was handled (output already printed), -/// or `None` if normal `Path::parent()` logic should be used. -fn handle_trailing_dot(path_bytes: &[u8]) -> Option<()> { - if !path_bytes.ends_with(b"/.") { - return None; +fn dirname_string_manipulation(path_bytes: &[u8]) -> Cow<'_, [u8]> { + if path_bytes.is_empty() { + return Cow::Borrowed(b"."); } - // Strip the "/." suffix and print the result - if path_bytes.len() == 2 { - // Special case: "/." -> "/" - print!("/"); - Some(()) - } else { - // General case: "/home/dos/." -> "/home/dos" - let stripped = &path_bytes[..path_bytes.len() - 2]; - #[cfg(unix)] - { - use std::os::unix::ffi::OsStrExt; - let result = std::ffi::OsStr::from_bytes(stripped); - print_verbatim(result).unwrap(); - Some(()) - } - #[cfg(not(unix))] - { - // On non-Unix, fall back to lossy conversion - if let Ok(s) = std::str::from_utf8(stripped) { - print!("{s}"); - Some(()) - } else { - // Can't handle non-UTF-8 on non-Unix, fall through to normal logic - None + let mut bytes = path_bytes; + + // Step 1: Strip trailing slashes (but not if the entire path is slashes) + let all_slashes = bytes.iter().all(|&b| b == b'/'); + if all_slashes { + return Cow::Borrowed(b"/"); + } + + while bytes.len() > 1 && bytes.ends_with(b"/") { + bytes = &bytes[..bytes.len() - 1]; + } + + // Step 2: Check if it ends with `/.` and strip the `/+.` pattern + if bytes.ends_with(b".") && bytes.len() >= 2 { + let dot_pos = bytes.len() - 1; + if bytes[dot_pos - 1] == b'/' { + // Find where the slashes before the dot start + let mut slash_start = dot_pos - 1; + while slash_start > 0 && bytes[slash_start - 1] == b'/' { + slash_start -= 1; } + // Return the stripped result + if slash_start == 0 { + // Result would be empty + return if path_bytes.starts_with(b"/") { + Cow::Borrowed(b"/") + } else { + Cow::Borrowed(b".") + }; + } + return Cow::Owned(bytes[..slash_start].to_vec()); } } + + // Step 3: Normal dirname - find last / and remove everything after it + if let Some(last_slash_pos) = bytes.iter().rposition(|&b| b == b'/') { + // Found a slash, remove everything after it + let mut result = &bytes[..last_slash_pos]; + + // Strip trailing slashes from result (but keep at least one if at the start) + while result.len() > 1 && result.ends_with(b"/") { + result = &result[..result.len() - 1]; + } + + if result.is_empty() { + return Cow::Borrowed(b"/"); + } + + return Cow::Owned(result.to_vec()); + } + + // No slash found, return "." + Cow::Borrowed(b".") } #[uucore::main] @@ -83,27 +117,25 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { for path in &dirnames { let path_bytes = uucore::os_str_as_bytes(path.as_os_str()).unwrap_or(&[]); + let result = dirname_string_manipulation(path_bytes); - if handle_trailing_dot(path_bytes).is_none() { - // Normal path handling using Path::parent() - let p = Path::new(path); - match p.parent() { - Some(d) => { - if d.components().next().is_none() { - print!("."); - } else { - print_verbatim(d).unwrap(); - } - } - None => { - if p.is_absolute() || path.as_os_str() == "/" { - print!("/"); - } else { - print!("."); - } - } + #[cfg(unix)] + { + use std::os::unix::ffi::OsStrExt; + let result_os = std::ffi::OsStr::from_bytes(&result); + print_verbatim(result_os).unwrap(); + } + #[cfg(not(unix))] + { + // On non-Unix, fall back to lossy conversion + if let Ok(s) = std::str::from_utf8(&result) { + print!("{s}"); + } else { + // Fallback for non-UTF-8 paths on non-Unix systems + print!("."); } } + print!("{line_ending}"); } diff --git a/tests/by-util/test_dirname.rs b/tests/by-util/test_dirname.rs index c7cdf3a46..bd6994107 100644 --- a/tests/by-util/test_dirname.rs +++ b/tests/by-util/test_dirname.rs @@ -9,6 +9,11 @@ fn test_invalid_arg() { new_ucmd!().arg("--definitely-invalid").fails_with_code(1); } +#[test] +fn test_missing_operand() { + new_ucmd!().fails_with_code(1); +} + #[test] fn test_path_with_trailing_slashes() { new_ucmd!() @@ -71,15 +76,11 @@ fn test_dirname_non_utf8_paths() { use std::ffi::OsStr; use std::os::unix::ffi::OsStrExt; - // Create a test file with non-UTF-8 bytes in the name let non_utf8_bytes = b"test_\xFF\xFE/file.txt"; let non_utf8_name = OsStr::from_bytes(non_utf8_bytes); - // Test that dirname handles non-UTF-8 paths without crashing let result = new_ucmd!().arg(non_utf8_name).succeeds(); - // Just verify it didn't crash and produced some output - // The exact output format may vary due to lossy conversion let output = result.stdout_str_lossy(); assert!(!output.is_empty()); assert!(output.contains("test_")); @@ -156,7 +157,7 @@ fn test_trailing_dot_edge_cases() { new_ucmd!() .arg("/home/dos//.") .succeeds() - .stdout_is("/home/dos/\n"); + .stdout_is("/home/dos\n"); // Path with . in middle (should use normal logic) new_ucmd!() @@ -216,3 +217,62 @@ fn test_existing_behavior_preserved() { .succeeds() .stdout_is("/home/dos\n"); } + +#[test] +fn test_multiple_paths_comprehensive() { + // Comprehensive test for multiple paths in single invocation + new_ucmd!() + .args(&[ + "/home/dos/.", + "/var/log", + ".", + "/tmp/.", + "", + "/", + "relative/path", + ]) + .succeeds() + .stdout_is("/home/dos\n/var\n.\n/tmp\n.\n/\nrelative\n"); +} + +#[test] +fn test_all_dot_slash_variations() { + // Tests for all the cases mentioned in issue #8910 comment + // https://github.com/uutils/coreutils/issues/8910#issuecomment-3408735720 + + new_ucmd!().arg("foo//.").succeeds().stdout_is("foo\n"); + + new_ucmd!().arg("foo///.").succeeds().stdout_is("foo\n"); + + new_ucmd!().arg("foo/./").succeeds().stdout_is("foo\n"); + + new_ucmd!() + .arg("foo/bar/./") + .succeeds() + .stdout_is("foo/bar\n"); + + new_ucmd!().arg("foo/./bar").succeeds().stdout_is("foo/.\n"); +} + +#[test] +fn test_dot_slash_component_preservation() { + // Ensure that /. components in the middle are preserved + // These should NOT be normalized away + + new_ucmd!().arg("a/./b").succeeds().stdout_is("a/.\n"); + + new_ucmd!() + .arg("a/./b/./c") + .succeeds() + .stdout_is("a/./b/.\n"); + + new_ucmd!() + .arg("foo/./bar/baz") + .succeeds() + .stdout_is("foo/./bar\n"); + + new_ucmd!() + .arg("/path/./to/file") + .succeeds() + .stdout_is("/path/./to\n"); +} From b5bbabc18a1121908848d836f869a4e98eb63886 Mon Sep 17 00:00:00 2001 From: Dalton Caron Date: Sat, 17 Jan 2026 09:08:14 -0800 Subject: [PATCH 234/425] install: prevent TOCTOU race attack (#10067) * install: prevent TOCTOU race attack * cspell: add TOCTOU acronym to jargon word list --- .../cspell.dictionaries/jargon.wordlist.txt | 1 + src/uu/install/locales/en-US.ftl | 2 +- src/uu/install/locales/fr-FR.ftl | 2 +- src/uu/install/src/install.rs | 54 +++++-------------- tests/by-util/test_install.rs | 22 ++++++++ 5 files changed, 39 insertions(+), 42 deletions(-) diff --git a/.vscode/cspell.dictionaries/jargon.wordlist.txt b/.vscode/cspell.dictionaries/jargon.wordlist.txt index 2f21572f7..e4987609f 100644 --- a/.vscode/cspell.dictionaries/jargon.wordlist.txt +++ b/.vscode/cspell.dictionaries/jargon.wordlist.txt @@ -184,6 +184,7 @@ inacc maint proc procs +TOCTOU # * constants xffff diff --git a/src/uu/install/locales/en-US.ftl b/src/uu/install/locales/en-US.ftl index 0261f7320..76265a2b1 100644 --- a/src/uu/install/locales/en-US.ftl +++ b/src/uu/install/locales/en-US.ftl @@ -30,7 +30,7 @@ install-error-chown-failed = failed to chown { $path }: { $error } install-error-invalid-target = invalid target { $path }: No such file or directory install-error-target-not-dir = target { $path } is not a directory install-error-backup-failed = cannot backup { $from } to { $to } -install-error-install-failed = cannot install { $from } to { $to } +install-error-install-failed = cannot install { $from } to { $to }: { $error } install-error-strip-failed = strip program failed: { $error } install-error-strip-abnormal = strip process terminated abnormally - exit code: { $code } install-error-metadata-failed = metadata error diff --git a/src/uu/install/locales/fr-FR.ftl b/src/uu/install/locales/fr-FR.ftl index 208712c21..330ceb7b4 100644 --- a/src/uu/install/locales/fr-FR.ftl +++ b/src/uu/install/locales/fr-FR.ftl @@ -30,7 +30,7 @@ install-error-chown-failed = échec du chown { $path } : { $error } install-error-invalid-target = cible invalide { $path } : Aucun fichier ou répertoire de ce type install-error-target-not-dir = la cible { $path } n'est pas un répertoire install-error-backup-failed = impossible de sauvegarder { $from } vers { $to } -install-error-install-failed = impossible d'installer { $from } vers { $to } +install-error-install-failed = impossible d'installer { $from } vers { $to }: { $error } install-error-strip-failed = échec du programme strip : { $error } install-error-strip-abnormal = le processus strip s'est terminé anormalement - code de sortie : { $code } install-error-metadata-failed = erreur de métadonnées diff --git a/src/uu/install/src/install.rs b/src/uu/install/src/install.rs index d3e0b3e89..e128470fc 100644 --- a/src/uu/install/src/install.rs +++ b/src/uu/install/src/install.rs @@ -14,8 +14,8 @@ use filetime::{FileTime, set_file_times}; use selinux::SecurityContext; use std::ffi::OsString; use std::fmt::Debug; -use std::fs::File; use std::fs::{self, metadata}; +use std::fs::{File, OpenOptions}; use std::path::{MAIN_SEPARATOR, Path, PathBuf}; use std::process; use thiserror::Error; @@ -36,7 +36,7 @@ use uucore::translate; use uucore::{format_usage, show, show_error, show_if_err}; #[cfg(unix)] -use std::os::unix::fs::{FileTypeExt, MetadataExt}; +use std::os::unix::fs::MetadataExt; #[cfg(unix)] use std::os::unix::prelude::OsStrExt; @@ -88,8 +88,8 @@ enum InstallError { #[error("{}", translate!("install-error-backup-failed", "from" => .0.quote(), "to" => .1.quote()))] BackupFailed(PathBuf, PathBuf, #[source] std::io::Error), - #[error("{}", translate!("install-error-install-failed", "from" => .0.quote(), "to" => .1.quote()))] - InstallFailed(PathBuf, PathBuf, #[source] std::io::Error), + #[error("{}", translate!("install-error-install-failed", "from" => .0.quote(), "to" => .1.quote(), "error" => .2.clone()))] + InstallFailed(PathBuf, PathBuf, String), #[error("{}", translate!("install-error-strip-failed", "error" => .0.clone()))] StripProgramFailed(String), @@ -796,22 +796,6 @@ fn perform_backup(to: &Path, b: &Behavior) -> UResult> { } } -/// Copy a non-special file using [`fs::copy`]. -/// -/// # Parameters -/// * `from` - The source file path. -/// * `to` - The destination file path. -/// -/// # Returns -/// -/// Returns an empty Result or an error in case of failure. -fn copy_normal_file(from: &Path, to: &Path) -> UResult<()> { - if let Err(err) = fs::copy(from, to) { - return Err(InstallError::InstallFailed(from.to_path_buf(), to.to_path_buf(), err).into()); - } - Ok(()) -} - /// Copy a file from one path to another. Handles the certain cases of special /// files (e.g character specials). /// @@ -838,8 +822,10 @@ fn copy_file(from: &Path, to: &Path) -> UResult<()> { ) .into()); } - // fs::copy fails if destination is a invalid symlink. - // so lets just remove all existing files at destination before copy. + + // Remove existing file at destination to allow overwriting + // Note: create_new() below provides TOCTOU protection; if something + // appears at this path between the remove and create, it will fail safely if let Err(e) = fs::remove_file(to) { if e.kind() != std::io::ErrorKind::NotFound { show_error!( @@ -849,25 +835,13 @@ fn copy_file(from: &Path, to: &Path) -> UResult<()> { } } - let ft = match metadata(from) { - Ok(ft) => ft.file_type(), - Err(err) => { - return Err( - InstallError::InstallFailed(from.to_path_buf(), to.to_path_buf(), err).into(), - ); - } - }; + let mut handle = File::open(from)?; + // create_new provides TOCTOU protection + let mut dest = OpenOptions::new().write(true).create_new(true).open(to)?; - // Stream-based copying to get around the limitations of std::fs::copy - #[cfg(unix)] - if ft.is_char_device() || ft.is_block_device() || ft.is_fifo() { - let mut handle = File::open(from)?; - let mut dest = File::create(to)?; - copy_stream(&mut handle, &mut dest)?; - return Ok(()); - } - - copy_normal_file(from, to)?; + copy_stream(&mut handle, &mut dest).map_err(|err| { + InstallError::InstallFailed(from.to_path_buf(), to.to_path_buf(), err.to_string()) + })?; Ok(()) } diff --git a/tests/by-util/test_install.rs b/tests/by-util/test_install.rs index b6a998a02..7a2ccb875 100644 --- a/tests/by-util/test_install.rs +++ b/tests/by-util/test_install.rs @@ -2545,3 +2545,25 @@ fn test_install_unprivileged_option_u_skips_chown() { assert!(at.file_exists(dst_ok)); assert_eq!(at.metadata(dst_ok).uid(), geteuid()); } + +#[test] +fn test_install_normal_file_replaces_symlink() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + at.write("source", "new content"); + at.write("sensitive", "important data"); + + // Create symlink at destination + at.symlink_file("sensitive", "dest"); + + // Install should replace symlink with normal file (not follow it) + scene.ucmd().arg("source").arg("dest").succeeds(); + + // Verify dest is now a normal file, not a symlink + assert!(at.file_exists("dest")); + assert_eq!(at.read("dest"), "new content"); + + // Verify sensitive file was NOT modified + assert_eq!(at.read("sensitive"), "important data"); +} From 8ad8a500acfc18e037d0c26014627253dbbc71e2 Mon Sep 17 00:00:00 2001 From: Chris Dryden Date: Sat, 17 Jan 2026 14:15:34 -0500 Subject: [PATCH 235/425] Add 'tests/tail/follow-name' to ignore list (#10297) This one keeps popping up in PR's recently --- .github/workflows/ignore-intermittent.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ignore-intermittent.txt b/.github/workflows/ignore-intermittent.txt index 1e5086c10..e6cb5dc64 100644 --- a/.github/workflows/ignore-intermittent.txt +++ b/.github/workflows/ignore-intermittent.txt @@ -8,3 +8,4 @@ tests/tty/tty-eof tests/misc/stdbuf tests/misc/usage_vs_getopt tests/misc/tee +tests/tail/follow-name From 0333fb51f0f1886c77866fd37b0c40a632cce6b4 Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Sun, 18 Jan 2026 04:17:14 +0900 Subject: [PATCH 236/425] CICD.yml: Remove zsh completion for [ (#10278) --- .github/workflows/CICD.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/CICD.yml b/.github/workflows/CICD.yml index bc42a6bc5..1d77f67b0 100644 --- a/.github/workflows/CICD.yml +++ b/.github/workflows/CICD.yml @@ -902,6 +902,7 @@ jobs: ${_uudoc} completion ${bin} zsh > share/zsh/site-functions/_${bin} ${_uudoc} completion ${bin} elvish > share/elvish/lib/${bin}.elv done + rm share/zsh/site-functions/_[ # not supported tar --zstd -cf docs.tar.zst share - name: Publish uses: softprops/action-gh-release@v2 From d94332468d9640b3be35bd93a8e71e67b41ba62c Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Sat, 17 Jan 2026 20:24:20 +0100 Subject: [PATCH 237/425] tee: allow multiple -a flags (#10293) Fixes issue where tee rejected multiple append flags with error "the argument '--append' cannot be used multiple times". GNU tee accepts multiple -a flags, so this adds compatibility. --- src/uu/tee/src/tee.rs | 3 ++- tests/by-util/test_tee.rs | 24 ++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/uu/tee/src/tee.rs b/src/uu/tee/src/tee.rs index 1325b0465..cf3d89c0a 100644 --- a/src/uu/tee/src/tee.rs +++ b/src/uu/tee/src/tee.rs @@ -115,7 +115,8 @@ pub fn uu_app() -> Command { .long(options::APPEND) .short('a') .help(translate!("tee-help-append")) - .action(ArgAction::SetTrue), + .action(ArgAction::SetTrue) + .overrides_with(options::APPEND), ) .arg( Arg::new(options::IGNORE_INTERRUPTS) diff --git a/tests/by-util/test_tee.rs b/tests/by-util/test_tee.rs index ba6993371..4a3e16912 100644 --- a/tests/by-util/test_tee.rs +++ b/tests/by-util/test_tee.rs @@ -91,6 +91,30 @@ fn test_tee_append() { assert_eq!(at.read(file), content.repeat(2)); } +#[test] +fn test_tee_multiple_append_flags() { + // Test for bug: https://bugs.launchpad.net/ubuntu/+source/rust-coreutils/+bug/2134578 + // The command should accept multiple -a flags for different files + let (at, mut ucmd) = at_and_ucmd!(); + let content = "don't fail me now rust"; + let file1 = "log1"; + let file2 = "log2"; + + // Pre-populate files with some content to verify append behavior + at.write(file1, "existing1\n"); + at.write(file2, "existing2\n"); + + ucmd.args(&["-a", file1, "-a", file2]) + .pipe_in(content) + .succeeds() + .stdout_is(content); + + assert!(at.file_exists(file1)); + assert!(at.file_exists(file2)); + assert_eq!(at.read(file1), format!("existing1\n{content}")); + assert_eq!(at.read(file2), format!("existing2\n{content}")); +} + #[test] fn test_readonly() { let (at, mut ucmd) = at_and_ucmd!(); From f01d1e8422744b271cb7574fe5b1205c7423c550 Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Sat, 17 Jan 2026 20:35:09 +0100 Subject: [PATCH 238/425] dirname: use Cow::Borrowed to avoid unnecessary heap allocations (#10294) + remove useless comments --- src/uu/dirname/src/dirname.rs | 4 ++-- tests/by-util/test_dirname.rs | 15 --------------- 2 files changed, 2 insertions(+), 17 deletions(-) diff --git a/src/uu/dirname/src/dirname.rs b/src/uu/dirname/src/dirname.rs index 8659465cd..6bd91d910 100644 --- a/src/uu/dirname/src/dirname.rs +++ b/src/uu/dirname/src/dirname.rs @@ -74,7 +74,7 @@ fn dirname_string_manipulation(path_bytes: &[u8]) -> Cow<'_, [u8]> { Cow::Borrowed(b".") }; } - return Cow::Owned(bytes[..slash_start].to_vec()); + return Cow::Borrowed(&bytes[..slash_start]); } } @@ -92,7 +92,7 @@ fn dirname_string_manipulation(path_bytes: &[u8]) -> Cow<'_, [u8]> { return Cow::Borrowed(b"/"); } - return Cow::Owned(result.to_vec()); + return Cow::Borrowed(result); } // No slash found, return "." diff --git a/tests/by-util/test_dirname.rs b/tests/by-util/test_dirname.rs index bd6994107..92350261d 100644 --- a/tests/by-util/test_dirname.rs +++ b/tests/by-util/test_dirname.rs @@ -106,8 +106,6 @@ fn test_emoji_handling() { #[test] fn test_trailing_dot() { - // Basic case: path ending with /. should return parent without stripping last component - // This matches GNU coreutils behavior and fixes issue #8910 new_ucmd!() .arg("/home/dos/.") .succeeds() @@ -183,26 +181,19 @@ fn test_trailing_dot_non_utf8() { use std::ffi::OsStr; use std::os::unix::ffi::OsStrExt; - // Create a path with non-UTF-8 bytes ending in /. let non_utf8_bytes = b"/test_\xFF\xFE/."; let non_utf8_path = OsStr::from_bytes(non_utf8_bytes); - // Test that dirname handles non-UTF-8 paths with /. suffix let result = new_ucmd!().arg(non_utf8_path).succeeds(); - // The output should be the path without the /. suffix let output = result.stdout_str_lossy(); assert!(!output.is_empty()); assert!(output.contains("test_")); - // Should not contain the . at the end assert!(!output.trim().ends_with('.')); } #[test] fn test_existing_behavior_preserved() { - // Ensure we didn't break existing test cases - // These tests verify backward compatibility - // Normal paths without /. should work as before new_ucmd!().arg("/home/dos").succeeds().stdout_is("/home\n"); @@ -237,9 +228,6 @@ fn test_multiple_paths_comprehensive() { #[test] fn test_all_dot_slash_variations() { - // Tests for all the cases mentioned in issue #8910 comment - // https://github.com/uutils/coreutils/issues/8910#issuecomment-3408735720 - new_ucmd!().arg("foo//.").succeeds().stdout_is("foo\n"); new_ucmd!().arg("foo///.").succeeds().stdout_is("foo\n"); @@ -256,9 +244,6 @@ fn test_all_dot_slash_variations() { #[test] fn test_dot_slash_component_preservation() { - // Ensure that /. components in the middle are preserved - // These should NOT be normalized away - new_ucmd!().arg("a/./b").succeeds().stdout_is("a/.\n"); new_ucmd!() From c4985953f8b077416df65b620bf035b306cd4d55 Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Sat, 17 Jan 2026 21:12:10 +0100 Subject: [PATCH 239/425] uucore: simplify cfg checks in signals.rs (#10296) Remove redundant unix conditions since macOS is already covered by the unix target family --- src/uucore/src/lib/features/signals.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/uucore/src/lib/features/signals.rs b/src/uucore/src/lib/features/signals.rs index 1c4d684a7..8f2c822cb 100644 --- a/src/uucore/src/lib/features/signals.rs +++ b/src/uucore/src/lib/features/signals.rs @@ -457,13 +457,13 @@ pub unsafe extern "C" fn capture_sigpipe_state() { #[cfg(unix)] macro_rules! init_sigpipe_capture { () => { - #[cfg(all(unix, not(target_os = "macos")))] + #[cfg(not(target_os = "macos"))] #[used] #[unsafe(link_section = ".init_array")] static CAPTURE_SIGPIPE_STATE: unsafe extern "C" fn() = $crate::signals::capture_sigpipe_state; - #[cfg(all(unix, target_os = "macos"))] + #[cfg(target_os = "macos")] #[used] #[unsafe(link_section = "__DATA,__mod_init_func")] static CAPTURE_SIGPIPE_STATE: unsafe extern "C" fn() = From 2c3c68706ed3f7df0b40fe066636a2e2c235f678 Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Sun, 18 Jan 2026 06:15:46 +0900 Subject: [PATCH 240/425] CICD.yml: Add man and completion for coreutils(1) to docs.tar.zst --- .github/workflows/CICD.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/CICD.yml b/.github/workflows/CICD.yml index 1d77f67b0..7f667076a 100644 --- a/.github/workflows/CICD.yml +++ b/.github/workflows/CICD.yml @@ -895,7 +895,7 @@ jobs: run: | mkdir -p share/{man/man1,bash-completion/completions,fish/vendor_completions.d,zsh/site-functions,elvish/lib} _uudoc=target/${{ matrix.job.target }}/release/uudoc - for bin in $('target/${{ matrix.job.target }}/release/coreutils' --list);do + for bin in $('target/${{ matrix.job.target }}/release/coreutils' --list) coreutils;do ${_uudoc} manpage ${bin} > share/man/man1/${bin}.1 ${_uudoc} completion ${bin} bash > share/bash-completion/completions/${bin}.bash ${_uudoc} completion ${bin} fish > share/fish/vendor_completions.d/${bin}.fish From 9ea24d66b0a0c89a7d73e6a960e08df21c74249a Mon Sep 17 00:00:00 2001 From: Chris Dryden Date: Sat, 17 Jan 2026 17:13:54 -0500 Subject: [PATCH 241/425] timeout: display signal 0 as '0' instead of 'EXIT' in verbose mode (#10194) This is done to match a change in GNU coreutils after v9.9 --- src/uu/timeout/src/timeout.rs | 6 +++++- tests/by-util/test_timeout.rs | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/uu/timeout/src/timeout.rs b/src/uu/timeout/src/timeout.rs index de20bec83..84d946348 100644 --- a/src/uu/timeout/src/timeout.rs +++ b/src/uu/timeout/src/timeout.rs @@ -210,7 +210,11 @@ fn catch_sigterm() { /// Report that a signal is being sent if the verbose flag is set. fn report_if_verbose(signal: usize, cmd: &str, verbose: bool) { if verbose { - let s = signal_name_by_value(signal).unwrap(); + let s = if signal == 0 { + "0".to_string() + } else { + signal_name_by_value(signal).unwrap().to_string() + }; show_error!( "{}", translate!("timeout-verbose-sending-signal", "signal" => s, "command" => cmd.quote()) diff --git a/tests/by-util/test_timeout.rs b/tests/by-util/test_timeout.rs index 9c5c6c1a4..adce254d5 100644 --- a/tests/by-util/test_timeout.rs +++ b/tests/by-util/test_timeout.rs @@ -58,7 +58,7 @@ fn test_verbose() { new_ucmd!() .args(&[verbose_flag, "-s0", "-k.1", ".1", "sleep", "1"]) .fails() - .stderr_only("timeout: sending signal EXIT to command 'sleep'\ntimeout: sending signal KILL to command 'sleep'\n"); + .stderr_only("timeout: sending signal 0 to command 'sleep'\ntimeout: sending signal KILL to command 'sleep'\n"); } } From f606980ddfd1536aeb4e848de1841a5a3792f68d Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Sat, 17 Jan 2026 16:35:33 +0100 Subject: [PATCH 242/425] prepare release 0.6.0 --- Cargo.lock | 214 +++++++++++++++++++------------------- Cargo.toml | 216 +++++++++++++++++++-------------------- fuzz/Cargo.lock | 32 +++--- fuzz/uufuzz/Cargo.toml | 4 +- src/uu/stdbuf/Cargo.toml | 2 +- util/update-version.sh | 4 +- 6 files changed, 236 insertions(+), 236 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5cfe22c15..3cac5c62f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -506,7 +506,7 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "coreutils" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "clap_complete", @@ -3011,7 +3011,7 @@ dependencies = [ [[package]] name = "uu_arch" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "fluent", @@ -3021,7 +3021,7 @@ dependencies = [ [[package]] name = "uu_base32" -version = "0.5.0" +version = "0.6.0" dependencies = [ "base64-simd", "clap", @@ -3031,7 +3031,7 @@ dependencies = [ [[package]] name = "uu_base64" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "codspeed-divan-compat", @@ -3043,7 +3043,7 @@ dependencies = [ [[package]] name = "uu_basename" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "fluent", @@ -3052,7 +3052,7 @@ dependencies = [ [[package]] name = "uu_basenc" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "fluent", @@ -3062,7 +3062,7 @@ dependencies = [ [[package]] name = "uu_cat" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "fluent", @@ -3077,7 +3077,7 @@ dependencies = [ [[package]] name = "uu_chcon" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "fluent", @@ -3090,7 +3090,7 @@ dependencies = [ [[package]] name = "uu_chgrp" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "fluent", @@ -3099,7 +3099,7 @@ dependencies = [ [[package]] name = "uu_chmod" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "fluent", @@ -3109,7 +3109,7 @@ dependencies = [ [[package]] name = "uu_chown" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "fluent", @@ -3118,7 +3118,7 @@ dependencies = [ [[package]] name = "uu_chroot" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "fluent", @@ -3128,7 +3128,7 @@ dependencies = [ [[package]] name = "uu_cksum" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "codspeed-divan-compat", @@ -3139,7 +3139,7 @@ dependencies = [ [[package]] name = "uu_comm" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "fluent", @@ -3148,7 +3148,7 @@ dependencies = [ [[package]] name = "uu_cp" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "codspeed-divan-compat", @@ -3168,7 +3168,7 @@ dependencies = [ [[package]] name = "uu_csplit" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "fluent", @@ -3179,7 +3179,7 @@ dependencies = [ [[package]] name = "uu_cut" -version = "0.5.0" +version = "0.6.0" dependencies = [ "bstr", "clap", @@ -3192,7 +3192,7 @@ dependencies = [ [[package]] name = "uu_date" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "codspeed-divan-compat", @@ -3207,7 +3207,7 @@ dependencies = [ [[package]] name = "uu_dd" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "codspeed-divan-compat", @@ -3223,7 +3223,7 @@ dependencies = [ [[package]] name = "uu_df" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "codspeed-divan-compat", @@ -3236,7 +3236,7 @@ dependencies = [ [[package]] name = "uu_dir" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "uu_ls", @@ -3245,7 +3245,7 @@ dependencies = [ [[package]] name = "uu_dircolors" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "fluent", @@ -3254,7 +3254,7 @@ dependencies = [ [[package]] name = "uu_dirname" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "fluent", @@ -3263,7 +3263,7 @@ dependencies = [ [[package]] name = "uu_du" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "codspeed-divan-compat", @@ -3277,7 +3277,7 @@ dependencies = [ [[package]] name = "uu_echo" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "fluent", @@ -3286,7 +3286,7 @@ dependencies = [ [[package]] name = "uu_env" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "fluent", @@ -3298,7 +3298,7 @@ dependencies = [ [[package]] name = "uu_expand" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "codspeed-divan-compat", @@ -3311,7 +3311,7 @@ dependencies = [ [[package]] name = "uu_expr" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "fluent", @@ -3324,7 +3324,7 @@ dependencies = [ [[package]] name = "uu_factor" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "codspeed-divan-compat", @@ -3338,7 +3338,7 @@ dependencies = [ [[package]] name = "uu_false" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "fluent", @@ -3347,7 +3347,7 @@ dependencies = [ [[package]] name = "uu_fmt" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "fluent", @@ -3358,7 +3358,7 @@ dependencies = [ [[package]] name = "uu_fold" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "codspeed-divan-compat", @@ -3370,7 +3370,7 @@ dependencies = [ [[package]] name = "uu_groups" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "fluent", @@ -3380,7 +3380,7 @@ dependencies = [ [[package]] name = "uu_hashsum" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "codspeed-divan-compat", @@ -3391,7 +3391,7 @@ dependencies = [ [[package]] name = "uu_head" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "fluent", @@ -3402,7 +3402,7 @@ dependencies = [ [[package]] name = "uu_hostid" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "fluent", @@ -3412,7 +3412,7 @@ dependencies = [ [[package]] name = "uu_hostname" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "dns-lookup", @@ -3424,7 +3424,7 @@ dependencies = [ [[package]] name = "uu_id" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "fluent", @@ -3434,7 +3434,7 @@ dependencies = [ [[package]] name = "uu_install" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "file_diff", @@ -3447,7 +3447,7 @@ dependencies = [ [[package]] name = "uu_join" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "codspeed-divan-compat", @@ -3460,7 +3460,7 @@ dependencies = [ [[package]] name = "uu_kill" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "fluent", @@ -3470,7 +3470,7 @@ dependencies = [ [[package]] name = "uu_link" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "fluent", @@ -3479,7 +3479,7 @@ dependencies = [ [[package]] name = "uu_ln" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "fluent", @@ -3489,7 +3489,7 @@ dependencies = [ [[package]] name = "uu_logname" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "fluent", @@ -3499,7 +3499,7 @@ dependencies = [ [[package]] name = "uu_ls" -version = "0.5.0" +version = "0.6.0" dependencies = [ "ansi-width", "clap", @@ -3519,7 +3519,7 @@ dependencies = [ [[package]] name = "uu_mkdir" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "fluent", @@ -3528,7 +3528,7 @@ dependencies = [ [[package]] name = "uu_mkfifo" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "fluent", @@ -3539,7 +3539,7 @@ dependencies = [ [[package]] name = "uu_mknod" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "fluent", @@ -3549,7 +3549,7 @@ dependencies = [ [[package]] name = "uu_mktemp" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "fluent", @@ -3561,7 +3561,7 @@ dependencies = [ [[package]] name = "uu_more" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "crossterm", @@ -3573,7 +3573,7 @@ dependencies = [ [[package]] name = "uu_mv" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "codspeed-divan-compat", @@ -3589,7 +3589,7 @@ dependencies = [ [[package]] name = "uu_nice" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "fluent", @@ -3600,7 +3600,7 @@ dependencies = [ [[package]] name = "uu_nl" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "codspeed-divan-compat", @@ -3612,7 +3612,7 @@ dependencies = [ [[package]] name = "uu_nohup" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "fluent", @@ -3623,7 +3623,7 @@ dependencies = [ [[package]] name = "uu_nproc" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "fluent", @@ -3633,7 +3633,7 @@ dependencies = [ [[package]] name = "uu_numfmt" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "codspeed-divan-compat", @@ -3645,7 +3645,7 @@ dependencies = [ [[package]] name = "uu_od" -version = "0.5.0" +version = "0.6.0" dependencies = [ "byteorder", "clap", @@ -3657,7 +3657,7 @@ dependencies = [ [[package]] name = "uu_paste" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "fluent", @@ -3666,7 +3666,7 @@ dependencies = [ [[package]] name = "uu_pathchk" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "fluent", @@ -3676,7 +3676,7 @@ dependencies = [ [[package]] name = "uu_pinky" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "fluent", @@ -3685,7 +3685,7 @@ dependencies = [ [[package]] name = "uu_pr" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "fluent", @@ -3697,7 +3697,7 @@ dependencies = [ [[package]] name = "uu_printenv" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "fluent", @@ -3706,7 +3706,7 @@ dependencies = [ [[package]] name = "uu_printf" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "fluent", @@ -3715,7 +3715,7 @@ dependencies = [ [[package]] name = "uu_ptx" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "fluent", @@ -3726,7 +3726,7 @@ dependencies = [ [[package]] name = "uu_pwd" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "fluent", @@ -3735,7 +3735,7 @@ dependencies = [ [[package]] name = "uu_readlink" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "fluent", @@ -3744,7 +3744,7 @@ dependencies = [ [[package]] name = "uu_realpath" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "fluent", @@ -3753,7 +3753,7 @@ dependencies = [ [[package]] name = "uu_rm" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "codspeed-divan-compat", @@ -3768,7 +3768,7 @@ dependencies = [ [[package]] name = "uu_rmdir" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "fluent", @@ -3778,7 +3778,7 @@ dependencies = [ [[package]] name = "uu_runcon" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "fluent", @@ -3790,7 +3790,7 @@ dependencies = [ [[package]] name = "uu_seq" -version = "0.5.0" +version = "0.6.0" dependencies = [ "bigdecimal", "clap", @@ -3805,7 +3805,7 @@ dependencies = [ [[package]] name = "uu_shred" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "fluent", @@ -3816,7 +3816,7 @@ dependencies = [ [[package]] name = "uu_shuf" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "codspeed-divan-compat", @@ -3829,7 +3829,7 @@ dependencies = [ [[package]] name = "uu_sleep" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "fluent", @@ -3838,7 +3838,7 @@ dependencies = [ [[package]] name = "uu_sort" -version = "0.5.0" +version = "0.6.0" dependencies = [ "bigdecimal", "binary-heap-plus", @@ -3862,7 +3862,7 @@ dependencies = [ [[package]] name = "uu_split" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "codspeed-divan-compat", @@ -3875,7 +3875,7 @@ dependencies = [ [[package]] name = "uu_stat" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "fluent", @@ -3885,7 +3885,7 @@ dependencies = [ [[package]] name = "uu_stdbuf" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "fluent", @@ -3897,7 +3897,7 @@ dependencies = [ [[package]] name = "uu_stdbuf_libstdbuf" -version = "0.5.0" +version = "0.6.0" dependencies = [ "ctor", "libc", @@ -3905,7 +3905,7 @@ dependencies = [ [[package]] name = "uu_stty" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "fluent", @@ -3915,7 +3915,7 @@ dependencies = [ [[package]] name = "uu_sum" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "fluent", @@ -3924,7 +3924,7 @@ dependencies = [ [[package]] name = "uu_sync" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "fluent", @@ -3935,7 +3935,7 @@ dependencies = [ [[package]] name = "uu_tac" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "fluent", @@ -3949,7 +3949,7 @@ dependencies = [ [[package]] name = "uu_tail" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "fluent", @@ -3966,7 +3966,7 @@ dependencies = [ [[package]] name = "uu_tee" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "fluent", @@ -3975,7 +3975,7 @@ dependencies = [ [[package]] name = "uu_test" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "fluent", @@ -3987,7 +3987,7 @@ dependencies = [ [[package]] name = "uu_timeout" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "fluent", @@ -3998,7 +3998,7 @@ dependencies = [ [[package]] name = "uu_touch" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "filetime", @@ -4012,7 +4012,7 @@ dependencies = [ [[package]] name = "uu_tr" -version = "0.5.0" +version = "0.6.0" dependencies = [ "bytecount", "clap", @@ -4023,7 +4023,7 @@ dependencies = [ [[package]] name = "uu_true" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "fluent", @@ -4032,7 +4032,7 @@ dependencies = [ [[package]] name = "uu_truncate" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "fluent", @@ -4041,7 +4041,7 @@ dependencies = [ [[package]] name = "uu_tsort" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "codspeed-divan-compat", @@ -4055,7 +4055,7 @@ dependencies = [ [[package]] name = "uu_tty" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "fluent", @@ -4065,7 +4065,7 @@ dependencies = [ [[package]] name = "uu_uname" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "fluent", @@ -4075,7 +4075,7 @@ dependencies = [ [[package]] name = "uu_unexpand" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "codspeed-divan-compat", @@ -4088,7 +4088,7 @@ dependencies = [ [[package]] name = "uu_uniq" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "codspeed-divan-compat", @@ -4099,7 +4099,7 @@ dependencies = [ [[package]] name = "uu_unlink" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "fluent", @@ -4108,7 +4108,7 @@ dependencies = [ [[package]] name = "uu_uptime" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "fluent", @@ -4120,7 +4120,7 @@ dependencies = [ [[package]] name = "uu_users" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "fluent", @@ -4130,7 +4130,7 @@ dependencies = [ [[package]] name = "uu_vdir" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "uu_ls", @@ -4139,7 +4139,7 @@ dependencies = [ [[package]] name = "uu_wc" -version = "0.5.0" +version = "0.6.0" dependencies = [ "bytecount", "clap", @@ -4155,7 +4155,7 @@ dependencies = [ [[package]] name = "uu_who" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "fluent", @@ -4164,7 +4164,7 @@ dependencies = [ [[package]] name = "uu_whoami" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "fluent", @@ -4174,7 +4174,7 @@ dependencies = [ [[package]] name = "uu_yes" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "fluent", @@ -4185,7 +4185,7 @@ dependencies = [ [[package]] name = "uucore" -version = "0.5.0" +version = "0.6.0" dependencies = [ "base64-simd", "bigdecimal", @@ -4241,7 +4241,7 @@ dependencies = [ [[package]] name = "uucore_procs" -version = "0.5.0" +version = "0.6.0" dependencies = [ "proc-macro2", "quote", @@ -4259,7 +4259,7 @@ dependencies = [ [[package]] name = "uutests" -version = "0.5.0" +version = "0.6.0" dependencies = [ "ctor", "libc", diff --git a/Cargo.toml b/Cargo.toml index 2a3625625..7d3ee2462 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -302,7 +302,7 @@ homepage = "https://github.com/uutils/coreutils" keywords = ["coreutils", "uutils", "cross-platform", "cli", "utility"] license = "MIT" readme = "README.package.md" -version = "0.5.0" +version = "0.6.0" [workspace.dependencies] ansi-width = "0.1.0" @@ -400,11 +400,11 @@ fluent-bundle = "0.16.0" unic-langid = "0.9.6" fluent-syntax = "0.12.0" -uucore = { version = "0.5.0", package = "uucore", path = "src/uucore" } -uucore_procs = { version = "0.5.0", package = "uucore_procs", path = "src/uucore_procs" } -uu_ls = { version = "0.5.0", path = "src/uu/ls" } -uu_base32 = { version = "0.5.0", path = "src/uu/base32" } -uutests = { version = "0.5.0", package = "uutests", path = "tests/uutests" } +uucore = { version = "0.6.0", package = "uucore", path = "src/uucore" } +uucore_procs = { version = "0.6.0", package = "uucore_procs", path = "src/uucore_procs" } +uu_ls = { version = "0.6.0", path = "src/uu/ls" } +uu_base32 = { version = "0.6.0", path = "src/uu/base32" } +uutests = { version = "0.6.0", package = "uutests", path = "tests/uutests" } [dependencies] clap.workspace = true @@ -419,109 +419,109 @@ zip = { workspace = true, optional = true } # * uutils -uu_test = { optional = true, version = "0.5.0", package = "uu_test", path = "src/uu/test" } +uu_test = { optional = true, version = "0.6.0", package = "uu_test", path = "src/uu/test" } # -arch = { optional = true, version = "0.5.0", package = "uu_arch", path = "src/uu/arch" } -base32 = { optional = true, version = "0.5.0", package = "uu_base32", path = "src/uu/base32" } -base64 = { optional = true, version = "0.5.0", package = "uu_base64", path = "src/uu/base64" } -basename = { optional = true, version = "0.5.0", package = "uu_basename", path = "src/uu/basename" } -basenc = { optional = true, version = "0.5.0", package = "uu_basenc", path = "src/uu/basenc" } -cat = { optional = true, version = "0.5.0", package = "uu_cat", path = "src/uu/cat" } -chcon = { optional = true, version = "0.5.0", package = "uu_chcon", path = "src/uu/chcon" } -chgrp = { optional = true, version = "0.5.0", package = "uu_chgrp", path = "src/uu/chgrp" } -chmod = { optional = true, version = "0.5.0", package = "uu_chmod", path = "src/uu/chmod" } -chown = { optional = true, version = "0.5.0", package = "uu_chown", path = "src/uu/chown" } -chroot = { optional = true, version = "0.5.0", package = "uu_chroot", path = "src/uu/chroot" } -cksum = { optional = true, version = "0.5.0", package = "uu_cksum", path = "src/uu/cksum" } -comm = { optional = true, version = "0.5.0", package = "uu_comm", path = "src/uu/comm" } -cp = { optional = true, version = "0.5.0", package = "uu_cp", path = "src/uu/cp" } -csplit = { optional = true, version = "0.5.0", package = "uu_csplit", path = "src/uu/csplit" } -cut = { optional = true, version = "0.5.0", package = "uu_cut", path = "src/uu/cut" } -date = { optional = true, version = "0.5.0", package = "uu_date", path = "src/uu/date" } -dd = { optional = true, version = "0.5.0", package = "uu_dd", path = "src/uu/dd" } -df = { optional = true, version = "0.5.0", package = "uu_df", path = "src/uu/df" } -dir = { optional = true, version = "0.5.0", package = "uu_dir", path = "src/uu/dir" } -dircolors = { optional = true, version = "0.5.0", package = "uu_dircolors", path = "src/uu/dircolors" } -dirname = { optional = true, version = "0.5.0", package = "uu_dirname", path = "src/uu/dirname" } -du = { optional = true, version = "0.5.0", package = "uu_du", path = "src/uu/du" } -echo = { optional = true, version = "0.5.0", package = "uu_echo", path = "src/uu/echo" } -env = { optional = true, version = "0.5.0", package = "uu_env", path = "src/uu/env" } -expand = { optional = true, version = "0.5.0", package = "uu_expand", path = "src/uu/expand" } -expr = { optional = true, version = "0.5.0", package = "uu_expr", path = "src/uu/expr" } -factor = { optional = true, version = "0.5.0", package = "uu_factor", path = "src/uu/factor" } -false = { optional = true, version = "0.5.0", package = "uu_false", path = "src/uu/false" } -fmt = { optional = true, version = "0.5.0", package = "uu_fmt", path = "src/uu/fmt" } -fold = { optional = true, version = "0.5.0", package = "uu_fold", path = "src/uu/fold" } -groups = { optional = true, version = "0.5.0", package = "uu_groups", path = "src/uu/groups" } -hashsum = { optional = true, version = "0.5.0", package = "uu_hashsum", path = "src/uu/hashsum" } -head = { optional = true, version = "0.5.0", package = "uu_head", path = "src/uu/head" } -hostid = { optional = true, version = "0.5.0", package = "uu_hostid", path = "src/uu/hostid" } -hostname = { optional = true, version = "0.5.0", package = "uu_hostname", path = "src/uu/hostname" } -id = { optional = true, version = "0.5.0", package = "uu_id", path = "src/uu/id" } -install = { optional = true, version = "0.5.0", package = "uu_install", path = "src/uu/install" } -join = { optional = true, version = "0.5.0", package = "uu_join", path = "src/uu/join" } -kill = { optional = true, version = "0.5.0", package = "uu_kill", path = "src/uu/kill" } -link = { optional = true, version = "0.5.0", package = "uu_link", path = "src/uu/link" } -ln = { optional = true, version = "0.5.0", package = "uu_ln", path = "src/uu/ln" } -ls = { optional = true, version = "0.5.0", package = "uu_ls", path = "src/uu/ls" } -logname = { optional = true, version = "0.5.0", package = "uu_logname", path = "src/uu/logname" } -mkdir = { optional = true, version = "0.5.0", package = "uu_mkdir", path = "src/uu/mkdir" } -mkfifo = { optional = true, version = "0.5.0", package = "uu_mkfifo", path = "src/uu/mkfifo" } -mknod = { optional = true, version = "0.5.0", package = "uu_mknod", path = "src/uu/mknod" } -mktemp = { optional = true, version = "0.5.0", package = "uu_mktemp", path = "src/uu/mktemp" } -more = { optional = true, version = "0.5.0", package = "uu_more", path = "src/uu/more" } -mv = { optional = true, version = "0.5.0", package = "uu_mv", path = "src/uu/mv" } -nice = { optional = true, version = "0.5.0", package = "uu_nice", path = "src/uu/nice" } -nl = { optional = true, version = "0.5.0", package = "uu_nl", path = "src/uu/nl" } -nohup = { optional = true, version = "0.5.0", package = "uu_nohup", path = "src/uu/nohup" } -nproc = { optional = true, version = "0.5.0", package = "uu_nproc", path = "src/uu/nproc" } -numfmt = { optional = true, version = "0.5.0", package = "uu_numfmt", path = "src/uu/numfmt" } -od = { optional = true, version = "0.5.0", package = "uu_od", path = "src/uu/od" } -paste = { optional = true, version = "0.5.0", package = "uu_paste", path = "src/uu/paste" } -pathchk = { optional = true, version = "0.5.0", package = "uu_pathchk", path = "src/uu/pathchk" } -pinky = { optional = true, version = "0.5.0", package = "uu_pinky", path = "src/uu/pinky" } -pr = { optional = true, version = "0.5.0", package = "uu_pr", path = "src/uu/pr" } -printenv = { optional = true, version = "0.5.0", package = "uu_printenv", path = "src/uu/printenv" } -printf = { optional = true, version = "0.5.0", package = "uu_printf", path = "src/uu/printf" } -ptx = { optional = true, version = "0.5.0", package = "uu_ptx", path = "src/uu/ptx" } -pwd = { optional = true, version = "0.5.0", package = "uu_pwd", path = "src/uu/pwd" } -readlink = { optional = true, version = "0.5.0", package = "uu_readlink", path = "src/uu/readlink" } -realpath = { optional = true, version = "0.5.0", package = "uu_realpath", path = "src/uu/realpath" } -rm = { optional = true, version = "0.5.0", package = "uu_rm", path = "src/uu/rm" } -rmdir = { optional = true, version = "0.5.0", package = "uu_rmdir", path = "src/uu/rmdir" } -runcon = { optional = true, version = "0.5.0", package = "uu_runcon", path = "src/uu/runcon" } -seq = { optional = true, version = "0.5.0", package = "uu_seq", path = "src/uu/seq" } -shred = { optional = true, version = "0.5.0", package = "uu_shred", path = "src/uu/shred" } -shuf = { optional = true, version = "0.5.0", package = "uu_shuf", path = "src/uu/shuf" } -sleep = { optional = true, version = "0.5.0", package = "uu_sleep", path = "src/uu/sleep" } -sort = { optional = true, version = "0.5.0", package = "uu_sort", path = "src/uu/sort" } -split = { optional = true, version = "0.5.0", package = "uu_split", path = "src/uu/split" } -stat = { optional = true, version = "0.5.0", package = "uu_stat", path = "src/uu/stat" } -stdbuf = { optional = true, version = "0.5.0", package = "uu_stdbuf", path = "src/uu/stdbuf" } -stty = { optional = true, version = "0.5.0", package = "uu_stty", path = "src/uu/stty" } -sum = { optional = true, version = "0.5.0", package = "uu_sum", path = "src/uu/sum" } -sync = { optional = true, version = "0.5.0", package = "uu_sync", path = "src/uu/sync" } -tac = { optional = true, version = "0.5.0", package = "uu_tac", path = "src/uu/tac" } -tail = { optional = true, version = "0.5.0", package = "uu_tail", path = "src/uu/tail" } -tee = { optional = true, version = "0.5.0", package = "uu_tee", path = "src/uu/tee" } -timeout = { optional = true, version = "0.5.0", package = "uu_timeout", path = "src/uu/timeout" } -touch = { optional = true, version = "0.5.0", package = "uu_touch", path = "src/uu/touch" } -tr = { optional = true, version = "0.5.0", package = "uu_tr", path = "src/uu/tr" } -true = { optional = true, version = "0.5.0", package = "uu_true", path = "src/uu/true" } -truncate = { optional = true, version = "0.5.0", package = "uu_truncate", path = "src/uu/truncate" } -tsort = { optional = true, version = "0.5.0", package = "uu_tsort", path = "src/uu/tsort" } -tty = { optional = true, version = "0.5.0", package = "uu_tty", path = "src/uu/tty" } -uname = { optional = true, version = "0.5.0", package = "uu_uname", path = "src/uu/uname" } -unexpand = { optional = true, version = "0.5.0", package = "uu_unexpand", path = "src/uu/unexpand" } -uniq = { optional = true, version = "0.5.0", package = "uu_uniq", path = "src/uu/uniq" } -unlink = { optional = true, version = "0.5.0", package = "uu_unlink", path = "src/uu/unlink" } -uptime = { optional = true, version = "0.5.0", package = "uu_uptime", path = "src/uu/uptime" } -users = { optional = true, version = "0.5.0", package = "uu_users", path = "src/uu/users" } -vdir = { optional = true, version = "0.5.0", package = "uu_vdir", path = "src/uu/vdir" } -wc = { optional = true, version = "0.5.0", package = "uu_wc", path = "src/uu/wc" } -who = { optional = true, version = "0.5.0", package = "uu_who", path = "src/uu/who" } -whoami = { optional = true, version = "0.5.0", package = "uu_whoami", path = "src/uu/whoami" } -yes = { optional = true, version = "0.5.0", package = "uu_yes", path = "src/uu/yes" } +arch = { optional = true, version = "0.6.0", package = "uu_arch", path = "src/uu/arch" } +base32 = { optional = true, version = "0.6.0", package = "uu_base32", path = "src/uu/base32" } +base64 = { optional = true, version = "0.6.0", package = "uu_base64", path = "src/uu/base64" } +basename = { optional = true, version = "0.6.0", package = "uu_basename", path = "src/uu/basename" } +basenc = { optional = true, version = "0.6.0", package = "uu_basenc", path = "src/uu/basenc" } +cat = { optional = true, version = "0.6.0", package = "uu_cat", path = "src/uu/cat" } +chcon = { optional = true, version = "0.6.0", package = "uu_chcon", path = "src/uu/chcon" } +chgrp = { optional = true, version = "0.6.0", package = "uu_chgrp", path = "src/uu/chgrp" } +chmod = { optional = true, version = "0.6.0", package = "uu_chmod", path = "src/uu/chmod" } +chown = { optional = true, version = "0.6.0", package = "uu_chown", path = "src/uu/chown" } +chroot = { optional = true, version = "0.6.0", package = "uu_chroot", path = "src/uu/chroot" } +cksum = { optional = true, version = "0.6.0", package = "uu_cksum", path = "src/uu/cksum" } +comm = { optional = true, version = "0.6.0", package = "uu_comm", path = "src/uu/comm" } +cp = { optional = true, version = "0.6.0", package = "uu_cp", path = "src/uu/cp" } +csplit = { optional = true, version = "0.6.0", package = "uu_csplit", path = "src/uu/csplit" } +cut = { optional = true, version = "0.6.0", package = "uu_cut", path = "src/uu/cut" } +date = { optional = true, version = "0.6.0", package = "uu_date", path = "src/uu/date" } +dd = { optional = true, version = "0.6.0", package = "uu_dd", path = "src/uu/dd" } +df = { optional = true, version = "0.6.0", package = "uu_df", path = "src/uu/df" } +dir = { optional = true, version = "0.6.0", package = "uu_dir", path = "src/uu/dir" } +dircolors = { optional = true, version = "0.6.0", package = "uu_dircolors", path = "src/uu/dircolors" } +dirname = { optional = true, version = "0.6.0", package = "uu_dirname", path = "src/uu/dirname" } +du = { optional = true, version = "0.6.0", package = "uu_du", path = "src/uu/du" } +echo = { optional = true, version = "0.6.0", package = "uu_echo", path = "src/uu/echo" } +env = { optional = true, version = "0.6.0", package = "uu_env", path = "src/uu/env" } +expand = { optional = true, version = "0.6.0", package = "uu_expand", path = "src/uu/expand" } +expr = { optional = true, version = "0.6.0", package = "uu_expr", path = "src/uu/expr" } +factor = { optional = true, version = "0.6.0", package = "uu_factor", path = "src/uu/factor" } +false = { optional = true, version = "0.6.0", package = "uu_false", path = "src/uu/false" } +fmt = { optional = true, version = "0.6.0", package = "uu_fmt", path = "src/uu/fmt" } +fold = { optional = true, version = "0.6.0", package = "uu_fold", path = "src/uu/fold" } +groups = { optional = true, version = "0.6.0", package = "uu_groups", path = "src/uu/groups" } +hashsum = { optional = true, version = "0.6.0", package = "uu_hashsum", path = "src/uu/hashsum" } +head = { optional = true, version = "0.6.0", package = "uu_head", path = "src/uu/head" } +hostid = { optional = true, version = "0.6.0", package = "uu_hostid", path = "src/uu/hostid" } +hostname = { optional = true, version = "0.6.0", package = "uu_hostname", path = "src/uu/hostname" } +id = { optional = true, version = "0.6.0", package = "uu_id", path = "src/uu/id" } +install = { optional = true, version = "0.6.0", package = "uu_install", path = "src/uu/install" } +join = { optional = true, version = "0.6.0", package = "uu_join", path = "src/uu/join" } +kill = { optional = true, version = "0.6.0", package = "uu_kill", path = "src/uu/kill" } +link = { optional = true, version = "0.6.0", package = "uu_link", path = "src/uu/link" } +ln = { optional = true, version = "0.6.0", package = "uu_ln", path = "src/uu/ln" } +ls = { optional = true, version = "0.6.0", package = "uu_ls", path = "src/uu/ls" } +logname = { optional = true, version = "0.6.0", package = "uu_logname", path = "src/uu/logname" } +mkdir = { optional = true, version = "0.6.0", package = "uu_mkdir", path = "src/uu/mkdir" } +mkfifo = { optional = true, version = "0.6.0", package = "uu_mkfifo", path = "src/uu/mkfifo" } +mknod = { optional = true, version = "0.6.0", package = "uu_mknod", path = "src/uu/mknod" } +mktemp = { optional = true, version = "0.6.0", package = "uu_mktemp", path = "src/uu/mktemp" } +more = { optional = true, version = "0.6.0", package = "uu_more", path = "src/uu/more" } +mv = { optional = true, version = "0.6.0", package = "uu_mv", path = "src/uu/mv" } +nice = { optional = true, version = "0.6.0", package = "uu_nice", path = "src/uu/nice" } +nl = { optional = true, version = "0.6.0", package = "uu_nl", path = "src/uu/nl" } +nohup = { optional = true, version = "0.6.0", package = "uu_nohup", path = "src/uu/nohup" } +nproc = { optional = true, version = "0.6.0", package = "uu_nproc", path = "src/uu/nproc" } +numfmt = { optional = true, version = "0.6.0", package = "uu_numfmt", path = "src/uu/numfmt" } +od = { optional = true, version = "0.6.0", package = "uu_od", path = "src/uu/od" } +paste = { optional = true, version = "0.6.0", package = "uu_paste", path = "src/uu/paste" } +pathchk = { optional = true, version = "0.6.0", package = "uu_pathchk", path = "src/uu/pathchk" } +pinky = { optional = true, version = "0.6.0", package = "uu_pinky", path = "src/uu/pinky" } +pr = { optional = true, version = "0.6.0", package = "uu_pr", path = "src/uu/pr" } +printenv = { optional = true, version = "0.6.0", package = "uu_printenv", path = "src/uu/printenv" } +printf = { optional = true, version = "0.6.0", package = "uu_printf", path = "src/uu/printf" } +ptx = { optional = true, version = "0.6.0", package = "uu_ptx", path = "src/uu/ptx" } +pwd = { optional = true, version = "0.6.0", package = "uu_pwd", path = "src/uu/pwd" } +readlink = { optional = true, version = "0.6.0", package = "uu_readlink", path = "src/uu/readlink" } +realpath = { optional = true, version = "0.6.0", package = "uu_realpath", path = "src/uu/realpath" } +rm = { optional = true, version = "0.6.0", package = "uu_rm", path = "src/uu/rm" } +rmdir = { optional = true, version = "0.6.0", package = "uu_rmdir", path = "src/uu/rmdir" } +runcon = { optional = true, version = "0.6.0", package = "uu_runcon", path = "src/uu/runcon" } +seq = { optional = true, version = "0.6.0", package = "uu_seq", path = "src/uu/seq" } +shred = { optional = true, version = "0.6.0", package = "uu_shred", path = "src/uu/shred" } +shuf = { optional = true, version = "0.6.0", package = "uu_shuf", path = "src/uu/shuf" } +sleep = { optional = true, version = "0.6.0", package = "uu_sleep", path = "src/uu/sleep" } +sort = { optional = true, version = "0.6.0", package = "uu_sort", path = "src/uu/sort" } +split = { optional = true, version = "0.6.0", package = "uu_split", path = "src/uu/split" } +stat = { optional = true, version = "0.6.0", package = "uu_stat", path = "src/uu/stat" } +stdbuf = { optional = true, version = "0.6.0", package = "uu_stdbuf", path = "src/uu/stdbuf" } +stty = { optional = true, version = "0.6.0", package = "uu_stty", path = "src/uu/stty" } +sum = { optional = true, version = "0.6.0", package = "uu_sum", path = "src/uu/sum" } +sync = { optional = true, version = "0.6.0", package = "uu_sync", path = "src/uu/sync" } +tac = { optional = true, version = "0.6.0", package = "uu_tac", path = "src/uu/tac" } +tail = { optional = true, version = "0.6.0", package = "uu_tail", path = "src/uu/tail" } +tee = { optional = true, version = "0.6.0", package = "uu_tee", path = "src/uu/tee" } +timeout = { optional = true, version = "0.6.0", package = "uu_timeout", path = "src/uu/timeout" } +touch = { optional = true, version = "0.6.0", package = "uu_touch", path = "src/uu/touch" } +tr = { optional = true, version = "0.6.0", package = "uu_tr", path = "src/uu/tr" } +true = { optional = true, version = "0.6.0", package = "uu_true", path = "src/uu/true" } +truncate = { optional = true, version = "0.6.0", package = "uu_truncate", path = "src/uu/truncate" } +tsort = { optional = true, version = "0.6.0", package = "uu_tsort", path = "src/uu/tsort" } +tty = { optional = true, version = "0.6.0", package = "uu_tty", path = "src/uu/tty" } +uname = { optional = true, version = "0.6.0", package = "uu_uname", path = "src/uu/uname" } +unexpand = { optional = true, version = "0.6.0", package = "uu_unexpand", path = "src/uu/unexpand" } +uniq = { optional = true, version = "0.6.0", package = "uu_uniq", path = "src/uu/uniq" } +unlink = { optional = true, version = "0.6.0", package = "uu_unlink", path = "src/uu/unlink" } +uptime = { optional = true, version = "0.6.0", package = "uu_uptime", path = "src/uu/uptime" } +users = { optional = true, version = "0.6.0", package = "uu_users", path = "src/uu/users" } +vdir = { optional = true, version = "0.6.0", package = "uu_vdir", path = "src/uu/vdir" } +wc = { optional = true, version = "0.6.0", package = "uu_wc", path = "src/uu/wc" } +who = { optional = true, version = "0.6.0", package = "uu_who", path = "src/uu/who" } +whoami = { optional = true, version = "0.6.0", package = "uu_whoami", path = "src/uu/whoami" } +yes = { optional = true, version = "0.6.0", package = "uu_yes", path = "src/uu/yes" } # this breaks clippy linting with: "tests/by-util/test_factor_benches.rs: No such file or directory (os error 2)" # factor_benches = { optional = true, version = "0.0.0", package = "uu_factor_benches", path = "tests/benches/factor" } diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index df362fd02..1ec35d314 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -1586,7 +1586,7 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uu_cksum" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "fluent", @@ -1595,7 +1595,7 @@ dependencies = [ [[package]] name = "uu_cut" -version = "0.5.0" +version = "0.6.0" dependencies = [ "bstr", "clap", @@ -1606,7 +1606,7 @@ dependencies = [ [[package]] name = "uu_date" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "fluent", @@ -1619,7 +1619,7 @@ dependencies = [ [[package]] name = "uu_echo" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "fluent", @@ -1628,7 +1628,7 @@ dependencies = [ [[package]] name = "uu_env" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "fluent", @@ -1640,7 +1640,7 @@ dependencies = [ [[package]] name = "uu_expr" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "fluent", @@ -1653,7 +1653,7 @@ dependencies = [ [[package]] name = "uu_printf" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "fluent", @@ -1662,7 +1662,7 @@ dependencies = [ [[package]] name = "uu_seq" -version = "0.5.0" +version = "0.6.0" dependencies = [ "bigdecimal", "clap", @@ -1675,7 +1675,7 @@ dependencies = [ [[package]] name = "uu_sort" -version = "0.5.0" +version = "0.6.0" dependencies = [ "bigdecimal", "binary-heap-plus", @@ -1698,7 +1698,7 @@ dependencies = [ [[package]] name = "uu_split" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "fluent", @@ -1709,7 +1709,7 @@ dependencies = [ [[package]] name = "uu_test" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "fluent", @@ -1720,7 +1720,7 @@ dependencies = [ [[package]] name = "uu_tr" -version = "0.5.0" +version = "0.6.0" dependencies = [ "bytecount", "clap", @@ -1731,7 +1731,7 @@ dependencies = [ [[package]] name = "uu_wc" -version = "0.5.0" +version = "0.6.0" dependencies = [ "bytecount", "clap", @@ -1745,7 +1745,7 @@ dependencies = [ [[package]] name = "uucore" -version = "0.5.0" +version = "0.6.0" dependencies = [ "base64-simd", "bigdecimal", @@ -1814,7 +1814,7 @@ dependencies = [ [[package]] name = "uucore_procs" -version = "0.5.0" +version = "0.6.0" dependencies = [ "proc-macro2", "quote", @@ -1822,7 +1822,7 @@ dependencies = [ [[package]] name = "uufuzz" -version = "0.5.0" +version = "0.6.0" dependencies = [ "console", "libc", diff --git a/fuzz/uufuzz/Cargo.toml b/fuzz/uufuzz/Cargo.toml index c68bcb428..5e66b0b49 100644 --- a/fuzz/uufuzz/Cargo.toml +++ b/fuzz/uufuzz/Cargo.toml @@ -3,7 +3,7 @@ name = "uufuzz" authors = ["uutils developers"] description = "uutils ~ 'core' uutils fuzzing library" repository = "https://github.com/uutils/coreutils/tree/main/fuzz/uufuzz" -version = "0.5.0" +version = "0.6.0" edition.workspace = true license.workspace = true @@ -12,5 +12,5 @@ console = "0.16.0" libc = "0.2.153" rand = { version = "0.9.0", features = ["small_rng"] } similar = "2.5.0" -uucore = { version = "0.5.0", path = "../../src/uucore", features = ["parser"] } +uucore = { version = "0.6.0", path = "../../src/uucore", features = ["parser"] } tempfile = "3.15.0" diff --git a/src/uu/stdbuf/Cargo.toml b/src/uu/stdbuf/Cargo.toml index 41940f2df..802796199 100644 --- a/src/uu/stdbuf/Cargo.toml +++ b/src/uu/stdbuf/Cargo.toml @@ -20,7 +20,7 @@ path = "src/stdbuf.rs" [dependencies] clap = { workspace = true } -libstdbuf = { package = "uu_stdbuf_libstdbuf", version = "0.5.0", path = "src/libstdbuf" } +libstdbuf = { package = "uu_stdbuf_libstdbuf", version = "0.6.0", path = "src/libstdbuf" } tempfile = { workspace = true } uucore = { workspace = true, features = ["parser-size"] } thiserror = { workspace = true } diff --git a/util/update-version.sh b/util/update-version.sh index 9b89937ab..7d867b0cf 100755 --- a/util/update-version.sh +++ b/util/update-version.sh @@ -17,8 +17,8 @@ # 10) Create the release on github https://github.com/uutils/coreutils/releases/new # 11) Make sure we have good release notes -FROM="0.4.0" -TO="0.5.0" +FROM="0.5.0" +TO="0.6.0" PROGS=$(ls -1d src/uu/*/Cargo.toml src/uu/stdbuf/src/libstdbuf/Cargo.toml src/uucore/Cargo.toml Cargo.toml fuzz/uufuzz/Cargo.toml src/uu/stdbuf/Cargo.toml) From f4ed162cf3c355626c175bef56d5a99b6c1dac6d Mon Sep 17 00:00:00 2001 From: quantum-encoding Date: Sat, 17 Jan 2026 23:44:32 +0100 Subject: [PATCH 243/425] fix(sort): Enable locale-aware collation for UTF-8 locales (#9176) * fix(sort): Enable locale-aware collation for UTF-8 locales Fixes #9148 The sort implementation had locale support infrastructure (ICU collator) but it was never being used due to the fast_lexicographic optimization bypassing all locale-aware code. --- src/uu/sort/Cargo.toml | 4 ++ src/uu/sort/src/sort.rs | 61 ++++++++++++++++---- src/uucore/src/lib/features/i18n/collator.rs | 39 +++++++++++++ src/uucore/src/lib/features/i18n/mod.rs | 9 ++- tests/by-util/test_sort.rs | 54 +++++++++++++++++ 5 files changed, 156 insertions(+), 11 deletions(-) diff --git a/src/uu/sort/Cargo.toml b/src/uu/sort/Cargo.toml index 476375516..8b422898a 100644 --- a/src/uu/sort/Cargo.toml +++ b/src/uu/sort/Cargo.toml @@ -19,6 +19,9 @@ workspace = true [lib] path = "src/sort.rs" +[features] +i18n-collator = ["uucore/i18n-collator"] + [dependencies] bigdecimal = { workspace = true } binary-heap-plus = { workspace = true } @@ -39,6 +42,7 @@ uucore = { workspace = true, features = [ "parser-size", "version-cmp", "i18n-decimal", + "i18n-collator", ] } fluent = { workspace = true } diff --git a/src/uu/sort/src/sort.rs b/src/uu/sort/src/sort.rs index ddbf22576..efce29180 100644 --- a/src/uu/sort/src/sort.rs +++ b/src/uu/sort/src/sort.rs @@ -23,6 +23,7 @@ use chunks::LineData; use clap::builder::ValueParser; use clap::{Arg, ArgAction, ArgMatches, Command}; use custom_str_cmp::custom_str_cmp; + use ext_sort::ext_sort; use fnv::FnvHasher; use numeric_str_cmp::{NumInfo, NumInfoParseSettings, human_numeric_str_cmp, numeric_str_cmp}; @@ -47,6 +48,8 @@ use uucore::error::{FromIo, strip_errno}; use uucore::error::{UError, UResult, USimpleError, UUsageError}; use uucore::extendedbigdecimal::ExtendedBigDecimal; use uucore::format_usage; +#[cfg(feature = "i18n-collator")] +use uucore::i18n::collator::locale_cmp; use uucore::i18n::decimal::locale_decimal_separator; use uucore::line_ending::LineEnding; use uucore::parser::num_parser::{ExtendedParser, ExtendedParserError}; @@ -318,7 +321,10 @@ impl GlobalSettings { /// Precompute some data needed for sorting. /// This function **must** be called before starting to sort, and `GlobalSettings` may not be altered /// afterwards. - fn init_precomputed(&mut self) { + /// + /// When i18n-collator is enabled, `disable_fast_lexicographic` should be set to true if we're + /// in a UTF-8 locale (to force locale-aware collation instead of byte comparison). + fn init_precomputed(&mut self, disable_fast_lexicographic: bool) { self.precomputed.needs_tokens = self.selectors.iter().any(|s| s.needs_tokens); self.precomputed.selections_per_line = self.selectors.iter().filter(|s| s.needs_selection).count(); @@ -333,11 +339,15 @@ impl GlobalSettings { .filter(|s| matches!(s.settings.mode, SortMode::GeneralNumeric)) .count(); - self.precomputed.fast_lexicographic = self.can_use_fast_lexicographic(); + self.precomputed.fast_lexicographic = + !disable_fast_lexicographic && self.can_use_fast_lexicographic(); self.precomputed.fast_ascii_insensitive = self.can_use_fast_ascii_insensitive(); } /// Returns true when the fast lexicographic path can be used safely. + /// Note: When i18n-collator is enabled, the caller must have already determined + /// whether locale-aware collation is needed (via checking if we're in a UTF-8 locale). + /// This check is performed in uumain() before init_precomputed() is called. fn can_use_fast_lexicographic(&self) -> bool { self.mode == SortMode::Default && !self.ignore_case @@ -2065,7 +2075,15 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { emit_debug_warnings(&settings, &global_flags, &legacy_warnings); } - settings.init_precomputed(); + // Initialize locale collation if needed (UTF-8 locales) + // This MUST happen before init_precomputed() to avoid the performance regression + #[cfg(feature = "i18n-collator")] + let needs_locale_collation = uucore::i18n::collator::init_locale_collation(); + + #[cfg(not(feature = "i18n-collator"))] + let needs_locale_collation = false; + + settings.init_precomputed(needs_locale_collation); let result = exec(&mut files, &settings, output, &mut tmp_dir); // Wait here if `SIGINT` was received, @@ -2446,13 +2464,36 @@ fn compare_by<'a>( } SortMode::Month => month_compare(a_str, b_str), SortMode::Version => version_cmp(a_str, b_str), - SortMode::Default => custom_str_cmp( - a_str, - b_str, - settings.ignore_non_printing, - settings.dictionary_order, - settings.ignore_case, - ), + SortMode::Default => { + // Use locale-aware comparison if feature is enabled and no custom flags are set + #[cfg(feature = "i18n-collator")] + { + if settings.ignore_case + || settings.dictionary_order + || settings.ignore_non_printing + { + custom_str_cmp( + a_str, + b_str, + settings.ignore_non_printing, + settings.dictionary_order, + settings.ignore_case, + ) + } else { + locale_cmp(a_str, b_str) + } + } + #[cfg(not(feature = "i18n-collator"))] + { + custom_str_cmp( + a_str, + b_str, + settings.ignore_non_printing, + settings.dictionary_order, + settings.ignore_case, + ) + } + } }; if cmp != Ordering::Equal { return if settings.reverse { cmp.reverse() } else { cmp }; diff --git a/src/uucore/src/lib/features/i18n/collator.rs b/src/uucore/src/lib/features/i18n/collator.rs index fda8cd6e0..f0a9e6b35 100644 --- a/src/uucore/src/lib/features/i18n/collator.rs +++ b/src/uucore/src/lib/features/i18n/collator.rs @@ -30,6 +30,45 @@ pub fn init_collator(opts: CollatorOptions) { .expect("Collator already initialized"); } +/// Initialize the collator for locale-aware string comparison if needed. +/// +/// This function checks if the current locale requires locale-aware collation +/// (UTF-8 encoding) and initializes the ICU collator with appropriate settings +/// if necessary. For C/POSIX locales, no initialization is needed as byte +/// comparison is sufficient. +/// +/// # Returns +/// +/// `true` if the collator was initialized for a UTF-8 locale, `false` if +/// using C/POSIX locale (no initialization needed). +/// +/// # Example +/// +/// ``` +/// use uucore::i18n::collator::init_locale_collation; +/// +/// if init_locale_collation() { +/// // Using locale-aware collation +/// } else { +/// // Using byte comparison (C/POSIX locale) +/// } +/// ``` +pub fn init_locale_collation() -> bool { + use crate::i18n::{UEncoding, get_locale_encoding}; + + // Check if we need locale-aware collation + if get_locale_encoding() != UEncoding::Utf8 { + // C/POSIX locale - no collator needed + return false; + } + + // UTF-8 locale - initialize collator with Shifted mode to match GNU behavior + let mut opts = CollatorOptions::default(); + opts.alternate_handling = Some(AlternateHandling::Shifted); + + try_init_collator(opts) +} + /// Compare both strings with regard to the current locale. pub fn locale_cmp(left: &[u8], right: &[u8]) -> Ordering { // If the detected locale is 'C', just do byte-wise comparison diff --git a/src/uucore/src/lib/features/i18n/mod.rs b/src/uucore/src/lib/features/i18n/mod.rs index d47f2df98..79c804a03 100644 --- a/src/uucore/src/lib/features/i18n/mod.rs +++ b/src/uucore/src/lib/features/i18n/mod.rs @@ -20,7 +20,9 @@ pub enum UEncoding { Utf8, } -const DEFAULT_LOCALE: Locale = locale!("en-US-posix"); +// Use "und" (undefined) as the marker for C/POSIX locale +// This ensures real locales like "en-US" won't match +const DEFAULT_LOCALE: Locale = locale!("und"); /// Look at 3 environment variables in the following order /// @@ -38,6 +40,11 @@ fn get_locale_from_env(locale_name: &str) -> (Locale, UEncoding) { let mut split = locale_var_str.split(&['.', '@']); if let Some(simple) = split.next() { + // Handle explicit C and POSIX locales - these should always use byte comparison + if simple == "C" || simple == "POSIX" { + return (DEFAULT_LOCALE, UEncoding::Ascii); + } + // Naively convert the locale name to BCP47 tag format. // // See https://en.wikipedia.org/wiki/IETF_language_tag diff --git a/tests/by-util/test_sort.rs b/tests/by-util/test_sort.rs index b478912dd..0106d719f 100644 --- a/tests/by-util/test_sort.rs +++ b/tests/by-util/test_sort.rs @@ -2463,4 +2463,58 @@ fn test_start_buffer() { .stdout_only_bytes(&expected); } +#[test] +fn test_locale_collation_c_locale() { + // C locale uses byte order - this is deterministic and tests the fix for #9148 + // Accented characters (UTF-8 multibyte) sort after ASCII letters + let input = "é\ne\nE\na\nA\nz\n"; + // C locale byte order: A=0x41, E=0x45, a=0x61, e=0x65, z=0x7A, é=0xC3 0xA9 + let expected = "A\nE\na\ne\nz\né\n"; + + new_ucmd!() + .env("LC_ALL", "C") + .pipe_in(input) + .succeeds() + .stdout_is(expected); +} + +#[test] +fn test_locale_collation_utf8() { + // Test French UTF-8 locale handling - behavior depends on i18n-collator feature + // With feature: locale-aware collation (é sorts near e) + // Without feature: byte order (é after z, since 0xC3A9 > 0x7A) + let input = "z\né\ne\na\n"; + + let result = new_ucmd!() + .env("LC_ALL", "fr_FR.UTF-8") + .pipe_in(input) + .succeeds(); + + let output = result.stdout_str(); + let lines: Vec<&str> = output.lines().collect(); + + assert_eq!(lines.len(), 4, "Expected 4 sorted lines"); + assert_eq!(lines[0], "a", "'a' (0x61) should always sort first"); + + // Validate based on which collation mode is active + if lines[3] == "é" { + // Byte order mode: é (0xC3A9) > z (0x7A) + assert_eq!( + lines, + vec!["a", "e", "z", "é"], + "Byte order mode: expected a < e < z < é" + ); + } else { + // Locale collation mode: é sorts with base letter e + assert_eq!(lines[3], "z", "Locale mode: 'z' should sort last"); + let z_pos = lines.iter().position(|&x| x == "z").unwrap(); + let e_pos = lines.iter().position(|&x| x == "e").unwrap(); + let e_accent_pos = lines.iter().position(|&x| x == "é").unwrap(); + assert!( + e_pos < z_pos && e_accent_pos < z_pos, + "Locale mode: 'e' ({e_pos}) and 'é' ({e_accent_pos}) should sort before 'z' ({z_pos})" + ); + } +} + /* spell-checker: enable */ From fc17efe7beb08446b07fd208afec0bce45cb45be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gon=C3=A7alo=20Gomes?= Date: Wed, 14 Jan 2026 10:58:10 +0000 Subject: [PATCH 244/425] rm: don't treat symlinks as write-protected GNU rm does not check for write-protection on symbolic links; it instead prompts to "remove symbolic link" regardless of the link's permissions or its target's status. This change: - Ensures `prompt_file` checks for symlinks specifically using `symlink_metadata`, avoiding the incorrect "write-protected" prompt. - Refactors permission checks into `is_writable_metadata` to allow using the already-fetched metadata, which also optimizes performance by reducing redundant `stat` calls. - Updates `prompt_file_permission_readonly` to operate on metadata directly. --- src/uu/rm/src/rm.rs | 43 ++++++++++++++-------------------------- tests/by-util/test_rm.rs | 17 ++++++++++++++++ 2 files changed, 32 insertions(+), 28 deletions(-) diff --git a/src/uu/rm/src/rm.rs b/src/uu/rm/src/rm.rs index 55c5b932f..32bf94fd0 100644 --- a/src/uu/rm/src/rm.rs +++ b/src/uu/rm/src/rm.rs @@ -549,19 +549,8 @@ fn is_writable_metadata(metadata: &Metadata) -> bool { (mode & 0o200) > 0 } -/// Whether the given file or directory is writable. -#[cfg(unix)] -fn is_writable(path: &Path) -> bool { - match fs::metadata(path) { - Err(_) => false, - Ok(metadata) => is_writable_metadata(&metadata), - } -} - -/// Whether the given file or directory is writable. #[cfg(not(unix))] -fn is_writable(_path: &Path) -> bool { - // TODO Not yet implemented. +fn is_writable_metadata(_metadata: &Metadata) -> bool { true } @@ -799,35 +788,33 @@ fn prompt_file(path: &Path, options: &Options) -> bool { if options.interactive == InteractiveMode::Never { return true; } - // If interactive is Always we want to check if the file is symlink to prompt the right message - if options.interactive == InteractiveMode::Always { - if let Ok(metadata) = fs::symlink_metadata(path) { - if metadata.is_symlink() { - return prompt_yes!("remove symbolic link {}?", path.quote()); - } - } - } - let Ok(metadata) = fs::metadata(path) else { + let Ok(metadata) = fs::symlink_metadata(path) else { return true; }; - if options.interactive == InteractiveMode::Always && is_writable(path) { + if metadata.is_symlink() { + return options.interactive != InteractiveMode::Always + || prompt_yes!("remove symbolic link {}?", path.quote()); + } + + if options.interactive == InteractiveMode::Always && is_writable_metadata(&metadata) { return if metadata.len() == 0 { prompt_yes!("remove regular empty file {}?", path.quote()) } else { prompt_yes!("remove file {}?", path.quote()) }; } - prompt_file_permission_readonly(path, options) + + prompt_file_permission_readonly(path, options, &metadata) } -fn prompt_file_permission_readonly(path: &Path, options: &Options) -> bool { +fn prompt_file_permission_readonly(path: &Path, options: &Options, metadata: &Metadata) -> bool { let stdin_ok = options.__presume_input_tty.unwrap_or(false) || stdin().is_terminal(); - match (stdin_ok, fs::metadata(path), options.interactive) { - (false, _, InteractiveMode::PromptProtected) => true, - (_, Ok(_), _) if is_writable(path) => true, - (_, Ok(metadata), _) if metadata.len() == 0 => prompt_yes!( + match (stdin_ok, options.interactive) { + (false, InteractiveMode::PromptProtected) => true, + _ if is_writable_metadata(metadata) => true, + _ if metadata.len() == 0 => prompt_yes!( "remove write-protected regular empty file {}?", path.quote() ), diff --git a/tests/by-util/test_rm.rs b/tests/by-util/test_rm.rs index 38230f2ad..d0a8bca2e 100644 --- a/tests/by-util/test_rm.rs +++ b/tests/by-util/test_rm.rs @@ -1217,3 +1217,20 @@ fn test_progress_no_output_on_error() { .stderr_contains("cannot remove") .stderr_contains("No such file or directory"); } + +#[cfg(unix)] +#[test] +fn test_symlink_to_readonly_no_prompt() { + let (at, mut ucmd) = at_and_ucmd!(); + + at.touch("foo"); + at.set_mode("foo", 0o444); + at.symlink_file("foo", "bar"); + + ucmd.arg("---presume-input-tty") + .arg("bar") + .succeeds() + .no_stderr(); + + assert!(!at.symlink_exists("bar")); +} From 799de29ec1439401df47434a207d0180a686522b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=E1=BA=A3=20th=E1=BA=BF=20gi=E1=BB=9Bi=20l=C3=A0=20Rust?= <90588855+naoNao89@users.noreply.github.com> Date: Sun, 18 Jan 2026 06:02:57 +0700 Subject: [PATCH 245/425] fix: properly handle write errors for --version and --help output (#10223) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Modified ClapErrorWrapper to detect and report when printing --version/--help fails - Added test_version_help_dev_full to verify error handling - Fixes issue where cat --version > /dev/full silently succeeded instead of failing Addresses feedback from commit 9cc2e096d83a3fd419cbd8040b939d4b309f34a1 Co-authored-by: Cả thế giới là Rust --- src/uucore/src/lib/mods/error.rs | 37 ++++++++++++++++++++++------ tests/by-util/test_cat.rs | 16 ++++++++++++ tests/by-util/test_eintr_handling.rs | 6 ++--- 3 files changed, 48 insertions(+), 11 deletions(-) diff --git a/src/uucore/src/lib/mods/error.rs b/src/uucore/src/lib/mods/error.rs index ef270546c..d2239d128 100644 --- a/src/uucore/src/lib/mods/error.rs +++ b/src/uucore/src/lib/mods/error.rs @@ -55,8 +55,10 @@ // spell-checker:ignore uioerror rustdoc use std::{ + cell::Cell, error::Error, fmt::{Display, Formatter}, + io::Write, sync::atomic::{AtomicI32, Ordering}, }; @@ -700,6 +702,7 @@ impl From for Box { pub struct ClapErrorWrapper { code: i32, error: clap::Error, + print_failed: Cell, } /// Extension trait for `clap::Error` to adjust the exit code. @@ -710,13 +713,21 @@ pub trait UClapError { impl From for Box { fn from(e: clap::Error) -> Self { - Box::new(ClapErrorWrapper { code: 1, error: e }) + Box::new(ClapErrorWrapper { + code: 1, + error: e, + print_failed: Cell::new(false), + }) } } impl UClapError for clap::Error { fn with_exit_code(self, code: i32) -> ClapErrorWrapper { - ClapErrorWrapper { code, error: self } + ClapErrorWrapper { + code, + error: self, + print_failed: Cell::new(false), + } } } @@ -731,12 +742,11 @@ impl UClapError> impl UError for ClapErrorWrapper { fn code(&self) -> i32 { // If the error is a DisplayHelp or DisplayVersion variant, - // we don't want to apply the custom error code, but leave - // it 0. + // check if printing failed. If it did, return 1, otherwise 0. if let clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion = self.error.kind() { - 0 + i32::from(self.print_failed.get()) } else { self.code } @@ -748,9 +758,20 @@ impl Error for ClapErrorWrapper {} // This is abuse of the Display trait impl Display for ClapErrorWrapper { fn fmt(&self, _f: &mut Formatter<'_>) -> Result<(), std::fmt::Error> { - // Intentionally ignore the result - error.print() writes directly to stderr - // and we always return Ok(()) to satisfy Display's contract - let _ = self.error.print(); + // Check if printing succeeds. For DisplayHelp and DisplayVersion, + // error.print() writes to stdout, so we need to detect write failures + // (e.g., when stdout is /dev/full). + if let Err(print_fail) = self.error.print() { + // Mark that printing failed so code() can return the appropriate exit code + self.print_failed.set(true); + // Try to display this error to stderr, but ignore if that fails too + // since we're already in an error state. + let _ = writeln!(std::io::stderr(), "{}: {print_fail}", crate::util_name()); + // Mirror GNU behavior: when failing to print help or version, exit with error code. + // This avoids silent failures when stdout is full or closed. + set_exit_code(1); + } + // Always return Ok(()) to satisfy Display's contract and prevent panic Ok(()) } } diff --git a/tests/by-util/test_cat.rs b/tests/by-util/test_cat.rs index 2d35a2e25..7cb824f60 100644 --- a/tests/by-util/test_cat.rs +++ b/tests/by-util/test_cat.rs @@ -864,6 +864,22 @@ fn test_write_error_handling() { .stderr_contains("No space left on device"); } +#[test] +#[cfg(target_os = "linux")] +fn test_version_help_dev_full() { + use std::fs::OpenOptions; + + for option in ["--version", "--help"] { + let dev_full = OpenOptions::new().write(true).open("/dev/full").unwrap(); + + new_ucmd!() + .arg(option) + .set_stdout(dev_full) + .fails() + .stderr_contains("No space left on device"); + } +} + #[test] fn test_cat_eintr_handling() { // Test that cat properly handles EINTR (ErrorKind::Interrupted) during I/O operations diff --git a/tests/by-util/test_eintr_handling.rs b/tests/by-util/test_eintr_handling.rs index 313a69f63..f195ff582 100644 --- a/tests/by-util/test_eintr_handling.rs +++ b/tests/by-util/test_eintr_handling.rs @@ -11,9 +11,9 @@ //! # CI Integration //! EINTR handling tests are NOW visible in CI logs through integration tests: //! - `test_cat_eintr_handling` in `tests/by-util/test_cat.rs` -//! - `test_comm_eintr_handling` in `tests/by-util/test_comm.rs` +//! - `test_comm_eintr_handling` in `tests/by-util/test_comm.rs` //! - `test_od_eintr_handling` in `tests/by-util/test_od.rs` -//! +//! //! These integration tests use the mock utilities from this module to verify //! that each utility properly handles signal interruptions during I/O operations. //! Test results appear in CI logs under the "Test" steps when running `cargo nextest run`. @@ -171,7 +171,7 @@ mod tests { assert_eq!(n, 5); assert_eq!(&buf, b"hello"); - // Read rest of data without interruption + // Read rest of data without interruption let n = reader.read(&mut buf).unwrap(); assert_eq!(n, 5); assert_eq!(&buf, b" worl"); // Second chunk of "hello world" From 49e8006aae6fd10c0197ad9df0e4ca7f903b4df5 Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Sun, 18 Jan 2026 18:11:36 +0900 Subject: [PATCH 246/425] CIDD.yml: cargo fetch platform spec crates only --- .github/workflows/CICD.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/CICD.yml b/.github/workflows/CICD.yml index 7f667076a..160a193e7 100644 --- a/.github/workflows/CICD.yml +++ b/.github/workflows/CICD.yml @@ -196,7 +196,7 @@ jobs: ## Confirm MinSRV compatible '*/Cargo.lock' # * '*/Cargo.lock' is required to be in a format that `cargo` of MinSRV can interpret (eg, v1-format for MinSRV < v1.38) for dir in "." "fuzz"; do - ( cd "$dir" && cargo fetch --locked --quiet ) || { echo "::error file=$dir/Cargo.lock::Incompatible (or out-of-date) '$dir/Cargo.lock' file; update using \`cd '$dir' && cargo +${{ env.RUST_MIN_SRV }} update\`" ; exit 1 ; } + ( cd "$dir" && cargo fetch --locked --quiet --target $(rustc --print host-tuple)) || { echo "::error file=$dir/Cargo.lock::Incompatible (or out-of-date) '$dir/Cargo.lock' file; update using \`cd '$dir' && cargo +${{ env.RUST_MIN_SRV }} update\`" ; exit 1 ; } done - name: Install/setup prerequisites shell: bash @@ -221,7 +221,7 @@ jobs: # dependencies echo "## dependency list" ## * using the 'stable' toolchain is necessary to avoid "unexpected '--filter-platform'" errors - RUSTUP_TOOLCHAIN=stable cargo fetch --locked --quiet + RUSTUP_TOOLCHAIN=stable cargo fetch --locked --quiet --target $(rustc --print host-tuple) RUSTUP_TOOLCHAIN=stable cargo tree --no-dedupe --locked -e=no-dev --prefix=none ${{ steps.vars.outputs.CARGO_FEATURES_OPTION }} | grep -vE "$PWD" | sort --unique - name: Test run: cargo nextest run --hide-progress-bar --profile ci ${{ steps.vars.outputs.CARGO_FEATURES_OPTION }} -p uucore -p coreutils @@ -259,7 +259,7 @@ jobs: ## `cargo update` testing # * convert any errors/warnings to GHA UI annotations; ref: for dir in "." "fuzz"; do - ( cd "$dir" && cargo fetch --locked --quiet ) || { echo "::error file=$dir/Cargo.lock::'$dir/Cargo.lock' file requires update (use \`cd '$dir' && cargo +${{ env.RUST_MIN_SRV }} update\`)" ; exit 1 ; } + ( cd "$dir" && cargo fetch --locked --quiet --target $(rustc --print host-tuple)) || { echo "::error file=$dir/Cargo.lock::'$dir/Cargo.lock' file requires update (use \`cd '$dir' && cargo +${{ env.RUST_MIN_SRV }} update\`)" ; exit 1 ; } done build_makefile: @@ -835,7 +835,7 @@ jobs: cargo tree -V # dependencies echo "## dependency list" - cargo fetch --locked --quiet + cargo fetch --locked --quiet --target $(rustc --print host-tuple) cargo tree --locked --target=${{ matrix.job.target }} ${{ matrix.job.cargo-options }} ${{ steps.vars.outputs.CARGO_FEATURES_OPTION }} ${{ steps.vars.outputs.CARGO_DEFAULT_FEATURES_OPTION }} --no-dedupe -e=no-dev --prefix=none | grep -vE "$PWD" | sort --unique - name: Build shell: bash From 337da9556563001e971be6c16b436391cd003fba Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Sun, 18 Jan 2026 18:53:52 +0900 Subject: [PATCH 247/425] build-gnu.sh: Enable help-version-getopt.sh (PASS) --- util/build-gnu.sh | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/util/build-gnu.sh b/util/build-gnu.sh index 13b055ae0..59a602e43 100755 --- a/util/build-gnu.sh +++ b/util/build-gnu.sh @@ -159,9 +159,7 @@ else # 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' \ - -e '/tests\/help\/help-version-getopt.sh/ D' \ - Makefile + sed -i '/tests\/help\/help-version.sh/ D' Makefile touch gnu-built fi From 4f9aad9a98c8c6788665b80bc0685ec1f675726e Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Sun, 18 Jan 2026 20:21:18 +0900 Subject: [PATCH 248/425] Drop find_prefixed_util --- src/common/validation.rs | 44 ---------------------------------------- 1 file changed, 44 deletions(-) diff --git a/src/common/validation.rs b/src/common/validation.rs index 057e8a912..f3923adb8 100644 --- a/src/common/validation.rs +++ b/src/common/validation.rs @@ -62,19 +62,6 @@ fn get_canonical_util_name(util_name: &str) -> &str { } } -/// Finds a utility with a prefix (e.g., "uu_test" -> "test") -pub fn find_prefixed_util<'a>( - binary_name: &str, - mut util_keys: impl Iterator, -) -> Option<&'a str> { - util_keys.find(|util| { - binary_name.ends_with(*util) - && binary_name.len() > util.len() // Ensure there's actually a prefix - && !binary_name[..binary_name.len() - (*util).len()] - .ends_with(char::is_alphanumeric) - }) -} - /// Gets the binary path from command line arguments /// # Panics /// Panics if the binary path cannot be determined @@ -123,35 +110,4 @@ mod tests { assert_eq!(name(Path::new("")), None); assert_eq!(name(Path::new("/")), None); } - - #[test] - fn test_find_prefixed_util() { - let utils = ["test", "cat", "ls", "cp"]; - - // Test exact prefixed matches - assert_eq!( - find_prefixed_util("uu_test", utils.iter().copied()), - Some("test") - ); - assert_eq!( - find_prefixed_util("my-cat", utils.iter().copied()), - Some("cat") - ); - assert_eq!( - find_prefixed_util("prefix_ls", utils.iter().copied()), - Some("ls") - ); - - // Test non-alphanumeric separator requirement - assert_eq!(find_prefixed_util("prefixcat", utils.iter().copied()), None); // no separator - assert_eq!(find_prefixed_util("testcat", utils.iter().copied()), None); // no separator - - // Test no match - assert_eq!(find_prefixed_util("unknown", utils.iter().copied()), None); - assert_eq!(find_prefixed_util("", utils.iter().copied()), None); - - // Test exact util name (should not match as prefixed) - assert_eq!(find_prefixed_util("test", utils.iter().copied()), None); - assert_eq!(find_prefixed_util("cat", utils.iter().copied()), None); - } } From 037b9583bc03d814e8516df54ebcda6f681fe1f8 Mon Sep 17 00:00:00 2001 From: Ruiyang Wang <56065503+rynewang@users.noreply.github.com> Date: Sun, 18 Jan 2026 03:42:14 -0800 Subject: [PATCH 249/425] mkdir: create directories atomically with correct permissions (#10036) * mkdir: create directories atomically with correct permissions Fix #10022: mkdir -m MODE was creating directories with umask-based permissions first, then calling chmod afterward. This left a brief window where the directory existed with wrong permissions. Now we match GNU mkdir behavior by temporarily setting umask to 0 before the mkdir syscall, passing the exact requested mode to the kernel, then restoring the original umask. The directory is created atomically with the correct permissions. Before (two syscalls, race condition): mkdir("dir", 0777) -> created with 0755 (umask applied) chmod("dir", 0700) -> fixed afterward After (single syscall, atomic): umask(0) mkdir("dir", 0700) -> created with exact mode umask(original) Also added tests to verify -m MODE bypasses umask correctly. * mkdir: add UmaskGuard for panic-safe umask restoration Add RAII guard to ensure umask is restored even on panic. Encapsulate unsafe umask calls in UmaskGuard::set() for a safe interface. * mkdir: fix clippy and spelling warnings - Add RAII to spell-checker ignore list - Narrow chmod cfg to Linux only (only used for ACL bits) * mkdir: fix unused import on non-Linux FromIo is only used in chmod, which is now Linux-only. --- src/uu/mkdir/src/mkdir.rs | 94 +++++++++++++++++++--------- tests/by-util/test_mkdir.rs | 120 ++++++++++++++++++++++++++++++++++++ 2 files changed, 185 insertions(+), 29 deletions(-) diff --git a/src/uu/mkdir/src/mkdir.rs b/src/uu/mkdir/src/mkdir.rs index 88a196ebb..d08640ff0 100644 --- a/src/uu/mkdir/src/mkdir.rs +++ b/src/uu/mkdir/src/mkdir.rs @@ -3,14 +3,14 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore (ToDO) ugoa cmode +// spell-checker:ignore (ToDO) ugoa cmode RAII use clap::builder::ValueParser; use clap::parser::ValuesRef; use clap::{Arg, ArgAction, ArgMatches, Command}; use std::ffi::OsString; use std::path::{Path, PathBuf}; -#[cfg(not(windows))] +#[cfg(all(unix, target_os = "linux"))] use uucore::error::FromIo; use uucore::error::{UResult, USimpleError}; use uucore::translate; @@ -191,7 +191,8 @@ pub fn mkdir(path: &Path, config: &Config) -> UResult<()> { create_dir(path, false, config) } -#[cfg(any(unix, target_os = "redox"))] +/// Only needed on Linux to add ACL permission bits after directory creation. +#[cfg(all(unix, target_os = "linux"))] fn chmod(path: &Path, mode: u32) -> UResult<()> { use std::fs::{Permissions, set_permissions}; use std::os::unix::fs::PermissionsExt; @@ -201,12 +202,6 @@ fn chmod(path: &Path, mode: u32) -> UResult<()> { ) } -#[cfg(windows)] -fn chmod(_path: &Path, _mode: u32) -> UResult<()> { - // chmod on Windows only sets the readonly flag, which isn't even honored on directories - Ok(()) -} - // Create a directory at the given path. // Uses iterative approach instead of recursion to avoid stack overflow with deep nesting. fn create_dir(path: &Path, is_parent: bool, config: &Config) -> UResult<()> { @@ -250,13 +245,67 @@ fn create_dir(path: &Path, is_parent: bool, config: &Config) -> UResult<()> { create_single_dir(path, is_parent, config) } +/// RAII guard to restore umask on drop, ensuring cleanup even on panic. +#[cfg(unix)] +struct UmaskGuard(uucore::libc::mode_t); + +#[cfg(unix)] +impl UmaskGuard { + /// Set umask to the given value and return a guard that restores the original on drop. + fn set(new_mask: uucore::libc::mode_t) -> Self { + let old_mask = unsafe { uucore::libc::umask(new_mask) }; + Self(old_mask) + } +} + +#[cfg(unix)] +impl Drop for UmaskGuard { + fn drop(&mut self) { + unsafe { + uucore::libc::umask(self.0); + } + } +} + +/// Create a directory with the exact mode specified, bypassing umask. +/// +/// GNU mkdir temporarily sets umask to 0 before calling mkdir(2), ensuring the +/// directory is created atomically with the correct permissions. This avoids a +/// race condition where the directory briefly exists with umask-based permissions. +#[cfg(unix)] +fn create_dir_with_mode(path: &Path, mode: u32) -> std::io::Result<()> { + use std::os::unix::fs::DirBuilderExt; + + // Temporarily set umask to 0 so the directory is created with the exact mode. + // The guard restores the original umask on drop, even if we panic. + let _guard = UmaskGuard::set(0); + + std::fs::DirBuilder::new().mode(mode).create(path) +} + +#[cfg(not(unix))] +fn create_dir_with_mode(path: &Path, _mode: u32) -> std::io::Result<()> { + std::fs::create_dir(path) +} + // Helper function to create a single directory with appropriate permissions // `is_parent` argument is not used on windows #[allow(unused_variables)] fn create_single_dir(path: &Path, is_parent: bool, config: &Config) -> UResult<()> { let path_exists = path.exists(); - match std::fs::create_dir(path) { + // Calculate the mode to use for directory creation + #[cfg(unix)] + let create_mode = if is_parent { + // For parent directories with -p, use umask-derived mode with u+wx + (!mode::get_umask() & 0o777) | 0o300 + } else { + config.mode + }; + #[cfg(not(unix))] + let create_mode = config.mode; + + match create_dir_with_mode(path, create_mode) { Ok(()) => { if config.verbose { println!( @@ -265,30 +314,17 @@ fn create_single_dir(path: &Path, is_parent: bool, config: &Config) -> UResult<( ); } + // On Linux, we may need to add ACL permission bits via chmod. + // On other Unix systems, the directory was already created with the correct mode. #[cfg(all(unix, target_os = "linux"))] - let new_mode = if path_exists { - config.mode - } else { + if !path_exists { // TODO: Make this macos and freebsd compatible by creating a function to get permission bits from // acl in extended attributes let acl_perm_bits = uucore::fsxattr::get_acl_perm_bits_from_xattr(path); - - if is_parent { - (!mode::get_umask() & 0o777) | 0o300 | acl_perm_bits - } else { - config.mode | acl_perm_bits + if acl_perm_bits != 0 { + chmod(path, create_mode | acl_perm_bits)?; } - }; - #[cfg(all(unix, not(target_os = "linux")))] - let new_mode = if is_parent { - (!mode::get_umask() & 0o777) | 0o300 - } else { - config.mode - }; - #[cfg(windows)] - let new_mode = config.mode; - - chmod(path, new_mode)?; + } // Apply SELinux context if requested #[cfg(feature = "selinux")] diff --git a/tests/by-util/test_mkdir.rs b/tests/by-util/test_mkdir.rs index 2ccfbb44a..5d68fadfd 100644 --- a/tests/by-util/test_mkdir.rs +++ b/tests/by-util/test_mkdir.rs @@ -788,6 +788,126 @@ fn test_mkdir_environment_expansion() { } } +/// Test that mkdir -m creates directories with the exact requested mode, +/// bypassing umask. This verifies the fix for issue #10022. +/// +/// Previously, mkdir would create the directory with umask-based permissions +/// and then chmod afterward, leaving a brief window with wrong permissions. +/// Now it temporarily sets umask to 0 and creates with the exact mode. +#[cfg(not(windows))] +#[test] +fn test_mkdir_mode_ignores_umask() { + // Test that -m 0700 with restrictive umask still creates 0700 + { + let (at, mut ucmd) = at_and_ucmd!(); + let restrictive_umask: mode_t = 0o077; // Would normally block group/other + + ucmd.arg("-m") + .arg("0700") + .arg("test_700") + .umask(restrictive_umask) + .succeeds(); + + let perms = at.metadata("test_700").permissions().mode() as mode_t; + assert_eq!(perms, 0o40700, "Expected 0700, got {:o}", perms & 0o777); + } + + // Test that -m 0777 is honored even with umask 022 + // This is the key test: without the fix, 0777 & ~022 = 0755 + { + let (at, mut ucmd) = at_and_ucmd!(); + let common_umask: mode_t = 0o022; + + ucmd.arg("-m") + .arg("0777") + .arg("test_777") + .umask(common_umask) + .succeeds(); + + let perms = at.metadata("test_777").permissions().mode() as mode_t; + assert_eq!( + perms, + 0o40777, + "Expected 0777 (umask should be ignored with -m), got {:o}", + perms & 0o777 + ); + } + + // Test that -m 0755 with umask 077 still creates 0755 + { + let (at, mut ucmd) = at_and_ucmd!(); + let very_restrictive_umask: mode_t = 0o077; + + ucmd.arg("-m") + .arg("0755") + .arg("test_755") + .umask(very_restrictive_umask) + .succeeds(); + + let perms = at.metadata("test_755").permissions().mode() as mode_t; + assert_eq!(perms, 0o40755, "Expected 0755, got {:o}", perms & 0o777); + } + + // Test symbolic mode also ignores umask + { + let (at, mut ucmd) = at_and_ucmd!(); + let umask: mode_t = 0o022; + + ucmd.arg("-m") + .arg("a=rwx") + .arg("test_symbolic") + .umask(umask) + .succeeds(); + + let perms = at.metadata("test_symbolic").permissions().mode() as mode_t; + assert_eq!(perms, 0o40777, "Expected 0777, got {:o}", perms & 0o777); + } +} + +/// Test that mkdir -p -m applies mode correctly: +/// - Parent directories use umask-derived permissions (with u+wx) +/// - Final directory uses the exact requested mode (ignoring umask) +#[cfg(not(windows))] +#[test] +fn test_mkdir_parent_mode_with_explicit_mode() { + let (at, mut ucmd) = at_and_ucmd!(); + let umask: mode_t = 0o022; + + ucmd.arg("-p") + .arg("-m") + .arg("0700") + .arg("parent/child/target") + .umask(umask) + .succeeds(); + + // Parent directories created by -p use umask-derived mode with u+wx + let parent_perms = at.metadata("parent").permissions().mode() as mode_t; + let expected_parent = ((!umask & 0o777) | 0o300) + 0o40000; + assert_eq!( + parent_perms, + expected_parent, + "Parent should have umask-derived mode, got {:o}", + parent_perms & 0o777 + ); + + let child_perms = at.metadata("parent/child").permissions().mode() as mode_t; + assert_eq!( + child_perms, + expected_parent, + "Intermediate dir should have umask-derived mode, got {:o}", + child_perms & 0o777 + ); + + // Final directory should have exactly the requested mode + let target_perms = at.metadata("parent/child/target").permissions().mode() as mode_t; + assert_eq!( + target_perms, + 0o40700, + "Target should have exact requested mode 0700, got {:o}", + target_perms & 0o777 + ); +} + #[test] fn test_mkdir_concurrent_creation() { // Test concurrent mkdir -p operations: 10 iterations, 8 threads, 40 levels nesting From ca13e3391a0cf5ef95f2f9faa7635694b58765ce Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Mon, 19 Jan 2026 00:02:28 +0900 Subject: [PATCH 250/425] nice: -n huge_num true (#10213) --- src/uu/nice/src/nice.rs | 19 +++++++++++++------ tests/by-util/test_nice.rs | 30 ++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/src/uu/nice/src/nice.rs b/src/uu/nice/src/nice.rs index fc1e9057b..78036649f 100644 --- a/src/uu/nice/src/nice.rs +++ b/src/uu/nice/src/nice.rs @@ -9,6 +9,7 @@ use clap::{Arg, ArgAction, Command}; use libc::PRIO_PROCESS; use std::ffi::OsString; use std::io::{Error, ErrorKind, Write}; +use std::num::IntErrorKind; use std::os::unix::process::CommandExt; use std::process; @@ -23,6 +24,8 @@ pub mod options { pub static COMMAND: &str = "COMMAND"; } +const NICE_BOUND_NO_OVERFLOW: i32 = 50; + fn is_prefix_of(maybe_prefix: &str, target: &str, min_match: usize) -> bool { if maybe_prefix.len() < min_match || maybe_prefix.len() > target.len() { return false; @@ -126,12 +129,16 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { } match nstr.parse::() { Ok(num) => num, - Err(e) => { - return Err(USimpleError::new( - 125, - translate!("nice-error-invalid-number", "value" => nstr.clone(), "error" => e), - )); - } + Err(e) => match e.kind() { + IntErrorKind::PosOverflow => NICE_BOUND_NO_OVERFLOW, + IntErrorKind::NegOverflow => -NICE_BOUND_NO_OVERFLOW, + _ => { + return Err(USimpleError::new( + 125, + translate!("nice-error-invalid-number", "value" => nstr.clone(), "error" => e), + )); + } + }, } } None => { diff --git a/tests/by-util/test_nice.rs b/tests/by-util/test_nice.rs index 73ebf2672..8dac25277 100644 --- a/tests/by-util/test_nice.rs +++ b/tests/by-util/test_nice.rs @@ -90,3 +90,33 @@ fn test_trailing_empty_adjustment() { "error: The argument '--adjustment ' requires a value but none was supplied", ); } + +#[test] +fn test_nice_huge() { + new_ucmd!() + .args(&[ + "-n", + "99999999999999999999999999999999999999999999999999999999999999999999999999999999999999999", + "true", + ]) + .succeeds() + .no_stdout(); +} + +#[test] +fn test_nice_huge_negative() { + new_ucmd!().args(&["-n", "-9999999999", "true"]).succeeds(); + //.stderr_contains("Permission denied"); Depending on platform? +} + +#[test] +fn test_sign_middle() { + new_ucmd!() + .args(&["-n", "-2+4", "true"]) + .fails_with_code(125) + .no_stdout() + .stderr_contains("invalid"); +} +//uu: "-2+4" is not a valid number: invalid digit found in string +//gnu: invalid adjustment `-2+4' +//Both message is fine From f41798f22dd24dc359ccc4778235944e89170ea5 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 18 Jan 2026 17:05:32 +0000 Subject: [PATCH 251/425] chore(deps): update rust crate thiserror to v2.0.18 --- Cargo.lock | 96 +++++++++++++++++++++++++++--------------------------- 1 file changed, 48 insertions(+), 48 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3cac5c62f..7a09ed5fc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1104,7 +1104,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "54f0d287c53ffd184d04d8677f590f4ac5379785529e5e08b1c8083acdd5c198" dependencies = [ "memchr", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -2489,7 +2489,7 @@ dependencies = [ "once_cell", "parking_lot", "selinux-sys", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -2794,11 +2794,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.17", + "thiserror-impl 2.0.18", ] [[package]] @@ -2814,9 +2814,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", @@ -3069,7 +3069,7 @@ dependencies = [ "memchr", "nix", "tempfile", - "thiserror 2.0.17", + "thiserror 2.0.18", "uucore", "winapi-util", "windows-sys 0.61.2", @@ -3084,7 +3084,7 @@ dependencies = [ "fts-sys", "libc", "selinux", - "thiserror 2.0.17", + "thiserror 2.0.18", "uucore", ] @@ -3103,7 +3103,7 @@ version = "0.6.0" dependencies = [ "clap", "fluent", - "thiserror 2.0.17", + "thiserror 2.0.18", "uucore", ] @@ -3122,7 +3122,7 @@ version = "0.6.0" dependencies = [ "clap", "fluent", - "thiserror 2.0.17", + "thiserror 2.0.18", "uucore", ] @@ -3160,7 +3160,7 @@ dependencies = [ "linux-raw-sys 0.12.1", "selinux", "tempfile", - "thiserror 2.0.17", + "thiserror 2.0.18", "uucore", "walkdir", "xattr", @@ -3173,7 +3173,7 @@ dependencies = [ "clap", "fluent", "regex", - "thiserror 2.0.17", + "thiserror 2.0.18", "uucore", ] @@ -3217,7 +3217,7 @@ dependencies = [ "nix", "signal-hook 0.4.1", "tempfile", - "thiserror 2.0.17", + "thiserror 2.0.18", "uucore", ] @@ -3229,7 +3229,7 @@ dependencies = [ "codspeed-divan-compat", "fluent", "tempfile", - "thiserror 2.0.17", + "thiserror 2.0.18", "unicode-width 0.2.2", "uucore", ] @@ -3270,7 +3270,7 @@ dependencies = [ "fluent", "glob", "tempfile", - "thiserror 2.0.17", + "thiserror 2.0.18", "uucore", "windows-sys 0.61.2", ] @@ -3292,7 +3292,7 @@ dependencies = [ "fluent", "nix", "rust-ini", - "thiserror 2.0.17", + "thiserror 2.0.18", "uucore", ] @@ -3304,7 +3304,7 @@ dependencies = [ "codspeed-divan-compat", "fluent", "tempfile", - "thiserror 2.0.17", + "thiserror 2.0.18", "unicode-width 0.2.2", "uucore", ] @@ -3318,7 +3318,7 @@ dependencies = [ "num-bigint", "num-traits", "onig", - "thiserror 2.0.17", + "thiserror 2.0.18", "uucore", ] @@ -3351,7 +3351,7 @@ version = "0.6.0" dependencies = [ "clap", "fluent", - "thiserror 2.0.17", + "thiserror 2.0.18", "unicode-width 0.2.2", "uucore", ] @@ -3374,7 +3374,7 @@ version = "0.6.0" dependencies = [ "clap", "fluent", - "thiserror 2.0.17", + "thiserror 2.0.18", "uucore", ] @@ -3396,7 +3396,7 @@ dependencies = [ "clap", "fluent", "memchr", - "thiserror 2.0.17", + "thiserror 2.0.18", "uucore", ] @@ -3441,7 +3441,7 @@ dependencies = [ "filetime", "fluent", "selinux", - "thiserror 2.0.17", + "thiserror 2.0.18", "uucore", ] @@ -3454,7 +3454,7 @@ dependencies = [ "fluent", "memchr", "tempfile", - "thiserror 2.0.17", + "thiserror 2.0.18", "uucore", ] @@ -3483,7 +3483,7 @@ version = "0.6.0" dependencies = [ "clap", "fluent", - "thiserror 2.0.17", + "thiserror 2.0.18", "uucore", ] @@ -3512,7 +3512,7 @@ dependencies = [ "selinux", "tempfile", "terminal_size", - "thiserror 2.0.17", + "thiserror 2.0.18", "uucore", "uutils_term_grid", ] @@ -3555,7 +3555,7 @@ dependencies = [ "fluent", "rand 0.9.2", "tempfile", - "thiserror 2.0.17", + "thiserror 2.0.18", "uucore", ] @@ -3582,7 +3582,7 @@ dependencies = [ "indicatif", "libc", "tempfile", - "thiserror 2.0.17", + "thiserror 2.0.18", "uucore", "windows-sys 0.61.2", ] @@ -3617,7 +3617,7 @@ dependencies = [ "clap", "fluent", "libc", - "thiserror 2.0.17", + "thiserror 2.0.18", "uucore", ] @@ -3639,7 +3639,7 @@ dependencies = [ "codspeed-divan-compat", "fluent", "tempfile", - "thiserror 2.0.17", + "thiserror 2.0.18", "uucore", ] @@ -3691,7 +3691,7 @@ dependencies = [ "fluent", "itertools 0.14.0", "regex", - "thiserror 2.0.17", + "thiserror 2.0.18", "uucore", ] @@ -3720,7 +3720,7 @@ dependencies = [ "clap", "fluent", "regex", - "thiserror 2.0.17", + "thiserror 2.0.18", "uucore", ] @@ -3761,7 +3761,7 @@ dependencies = [ "indicatif", "libc", "tempfile", - "thiserror 2.0.17", + "thiserror 2.0.18", "uucore", "windows-sys 0.61.2", ] @@ -3784,7 +3784,7 @@ dependencies = [ "fluent", "libc", "selinux", - "thiserror 2.0.17", + "thiserror 2.0.18", "uucore", ] @@ -3799,7 +3799,7 @@ dependencies = [ "num-bigint", "num-traits", "tempfile", - "thiserror 2.0.17", + "thiserror 2.0.18", "uucore", ] @@ -3855,7 +3855,7 @@ dependencies = [ "rayon", "self_cell", "tempfile", - "thiserror 2.0.17", + "thiserror 2.0.18", "unicode-width 0.2.2", "uucore", ] @@ -3869,7 +3869,7 @@ dependencies = [ "fluent", "memchr", "tempfile", - "thiserror 2.0.17", + "thiserror 2.0.18", "uucore", ] @@ -3879,7 +3879,7 @@ version = "0.6.0" dependencies = [ "clap", "fluent", - "thiserror 2.0.17", + "thiserror 2.0.18", "uucore", ] @@ -3890,7 +3890,7 @@ dependencies = [ "clap", "fluent", "tempfile", - "thiserror 2.0.17", + "thiserror 2.0.18", "uu_stdbuf_libstdbuf", "uucore", ] @@ -3943,7 +3943,7 @@ dependencies = [ "memmap2", "regex", "tempfile", - "thiserror 2.0.17", + "thiserror 2.0.18", "uucore", ] @@ -3981,7 +3981,7 @@ dependencies = [ "fluent", "libc", "tempfile", - "thiserror 2.0.17", + "thiserror 2.0.18", "uucore", ] @@ -4005,7 +4005,7 @@ dependencies = [ "fluent", "jiff", "parse_datetime", - "thiserror 2.0.17", + "thiserror 2.0.18", "uucore", "windows-sys 0.61.2", ] @@ -4049,7 +4049,7 @@ dependencies = [ "nix", "string-interner", "tempfile", - "thiserror 2.0.17", + "thiserror 2.0.18", "uucore", ] @@ -4081,7 +4081,7 @@ dependencies = [ "codspeed-divan-compat", "fluent", "tempfile", - "thiserror 2.0.17", + "thiserror 2.0.18", "unicode-width 0.2.2", "uucore", ] @@ -4113,7 +4113,7 @@ dependencies = [ "clap", "fluent", "jiff", - "thiserror 2.0.17", + "thiserror 2.0.18", "utmp-classic", "uucore", ] @@ -4148,7 +4148,7 @@ dependencies = [ "libc", "nix", "tempfile", - "thiserror 2.0.17", + "thiserror 2.0.18", "unicode-width 0.2.2", "uucore", ] @@ -4225,7 +4225,7 @@ dependencies = [ "sha3", "sm3", "tempfile", - "thiserror 2.0.17", + "thiserror 2.0.18", "time", "unic-langid", "unit-prefix", @@ -4435,7 +4435,7 @@ checksum = "d5cec722a3274e47d1524cbe2cea762f2c19d615bd9d73ada21db9066349d57e" dependencies = [ "proc-macro2", "quote", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] From 7654075306040026775314b605c31e301707f269 Mon Sep 17 00:00:00 2001 From: AnandajithS Date: Sun, 18 Jan 2026 22:51:48 +0530 Subject: [PATCH 252/425] tail: fix behaviour of `tail -n0` in follow mode (#9114) * tail: fix behaviour of -n0 in follow mode * tests: added test for checking -n0 in follow mode --------- Co-authored-by: Sylvestre Ledru --- src/uu/tail/src/tail.rs | 9 +++++++-- tests/by-util/test_tail.rs | 22 ++++++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/uu/tail/src/tail.rs b/src/uu/tail/src/tail.rs index 2ffb537cf..1e3253071 100644 --- a/src/uu/tail/src/tail.rs +++ b/src/uu/tail/src/tail.rs @@ -470,7 +470,7 @@ fn bounded_tail(file: &mut File, settings: &Settings) { file.seek(SeekFrom::Start(i as u64)).unwrap(); } FilterMode::Lines(Signum::MinusZero, _) => { - return; + file.seek(SeekFrom::End(0)).unwrap(); } FilterMode::Bytes(Signum::Negative(count)) => { if file.seek(SeekFrom::End(-(*count as i64))).is_err() { @@ -484,7 +484,7 @@ fn bounded_tail(file: &mut File, settings: &Settings) { file.seek(SeekFrom::Start(*count - 1)).unwrap(); } FilterMode::Bytes(Signum::MinusZero) => { - return; + file.seek(SeekFrom::End(0)).unwrap(); } _ => {} } @@ -524,6 +524,11 @@ fn unbounded_tail(reader: &mut BufReader, settings: &Settings) -> UR chunks.fill(reader)?; chunks.print(&mut writer)?; } + FilterMode::Lines(Signum::MinusZero, sep) => { + let mut chunks = chunks::LinesChunkBuffer::new(*sep, 0); + chunks.fill(reader)?; + chunks.print(&mut writer)?; + } FilterMode::Bytes(Signum::PlusZero | Signum::Positive(1)) => { io::copy(reader, &mut writer)?; } diff --git a/tests/by-util/test_tail.rs b/tests/by-util/test_tail.rs index 134cc78eb..de0dcde8d 100644 --- a/tests/by-util/test_tail.rs +++ b/tests/by-util/test_tail.rs @@ -233,6 +233,28 @@ fn test_nc_0_wo_follow2() { .no_output(); } +#[test] +#[cfg(not(target_os = "windows"))] +fn test_n0_with_follow() { + let (at, mut ucmd) = at_and_ucmd!(); + let test_file = "test.txt"; + // Create file with multiple lines + at.write(test_file, "line1\nline2\nline3\n"); + + let mut child = ucmd.arg("-n0").arg("-f").arg(test_file).run_no_wait(); + child.make_assertion_with_delay(500).is_alive(); + + // Append a new line + at.append(test_file, "new\n"); + + // Should only print the newly appended line + child + .make_assertion_with_delay(DEFAULT_SLEEP_INTERVAL_MILLIS) + .with_current_output() + .stdout_only("new\n"); + child.kill(); +} + // TODO: Add similar test for windows #[test] #[cfg(unix)] From 309e3beff2b018320c1b7bed695c247ba1234b34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=E1=BA=A3=20th=E1=BA=BF=20gi=E1=BB=9Bi=20l=C3=A0=20Rust?= <90588855+naoNao89@users.noreply.github.com> Date: Mon, 19 Jan 2026 00:36:17 +0700 Subject: [PATCH 253/425] Merge pull request #9437 from naoNao89/sync-remove-unwrap sync: Reset O_NONBLOCK flag after file validation to match GNU behavior --- .../cspell.dictionaries/jargon.wordlist.txt | 1 + src/uu/sync/src/sync.rs | 33 +++++--- tests/by-util/test_pinky.rs | 33 +++++++- tests/by-util/test_sync.rs | 77 +++++++++++++++++++ 4 files changed, 132 insertions(+), 12 deletions(-) diff --git a/.vscode/cspell.dictionaries/jargon.wordlist.txt b/.vscode/cspell.dictionaries/jargon.wordlist.txt index e4987609f..d0957090f 100644 --- a/.vscode/cspell.dictionaries/jargon.wordlist.txt +++ b/.vscode/cspell.dictionaries/jargon.wordlist.txt @@ -136,6 +136,7 @@ semiprimes setcap setfacl setfattr +SETFL setlocale shortcode shortcodes diff --git a/src/uu/sync/src/sync.rs b/src/uu/sync/src/sync.rs index 76162ffc4..de79a957b 100644 --- a/src/uu/sync/src/sync.rs +++ b/src/uu/sync/src/sync.rs @@ -29,11 +29,16 @@ static ARG_FILES: &str = "files"; #[cfg(unix)] mod platform { + #[cfg(any(target_os = "linux", target_os = "android"))] + use nix::fcntl::{FcntlArg, OFlag, fcntl}; use nix::unistd::sync; #[cfg(any(target_os = "linux", target_os = "android"))] use nix::unistd::{fdatasync, syncfs}; #[cfg(any(target_os = "linux", target_os = "android"))] use std::fs::File; + #[cfg(any(target_os = "linux", target_os = "android"))] + use uucore::error::FromIo; + use uucore::error::UResult; pub fn do_sync() -> UResult<()> { @@ -44,7 +49,9 @@ mod platform { #[cfg(any(target_os = "linux", target_os = "android"))] pub fn do_syncfs(files: Vec) -> UResult<()> { for path in files { - let f = File::open(path).unwrap(); + let f = File::open(&path).map_err_context(|| path.clone())?; + // Reset O_NONBLOCK flag if it was set (matches GNU behavior) + let _ = fcntl(&f, FcntlArg::F_SETFL(OFlag::empty())); syncfs(f)?; } Ok(()) @@ -53,7 +60,9 @@ mod platform { #[cfg(any(target_os = "linux", target_os = "android"))] pub fn do_fdatasync(files: Vec) -> UResult<()> { for path in files { - let f = File::open(path).unwrap(); + let f = File::open(&path).map_err_context(|| path.clone())?; + // Reset O_NONBLOCK flag if it was set (matches GNU behavior) + let _ = fcntl(&f, FcntlArg::F_SETFL(OFlag::empty())); fdatasync(f)?; } Ok(()) @@ -157,15 +166,17 @@ mod platform { pub fn do_syncfs(files: Vec) -> UResult<()> { for path in files { - flush_volume( - Path::new(&path) - .components() - .next() - .unwrap() - .as_os_str() - .to_str() - .unwrap(), - )?; + let maybe_first = Path::new(&path).components().next(); + let vol_name = match maybe_first { + Some(c) => c.as_os_str().to_string_lossy().into_owned(), + None => { + return Err(USimpleError::new( + 1, + translate!("sync-error-no-such-file", "file" => path), + )); + } + }; + flush_volume(&vol_name)?; } Ok(()) } diff --git a/tests/by-util/test_pinky.rs b/tests/by-util/test_pinky.rs index cb52bff23..98eefc781 100644 --- a/tests/by-util/test_pinky.rs +++ b/tests/by-util/test_pinky.rs @@ -91,7 +91,38 @@ fn test_lookup() { let expect = unwrap_or_return!(expected_result(&ts, &[])).stdout_move_str(); let v_actual: Vec<&str> = actual.split_whitespace().collect(); let v_expect: Vec<&str> = expect.split_whitespace().collect(); - assert_eq!(v_actual, v_expect); + // The "Idle" field (index 3 in header) contains a dynamic time value that can change + // between when the two commands run (e.g., "00:09" vs "00:10"), causing flaky tests. + // We filter out values matching the idle time pattern (HH:MM format) to avoid race conditions. + // Header: ["Login", "Name", "TTY", "Idle", "When", "Where"] + fn filter_idle_times(v: &[&str]) -> Vec { + v.iter() + .enumerate() + .filter(|(i, s)| { + // Skip the "Idle" header at index 3 + if *i == 3 { + return false; + } + // Skip any value that looks like an idle time (HH:MM format like "00:09") + // These appear after the header in user data rows + if *i >= 6 && s.len() == 5 && s.chars().nth(2) == Some(':') { + let chars: Vec = s.chars().collect(); + if chars[0].is_ascii_digit() + && chars[1].is_ascii_digit() + && chars[3].is_ascii_digit() + && chars[4].is_ascii_digit() + { + return false; + } + } + true + }) + .map(|(_, s)| (*s).to_string()) + .collect() + } + let v_actual_filtered = filter_idle_times(&v_actual); + let v_expect_filtered = filter_idle_times(&v_expect); + assert_eq!(v_actual_filtered, v_expect_filtered); } #[cfg(unix)] diff --git a/tests/by-util/test_sync.rs b/tests/by-util/test_sync.rs index 15dafa28f..9c3df3a1e 100644 --- a/tests/by-util/test_sync.rs +++ b/tests/by-util/test_sync.rs @@ -90,3 +90,80 @@ fn test_sync_no_permission_file() { ts.ccmd("chmod").arg("0200").arg(f).succeeds(); ts.ucmd().arg(f).succeeds(); } + +#[cfg(any(target_os = "linux", target_os = "android"))] +#[test] +fn test_sync_data_nonblock_flag_reset() { + // Test that O_NONBLOCK flag is properly reset when syncing files + use uutests::util::TestScenario; + use uutests::util_name; + + let ts = TestScenario::new(util_name!()); + let at = &ts.fixtures; + let test_file = "test_file.txt"; + + // Create a test file + at.write(test_file, "test content"); + + // Run sync --data with the file - should succeed + ts.ucmd().arg("--data").arg(test_file).succeeds(); +} + +#[cfg(any(target_os = "linux", target_os = "android"))] +#[test] +fn test_sync_fs_nonblock_flag_reset() { + // Test that O_NONBLOCK flag is properly reset when syncing filesystems + use std::fs; + use tempfile::tempdir; + + let temporary_directory = tempdir().unwrap(); + let temporary_path = fs::canonicalize(temporary_directory.path()).unwrap(); + + // Run sync --file-system with the path - should succeed + new_ucmd!() + .arg("--file-system") + .arg(&temporary_path) + .succeeds(); +} + +#[cfg(any(target_os = "linux", target_os = "android"))] +#[test] +fn test_sync_fdatasync_error_handling() { + // Test that fdatasync properly handles file opening errors + new_ucmd!() + .arg("--data") + .arg("/nonexistent/path/to/file") + .fails() + .stderr_contains("error opening"); +} + +#[cfg(target_os = "macos")] +#[test] +fn test_sync_syncfs_error_handling_macos() { + // Test that syncfs properly handles invalid paths on macOS + new_ucmd!() + .arg("--file-system") + .arg("/nonexistent/path/to/file") + .fails() + .stderr_contains("error opening"); +} + +#[test] +fn test_sync_multiple_files() { + // Test syncing multiple files at once + use std::fs; + use tempfile::tempdir; + + let temporary_directory = tempdir().unwrap(); + let temp_path = temporary_directory.path(); + + // Create multiple test files + let file1 = temp_path.join("file1.txt"); + let file2 = temp_path.join("file2.txt"); + + fs::write(&file1, "content1").unwrap(); + fs::write(&file2, "content2").unwrap(); + + // Sync both files + new_ucmd!().arg("--data").arg(&file1).arg(&file2).succeeds(); +} From 3c55820f5e49b344f796b2269edf5e388255be2a Mon Sep 17 00:00:00 2001 From: Quin Gillespie Date: Sun, 18 Jan 2026 10:50:14 -0700 Subject: [PATCH 254/425] Fixed passing -0 to tail. Closes #10191 (#10209) --- src/uu/tail/src/tail.rs | 4 ++++ tests/by-util/test_tail.rs | 10 ++++++++++ 2 files changed, 14 insertions(+) diff --git a/src/uu/tail/src/tail.rs b/src/uu/tail/src/tail.rs index 1e3253071..17b09013a 100644 --- a/src/uu/tail/src/tail.rs +++ b/src/uu/tail/src/tail.rs @@ -413,6 +413,10 @@ fn forwards_thru_file( /// `num_delimiters` instance of `delimiter`. The `file` is left seek'd to the /// position just after that delimiter. fn backwards_thru_file(file: &mut File, num_delimiters: u64, delimiter: u8) { + if num_delimiters == 0 { + file.seek(SeekFrom::End(0)).unwrap(); + return; + } // This variable counts the number of delimiters found in the file // so far (reading from the end of the file toward the beginning). let mut counter = 0; diff --git a/tests/by-util/test_tail.rs b/tests/by-util/test_tail.rs index de0dcde8d..9d4a270e2 100644 --- a/tests/by-util/test_tail.rs +++ b/tests/by-util/test_tail.rs @@ -1147,6 +1147,16 @@ fn test_obsolete_syntax_small_file() { .stdout_is("a\nb\nc\nd\ne\n"); } +/// Test for obsolete syntax `tail -0 FILE`: print nothing and exit cleanly. +#[test] +fn test_obsolete_syntax_zero_lines_file() { + new_ucmd!() + .args(&["-0", "foobar.txt"]) + .succeeds() + .no_stderr() + .no_stdout(); +} + /// Test for reading all lines, specified by `tail -n +0`. #[test] fn test_positive_zero_lines() { From eb482fb2eeac31765f8a3e7aafa1e097d258ddcf Mon Sep 17 00:00:00 2001 From: Ruiyang Wang <56065503+rynewang@users.noreply.github.com> Date: Sun, 18 Jan 2026 09:54:50 -0800 Subject: [PATCH 255/425] uudoc: move tldr.zip warning to build.rs (#10039) Move the tldr.zip missing warning from runtime (uudoc.rs) to compile time (build.rs). This ensures the warning is printed only once during the build, instead of 100+ times when make calls uudoc separately for each utility. Fixes #9940 --- build.rs | 14 +++++++++++++- src/bin/uudoc.rs | 19 ------------------- tests/uudoc/mod.rs | 18 ++++++++++++------ 3 files changed, 25 insertions(+), 26 deletions(-) diff --git a/build.rs b/build.rs index 9b35eac5e..aabd96832 100644 --- a/build.rs +++ b/build.rs @@ -3,7 +3,7 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore (vars) krate mangen +// spell-checker:ignore (vars) krate mangen tldr use std::env; use std::fs::File; @@ -19,6 +19,18 @@ pub fn main() { // See println!("cargo:rerun-if-changed=build.rs"); + // Check for tldr.zip when building uudoc to warn users once at build time + // instead of repeatedly at runtime for each utility + if env::var("CARGO_FEATURE_UUDOC").is_ok() && !Path::new("docs/tldr.zip").exists() { + println!( + "cargo:warning=No tldr archive found, so the documentation will not include examples." + ); + println!("cargo:warning=To include examples, download the tldr archive:"); + println!( + "cargo:warning= curl -L https://github.com/tldr-pages/tldr/releases/latest/download/tldr.zip -o docs/tldr.zip" + ); + } + if let Ok(profile) = env::var("PROFILE") { println!("cargo:rustc-cfg=build={profile:?}"); } diff --git a/src/bin/uudoc.rs b/src/bin/uudoc.rs index 392375f9e..fe536b8e0 100644 --- a/src/bin/uudoc.rs +++ b/src/bin/uudoc.rs @@ -133,19 +133,6 @@ fn gen_completions(args: impl Iterator, util_map: &Uti process::exit(0); } -/// print tldr error -fn print_tldr_error() { - eprintln!("Warning: No tldr archive found, so the documentation will not include examples."); - eprintln!( - "To include examples in the documentation, download the tldr archive and put it in the docs/ folder." - ); - eprintln!(); - eprintln!( - " curl -L https://github.com/tldr-pages/tldr/releases/latest/download/tldr.zip -o docs/tldr.zip" - ); - eprintln!(); -} - /// # Errors /// Returns an error if the writer fails. #[allow(clippy::too_many_lines)] @@ -162,9 +149,6 @@ fn main() -> io::Result<()> { match command { "manpage" => { let args_iter = args.into_iter().skip(2); - if tldr_zip.is_none() { - print_tldr_error(); - } gen_manpage( &mut tldr_zip, args_iter, @@ -186,9 +170,6 @@ fn main() -> io::Result<()> { } } } - if tldr_zip.is_none() { - print_tldr_error(); - } let utils = util_map::>>(); match std::fs::create_dir("docs/src/utils/") { Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(()), diff --git a/tests/uudoc/mod.rs b/tests/uudoc/mod.rs index 455d3984f..010d6cda3 100644 --- a/tests/uudoc/mod.rs +++ b/tests/uudoc/mod.rs @@ -28,9 +28,11 @@ fn test_manpage_generation() { "Command failed with status: {}", output.status ); + // Note: tldr warning is now printed at build time (in build.rs), not at runtime assert!( - String::from_utf8_lossy(&output.stderr).contains("Warning: No tldr archive found"), - "stderr should contains tldr alert", + output.stderr.is_empty(), + "stderr should be empty but got: {}", + String::from_utf8_lossy(&output.stderr) ); let output_str = String::from_utf8_lossy(&output.stdout); @@ -52,9 +54,11 @@ fn test_manpage_coreutils() { "Command failed with status: {}", output.status ); + // Note: tldr warning is now printed at build time (in build.rs), not at runtime assert!( - String::from_utf8_lossy(&output.stderr).contains("Warning: No tldr archive found"), - "stderr should contains tldr alert", + output.stderr.is_empty(), + "stderr should be empty but got: {}", + String::from_utf8_lossy(&output.stderr) ); let output_str = String::from_utf8_lossy(&output.stdout); @@ -123,9 +127,11 @@ fn test_manpage_base64() { "Command failed with status: {}", output.status ); + // Note: tldr warning is now printed at build time (in build.rs), not at runtime assert!( - String::from_utf8_lossy(&output.stderr).contains("Warning: No tldr archive found"), - "stderr should contains tldr alert", + output.stderr.is_empty(), + "stderr should be empty but got: {}", + String::from_utf8_lossy(&output.stderr) ); let output_str = String::from_utf8_lossy(&output.stdout); From 238b27eddf8b4ec691bf912d12ad6e0367fa2c08 Mon Sep 17 00:00:00 2001 From: mattsu <35655889+mattsu2020@users.noreply.github.com> Date: Mon, 19 Jan 2026 02:57:37 +0900 Subject: [PATCH 256/425] wc:Ensure the output order of stdout and stderror remains unchanged. (#9905) --------- Co-authored-by: Sylvestre Ledru --- src/uu/wc/src/wc.rs | 18 ++++++++++++------ tests/by-util/test_wc.rs | 19 ++++++++++++++++++- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/src/uu/wc/src/wc.rs b/src/uu/wc/src/wc.rs index 1f4b67c20..4ae07fe2b 100644 --- a/src/uu/wc/src/wc.rs +++ b/src/uu/wc/src/wc.rs @@ -950,12 +950,13 @@ fn wc(inputs: &Inputs, settings: &Settings) -> UResult<()> { } }; - let word_count = match word_count_from_input(&input, settings) { - CountResult::Success(word_count) => word_count, - CountResult::Interrupted(word_count, err) => { - show!(err.map_err_context(|| input.path_display())); - word_count - } + // Store any I/O error from reading to print AFTER stats (matches GNU wc behavior) + let (word_count, deferred_error) = match word_count_from_input(&input, settings) { + CountResult::Success(word_count) => (word_count, None), + CountResult::Interrupted(word_count, err) => ( + word_count, + Some(err.map_err_context(|| input.path_display())), + ), CountResult::Failure(err) => { show!(err.map_err_context(|| input.path_display())); continue; @@ -970,6 +971,11 @@ fn wc(inputs: &Inputs, settings: &Settings) -> UResult<()> { show!(err.map_err_context(|| translate!("wc-error-failed-to-print-result", "title" => title.to_string_lossy()))); } } + // Print deferred error after stats to match GNU wc output order + if let Some(err) = deferred_error { + let _ = io::stdout().flush(); + show!(err); + } } if settings.total_when.is_total_row_visible(num_inputs) { diff --git a/tests/by-util/test_wc.rs b/tests/by-util/test_wc.rs index d1266e09d..be2374283 100644 --- a/tests/by-util/test_wc.rs +++ b/tests/by-util/test_wc.rs @@ -8,7 +8,7 @@ use uutests::at_and_ucmd; use uutests::new_ucmd; use uutests::util::vec_of_size; -// spell-checker:ignore (flags) lwmcL clmwL ; (path) bogusfile emptyfile manyemptylines moby notrailingnewline onelongemptyline onelongword weirdchars +// spell-checker:ignore (flags) lwmcL clmwL ; (path) bogusfile emptyfile manyemptylines moby notrailingnewline onelongemptyline onelongword weirdchars ioerrdir #[test] fn test_invalid_arg() { new_ucmd!().arg("--definitely-invalid").fails_with_code(1); @@ -449,6 +449,23 @@ fn test_read_from_directory_error() { .stdout_is(STDOUT); } +#[cfg(unix)] +#[test] +fn test_read_error_order_with_stderr_to_stdout() { + let (at, mut ucmd) = at_and_ucmd!(); + at.mkdir("ioerrdir"); + + let expected = format!( + "{:>7} {:>7} {:>7} ioerrdir\nwc: ioerrdir: Is a directory\n", + 0, 0, 0 + ); + + ucmd.arg("ioerrdir") + .stderr_to_stdout() + .fails() + .stdout_only(expected); +} + /// Test that getting counts from nonexistent file is an error. #[test] fn test_read_from_nonexistent_file() { From 5108d49c922d059dc6be3f3844ab2c9ca2c8155e Mon Sep 17 00:00:00 2001 From: Sebastian Bentmar Holgersson Date: Sun, 18 Jan 2026 18:59:23 +0100 Subject: [PATCH 257/425] numfmt: align error messages for suffixes (#9887) --- src/uu/numfmt/locales/en-US.ftl | 1 + src/uu/numfmt/locales/fr-FR.ftl | 1 + src/uu/numfmt/src/format.rs | 207 ++++++++++++++++++++++++++++---- src/uu/numfmt/src/units.rs | 20 +++ tests/by-util/test_numfmt.rs | 42 ++++++- 5 files changed, 244 insertions(+), 27 deletions(-) diff --git a/src/uu/numfmt/locales/en-US.ftl b/src/uu/numfmt/locales/en-US.ftl index a2ad787bd..d718e368a 100644 --- a/src/uu/numfmt/locales/en-US.ftl +++ b/src/uu/numfmt/locales/en-US.ftl @@ -59,6 +59,7 @@ numfmt-error-invalid-header = invalid header value { $value } numfmt-error-grouping-cannot-be-combined-with-to = grouping cannot be combined with --to numfmt-error-delimiter-must-be-single-character = the delimiter must be a single character numfmt-error-invalid-number-empty = invalid number: '' +numfmt-error-invalid-specific-suffix = invalid suffix in input { $input }: { $suffix } numfmt-error-invalid-suffix = invalid suffix in input: { $input } numfmt-error-invalid-number = invalid number: { $input } numfmt-error-missing-i-suffix = missing 'i' suffix in input: '{ $number }{ $suffix }' (e.g Ki/Mi/Gi) diff --git a/src/uu/numfmt/locales/fr-FR.ftl b/src/uu/numfmt/locales/fr-FR.ftl index 1a6294184..20bd91db9 100644 --- a/src/uu/numfmt/locales/fr-FR.ftl +++ b/src/uu/numfmt/locales/fr-FR.ftl @@ -59,6 +59,7 @@ numfmt-error-grouping-cannot-be-combined-with-to = le groupement ne peut pas êt numfmt-error-delimiter-must-be-single-character = le délimiteur doit être un seul caractère numfmt-error-invalid-number-empty = nombre invalide : '' numfmt-error-invalid-suffix = suffixe invalide dans l'entrée : { $input } +numfmt-error-invalid-specific-suffix = suffixe invalide dans l'entrée { $input } : { $suffix } numfmt-error-invalid-number = nombre invalide : { $input } numfmt-error-missing-i-suffix = suffixe 'i' manquant dans l'entrée : '{ $number }{ $suffix }' (par ex. Ki/Mi/Gi) numfmt-error-rejecting-suffix = rejet du suffixe dans l'entrée : '{ $number }{ $suffix }' (considérez utiliser --from) diff --git a/src/uu/numfmt/src/format.rs b/src/uu/numfmt/src/format.rs index f27926d87..3b1f41aa9 100644 --- a/src/uu/numfmt/src/format.rs +++ b/src/uu/numfmt/src/format.rs @@ -62,12 +62,97 @@ impl<'a> Iterator for WhitespaceSplitter<'a> { } } -fn parse_suffix(s: &str) -> Result<(f64, Option)> { +fn find_numeric_beginning(s: &str) -> Option<&str> { + let mut decimal_point_seen = false; + if s.is_empty() { + return None; + } + + for (idx, c) in s.char_indices() { + if c == '-' && idx == 0 { + continue; + } + if c.is_ascii_digit() { + continue; + } + if c == '.' && !decimal_point_seen { + decimal_point_seen = true; + continue; + } + if s[..idx].parse::().is_err() { + return None; + } + return Some(&s[..idx]); + } + + Some(s) +} + +// finds the valid beginning part of an input string, or None. +fn find_valid_number_with_suffix<'a>(s: &'a str, unit: &Unit) -> Option<&'a str> { + let numeric_part = find_numeric_beginning(s)?; + + let accepts_suffix = unit != &Unit::None; + let accepts_i = [Unit::Auto, Unit::Iec(true)].contains(unit); + + let mut characters = s.chars().skip(numeric_part.len()); + let potential_suffix = characters.next(); + let potential_i = characters.next(); + + if !accepts_suffix { + return Some(numeric_part); + } + + match (potential_suffix, potential_i) { + (Some(suffix), None) if RawSuffix::try_from(&suffix).is_ok() => { + Some(&s[..=numeric_part.len()]) + } + (Some(suffix), Some('i')) if accepts_i && RawSuffix::try_from(&suffix).is_ok() => { + Some(&s[..numeric_part.len() + 2]) + } + (Some(suffix), Some(_)) if RawSuffix::try_from(&suffix).is_ok() => { + Some(&s[..=numeric_part.len()]) + } + _ => Some(numeric_part), + } +} + +fn detailed_error_message(s: &str, unit: &Unit) -> Option { + if s.is_empty() { + return Some(translate!("numfmt-error-invalid-number-empty")); + } + + let valid_part = find_valid_number_with_suffix(s, unit) + .ok_or(translate!("numfmt-error-invalid-number", "input" => s.quote())) + .ok()?; + + if valid_part != s && valid_part.parse::().is_ok() { + return match s.chars().nth(valid_part.len()) { + Some(v) if RawSuffix::try_from(&v).is_ok() => Some( + translate!("numfmt-error-rejecting-suffix", "number" => valid_part, "suffix" => s[valid_part.len()..]), + ), + + _ => Some(translate!("numfmt-error-invalid-suffix", "input" => s.quote())), + }; + } + + if valid_part != s && valid_part.parse::().is_err() { + return Some( + translate!("numfmt-error-invalid-specific-suffix", "input" => s.quote(), "suffix" => s[valid_part.len()..].quote()), + ); + } + None +} + +fn parse_suffix(s: &str, unit: &Unit) -> Result<(f64, Option)> { if s.is_empty() { return Err(translate!("numfmt-error-invalid-number-empty")); } let with_i = s.ends_with('i'); + if with_i && ![Unit::Auto, Unit::Iec(true)].contains(unit) { + return Err(translate!("numfmt-error-invalid-suffix", "input" => s.quote())); + } let mut iter = s.chars(); if with_i { iter.next_back(); @@ -86,17 +171,7 @@ fn parse_suffix(s: &str) -> Result<(f64, Option)> { Some('Q') => Some((RawSuffix::Q, with_i)), Some('0'..='9') if !with_i => None, _ => { - // If with_i is true, the string ends with 'i' but there's no valid suffix letter - // This is always an invalid suffix (e.g., "1i", "2Ai") - if with_i { - return Err(translate!("numfmt-error-invalid-suffix", "input" => s.quote())); - } - // For other cases, check if the number part (without the last character) is valid - let number_part = &s[..s.len() - 1]; - if number_part.is_empty() || number_part.parse::().is_err() { - return Err(translate!("numfmt-error-invalid-number", "input" => s.quote())); - } - return Err(translate!("numfmt-error-invalid-suffix", "input" => s.quote())); + return Err(translate!("numfmt-error-invalid-number", "input" => s.quote())); } }; @@ -164,7 +239,8 @@ fn remove_suffix(i: f64, s: Option, u: &Unit) -> Result { } fn transform_from(s: &str, opts: &TransformOptions) -> Result { - let (i, suffix) = parse_suffix(s)?; + let (i, suffix) = parse_suffix(s, &opts.from) + .map_err(|original| detailed_error_message(s, &opts.from).unwrap_or(original))?; let i = i * (opts.from_unit as f64); remove_suffix(i, suffix, &opts.from).map(|n| { @@ -491,7 +567,7 @@ mod tests { #[test] fn test_parse_suffix_q_r_k() { - let result = parse_suffix("1Q"); + let result = parse_suffix("1Q", &Unit::Auto); assert!(result.is_ok()); let (number, suffix) = result.unwrap(); assert_eq!(number, 1.0); @@ -500,7 +576,7 @@ mod tests { assert_eq!(raw_suffix as i32, RawSuffix::Q as i32); assert!(!with_i); - let result = parse_suffix("2R"); + let result = parse_suffix("2R", &Unit::Auto); assert!(result.is_ok()); let (number, suffix) = result.unwrap(); assert_eq!(number, 2.0); @@ -509,7 +585,7 @@ mod tests { assert_eq!(raw_suffix as i32, RawSuffix::R as i32); assert!(!with_i); - let result = parse_suffix("3k"); + let result = parse_suffix("3k", &Unit::Auto); assert!(result.is_ok()); let (number, suffix) = result.unwrap(); assert_eq!(number, 3.0); @@ -518,7 +594,7 @@ mod tests { assert_eq!(raw_suffix as i32, RawSuffix::K as i32); assert!(!with_i); - let result = parse_suffix("4Qi"); + let result = parse_suffix("4Qi", &Unit::Auto); assert!(result.is_ok()); let (number, suffix) = result.unwrap(); assert_eq!(number, 4.0); @@ -527,7 +603,7 @@ mod tests { assert_eq!(raw_suffix as i32, RawSuffix::Q as i32); assert!(with_i); - let result = parse_suffix("5Ri"); + let result = parse_suffix("5Ri", &Unit::Auto); assert!(result.is_ok()); let (number, suffix) = result.unwrap(); assert_eq!(number, 5.0); @@ -539,22 +615,41 @@ mod tests { #[test] fn test_parse_suffix_error_messages() { - let result = parse_suffix("foo"); + let result = parse_suffix("foo", &Unit::Auto); assert!(result.is_err()); let error = result.unwrap_err(); assert!(error.contains("numfmt-error-invalid-number") || error.contains("invalid number")); assert!(!error.contains("invalid suffix")); - let result = parse_suffix("World"); + let result = parse_suffix("World", &Unit::Auto); assert!(result.is_err()); let error = result.unwrap_err(); assert!(error.contains("numfmt-error-invalid-number") || error.contains("invalid number")); assert!(!error.contains("invalid suffix")); + } - let result = parse_suffix("123i"); - assert!(result.is_err()); - let error = result.unwrap_err(); + #[test] + fn test_detailed_error_message() { + let result = detailed_error_message("123i", &Unit::Auto); + assert!(result.is_some()); + let error = result.unwrap(); assert!(error.contains("numfmt-error-invalid-suffix") || error.contains("invalid suffix")); + + let result = detailed_error_message("5MF", &Unit::Auto); + assert!(result.is_some()); + let error = result.unwrap(); + assert!( + error.contains("numfmt-error-invalid-specific-suffix") + || error.contains("invalid suffix") + ); + + let result = detailed_error_message("5KM", &Unit::Auto); + assert!(result.is_some()); + let error = result.unwrap(); + assert!( + error.contains("numfmt-error-invalid-specific-suffix") + || error.contains("invalid suffix") + ); } #[test] @@ -578,6 +673,72 @@ mod tests { assert_eq!(result.unwrap(), IEC_BASES[9]); } + #[test] + fn test_find_valid_part() { + assert_eq!( + find_valid_number_with_suffix("12345KL", &Unit::Auto), + Some("12345K") + ); + assert_eq!( + find_valid_number_with_suffix("12345K", &Unit::Auto), + Some("12345K") + ); + assert_eq!( + find_valid_number_with_suffix("12345", &Unit::Auto), + Some("12345") + ); + assert_eq!( + find_valid_number_with_suffix("asd12345KL", &Unit::Auto), + None + ); + assert_eq!( + find_valid_number_with_suffix("8asdf", &Unit::Auto), + Some("8") + ); + assert_eq!(find_valid_number_with_suffix("5i", &Unit::Si), Some("5")); + assert_eq!( + find_valid_number_with_suffix("5i", &Unit::Iec(true)), + Some("5") + ); + assert_eq!( + find_valid_number_with_suffix("0.1KL", &Unit::Auto), + Some("0.1K") + ); + assert_eq!( + find_valid_number_with_suffix("0.1", &Unit::Auto), + Some("0.1") + ); + assert_eq!( + find_valid_number_with_suffix("-0.1MT", &Unit::Auto), + Some("-0.1M") + ); + assert_eq!( + find_valid_number_with_suffix("-0.1PT", &Unit::Auto), + Some("-0.1P") + ); + assert_eq!( + find_valid_number_with_suffix("-0.1PT", &Unit::Auto), + Some("-0.1P") + ); + assert_eq!( + find_valid_number_with_suffix("123.4.5", &Unit::Auto), + Some("123.4") + ); + assert_eq!( + find_valid_number_with_suffix("0.55KiJ", &Unit::Iec(true)), + Some("0.55Ki") + ); + assert_eq!( + find_valid_number_with_suffix("0.55KiJ", &Unit::Iec(false)), + Some("0.55K") + ); + assert_eq!( + find_valid_number_with_suffix("123KICK", &Unit::Auto), + Some("123K") + ); + assert_eq!(find_valid_number_with_suffix("", &Unit::Auto), None); + } + #[test] fn test_consider_suffix_q_r() { use crate::options::RoundMethod; diff --git a/src/uu/numfmt/src/units.rs b/src/uu/numfmt/src/units.rs index bc5d480be..4343175f3 100644 --- a/src/uu/numfmt/src/units.rs +++ b/src/uu/numfmt/src/units.rs @@ -46,6 +46,26 @@ pub enum RawSuffix { Q, } +impl TryFrom<&char> for RawSuffix { + type Error = String; + + fn try_from(value: &char) -> Result { + match value { + 'K' | 'k' => Ok(Self::K), + 'M' => Ok(Self::M), + 'G' => Ok(Self::G), + 'T' => Ok(Self::T), + 'P' => Ok(Self::P), + 'E' => Ok(Self::E), + 'Z' => Ok(Self::Z), + 'Y' => Ok(Self::Y), + 'R' => Ok(Self::R), + 'Q' => Ok(Self::Q), + _ => Err(format!("Invalid suffix: {value}")), + } + } +} + pub type Suffix = (RawSuffix, WithI); pub struct DisplayableSuffix(pub Suffix, pub Unit); diff --git a/tests/by-util/test_numfmt.rs b/tests/by-util/test_numfmt.rs index 241286d07..f79c76006 100644 --- a/tests/by-util/test_numfmt.rs +++ b/tests/by-util/test_numfmt.rs @@ -62,6 +62,14 @@ fn test_from_iec_i_requires_suffix() { .stderr_is("numfmt: missing 'i' suffix in input: '10M' (e.g Ki/Mi/Gi)\n"); } +#[test] +fn test_from_iec_fails_if_i_suffix() { + new_ucmd!() + .args(&["--from=iec", "10Mi"]) + .fails_with_code(2) + .stderr_is("numfmt: invalid suffix in input '10Mi': 'i'\n"); +} + #[test] fn test_from_iec_i_without_suffix_are_bytes() { new_ucmd!() @@ -261,6 +269,34 @@ fn test_suffixes() { } } +#[test] +fn test_invalid_following_valid_suffix() { + let valid_suffixes = ['K', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y', 'R', 'Q', 'k']; + + for valid_suffix in valid_suffixes { + for c in ('A'..='Z').chain('a'..='z') { + let args = ["--from=si", "--to=si", &format!("1{valid_suffix}{c}")]; + + new_ucmd!() + .args(&args) + .fails_with_code(2) + .stderr_only(format!( + "numfmt: invalid suffix in input '1{valid_suffix}{c}': '{c}'\n" + )); + } + } +} + +#[test] +fn test_long_invalid_suffix() { + let args = ["--from=si", "--to=si", "1500VVVVVVVV"]; + + new_ucmd!() + .args(&args) + .fails_with_code(2) + .stderr_only("numfmt: invalid suffix in input: '1500VVVVVVVV'\n"); +} + #[test] fn test_should_report_invalid_suffix_on_nan() { // GNU numfmt reports this one as "invalid number" @@ -273,12 +309,11 @@ fn test_should_report_invalid_suffix_on_nan() { #[test] fn test_should_report_invalid_number_with_interior_junk() { - // GNU numfmt reports this as “invalid suffix” new_ucmd!() .args(&["--from=auto"]) .pipe_in("1x0K") .fails() - .stderr_is("numfmt: invalid number: '1x0K'\n"); + .stderr_is("numfmt: invalid suffix in input: '1x0K'\n"); } #[test] @@ -535,12 +570,11 @@ fn test_delimiter_from_si() { #[test] fn test_delimiter_overrides_whitespace_separator() { - // GNU numfmt reports this as “invalid suffix” new_ucmd!() .args(&["-d,"]) .pipe_in("1 234,56") .fails() - .stderr_is("numfmt: invalid number: '1 234'\n"); + .stderr_is("numfmt: invalid suffix in input: '1 234'\n"); } #[test] From a0a797d3e6249b257b9c67fd66a8addc4e646200 Mon Sep 17 00:00:00 2001 From: Rostyslav Toch Date: Sun, 18 Jan 2026 18:01:13 +0000 Subject: [PATCH 258/425] ptx: handle invalid regex arguments gracefully instead of panicking (#9825) --- src/uu/ptx/src/ptx.rs | 23 +++++++++++------------ tests/by-util/test_ptx.rs | 10 ++++++++++ 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/src/uu/ptx/src/ptx.rs b/src/uu/ptx/src/ptx.rs index ebaac28ff..b3cc319c1 100644 --- a/src/uu/ptx/src/ptx.rs +++ b/src/uu/ptx/src/ptx.rs @@ -285,16 +285,10 @@ fn read_input(input_files: &[OsString], config: &Config) -> std::io::Result e), - ) - })?) - } else { - None - }; + let sentence_splitter = config + .sentence_regex + .as_ref() + .and_then(|re_str| Regex::new(re_str).ok()); for filename in input_files { let mut reader: BufReader> = BufReader::new(if filename == "-" { @@ -343,8 +337,13 @@ fn read_lines( /// Go through every lines in the input files and record each match occurrence as a `WordRef`. fn create_word_set(config: &Config, filter: &WordFilter, file_map: &FileMap) -> BTreeSet { - let reg = Regex::new(&filter.word_regex).unwrap(); - let ref_reg = Regex::new(&config.context_regex).unwrap(); + let Some(reg) = Regex::new(&filter.word_regex).ok() else { + return BTreeSet::new(); + }; + let Some(ref_reg) = Regex::new(&config.context_regex).ok() else { + return BTreeSet::new(); + }; + let mut word_set: BTreeSet = BTreeSet::new(); for (file, lines) in file_map { let mut count: usize = 0; diff --git a/tests/by-util/test_ptx.rs b/tests/by-util/test_ptx.rs index 8facc8ab2..840759f7b 100644 --- a/tests/by-util/test_ptx.rs +++ b/tests/by-util/test_ptx.rs @@ -347,3 +347,13 @@ fn test_narrow_width_with_long_reference_no_panic() { .succeeds() .stdout_only(":1 content\n"); } + +#[test] +fn test_invalid_regex_word_trailing_backslash() { + new_ucmd!().args(&["-W", "bar\\"]).succeeds().no_stderr(); +} + +#[test] +fn test_invalid_regex_word_unclosed_group() { + new_ucmd!().args(&["-W", "(wrong"]).succeeds().no_stderr(); +} From 2122c5274646b6243b1aabbed70f0e16e056ae8d Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Mon, 19 Jan 2026 04:19:59 +0900 Subject: [PATCH 259/425] CICD.yml: Stop manually strippong (#10311) --- .github/workflows/CICD.yml | 23 +++-------------------- 1 file changed, 3 insertions(+), 20 deletions(-) diff --git a/.github/workflows/CICD.yml b/.github/workflows/CICD.yml index 160a193e7..e29bac73c 100644 --- a/.github/workflows/CICD.yml +++ b/.github/workflows/CICD.yml @@ -503,10 +503,8 @@ jobs: shell: bash run: | ## `make install` - make install DESTDIR=target/size-release/ - make install MULTICALL=y LN="ln -vf" DESTDIR=target/size-multi-release/ - # strip the results - strip target/size*/usr/local/bin/* + RUSTFLAGS="${RUSTFLAGS} -C strip=symbols" make install DESTDIR=target/size-release/ + RUSTFLAGS="${RUSTFLAGS} -C strip=symbols" make install MULTICALL=y LN="ln -vf" DESTDIR=target/size-multi-release/ - name: Test for hardlinks shell: bash run: | @@ -732,16 +730,6 @@ jobs: CARGO_TEST_OPTIONS='--workspace' ;; esac - outputs CARGO_TEST_OPTIONS - # * executable for `strip`? - STRIP="strip" - case ${{ matrix.job.target }} in - aarch64-*-linux-*) STRIP="aarch64-linux-gnu-strip" ;; - riscv64gc-*-linux-*) STRIP="riscv64-linux-gnu-strip" ;; - arm-*-linux-gnueabihf) STRIP="arm-linux-gnueabihf-strip" ;; - *-pc-windows-msvc) STRIP="" ;; - esac; - outputs STRIP - uses: taiki-e/install-action@v2 if: steps.vars.outputs.CARGO_CMD == 'cross' with: @@ -841,7 +829,7 @@ jobs: shell: bash run: | ## Build - ${{ steps.vars.outputs.CARGO_CMD }} ${{ steps.vars.outputs.CARGO_CMD_OPTIONS }} build --release \ + ${{ steps.vars.outputs.CARGO_CMD }} ${{ steps.vars.outputs.CARGO_CMD_OPTIONS }} build --release --config=profile.release.strip=true \ --target=${{ matrix.job.target }} ${{ matrix.job.cargo-options }} ${{ steps.vars.outputs.CARGO_FEATURES_OPTION }} ${{ steps.vars.outputs.CARGO_DEFAULT_FEATURES_OPTION }} - name: Test if: matrix.job.skip-tests != true @@ -874,11 +862,6 @@ jobs: # binaries cp 'target/${{ matrix.job.target }}/release/${{ env.PROJECT_NAME }}${{ steps.vars.outputs.EXE_suffix }}' '${{ steps.vars.outputs.STAGING }}/${{ steps.vars.outputs.PKG_BASENAME }}/' cp 'target/${{ matrix.job.target }}/release/uudoc${{ steps.vars.outputs.EXE_suffix }}' '${{ steps.vars.outputs.STAGING }}/${{ steps.vars.outputs.PKG_BASENAME }}/' || : - # `strip` binary (if needed) - if [ -n "${{ steps.vars.outputs.STRIP }}" ]; then - "${{ steps.vars.outputs.STRIP }}" '${{ steps.vars.outputs.STAGING }}/${{ steps.vars.outputs.PKG_BASENAME }}/${{ env.PROJECT_NAME }}${{ steps.vars.outputs.EXE_suffix }}' - "${{ steps.vars.outputs.STRIP }}" '${{ steps.vars.outputs.STAGING }}/${{ steps.vars.outputs.PKG_BASENAME }}/uudoc' || : - fi # README and LICENSE # * spell-checker:ignore EADME ICENSE (shopt -s nullglob; for f in [R]"EADME"{,.*}; do cp $f '${{ steps.vars.outputs.STAGING }}/${{ steps.vars.outputs.PKG_BASENAME }}/' ; done) From 500604287b004fe7ebcde974a420286e93dad2eb Mon Sep 17 00:00:00 2001 From: Jeffrey Finkelstein Date: Sun, 18 Jan 2026 09:48:42 -0500 Subject: [PATCH 260/425] pr: remove inaccurate unit test Remove unit test for `pr` that was enforcing incorrect behavior. These tests were ostensibly designed to match corresponding ones in the GNU test suite, but they don't match the current behavior of GNU `pr`, so it is not valuable to keep them. --- tests/by-util/test_pr.rs | 46 ---------------------------------------- 1 file changed, 46 deletions(-) diff --git a/tests/by-util/test_pr.rs b/tests/by-util/test_pr.rs index 19afb57bc..2220e9c39 100644 --- a/tests/by-util/test_pr.rs +++ b/tests/by-util/test_pr.rs @@ -478,52 +478,6 @@ fn test_with_date_format_env() { .stdout_matches(®ex); } -#[test] -fn test_with_pr_core_utils_tests() { - let test_cases = vec![ - ("", vec!["0Ft"], vec!["0F"], 0), - ("", vec!["0Fnt"], vec!["0Fnt-expected"], 0), - ("+3", vec!["0Ft"], vec!["3-0F"], 0), - ("+3 -f", vec!["0Ft"], vec!["3f-0F"], 0), - ("-a -3", vec!["0Ft"], vec!["a3-0F"], 0), - ("-a -3 -f", vec!["0Ft"], vec!["a3f-0F"], 0), - ("-a -3 -f", vec!["0Fnt"], vec!["a3f-0Fnt-expected"], 0), - ("+3 -a -3 -f", vec!["0Ft"], vec!["3a3f-0F"], 0), - ("-l 24", vec!["FnFn"], vec!["l24-FF"], 0), - ("-W 20 -l24 -f", vec!["tFFt-ll"], vec!["W20l24f-ll"], 0), - ]; - - for test_case in test_cases { - let (flags, input_file, expected_file, return_code) = test_case; - let mut scenario = new_ucmd!(); - let input_file_path = input_file.first().unwrap(); - let test_file_path = expected_file.first().unwrap(); - let value = file_last_modified_time(&scenario, input_file_path); - let mut arguments: Vec<&str> = flags - .split(' ') - .filter(|i| i.trim() != "") - .collect::>(); - - arguments.extend(input_file.clone()); - - let scenario_with_args = scenario.args(&arguments); - - let scenario_with_expected_status = if return_code == 0 { - scenario_with_args.succeeds() - } else { - scenario_with_args.fails() - }; - - scenario_with_expected_status.stdout_is_templated_fixture( - test_file_path, - &[ - ("{last_modified_time}", &value), - ("{file_name}", input_file_path), - ], - ); - } -} - #[test] fn test_with_join_lines_option() { let test_file_1 = "hosts.log"; From 1a81d1bb6b23ee5f0c41b9efa81601d89f15746f Mon Sep 17 00:00:00 2001 From: Jeffrey Finkelstein Date: Sun, 18 Jan 2026 09:55:29 -0500 Subject: [PATCH 261/425] pr: uniformly scan for form feed and newline chars Fix the way form feed characters are interpreted by changing the way lines and pages are found. Before this commit, a file comprising two form feed characters (`/f/f`) would result in too few trailing newlines at the end of the second page. After this change, each page is produced with the correct number of lines. This commit changes the way files are read, replacing complex iterators with a loop-based approach, iteratively scanning for newline or form feed characters. The `memchr` library is used to efficiently scan for these two characters. One downside of this implementation is that it currently reads the entire input file into memory; this can be improved in subsequent merge requests. --- Cargo.lock | 1 + src/uu/pr/Cargo.toml | 1 + src/uu/pr/src/pr.rs | 421 +++++++++++++++++++-------------------- tests/by-util/test_pr.rs | 25 +++ 4 files changed, 231 insertions(+), 217 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3cac5c62f..e0da285d3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3690,6 +3690,7 @@ dependencies = [ "clap", "fluent", "itertools 0.14.0", + "memchr", "regex", "thiserror 2.0.17", "uucore", diff --git a/src/uu/pr/Cargo.toml b/src/uu/pr/Cargo.toml index d8b5b2791..4eb7539dd 100644 --- a/src/uu/pr/Cargo.toml +++ b/src/uu/pr/Cargo.toml @@ -21,6 +21,7 @@ path = "src/pr.rs" clap = { workspace = true } uucore = { workspace = true, features = ["entries", "time"] } itertools = { workspace = true } +memchr = { workspace = true } regex = { workspace = true } thiserror = { workspace = true } fluent = { workspace = true } diff --git a/src/uu/pr/src/pr.rs b/src/uu/pr/src/pr.rs index a5a8b7b57..05a0eea10 100644 --- a/src/uu/pr/src/pr.rs +++ b/src/uu/pr/src/pr.rs @@ -9,10 +9,9 @@ use clap::{Arg, ArgAction, ArgMatches, Command}; use itertools::Itertools; use regex::Regex; -use std::fs::{File, metadata}; -use std::io::{BufRead, BufReader, Lines, Read, Write, stdin, stdout}; -#[cfg(unix)] -use std::os::unix::fs::FileTypeExt; +use std::fs::metadata; +use std::io::{Read, Write, stdin, stdout}; +use std::string::FromUtf8Error; use std::time::SystemTime; use thiserror::Error; @@ -28,11 +27,11 @@ const LINES_PER_PAGE_FOR_FORM_FEED: usize = 63; const HEADER_LINES_PER_PAGE: usize = 5; const TRAILER_LINES_PER_PAGE: usize = 5; const FILE_STDIN: &str = "-"; -const READ_BUFFER_SIZE: usize = 1024 * 64; const DEFAULT_COLUMN_WIDTH: usize = 72; const DEFAULT_COLUMN_WIDTH_WITH_S_OPTION: usize = 512; const DEFAULT_COLUMN_SEPARATOR: &char = &TAB; const FF: u8 = 0x0C_u8; +const NL: u8 = b'\n'; mod options { pub const HEADER: &str = "header"; @@ -82,13 +81,32 @@ struct OutputOptions { line_width: Option, } +/// One line of an input file, annotated with file, page, and line number. +#[derive(Default, Clone)] struct FileLine { file_id: usize, - line_number: usize, page_number: usize, - group_key: usize, - line_content: Result, - form_feeds_after: usize, + line_number: usize, + line_content: String, +} + +impl FileLine { + fn from_buf( + file_id: usize, + page_number: usize, + line_number: usize, + buf: &[u8], + ) -> Result { + // TODO Don't read bytes to String just to directly write them + // out again anyway. + let line_content = String::from_utf8(buf.to_vec())?; + Ok(Self { + file_id, + page_number, + line_number, + line_content, + }) + } } struct ColumnModeOptions { @@ -115,19 +133,6 @@ impl Default for NumberingMode { } } -impl Default for FileLine { - fn default() -> Self { - Self { - file_id: 0, - line_number: 0, - page_number: 0, - group_key: 0, - line_content: Ok(String::new()), - form_feeds_after: 0, - } - } -} - impl From for PrError { fn from(err: std::io::Error) -> Self { Self::EncounteredErrors { @@ -136,25 +141,18 @@ impl From for PrError { } } +impl From for PrError { + fn from(err: FromUtf8Error) -> Self { + Self::EncounteredErrors { + msg: err.to_string(), + } + } +} + #[derive(Debug, Error)] enum PrError { - #[error("{}", translate!("pr-error-reading-input", "file" => file.clone()))] - Input { - #[source] - source: std::io::Error, - file: String, - }, - #[error("{}", translate!("pr-error-unknown-filetype", "file" => file.clone()))] - UnknownFiletype { file: String }, #[error("pr: {msg}")] EncounteredErrors { msg: String }, - #[error("{}", translate!("pr-error-is-directory", "file" => file.clone()))] - IsDirectory { file: String }, - #[cfg(not(windows))] - #[error("{}", translate!("pr-error-socket-not-supported", "file" => file.clone()))] - IsSocket { file: String }, - #[error("{}", translate!("pr-error-no-such-file", "file" => file.clone()))] - NotExists { file: String }, } pub fn uu_app() -> Command { @@ -764,95 +762,22 @@ fn build_options( }) } -fn open(path: &str) -> Result, PrError> { - if path == FILE_STDIN { - let stdin = stdin(); - return Ok(Box::new(stdin) as Box); - } - - metadata(path).map_or_else( - |_| { - Err(PrError::NotExists { - file: path.to_string(), - }) - }, - |i| { - let path_string = path.to_string(); - match i.file_type() { - #[cfg(unix)] - ft if ft.is_socket() => Err(PrError::IsSocket { file: path_string }), - ft if ft.is_dir() => Err(PrError::IsDirectory { file: path_string }), - - ft => { - #[allow(unused_mut)] - let mut is_valid = ft.is_file() || ft.is_symlink(); - - #[cfg(unix)] - { - is_valid = - is_valid || ft.is_char_device() || ft.is_block_device() || ft.is_fifo(); - } - - if is_valid { - Ok(Box::new(File::open(path).map_err(|e| PrError::Input { - source: e, - file: path.to_string(), - })?) as Box) - } else { - Err(PrError::UnknownFiletype { file: path_string }) - } - } - } - }, - ) -} - -fn split_lines_if_form_feed(file_content: Result) -> Vec { - file_content.map_or_else( - |e| { - vec![FileLine { - line_content: Err(e), - ..FileLine::default() - }] - }, - |content| { - let mut lines = Vec::new(); - let mut f_occurred = 0; - let mut chunk = Vec::new(); - for byte in content.as_bytes() { - if byte == &FF { - f_occurred += 1; - } else { - if f_occurred != 0 { - // First time byte occurred in the scan - lines.push(FileLine { - line_content: Ok(String::from_utf8(chunk.clone()).unwrap()), - form_feeds_after: f_occurred, - ..FileLine::default() - }); - chunk.clear(); - } - chunk.push(*byte); - f_occurred = 0; - } - } - - lines.push(FileLine { - line_content: Ok(String::from_utf8(chunk).unwrap()), - form_feeds_after: f_occurred, - ..FileLine::default() - }); - - lines - }, - ) -} - fn pr(path: &str, options: &OutputOptions) -> Result { - let lines = BufReader::with_capacity(READ_BUFFER_SIZE, open(path)?).lines(); + // Read the entire contents of the file into a buffer. + // + // TODO Read incrementally. + let buf = if path == "-" { + let mut f = stdin(); + let mut buf = vec![]; + f.read_to_end(&mut buf)?; + buf + } else { + std::fs::read(path)? + }; - let pages = read_stream_and_create_pages(options, lines, 0); + let pages = get_pages(options, 0, &buf)?; + // Split the text into pages, and then print each line in each page. for page_with_page_number in pages { let page_number = page_with_page_number.0 + 1; let page = page_with_page_number.1; @@ -862,115 +787,180 @@ fn pr(path: &str, options: &OutputOptions) -> Result { Ok(0) } -fn read_stream_and_create_pages( +/// Group lines of a file into pages. +/// +/// Returns a list of the form `(page_num, lines)`. +/// +/// # Errors +/// +/// Returns an error if the bytes are not a valid UTF-8 string. +fn get_pages( options: &OutputOptions, - lines: Lines>>, file_id: usize, -) -> Box)>> { + buf: &[u8], +) -> Result)>, FromUtf8Error> { let start_page = options.start_page; - let start_line_number = get_start_line_number(options); - let last_page = options.end_page; + let end_page = options.end_page; let lines_needed_per_page = lines_to_read_for_page(options); - Box::new( - lines - .flat_map(split_lines_if_form_feed) - .enumerate() - .map(move |(i, line)| FileLine { - line_number: i + start_line_number, - file_id, - ..line - }) // Add line number and file_id - .batching(move |it| { - let mut first_page = Vec::new(); - let mut page_with_lines = Vec::new(); - for line in it { - let form_feeds_after = line.form_feeds_after; - first_page.push(line); + // Keep a running total of the number of lines read, starting with + // 0 or another specified number. + let mut line_num = get_start_line_number(options); - if form_feeds_after > 1 { - // insert empty pages - page_with_lines.push(first_page); - for _i in 1..form_feeds_after { - page_with_lines.push(vec![]); - } - return Some(page_with_lines); - } + // We will collect each page into a list of pages, along with + // its page number. + let mut pages: Vec<(usize, Vec)> = vec![]; - if first_page.len() == lines_needed_per_page || form_feeds_after == 1 { - break; - } + // We will build each page iteratively, since one page may + // contain multiple lines and may be interrupted by either a + // form feed or by reaching a line limit. + let mut page = vec![]; + let mut page_num = 0; + + // Remember the index of the end of the last line to use as the + // beginning of the next line. + let mut prev = 0; + + // Search for either the form feed character `\f` or the newline + // character `\n`. The newline character marks the end of a line, + // and a page comprises several lines. A form feed character marks + // the end of a page regardless of how many lines have been read. + for i in memchr::memchr2_iter(FF, NL, buf) { + if buf[i] == FF { + // Treat everything up to (but not including) the form feed + // character as the last line of the page. + let file_line = FileLine::from_buf(file_id, page_num, line_num, &buf[prev..i])?; + page.push(file_line); + + // Remember where the last line ended. + prev = i + 1; + + // The page is finished, so we add it to the list of + // pages and clear the `page` buffer for the next + // iteration. + // + // TODO Optimization opportunity: don't bother pushing + // lines and pages if we aren't going to display it. + if start_page <= page_num + 1 && end_page.is_none_or(|e| page_num < e) { + pages.push((page_num, page.clone())); + } + page_num += 1; + page.clear(); + } else { + // Add everything up to (but not including) the newline + // character as one line of the page. + let file_line = FileLine::from_buf(file_id, page_num, line_num, &buf[prev..i])?; + page.push(file_line); + line_num += 1; + + // Remember where the last line ended. + prev = i + 1; + + // If the page is finished, add it to the list of pages + // and clear the `page` buffer for the next iteration. + if page.len() >= lines_needed_per_page { + if start_page <= page_num + 1 && end_page.is_none_or(|e| page_num < e) { + pages.push((page_num, page.clone())); } + page_num += 1; + page.clear(); + } + } + } - if first_page.is_empty() { - return None; - } - page_with_lines.push(first_page); - Some(page_with_lines) - }) // Create set of pages as form feeds could lead to empty pages - .flatten() // Flatten to pages from page sets - .enumerate() // Assign page number - .skip_while(move |(x, _)| { - // Skip the not needed pages - let current_page = x + 1; - current_page < start_page - }) - .take_while(move |(x, _)| { - // Take only the required pages - let current_page = x + 1; + // Consider all trailing bytes as the last line. + if prev < buf.len() { + let file_line = FileLine::from_buf(file_id, page_num, line_num, &buf[prev..])?; + page.push(file_line); + } - current_page >= start_page - && last_page.is_none_or(|last_page| current_page <= last_page) - }), - ) + // Consider all trailing lines as the last page. + if !page.is_empty() && start_page <= page_num + 1 && end_page.is_none_or(|e| page_num < e) { + pages.push((page_num, page.clone())); + } + + Ok(pages) +} + +/// Key used to group lines together according to their file and page number. +fn group_key(num_files: usize, line: &FileLine) -> usize { + (line.page_number + 1) * num_files + line.file_id +} + +/// Group each line by its file and page number. +/// +/// The input list of `lines` must be already sorted according to the +/// `group_key`. +fn group_lines(num_files: usize, lines: Vec) -> Vec<(usize, Vec)> { + let mut result: Vec<(usize, Vec)> = vec![]; + let mut current_key: Option = None; + let mut current_group: Vec = vec![]; + for file_line in lines { + match current_key { + None => { + current_key = Some(group_key(num_files, &file_line)); + current_group.push(file_line); + } + Some(key) if group_key(num_files, &file_line) == key => { + current_group.push(file_line); + } + Some(key) => { + result.push((key, current_group.clone())); + current_group.clear(); + current_key = Some(group_key(num_files, &file_line)); + current_group.push(file_line); + } + } + } + // TODO Handle empty file. + result.push((current_key.unwrap(), current_group)); + result +} + +/// Group each line by its file and page number. +/// +/// Each group can then be merged into columns of a single page. +fn get_file_line_groups( + options: &OutputOptions, + paths: &[&str], +) -> Result)>, PrError> { + let num_files = paths.len(); + let mut all_lines = vec![]; + for (file_id, path) in paths.iter().enumerate() { + // Read the entire contents of the file into a buffer. + // + // TODO Read incrementally. + let buf = if *path == "-" { + let mut f = stdin(); + let mut buf = vec![]; + f.read_to_end(&mut buf)?; + buf + } else { + std::fs::read(path)? + }; + + // Split the text into pages and collect each line for + // subsequent grouping. + for (_, mut page) in get_pages(options, file_id, &buf)? { + all_lines.append(&mut page); + } + } + // Sort each line by group number and then by line number. + all_lines.sort_by_key(|l| (group_key(num_files, l), l.line_number)); + + Ok(group_lines(num_files, all_lines)) } fn mpr(paths: &[&str], options: &OutputOptions) -> Result { - let n_files = paths.len(); - - // Check if files exists - for path in paths { - open(path)?; - } - - let file_line_groups = paths - .iter() - .enumerate() - .map(|(i, path)| { - let lines = BufReader::with_capacity(READ_BUFFER_SIZE, open(path).unwrap()).lines(); - - read_stream_and_create_pages(options, lines, i).flat_map(move |(x, line)| { - let file_line = line; - let page_number = x + 1; - file_line - .into_iter() - .map(|fl| FileLine { - page_number, - group_key: page_number * n_files + fl.file_id, - ..fl - }) - .collect::>() - }) - }) - .kmerge_by(|a, b| { - if a.group_key == b.group_key { - a.line_number < b.line_number - } else { - a.group_key < b.group_key - } - }) - .chunk_by(|file_line| file_line.group_key); + let file_line_groups = get_file_line_groups(options, paths)?; let start_page = options.start_page; let mut lines = Vec::new(); let mut page_counter = start_page; - for (_key, file_line_group) in &file_line_groups { + for (_key, file_line_group) in file_line_groups { for file_line in file_line_group { - if let Err(e) = file_line.line_content { - return Err(e.into()); - } - let new_page_number = file_line.page_number; + let new_page_number = file_line.page_number + 1; if page_counter != new_page_number { print_page(&lines, options, page_counter)?; lines = Vec::new(); @@ -1124,10 +1114,7 @@ fn get_line_for_printing( let blank_line = String::new(); let formatted_line_number = get_formatted_line_number(options, file_line.line_number, index); - let mut complete_line = format!( - "{formatted_line_number}{}", - file_line.line_content.as_ref().unwrap() - ); + let mut complete_line = format!("{formatted_line_number}{}", file_line.line_content); let offset_spaces = &options.offset_spaces; diff --git a/tests/by-util/test_pr.rs b/tests/by-util/test_pr.rs index 2220e9c39..80478258c 100644 --- a/tests/by-util/test_pr.rs +++ b/tests/by-util/test_pr.rs @@ -606,3 +606,28 @@ fn test_omit_pagination_option() { .pipe_in("a\nb\n") .succeeds(); } + +#[test] +fn test_form_feed_newlines() { + // Here we define the expected output. + // + // Each page should have the same number of blank lines before the + // form-feed character. + let whitespace = " ".repeat(50); + let datetime_pattern = r"\d\d\d\d-\d\d-\d\d \d\d:\d\d"; + let page1 = format!("\n\n{datetime_pattern}{whitespace}Page 1\n\n\n\n\x0c"); + let page2 = format!("\n\n{datetime_pattern}{whitespace}Page 2\n\n\n\n\x0c"); + let pattern = format!("{page1}{page2}"); + let regex = Regex::new(&pattern).unwrap(); + + // Command line: `printf "\f\f" | pr -f`. + // + // Escape code `\x0c` in a Rust string literal is the ASCII escape + // code `\f` for the "form feed" character (which appears like + // `^L` in the terminal). + new_ucmd!() + .arg("-f") + .pipe_in("\x0c\x0c") + .succeeds() + .stdout_matches(®ex); +} From 4e7fa9fc94eb6b5a07005f969a1e64471b958de1 Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Sun, 18 Jan 2026 21:06:35 +0100 Subject: [PATCH 262/425] Change fuzz_seq_parse_number to should_pass: false Fails with: Failing input: fuzz/artifacts/fuzz_seq_parse_number/crash-880e3fa1db64b55230dca321d3c6a3aa7c1ad977 Output of `std::fmt::Debug`: [48, 120, 55, 46, 48, 48, 49, 49, 48, 48, 48, 48, 48, 48, 48, 112, 45, 54, 56, 48, 48, 48, 49, 49, 49, 48, 48, 49, 53, 55, 57, 49, 55, 55, 55, 91, 55, 105, 110, 102, 105, 110, 105, 116, 121] Reproduce with: cargo fuzz run fuzz_seq_parse_number fuzz/artifacts/fuzz_seq_parse_number/crash-880e3fa1db64b55230dca321d3c6a3aa7c1ad977 Minimize test case with: cargo fuzz tmin fuzz_seq_parse_number fuzz/artifacts/fuzz_seq_parse_number/crash-880e3fa1db64b55230dca321d3c6a3aa7c1ad977 --- .github/workflows/fuzzing.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/fuzzing.yml b/.github/workflows/fuzzing.yml index aaf7080e6..3b1515e69 100644 --- a/.github/workflows/fuzzing.yml +++ b/.github/workflows/fuzzing.yml @@ -94,7 +94,7 @@ jobs: - { name: fuzz_parse_glob, should_pass: true } - { name: fuzz_parse_size, should_pass: true } - { name: fuzz_parse_time, should_pass: true } - - { name: fuzz_seq_parse_number, should_pass: true } + - { name: fuzz_seq_parse_number, should_pass: false } - { name: fuzz_non_utf8_paths, should_pass: true } steps: From 7c5383dd89d57128ff8a8780ff7664f73c153fc3 Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Sat, 17 Jan 2026 15:22:06 +0100 Subject: [PATCH 263/425] dirname: add fuzzer to test GNU compatibility --- .github/workflows/fuzzing.yml | 1 + fuzz/Cargo.lock | 10 ++ fuzz/Cargo.toml | 7 + fuzz/fuzz_targets/fuzz_dirname.rs | 211 ++++++++++++++++++++++++++++++ 4 files changed, 229 insertions(+) create mode 100644 fuzz/fuzz_targets/fuzz_dirname.rs diff --git a/.github/workflows/fuzzing.yml b/.github/workflows/fuzzing.yml index 3b1515e69..789096ddd 100644 --- a/.github/workflows/fuzzing.yml +++ b/.github/workflows/fuzzing.yml @@ -96,6 +96,7 @@ jobs: - { name: fuzz_parse_time, should_pass: true } - { name: fuzz_seq_parse_number, should_pass: false } - { name: fuzz_non_utf8_paths, should_pass: true } + - { name: fuzz_dirname, should_pass: true } steps: - uses: actions/checkout@v6 diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index 1ec35d314..31215113e 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -1617,6 +1617,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "uu_dirname" +version = "0.6.0" +dependencies = [ + "clap", + "fluent", + "uucore", +] + [[package]] name = "uu_echo" version = "0.6.0" @@ -1798,6 +1807,7 @@ dependencies = [ "uu_cksum", "uu_cut", "uu_date", + "uu_dirname", "uu_echo", "uu_env", "uu_expr", diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index d3c987f22..6d5e2d4d6 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -41,6 +41,7 @@ uu_split = { path = "../src/uu/split" } uu_tr = { path = "../src/uu/tr" } uu_env = { path = "../src/uu/env" } uu_cksum = { path = "../src/uu/cksum" } +uu_dirname = { path = "../src/uu/dirname" } [[bin]] name = "fuzz_date" @@ -149,3 +150,9 @@ name = "fuzz_non_utf8_paths" path = "fuzz_targets/fuzz_non_utf8_paths.rs" test = false doc = false + +[[bin]] +name = "fuzz_dirname" +path = "fuzz_targets/fuzz_dirname.rs" +test = false +doc = false diff --git a/fuzz/fuzz_targets/fuzz_dirname.rs b/fuzz/fuzz_targets/fuzz_dirname.rs new file mode 100644 index 000000000..bfb127a5a --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_dirname.rs @@ -0,0 +1,211 @@ +// This file is part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +#![no_main] +use libfuzzer_sys::fuzz_target; +use uu_dirname::uumain; + +use rand::Rng; +use rand::prelude::IndexedRandom; +use std::ffi::OsString; + +use uufuzz::CommandResult; +use uufuzz::{compare_result, generate_and_run_uumain, generate_random_string, run_gnu_cmd}; + +static CMD_PATH: &str = "dirname"; + +fn generate_dirname_args() -> Vec { + let mut rng = rand::rng(); + let mut args = Vec::new(); + + // 20% chance to include -z/--zero flag + if rng.random_bool(0.2) { + if rng.random_bool(0.5) { + args.push("-z".to_string()); + } else { + args.push("--zero".to_string()); + } + } + + // 30% chance to use one of the specific issue #8924 cases + if rng.random_bool(0.3) { + let issue_cases = [ + "foo//.", + "foo/./", + "foo/bar/./", + "bar//.", + "test/./", + "a/b/./", + "x//.", + "dir/subdir/./", + ]; + args.push(issue_cases.choose(&mut rng).unwrap().to_string()); + } else { + // Generate 1-3 path arguments normally + let num_paths = rng.random_range(1..=3); + for _ in 0..num_paths { + args.push(generate_path()); + } + } + + args +} + +fn generate_path() -> String { + let mut rng = rand::rng(); + + // Different types of paths to test + let path_type = rng.random_range(0..15); + + match path_type { + // Simple paths + 0 => generate_random_string(rng.random_range(1..=20)), + + // Paths with slashes + 1 => { + let mut path = String::new(); + let components = rng.random_range(1..=5); + for i in 0..components { + if i > 0 { + path.push('/'); + } + path.push_str(&generate_random_string(rng.random_range(1..=10))); + } + path + } + + // Root path + 2 => "/".to_string(), + + // Absolute paths + 3 => { + let mut path = "/".to_string(); + let components = rng.random_range(1..=4); + for _ in 0..components { + path.push_str(&generate_random_string(rng.random_range(1..=8))); + path.push('/'); + } + // Remove trailing slash sometimes + if rng.random_bool(0.5) && path.len() > 1 { + path.pop(); + } + path + } + + // Paths ending with "/." (specific case from issue #8924) + 4 => { + let base = if rng.random_bool(0.3) { + "/".to_string() + } else { + format!("/{}", generate_random_string(rng.random_range(1..=10))) + }; + format!("{}.", base) + } + + // Paths with multiple slashes + 5 => { + let base = generate_random_string(rng.random_range(1..=10)); + format!( + "///{}//{}", + base, + generate_random_string(rng.random_range(1..=8)) + ) + } + + // Paths with dots + 6 => { + let components = [".", "..", "...", "...."]; + let chosen = components.choose(&mut rng).unwrap(); + if rng.random_bool(0.5) { + format!("/{}", chosen) + } else { + chosen.to_string() + } + } + + // Single character paths + 7 => { + let chars = ['a', 'x', '1', '-', '_', '.']; + chars.choose(&mut rng).unwrap().to_string() + } + + // Empty string (edge case) + 8 => "".to_string(), + + // Issue #8924 specific cases: paths like "foo//." + 9 => { + let base = generate_random_string(rng.random_range(1..=10)); + format!("{}//.", base) + } + + // Issue #8924 specific cases: paths like "foo/./" + 10 => { + let base = generate_random_string(rng.random_range(1..=10)); + format!("{}/./", base) + } + + // Issue #8924 specific cases: paths like "foo/bar/./" + 11 => { + let base1 = generate_random_string(rng.random_range(1..=8)); + let base2 = generate_random_string(rng.random_range(1..=8)); + format!("{}/{}/./", base1, base2) + } + + // More complex patterns with ./ and multiple slashes + 12 => { + let base = generate_random_string(rng.random_range(1..=10)); + let patterns = ["/./", "//./", "//.//", "/.//"]; + let pattern = patterns.choose(&mut rng).unwrap(); + format!("{}{}", base, pattern) + } + + // Patterns with .. and multiple slashes + 13 => { + let base = generate_random_string(rng.random_range(1..=10)); + let patterns = ["/..", "//..", "/../", "//..//"]; + let pattern = patterns.choose(&mut rng).unwrap(); + format!("{}{}", base, pattern) + } + + // Complex paths with special cases + _ => { + let special_endings = [".", "..", "/.", "/..", "//", "/", "/./.", "//.", "./"]; + let base = generate_random_string(rng.random_range(1..=15)); + let ending = special_endings.choose(&mut rng).unwrap(); + format!("{}{}", base, ending) + } + } +} + +fuzz_target!(|_data: &[u8]| { + let dirname_args = generate_dirname_args(); + let mut args = vec![OsString::from("dirname")]; + args.extend(dirname_args.iter().map(OsString::from)); + + let rust_result = generate_and_run_uumain(&args, uumain, None); + + let gnu_result = match run_gnu_cmd(CMD_PATH, &args[1..], false, None) { + Ok(result) => result, + Err(error_result) => { + eprintln!("Failed to run GNU command:"); + eprintln!("Stderr: {}", error_result.stderr); + eprintln!("Exit Code: {}", error_result.exit_code); + CommandResult { + stdout: String::new(), + stderr: error_result.stderr, + exit_code: error_result.exit_code, + } + } + }; + + compare_result( + "dirname", + &format!("{:?}", &args[1..]), + None, + &rust_result, + &gnu_result, + false, + ); +}); From 47125579089d18e92b9cb19edb10211ba1003777 Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Sat, 17 Jan 2026 16:20:52 +0100 Subject: [PATCH 264/425] fuzzing: fix should_pass evaluation in CI workflow Fixed two bugs in the fuzzing workflow where matrix.test-target.name.should_pass was used instead of matrix.test-target.should_pass. This caused continue-on-error to always be true, making CI jobs pass even when fuzzers correctly detected incompatibilities. --- .github/workflows/fuzzing.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/fuzzing.yml b/.github/workflows/fuzzing.yml index 789096ddd..19a10523e 100644 --- a/.github/workflows/fuzzing.yml +++ b/.github/workflows/fuzzing.yml @@ -118,7 +118,7 @@ jobs: - name: Run ${{ matrix.test-target.name }} for XX seconds id: run_fuzzer shell: bash - continue-on-error: ${{ !matrix.test-target.name.should_pass }} + continue-on-error: ${{ !matrix.test-target.should_pass }} run: | mkdir -p fuzz/stats STATS_FILE="fuzz/stats/${{ matrix.test-target.name }}.txt" @@ -156,7 +156,7 @@ jobs: echo "Runs: $(grep -q "stat::number_of_executed_units" "$STATS_FILE" && grep "stat::number_of_executed_units" "$STATS_FILE" | awk '{print $2}' || echo "unknown")" echo "Execution Rate: $(grep -q "stat::average_exec_per_sec" "$STATS_FILE" && grep "stat::average_exec_per_sec" "$STATS_FILE" | awk '{print $2}' || echo "unknown") execs/sec" echo "New Units: $(grep -q "stat::new_units_added" "$STATS_FILE" && grep "stat::new_units_added" "$STATS_FILE" | awk '{print $2}' || echo "unknown")" - echo "Expected: ${{ matrix.test-target.name.should_pass }}" + echo "Expected: ${{ matrix.test-target.should_pass }}" if grep -q "SUMMARY: " "$STATS_FILE"; then echo "Status: $(grep "SUMMARY: " "$STATS_FILE" | head -1)" else From b57838227e6c6b2ef76a1bc1e1a01622117c4069 Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Mon, 19 Jan 2026 06:41:34 +0900 Subject: [PATCH 265/425] GnuTests: Replace incompat tests (#9976) Co-authored-by: oech3 <> Co-authored-by: Sylvestre Ledru --- util/build-gnu.sh | 3 --- util/fetch-gnu.sh | 4 ++++ 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/util/build-gnu.sh b/util/build-gnu.sh index a68929087..5c506cdc2 100755 --- a/util/build-gnu.sh +++ b/util/build-gnu.sh @@ -105,7 +105,6 @@ fi cd "${path_GNU}" && echo "[ pwd:'${PWD}' ]" # Any binaries that aren't built become `false` to make tests failure -# Note that some test (e.g. runcon/runcon-compute.sh) incorrectly passes by this for binary in $(./build-aux/gen-lists-of-programs.sh --list-progs); do bin_path="${UU_BUILD_DIR}/${binary}" test -f "${bin_path}" || cp -v /usr/bin/false "${bin_path}" @@ -170,8 +169,6 @@ 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 # We use coreutils yes sed -i "s|--coreutils-prog=||g" tests/misc/coreutils.sh # Different message diff --git a/util/fetch-gnu.sh b/util/fetch-gnu.sh index 8b2cebe6c..064f84f93 100755 --- a/util/fetch-gnu.sh +++ b/util/fetch-gnu.sh @@ -11,6 +11,10 @@ curl -L ${repo}/raw/refs/heads/master/tests/mkdir/writable-under-readonly.sh > t curl -L ${repo}/raw/refs/heads/master/tests/cp/cp-mv-enotsup-xattr.sh > tests/cp/cp-mv-enotsup-xattr.sh #spell-checker:disable-line curl -L ${repo}/raw/refs/heads/master/tests/cp/nfs-removal-race.sh > tests/cp/nfs-removal-race.sh curl -L ${repo}/raw/refs/heads/master/tests/csplit/csplit-io-err.sh > tests/csplit/csplit-io-err.sh +# Replace tests not compatible with our binaries +sed -i -e 's/no-mtab-status.sh/no-mtab-status-masked-proc.sh/' -e 's/nproc-quota.sh/nproc-quota-systemd.sh/' tests/local.mk +curl -L ${repo}/raw/refs/heads/master/tests/df/no-mtab-status-masked-proc.sh > tests/df/no-mtab-status-masked-proc.sh +curl -L ${repo}/raw/refs/heads/master/tests/nproc/nproc-quota-systemd.sh > tests/nproc/nproc-quota-systemd.sh curl -L ${repo}/raw/refs/heads/master/tests/stty/bad-speed.sh > tests/stty/bad-speed.sh # Avoid incorrect PASS curl -L ${repo}/raw/refs/heads/master/tests/runcon/runcon-compute.sh > tests/runcon/runcon-compute.sh From 525d1f88ec91ca8bd9bf57f41a73d32b3327adee Mon Sep 17 00:00:00 2001 From: Chris Dryden Date: Sun, 18 Jan 2026 16:42:14 -0500 Subject: [PATCH 266/425] cp/mv: suppress xattr ENOTSUP errors for optional preservation (#10083) * cp/mv: suppress xattr ENOTSUP errors for optional preservation * mv: copy xattrs for symlink fallback and format tests * cp: fix test expectation for GNU-compatible error format * fix test portability for Android and xattr detection --------- Co-authored-by: Sylvestre Ledru --- .../cspell.dictionaries/jargon.wordlist.txt | 2 + src/uu/cp/locales/en-US.ftl | 1 + src/uu/cp/src/cp.rs | 40 +++++++++-- src/uu/mv/src/mv.rs | 41 ++++++++---- tests/by-util/test_cp.rs | 67 +++++++++++++++++-- tests/by-util/test_mv.rs | 24 +++++++ 6 files changed, 155 insertions(+), 20 deletions(-) diff --git a/.vscode/cspell.dictionaries/jargon.wordlist.txt b/.vscode/cspell.dictionaries/jargon.wordlist.txt index d0957090f..fd1352931 100644 --- a/.vscode/cspell.dictionaries/jargon.wordlist.txt +++ b/.vscode/cspell.dictionaries/jargon.wordlist.txt @@ -220,5 +220,7 @@ TUNABLES tunables VMULL vmull +ENOTSUP +enotsup SETFL tmpfs diff --git a/src/uu/cp/locales/en-US.ftl b/src/uu/cp/locales/en-US.ftl index a0b95cf6c..f4e9df006 100644 --- a/src/uu/cp/locales/en-US.ftl +++ b/src/uu/cp/locales/en-US.ftl @@ -91,6 +91,7 @@ cp-error-failed-to-create-whole-tree = failed to create whole tree cp-error-failed-to-create-directory = Failed to create directory: { $error } cp-error-backup-format = cp: { $error } Try '{ $exec } --help' for more information. +cp-error-setting-attributes = setting attributes for { $path } # Debug enum strings cp-debug-enum-no = no diff --git a/src/uu/cp/src/cp.rs b/src/uu/cp/src/cp.rs index b8745d649..4fd3fba47 100644 --- a/src/uu/cp/src/cp.rs +++ b/src/uu/cp/src/cp.rs @@ -1316,6 +1316,20 @@ fn parse_path_args( Ok((paths, target)) } +/// Check if an error is ENOTSUP/EOPNOTSUPP (operation not supported). +/// This is used to suppress xattr errors on filesystems that don't support them. +fn is_enotsup_error(error: &CpError) -> bool { + #[cfg(unix)] + const EOPNOTSUPP: i32 = libc::EOPNOTSUPP; + #[cfg(not(unix))] + const EOPNOTSUPP: i32 = 95; + + match error { + CpError::IoErr(e) | CpError::IoErrContext(e, _) => e.raw_os_error() == Some(EOPNOTSUPP), + _ => false, + } +} + /// When handling errors, we don't always want to show them to the user. This function handles that. fn show_error_if_needed(error: &CpError) { match error { @@ -1328,6 +1342,11 @@ fn show_error_if_needed(error: &CpError) { // touch a b && echo "n"|cp -i a b && echo $? // should return an error from GNU 9.2 } + // Format IoErrContext using strip_errno to remove "(os error N)" suffix + // for GNU-compatible output + CpError::IoErrContext(io_err, context) => { + show_error!("{}: {}", context, uucore::error::strip_errno(io_err)); + } _ => { show_error!("{error}"); } @@ -1630,6 +1649,10 @@ impl OverwriteMode { /// Handles errors for attributes preservation. If the attribute is not required, and /// errored, tries to show error (see `show_error_if_needed` for additional behavior details). /// If it's required, then the error is thrown. +/// +/// Note: ENOTSUP/EOPNOTSUPP errors are silently ignored when not required, as per GNU cp +/// documentation: "Try to preserve SELinux security context and extended attributes (xattr), +/// but ignore any failure to do that and print no corresponding diagnostic." fn handle_preserve CopyResult<()>>(p: &Preserve, f: F) -> CopyResult<()> { match p { Preserve::No { .. } => {} @@ -1637,8 +1660,12 @@ fn handle_preserve CopyResult<()>>(p: &Preserve, f: F) -> CopyResult< let result = f(); if *required { result?; - } else if let Err(error) = result { - show_error_if_needed(&error); + } else if let Err(ref error) = result { + // Suppress ENOTSUP errors when preservation is optional. + // This matches GNU cp behavior for -a and --preserve=all. + if !is_enotsup_error(error) { + show_error_if_needed(error); + } } } } @@ -1675,8 +1702,13 @@ fn copy_extended_attrs(source: &Path, dest: &Path) -> CopyResult<()> { fs::set_permissions(dest, revert_perms)?; } - // If copying xattrs failed, propagate that error now. - copy_xattrs_result?; + // If copying xattrs failed, propagate that error now with context. + copy_xattrs_result.map_err(|e| { + CpError::IoErrContext( + e, + translate!("cp-error-setting-attributes", "path" => dest.quote()), + ) + })?; Ok(()) } diff --git a/src/uu/mv/src/mv.rs b/src/uu/mv/src/mv.rs index 860683e7a..44540abfa 100644 --- a/src/uu/mv/src/mv.rs +++ b/src/uu/mv/src/mv.rs @@ -908,7 +908,12 @@ fn rename_fifo_fallback(_from: &Path, _to: &Path) -> io::Result<()> { #[cfg(unix)] fn rename_symlink_fallback(from: &Path, to: &Path) -> io::Result<()> { let path_symlink_points_to = fs::read_link(from)?; - unix::fs::symlink(path_symlink_points_to, to).and_then(|_| fs::remove_file(from)) + unix::fs::symlink(path_symlink_points_to, to)?; + #[cfg(not(any(target_os = "macos", target_os = "redox")))] + { + let _ = copy_xattrs_if_supported(from, to); + } + fs::remove_file(from) } #[cfg(windows)] @@ -1147,13 +1152,11 @@ fn copy_file_with_hardlinks_helper( rename_symlink_fallback(from, to)?; } else { // Copy a regular file. + fs::copy(from, to)?; + // Copy xattrs, ignoring ENOTSUP errors (filesystem doesn't support xattrs) #[cfg(all(unix, not(any(target_os = "macos", target_os = "redox"))))] { - fs::copy(from, to).and_then(|_| fsxattr::copy_xattrs(&from, &to))?; - } - #[cfg(any(target_os = "macos", target_os = "redox"))] - { - fs::copy(from, to)?; + let _ = copy_xattrs_if_supported(from, to); } } @@ -1195,18 +1198,32 @@ fn rename_file_fallback( } // Regular file copy - #[cfg(all(unix, not(any(target_os = "macos", target_os = "redox"))))] fs::copy(from, to) - .and_then(|_| fsxattr::copy_xattrs(&from, &to)) - .and_then(|_| fs::remove_file(from)) .map_err(|err| io::Error::new(err.kind(), translate!("mv-error-permission-denied")))?; - #[cfg(any(target_os = "macos", target_os = "redox", not(unix)))] - fs::copy(from, to) - .and_then(|_| fs::remove_file(from)) + + // Copy xattrs, ignoring ENOTSUP errors (filesystem doesn't support xattrs) + #[cfg(all(unix, not(any(target_os = "macos", target_os = "redox"))))] + { + let _ = copy_xattrs_if_supported(from, to); + } + + fs::remove_file(from) .map_err(|err| io::Error::new(err.kind(), translate!("mv-error-permission-denied")))?; Ok(()) } +/// Copy xattrs from source to destination, ignoring ENOTSUP/EOPNOTSUPP errors. +/// These errors indicate the filesystem doesn't support extended attributes, +/// which is acceptable when moving files across filesystems. +#[cfg(all(unix, not(any(target_os = "macos", target_os = "redox"))))] +fn copy_xattrs_if_supported(from: &Path, to: &Path) -> io::Result<()> { + match fsxattr::copy_xattrs(from, to) { + Ok(()) => Ok(()), + Err(e) if e.raw_os_error() == Some(libc::EOPNOTSUPP) => Ok(()), + Err(e) => Err(e), + } +} + fn is_empty_dir(path: &Path) -> bool { fs::read_dir(path).is_ok_and(|mut contents| contents.next().is_none()) } diff --git a/tests/by-util/test_cp.rs b/tests/by-util/test_cp.rs index 8cb844ce8..dd77ddd61 100644 --- a/tests/by-util/test_cp.rs +++ b/tests/by-util/test_cp.rs @@ -2615,7 +2615,7 @@ fn test_cp_reflink_insufficient_permission() { .arg("unreadable") .arg(TEST_EXISTING_FILE) .fails() - .stderr_only("cp: 'unreadable' -> 'existing_file.txt': Permission denied (os error 13)\n"); + .stderr_only("cp: 'unreadable' -> 'existing_file.txt': Permission denied\n"); } #[cfg(target_os = "linux")] @@ -3131,9 +3131,8 @@ fn test_cp_archive_on_nonexistent_file() { .arg(TEST_NONEXISTENT_FILE) .arg(TEST_EXISTING_FILE) .fails() - .stderr_only( - "cp: cannot stat 'nonexistent_file.txt': No such file or directory (os error 2)\n", - ); + .stderr_contains("cannot stat 'nonexistent_file.txt'") + .stderr_contains("No such file or directory"); } #[test] @@ -7512,3 +7511,63 @@ fn test_cp_to_existing_file_permissions() { let new_dst_mode = std::fs::metadata(&dst_path).unwrap().permissions().mode(); assert_eq!(dst_mode, new_dst_mode); } + +/// Test xattr ENOTSUP handling: -a/--preserve=all silent, --preserve=xattr errors +#[test] +#[cfg(target_os = "linux")] +fn test_cp_xattr_enotsup_handling() { + use std::process::Command; + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + at.write("src", "x"); + + // Check if setfattr is available and source fs supports xattrs + if !Command::new("setfattr") + .args(["-n", "user.t", "-v", "v", &at.plus_as_string("src")]) + .status() + .is_ok_and(|s| s.success()) + { + return; // Skip: setfattr not available or source doesn't support xattrs + } + + // Check if /dev/shm exists + if !std::path::Path::new("/dev/shm").exists() { + return; // Skip: /dev/shm not available + } + + // Check if /dev/shm actually doesn't support xattrs by trying to set one + let shm_test_file = "/dev/shm/xattr_test_probe"; + std::fs::write(shm_test_file, "test").ok(); + let shm_supports_xattr = Command::new("setfattr") + .args(["-n", "user.t", "-v", "v", shm_test_file]) + .status() + .is_ok_and(|s| s.success()); + std::fs::remove_file(shm_test_file).ok(); + + if shm_supports_xattr { + return; // Skip: /dev/shm supports xattrs on this system + } + + // -a: silent success + scene + .ucmd() + .args(&["-a", &at.plus_as_string("src"), "/dev/shm/t1"]) + .succeeds() + .no_stderr(); + // --preserve=all: silent success + scene + .ucmd() + .args(&["--preserve=all", &at.plus_as_string("src"), "/dev/shm/t2"]) + .succeeds() + .no_stderr(); + // --preserve=xattr: must fail with proper message + scene + .ucmd() + .args(&["--preserve=xattr", &at.plus_as_string("src"), "/dev/shm/t3"]) + .fails() + .stderr_contains("setting attributes") + .stderr_contains("Operation not supported"); + for f in ["/dev/shm/t1", "/dev/shm/t2", "/dev/shm/t3"] { + std::fs::remove_file(f).ok(); + } +} diff --git a/tests/by-util/test_mv.rs b/tests/by-util/test_mv.rs index a3d196562..5592f9c1e 100644 --- a/tests/by-util/test_mv.rs +++ b/tests/by-util/test_mv.rs @@ -2821,3 +2821,27 @@ fn test_mv_no_prompt_unwriteable_file_with_no_tty() { assert!(!at.file_exists("source_notty")); assert!(at.file_exists("target_notty")); } + +/// Test mv silently succeeds when dest filesystem doesn't support xattrs (ENOTSUP) +#[test] +#[cfg(target_os = "linux")] +fn test_mv_xattr_enotsup_silent() { + use std::process::Command; + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + at.write("src", "x"); + + if Command::new("setfattr") + .args(["-n", "user.t", "-v", "v", &at.plus_as_string("src")]) + .status() + .is_ok_and(|s| s.success()) + { + scene + .ucmd() + .arg(at.plus_as_string("src")) + .arg("/dev/shm/mv_test") + .succeeds() + .no_stderr(); + std::fs::remove_file("/dev/shm/mv_test").ok(); + } +} From 87c332c727227be592fbe70f6f796e5b92659bfc Mon Sep 17 00:00:00 2001 From: mattsu <35655889+mattsu2020@users.noreply.github.com> Date: Mon, 19 Jan 2026 06:46:17 +0900 Subject: [PATCH 267/425] sort : gnu core utils test (sort-merge-fdlimit.sh) (#9849) --- Cargo.lock | 606 ++++++++++++++++++---------------- deny.toml | 2 + src/uu/sort/Cargo.toml | 4 +- src/uu/sort/locales/en-US.ftl | 1 + src/uu/sort/locales/fr-FR.ftl | 1 + src/uu/sort/src/chunks.rs | 96 +++++- src/uu/sort/src/merge.rs | 15 +- src/uu/sort/src/sort.rs | 162 ++++++++- src/uu/sort/src/tmp_dir.rs | 23 +- 9 files changed, 606 insertions(+), 304 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e0da285d3..dff0506ac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10,9 +10,9 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "aho-corasick" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" dependencies = [ "memchr", ] @@ -43,9 +43,9 @@ dependencies = [ [[package]] name = "anstream" -version = "0.6.19" +version = "0.6.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "301af1932e46185686725e0fad2f8f2aa7da69dd70bf6ecc44d6b703844a3933" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" dependencies = [ "anstyle", "anstyle-parse", @@ -58,9 +58,9 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.11" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" [[package]] name = "anstyle-parse" @@ -73,22 +73,22 @@ dependencies = [ [[package]] name = "anstyle-query" -version = "1.1.3" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c8bdeb6047d8983be085bab0ba1472e6dc604e7041dbf6fcd5e71523014fae9" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "anstyle-wincon" -version = "3.0.9" +version = "3.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "403f75924867bb1033c59fbf0797484329750cfbe3c4325cd33127941fabc882" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -120,9 +120,9 @@ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" [[package]] name = "autocfg" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "base64-simd" @@ -162,7 +162,7 @@ version = "0.72.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", "cexpr", "clang-sys", "itertools 0.13.0", @@ -184,9 +184,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.9.1" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" [[package]] name = "bitvec" @@ -234,6 +234,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + [[package]] name = "bstr" version = "1.12.1" @@ -247,9 +256,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.18.1" +version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "793db76d6187cd04dff33004d8e6c9cc4e05cd330500379d2394209271b4aeee" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" [[package]] name = "bytecount" @@ -265,10 +274,11 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "cc" -version = "1.2.27" +version = "1.2.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d487aa071b5f64da6f19a3e848e3578944b726ee5a4854b82172f02aa876bfdc" +checksum = "cd4932aefd12402b36c60956a4fe0035421f544799057659ff86f923657aada3" dependencies = [ + "find-msvc-tools", "shlex", ] @@ -283,9 +293,9 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.1" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" @@ -295,9 +305,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chrono" -version = "0.4.42" +version = "0.4.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" dependencies = [ "iana-time-zone", "num-traits", @@ -348,9 +358,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.5" +version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" +checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" [[package]] name = "clap_mangen" @@ -371,7 +381,7 @@ dependencies = [ "anyhow", "cc", "colored", - "getrandom 0.2.16", + "getrandom 0.2.17", "glob", "libc", "nix", @@ -452,15 +462,15 @@ checksum = "baf0a07a401f374238ab8e2f11a104d2851bf9ce711ec69804834de8af45c7af" [[package]] name = "console" -version = "0.16.0" +version = "0.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e09ced7ebbccb63b4c65413d821f2e00ce54c5ca4514ddc6b3c892fdbcbc69d" +checksum = "03e45a4a8926227e4197636ba97a9fc9b00477e9f4bd711395687c5f0734bec4" dependencies = [ "encode_unicode", "libc", "once_cell", "unicode-width 0.2.2", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -478,7 +488,7 @@ version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" dependencies = [ - "getrandom 0.2.16", + "getrandom 0.2.17", "once_cell", "tiny-keccak", ] @@ -491,9 +501,9 @@ checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" [[package]] name = "convert_case" -version = "0.7.1" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb402b8d4c85569410425650ce3eddc7d698ed96d39a73f941b08fb63082f1e7" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" dependencies = [ "unicode-segmentation", ] @@ -721,7 +731,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", "crossterm_winapi", "derive_more", "document-features", @@ -745,15 +755,15 @@ dependencies = [ [[package]] name = "crunchy" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43da5946c66ffcc7745f48db692ffbb10a83bfe0afd96235c5c2a4fb23994929" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] name = "crypto-common" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", "typenum", @@ -777,12 +787,13 @@ checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" [[package]] name = "ctrlc" -version = "3.4.7" +version = "3.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46f93780a459b7d656ef7f071fe699c4d3d2cb201c4b24d085b6ddc505276e73" +checksum = "73736a89c4aff73035ba2ed2e565061954da00d4970fc9ac25dcc85a2a20d790" dependencies = [ + "dispatch2", "nix", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -848,31 +859,32 @@ dependencies = [ [[package]] name = "deranged" -version = "0.5.2" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75d7cc94194b4dd0fa12845ef8c911101b7f37633cda14997a6e82099aa0b693" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" dependencies = [ "powerfmt", ] [[package]] name = "derive_more" -version = "2.0.1" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" dependencies = [ "derive_more-impl", ] [[package]] name = "derive_more-impl" -version = "2.0.1" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ "convert_case", "proc-macro2", "quote", + "rustc_version", "syn", ] @@ -892,6 +904,18 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "dispatch2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +dependencies = [ + "bitflags 2.10.0", + "block2", + "libc", + "objc2", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -937,18 +961,18 @@ dependencies = [ [[package]] name = "document-features" -version = "0.2.11" +version = "0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95249b50c6c185bee49034bcb378a49dc2b5dff0be90ff6616d31d64febab05d" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" dependencies = [ "litrs", ] [[package]] name = "dtor" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e58a0764cddb55ab28955347b45be00ade43d4d6f3ba4bf3dc354e4ec9432934" +checksum = "404d02eeb088a82cfd873006cb713fe411306c7d182c344905e101fb1167d301" dependencies = [ "dtor-proc-macro", ] @@ -985,12 +1009,12 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "errno" -version = "0.3.12" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cea14ef9355e3beab063703aa9dab15afd25f0667c341310c1e5274bb1d0da18" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -999,7 +1023,7 @@ version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22be12de19decddab85d09f251ec8363f060ccb22ec9c81bc157c0c8433946d8" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", "log", "scopeguard", "uuid", @@ -1041,10 +1065,16 @@ dependencies = [ ] [[package]] -name = "fixed_decimal" -version = "0.7.0" +name = "find-msvc-tools" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35943d22b2f19c0cb198ecf915910a8158e94541c89dcc63300d7799d46c2c5e" +checksum = "f449e6c6c08c865631d4890cfacf252b3d396c9bcc83adb6623cdb02a8336c41" + +[[package]] +name = "fixed_decimal" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35eabf480f94d69182677e37571d3be065822acfafd12f2f085db44fbbcc8e57" dependencies = [ "displaydoc", "smallvec", @@ -1053,13 +1083,13 @@ dependencies = [ [[package]] name = "flate2" -version = "1.1.2" +version = "1.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a3d7db9596fecd151c5f638c0ee5d5bd487b6e0ea232e5dc96d5250f6f94b1d" +checksum = "b375d6465b98090a5f25b1c7703f3859783755aa9a80433b36e0379a3ec2f369" dependencies = [ "crc32fast", - "libz-rs-sys", "miniz_oxide", + "zlib-rs", ] [[package]] @@ -1090,9 +1120,9 @@ dependencies = [ [[package]] name = "fluent-langneg" -version = "0.13.0" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c4ad0989667548f06ccd0e306ed56b61bd4d35458d54df5ec7587c0e8ed5e94" +checksum = "7eebbe59450baee8282d71676f3bfed5689aeab00b27545e83e5f14b1195e8b0" dependencies = [ "unic-langid", ] @@ -1211,25 +1241,25 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", "libc", - "wasi 0.11.1+wasi-snapshot-preview1", + "wasi", ] [[package]] name = "getrandom" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "libc", "r-efi", - "wasi 0.14.2+wasi-0.2.4", + "wasip2", ] [[package]] @@ -1246,7 +1276,7 @@ checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ "cfg-if", "crunchy", - "zerocopy 0.8.27", + "zerocopy 0.8.33", ] [[package]] @@ -1257,15 +1287,21 @@ checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" [[package]] name = "hashbrown" -version = "0.15.4" +version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", "foldhash", ] +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + [[package]] name = "hex" version = "0.4.3" @@ -1403,9 +1439,9 @@ dependencies = [ [[package]] name = "icu_locale_data" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f03e2fcaefecdf05619f3d6f91740e79ab969b4dd54f77cbf546b1d0d28e3147" +checksum = "1c5f1d16b4c3a2642d3a719f18f6b06070ab0aef246a6418130c955ae08aa831" [[package]] name = "icu_normalizer" @@ -1432,9 +1468,9 @@ checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" [[package]] name = "icu_properties" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" dependencies = [ "icu_collections", "icu_locale_core", @@ -1446,9 +1482,9 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" [[package]] name = "icu_provider" @@ -1475,12 +1511,12 @@ checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" [[package]] name = "indexmap" -version = "2.9.0" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", - "hashbrown 0.15.4", + "hashbrown 0.16.1", ] [[package]] @@ -1502,7 +1538,7 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f37dccff2791ab604f9babef0ba14fbe0be30bd368dc541e2b08d07c8aa908f3" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", "inotify-sys", "libc", ] @@ -1537,9 +1573,9 @@ dependencies = [ [[package]] name = "is_terminal_polyfill" -version = "1.70.1" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "itertools" @@ -1561,9 +1597,9 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.15" +version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "jiff" @@ -1593,9 +1629,9 @@ dependencies = [ [[package]] name = "jiff-tzdb" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1283705eb0a21404d2bfd6eef2a7593d240bc42a0bdb39db0ad6fa2ec026524" +checksum = "68971ebff725b9e2ca27a601c5eb38a4c5d64422c4cbab0c535f248087eda5c2" [[package]] name = "jiff-tzdb-platform" @@ -1608,9 +1644,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.77" +version = "0.3.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" dependencies = [ "once_cell", "wasm-bindgen", @@ -1659,12 +1695,12 @@ checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" [[package]] name = "libloading" -version = "0.8.8" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07033963ba89ebaf1584d767badaa2e8fcec21aedea6b8c0346d487d49c28667" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" dependencies = [ "cfg-if", - "windows-targets 0.53.2", + "windows-link", ] [[package]] @@ -1675,22 +1711,13 @@ checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" [[package]] name = "libredox" -version = "0.1.3" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", "libc", - "redox_syscall", -] - -[[package]] -name = "libz-rs-sys" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "172a788537a2221661b480fee8dc5f96c580eb34fa88764d3205dc356c7e4221" -dependencies = [ - "zlib-rs", + "redox_syscall 0.7.0", ] [[package]] @@ -1707,31 +1734,30 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" [[package]] name = "litrs" -version = "0.4.1" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ce301924b7887e9d637144fdade93f9dfff9b60981d4ac161db09720d39aa5" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" [[package]] name = "lock_api" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" dependencies = [ - "autocfg", "scopeguard", ] [[package]] name = "log" -version = "0.4.27" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "lru" @@ -1739,7 +1765,7 @@ version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" dependencies = [ - "hashbrown 0.15.4", + "hashbrown 0.15.5", ] [[package]] @@ -1799,17 +1825,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ "adler2", + "simd-adler32", ] [[package]] name = "mio" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", "log", - "wasi 0.11.1+wasi-snapshot-preview1", + "wasi", "windows-sys 0.61.2", ] @@ -1819,7 +1846,7 @@ version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", "cfg-if", "cfg_aliases", "libc", @@ -1851,7 +1878,7 @@ version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", "fsevent-sys", "inotify", "kqueue", @@ -1949,6 +1976,21 @@ dependencies = [ "libc", ] +[[package]] +name = "objc2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + [[package]] name = "once_cell" version = "1.21.3" @@ -1957,9 +1999,9 @@ checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] name = "once_cell_polyfill" -version = "1.70.1" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "onig" @@ -1967,7 +2009,7 @@ version = "6.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "336b9c63443aceef14bea841b899035ae3abe89b7c486aaf4c5bd8aafedac3f0" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", "libc", "once_cell", "onig_sys", @@ -2010,9 +2052,9 @@ checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" [[package]] name = "parking_lot" -version = "0.12.4" +version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" dependencies = [ "lock_api", "parking_lot_core", @@ -2020,15 +2062,15 @@ dependencies = [ [[package]] name = "parking_lot_core" -version = "0.9.11" +version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", - "windows-targets 0.52.6", + "windows-link", ] [[package]] @@ -2111,9 +2153,9 @@ dependencies = [ [[package]] name = "portable-atomic" -version = "1.11.1" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" +checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950" [[package]] name = "portable-atomic-util" @@ -2147,7 +2189,7 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" dependencies = [ - "zerocopy 0.8.27", + "zerocopy 0.8.33", ] [[package]] @@ -2162,9 +2204,9 @@ dependencies = [ [[package]] name = "prettyplease" -version = "0.2.34" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6837b9e10d61f45f987d50808f83d1ee3d206c66acf650c3e4ae2e1f6ddedf55" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", "syn", @@ -2172,9 +2214,9 @@ dependencies = [ [[package]] name = "proc-macro-crate" -version = "3.3.0" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edce586971a4dfaa28950c6f18ed55e0406c1ab88bbce2c6f6293a7aaba73d35" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" dependencies = [ "toml_edit", ] @@ -2194,7 +2236,7 @@ version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25485360a54d6861439d60facef26de713b1e126bf015ec8f98239467a2b82f7" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", "chrono", "flate2", "procfs-core", @@ -2207,7 +2249,7 @@ version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6401bf7b6af22f78b563665d15a22e9aef27775b79b149a66ca022468a4e405" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", "chrono", "hex", ] @@ -2223,9 +2265,9 @@ dependencies = [ [[package]] name = "r-efi" -version = "5.2.0" +version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] name = "radium" @@ -2280,7 +2322,7 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom 0.2.16", + "getrandom 0.2.17", ] [[package]] @@ -2289,7 +2331,7 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ - "getrandom 0.3.3", + "getrandom 0.3.4", ] [[package]] @@ -2314,11 +2356,20 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.5.13" +version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d04b7d0ee6b4a0207a0a7adb104d23ecb0b47d6beae7152d0fa34b692b29fd6" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", +] + +[[package]] +name = "redox_syscall" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f3fe0889e69e2ae9e41f4d6c4c0181701d00e4697b356fb1f74173a5e0ee27" +dependencies = [ + "bitflags 2.10.0", ] [[package]] @@ -2335,9 +2386,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.12" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "722166aa0d7438abbaa4d5cc2c649dac844e8c56d82fb3d33e9c34b5cd268fc6" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" dependencies = [ "aho-corasick", "memchr", @@ -2346,15 +2397,15 @@ dependencies = [ [[package]] name = "regex-lite" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "943f41321c63ef1c92fd763bfe054d2668f7f225a5c29f0105903dc2fc04ba30" +checksum = "8d942b98df5e658f56f20d592c7f868833fe38115e65c33003d8cd224b0155da" [[package]] name = "regex-syntax" -version = "0.8.5" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" [[package]] name = "relative-path" @@ -2433,11 +2484,11 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.2" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", "errno", "libc", "linux-raw-sys 0.11.0", @@ -2446,15 +2497,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.21" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d" - -[[package]] -name = "ryu" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "same-file" @@ -2483,7 +2528,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ef2ca58174235414aee5465f5d8ef9f5833023b31484eb52ca505f306f4573c" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", "errno", "libc", "once_cell", @@ -2506,9 +2551,9 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.26" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" [[package]] name = "serde" @@ -2542,14 +2587,15 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.140" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ "itoa", "memchr", - "ryu", "serde", + "serde_core", + "zmij", ] [[package]] @@ -2623,18 +2669,19 @@ dependencies = [ [[package]] name = "signal-hook-registry" -version = "1.4.5" +version = "1.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9203b8055f63a2a00e2f593bb0510367fe707d7ff1e5c872de2f537b339e5410" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" dependencies = [ + "errno", "libc", ] [[package]] name = "simd-adler32" -version = "0.3.7" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" [[package]] name = "siphasher" @@ -2644,12 +2691,9 @@ checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" [[package]] name = "slab" -version = "0.4.9" +version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" -dependencies = [ - "autocfg", -] +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" [[package]] name = "sm3" @@ -2674,12 +2718,12 @@ checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" [[package]] name = "socket2" -version = "0.6.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -2690,9 +2734,9 @@ checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" [[package]] name = "stable_deref_trait" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "statrs" @@ -2710,7 +2754,7 @@ version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23de088478b31c349c9ba67816fa55d9355232d63c3afea8bf513e31f0f1d2c0" dependencies = [ - "hashbrown 0.15.4", + "hashbrown 0.15.5", "serde", ] @@ -2750,12 +2794,12 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" [[package]] name = "tempfile" -version = "3.23.0" +version = "3.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" dependencies = [ "fastrand", - "getrandom 0.3.3", + "getrandom 0.3.4", "once_cell", "rustix", "windows-sys 0.61.2", @@ -2867,28 +2911,42 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" dependencies = [ "displaydoc", + "serde_core", "zerovec", ] [[package]] name = "toml_datetime" -version = "0.6.11" +version = "0.7.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] [[package]] name = "toml_edit" -version = "0.22.27" +version = "0.23.10+spec-1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" dependencies = [ "indexmap", "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" +dependencies = [ "winnow", ] @@ -2903,9 +2961,9 @@ dependencies = [ [[package]] name = "typenum" -version = "1.18.0" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" [[package]] name = "unic-langid" @@ -2927,9 +2985,9 @@ dependencies = [ [[package]] name = "unicode-ident" -version = "1.0.18" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" [[package]] name = "unicode-linebreak" @@ -4250,9 +4308,9 @@ dependencies = [ [[package]] name = "uuid" -version = "1.17.0" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cf4199d1e5d15ddd86a694e4d0dffa9c323ce759fea589f00fef9d81cc1931d" +checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" dependencies = [ "js-sys", "wasm-bindgen", @@ -4312,45 +4370,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] -name = "wasi" -version = "0.14.2+wasi-0.2.4" +name = "wasip2" +version = "1.0.1+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" dependencies = [ - "wit-bindgen-rt", + "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.100" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" dependencies = [ "cfg-if", "once_cell", "rustversion", "wasm-bindgen-macro", -] - -[[package]] -name = "wasm-bindgen-backend" -version = "0.2.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" -dependencies = [ - "bumpalo", - "log", - "proc-macro2", - "quote", - "syn", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.100" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4358,22 +4403,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.100" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" dependencies = [ + "bumpalo", "proc-macro2", "quote", "syn", - "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.100" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" dependencies = [ "unicode-ident", ] @@ -4525,7 +4570,7 @@ version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows-targets 0.53.2", + "windows-targets 0.53.5", ] [[package]] @@ -4555,18 +4600,19 @@ dependencies = [ [[package]] name = "windows-targets" -version = "0.53.2" +version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c66f69fcc9ce11da9966ddb31a40968cad001c5bedeb5c2b82ede4253ab48aef" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ - "windows_aarch64_gnullvm 0.53.0", - "windows_aarch64_msvc 0.53.0", - "windows_i686_gnu 0.53.0", - "windows_i686_gnullvm 0.53.0", - "windows_i686_msvc 0.53.0", - "windows_x86_64_gnu 0.53.0", - "windows_x86_64_gnullvm 0.53.0", - "windows_x86_64_msvc 0.53.0", + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", ] [[package]] @@ -4577,9 +4623,9 @@ checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] name = "windows_aarch64_gnullvm" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" [[package]] name = "windows_aarch64_msvc" @@ -4589,9 +4635,9 @@ checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] name = "windows_aarch64_msvc" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" [[package]] name = "windows_i686_gnu" @@ -4601,9 +4647,9 @@ checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" [[package]] name = "windows_i686_gnu" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" [[package]] name = "windows_i686_gnullvm" @@ -4613,9 +4659,9 @@ checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] name = "windows_i686_gnullvm" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" [[package]] name = "windows_i686_msvc" @@ -4625,9 +4671,9 @@ checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] name = "windows_i686_msvc" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" [[package]] name = "windows_x86_64_gnu" @@ -4637,9 +4683,9 @@ checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] name = "windows_x86_64_gnu" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" [[package]] name = "windows_x86_64_gnullvm" @@ -4649,9 +4695,9 @@ checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] name = "windows_x86_64_gnullvm" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" [[package]] name = "windows_x86_64_msvc" @@ -4661,27 +4707,24 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "windows_x86_64_msvc" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "0.7.11" +version = "0.7.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74c7b26e3480b707944fc872477815d29a8e429d2f93a1ce000f5fa84a15cbcd" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" dependencies = [ "memchr", ] [[package]] -name = "wit-bindgen-rt" -version = "0.39.0" +name = "wit-bindgen" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" -dependencies = [ - "bitflags 2.9.1", -] +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" [[package]] name = "write16" @@ -4722,11 +4765,10 @@ checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" [[package]] name = "yoke" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" dependencies = [ - "serde", "stable_deref_trait", "yoke-derive", "zerofrom", @@ -4734,9 +4776,9 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", @@ -4762,11 +4804,11 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.27" +version = "0.8.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" +checksum = "668f5168d10b9ee831de31933dc111a459c97ec93225beb307aed970d1372dfd" dependencies = [ - "zerocopy-derive 0.8.27", + "zerocopy-derive 0.8.33", ] [[package]] @@ -4782,9 +4824,9 @@ dependencies = [ [[package]] name = "zerocopy-derive" -version = "0.8.27" +version = "0.8.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" +checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1" dependencies = [ "proc-macro2", "quote", @@ -4814,9 +4856,9 @@ dependencies = [ [[package]] name = "zerotrie" -version = "0.2.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" dependencies = [ "displaydoc", "yoke", @@ -4837,9 +4879,9 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.1" +version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", @@ -4861,15 +4903,21 @@ dependencies = [ [[package]] name = "zlib-rs" -version = "0.5.1" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "626bd9fa9734751fc50d6060752170984d7053f5a39061f524cda68023d4db8a" +checksum = "40990edd51aae2c2b6907af74ffb635029d5788228222c4bb811e9351c0caad3" + +[[package]] +name = "zmij" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd8f3f50b848df28f887acb68e41201b5aea6bc8a8dacc00fb40635ff9a72fea" [[package]] name = "zopfli" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edfc5ee405f504cd4984ecc6f14d02d55cfda60fa4b689434ef4102aae150cd7" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" dependencies = [ "bumpalo", "crc32fast", diff --git a/deny.toml b/deny.toml index eb0e02300..027b3723e 100644 --- a/deny.toml +++ b/deny.toml @@ -89,6 +89,8 @@ skip = [ { name = "itertools", version = "0.13.0" }, # ordered-multimap { name = "hashbrown", version = "0.14.5" }, + # lru (via num-prime) + { name = "hashbrown", version = "0.15.5" }, # cexpr (via bindgen) { name = "nom", version = "7.1.3" }, # const-random-macro, rand_core diff --git a/src/uu/sort/Cargo.toml b/src/uu/sort/Cargo.toml index 8b422898a..ad1dcc118 100644 --- a/src/uu/sort/Cargo.toml +++ b/src/uu/sort/Cargo.toml @@ -27,7 +27,6 @@ bigdecimal = { workspace = true } binary-heap-plus = { workspace = true } clap = { workspace = true } compare = { workspace = true } -ctrlc = { workspace = true } fnv = { workspace = true } itertools = { workspace = true } memchr = { workspace = true } @@ -46,6 +45,9 @@ uucore = { workspace = true, features = [ ] } fluent = { workspace = true } +[target.'cfg(not(target_os = "redox"))'.dependencies] +ctrlc = { workspace = true } + [target.'cfg(unix)'.dependencies] nix = { workspace = true, features = ["resource"] } diff --git a/src/uu/sort/locales/en-US.ftl b/src/uu/sort/locales/en-US.ftl index 3ae3b0616..fb1ef25f8 100644 --- a/src/uu/sort/locales/en-US.ftl +++ b/src/uu/sort/locales/en-US.ftl @@ -85,6 +85,7 @@ sort-help-numeric = compare according to string numerical value sort-help-general-numeric = compare according to string general numerical value sort-help-version-sort = Sort by SemVer version number, eg 1.12.2 > 1.1.2 sort-help-random = shuffle in random order +sort-help-random-source = use FILE as a source of random data sort-help-dictionary-order = consider only blanks and alphanumeric characters sort-help-merge = merge already sorted files; do not sort sort-help-check = check for sorted input; do not sort diff --git a/src/uu/sort/locales/fr-FR.ftl b/src/uu/sort/locales/fr-FR.ftl index 1a01ebb6b..fe6c17215 100644 --- a/src/uu/sort/locales/fr-FR.ftl +++ b/src/uu/sort/locales/fr-FR.ftl @@ -69,6 +69,7 @@ sort-help-numeric = compare selon la valeur numérique de la chaîne sort-help-general-numeric = compare selon la valeur numérique générale de la chaîne sort-help-version-sort = Trie par numéro de version SemVer, par ex. 1.12.2 > 1.1.2 sort-help-random = mélange dans un ordre aléatoire +sort-help-random-source = utilise FICHIER comme source de données aléatoires sort-help-dictionary-order = considère seulement les espaces et les caractères alphanumériques sort-help-merge = fusionne les fichiers déjà triés ; ne trie pas sort-help-check = vérifie l'entrée triée ; ne trie pas diff --git a/src/uu/sort/src/chunks.rs b/src/uu/sort/src/chunks.rs index 837cb1fa9..61dbef73b 100644 --- a/src/uu/sort/src/chunks.rs +++ b/src/uu/sort/src/chunks.rs @@ -5,11 +5,13 @@ //! Utilities for reading files as chunks. +// spell-checker:ignore ELEMS #![allow(dead_code)] // Ignores non-used warning for `borrow_buffer` in `Chunk` use std::{ io::{ErrorKind, Read}, + ops::Range, sync::mpsc::SyncSender, }; @@ -17,7 +19,12 @@ use memchr::memchr_iter; use self_cell::self_cell; use uucore::error::{UResult, USimpleError}; -use crate::{GeneralBigDecimalParseResult, GlobalSettings, Line, numeric_str_cmp::NumInfo}; +use crate::{ + GeneralBigDecimalParseResult, GlobalSettings, Line, SortMode, numeric_str_cmp::NumInfo, +}; + +const MAX_TOKEN_BUFFER_BYTES: usize = 4 * 1024 * 1024; +const MAX_TOKEN_BUFFER_ELEMS: usize = MAX_TOKEN_BUFFER_BYTES / std::mem::size_of::>(); self_cell!( /// The chunk that is passed around between threads. @@ -35,6 +42,8 @@ self_cell!( pub struct ChunkContents<'a> { pub lines: Vec>, pub line_data: LineData<'a>, + pub token_buffer: Vec>, + pub line_count_hint: usize, } #[derive(Debug)] @@ -54,6 +63,7 @@ impl Chunk { contents.line_data.num_infos.clear(); contents.line_data.parsed_floats.clear(); contents.line_data.line_num_floats.clear(); + contents.token_buffer.clear(); let lines = unsafe { // SAFETY: It is safe to (temporarily) transmute to a vector of lines with a longer lifetime, // because the vector is empty. @@ -76,6 +86,8 @@ impl Chunk { std::mem::take(&mut contents.line_data.num_infos), std::mem::take(&mut contents.line_data.parsed_floats), std::mem::take(&mut contents.line_data.line_num_floats), + std::mem::take(&mut contents.token_buffer), + contents.line_count_hint, ) }); RecycledChunk { @@ -84,6 +96,8 @@ impl Chunk { num_infos: recycled_contents.2, parsed_floats: recycled_contents.3, line_num_floats: recycled_contents.4, + token_buffer: recycled_contents.5, + line_count_hint: recycled_contents.6, buffer: self.into_owner(), } } @@ -103,6 +117,8 @@ pub struct RecycledChunk { num_infos: Vec, parsed_floats: Vec, line_num_floats: Vec>, + token_buffer: Vec>, + line_count_hint: usize, buffer: Vec, } @@ -114,6 +130,8 @@ impl RecycledChunk { num_infos: Vec::new(), parsed_floats: Vec::new(), line_num_floats: Vec::new(), + token_buffer: Vec::new(), + line_count_hint: 0, buffer: vec![0; capacity], } } @@ -157,6 +175,8 @@ pub fn read( num_infos, parsed_floats, line_num_floats, + mut token_buffer, + mut line_count_hint, mut buffer, } = recycled_chunk; if buffer.len() < carry_over.len() { @@ -193,8 +213,21 @@ pub fn read( parsed_floats, line_num_floats, }; - parse_lines(read, &mut lines, &mut line_data, separator, settings); - Ok(ChunkContents { lines, line_data }) + parse_lines( + read, + &mut lines, + &mut line_data, + &mut token_buffer, + &mut line_count_hint, + separator, + settings, + ); + Ok(ChunkContents { + lines, + line_data, + token_buffer, + line_count_hint, + }) }); sender.send(payload?).unwrap(); } @@ -206,6 +239,8 @@ fn parse_lines<'a>( read: &'a [u8], lines: &mut Vec>, line_data: &mut LineData<'a>, + token_buffer: &mut Vec>, + line_count_hint: &mut usize, separator: u8, settings: &GlobalSettings, ) { @@ -216,12 +251,55 @@ fn parse_lines<'a>( assert!(line_data.num_infos.is_empty()); assert!(line_data.parsed_floats.is_empty()); assert!(line_data.line_num_floats.is_empty()); - let mut token_buffer = vec![]; - lines.extend( - read.split(|&c| c == separator) - .enumerate() - .map(|(index, line)| Line::create(line, index, line_data, &mut token_buffer, settings)), - ); + token_buffer.clear(); + if token_buffer.capacity() > MAX_TOKEN_BUFFER_ELEMS { + token_buffer.shrink_to(MAX_TOKEN_BUFFER_ELEMS); + } + const SMALL_CHUNK_BYTES: usize = 64 * 1024; + let mut estimated = (*line_count_hint).max(1); + let mut exact_line_count = None; + if *line_count_hint == 0 || read.len() <= SMALL_CHUNK_BYTES { + let count = if read.is_empty() { + 1 + } else { + memchr_iter(separator, read).count() + 1 + }; + exact_line_count = Some(count); + estimated = count; + } else if estimated == 1 { + const LINE_LEN_HINT: usize = 32; + estimated = (read.len() / LINE_LEN_HINT).max(1); + } + lines.reserve(estimated); + if settings.precomputed.selections_per_line > 0 { + line_data + .selections + .reserve(estimated.saturating_mul(settings.precomputed.selections_per_line)); + } + if settings.precomputed.num_infos_per_line > 0 { + line_data + .num_infos + .reserve(estimated.saturating_mul(settings.precomputed.num_infos_per_line)); + } + if settings.precomputed.floats_per_line > 0 { + line_data + .parsed_floats + .reserve(estimated.saturating_mul(settings.precomputed.floats_per_line)); + } + if settings.mode == SortMode::Numeric { + line_data.line_num_floats.reserve(estimated); + } + let mut start = 0usize; + let mut index = 0usize; + for sep_idx in memchr_iter(separator, read) { + let line = &read[start..sep_idx]; + lines.push(Line::create(line, index, line_data, token_buffer, settings)); + index += 1; + start = sep_idx + 1; + } + let line = &read[start..]; + lines.push(Line::create(line, index, line_data, token_buffer, settings)); + *line_count_hint = exact_line_count.unwrap_or(index + 1); } /// Read from `file` into `buffer`. diff --git a/src/uu/sort/src/merge.rs b/src/uu/sort/src/merge.rs index 502dcda82..39465827e 100644 --- a/src/uu/sort/src/merge.rs +++ b/src/uu/sort/src/merge.rs @@ -30,7 +30,7 @@ use uucore::error::{FromIo, UResult}; use crate::{ GlobalSettings, Output, SortError, chunks::{self, Chunk, RecycledChunk}, - compare_by, fd_soft_limit, open, + compare_by, current_open_fd_count, fd_soft_limit, open, tmp_dir::TmpDirWrapper, }; @@ -66,14 +66,19 @@ fn replace_output_file_in_input_files( /// file-descriptor soft limit after reserving stdio/output and a safety margin. fn effective_merge_batch_size(settings: &GlobalSettings) -> usize { const MIN_BATCH_SIZE: usize = 2; - const RESERVED_STDIO: usize = 3; - const RESERVED_OUTPUT: usize = 1; + const RESERVED_TMP_OUTPUT: usize = 1; + const RESERVED_CTRL_C: usize = 2; + const RESERVED_RANDOM_SOURCE: usize = 1; const SAFETY_MARGIN: usize = 1; let mut batch_size = settings.merge_batch_size.max(MIN_BATCH_SIZE); if let Some(limit) = fd_soft_limit() { - let reserved = RESERVED_STDIO + RESERVED_OUTPUT + SAFETY_MARGIN; - let available_inputs = limit.saturating_sub(reserved); + let open_fds = current_open_fd_count().unwrap_or(3); + let mut reserved = RESERVED_TMP_OUTPUT + RESERVED_CTRL_C + SAFETY_MARGIN; + if settings.salt.is_some() { + reserved = reserved.saturating_add(RESERVED_RANDOM_SOURCE); + } + let available_inputs = limit.saturating_sub(open_fds.saturating_add(reserved)); if available_inputs >= MIN_BATCH_SIZE { batch_size = batch_size.min(available_inputs); } else { diff --git a/src/uu/sort/src/sort.rs b/src/uu/sort/src/sort.rs index efce29180..8c27910dc 100644 --- a/src/uu/sort/src/sort.rs +++ b/src/uu/sort/src/sort.rs @@ -7,7 +7,7 @@ // https://pubs.opengroup.org/onlinepubs/9699919799/utilities/sort.html // https://www.gnu.org/software/coreutils/manual/html_node/sort-invocation.html -// spell-checker:ignore (misc) HFKJFK Mbdfhn getrlimit RLIMIT_NOFILE rlim bigdecimal extendedbigdecimal hexdigit behaviour keydef +// spell-checker:ignore (misc) HFKJFK Mbdfhn getrlimit RLIMIT_NOFILE rlim bigdecimal extendedbigdecimal hexdigit behaviour keydef GETFD mod buffer_hint; mod check; @@ -104,6 +104,7 @@ mod options { pub const TMP_DIR: &str = "temporary-directory"; pub const COMPRESS_PROG: &str = "compress-program"; pub const BATCH_SIZE: &str = "batch-size"; + pub const RANDOM_SOURCE: &str = "random-source"; pub const FILES: &str = "files"; } @@ -274,6 +275,7 @@ pub struct GlobalSettings { check: bool, check_silent: bool, salt: Option<[u8; 16]>, + random_source: Option, selectors: Vec, separator: Option, threads: String, @@ -402,6 +404,7 @@ impl Default for GlobalSettings { check: false, check_silent: false, salt: None, + random_source: None, selectors: vec![], separator: None, threads: String::new(), @@ -584,6 +587,14 @@ impl<'a> Line<'a> { token_buffer: &mut Vec, settings: &GlobalSettings, ) -> Self { + let needs_line_data = settings.precomputed.needs_tokens + || settings.precomputed.selections_per_line > 0 + || settings.precomputed.num_infos_per_line > 0 + || settings.precomputed.floats_per_line > 0 + || settings.mode == SortMode::Numeric; + if !needs_line_data { + return Self { line, index }; + } token_buffer.clear(); if settings.precomputed.needs_tokens { tokenize(line, settings.separator, token_buffer); @@ -1203,7 +1214,16 @@ fn make_sort_mode_arg(mode: &'static str, short: char, help: String) -> Arg { .action(ArgAction::SetTrue) } -#[cfg(target_os = "linux")] +#[cfg(all( + unix, + not(any( + target_os = "redox", + target_os = "fuchsia", + target_os = "haiku", + target_os = "solaris", + target_os = "illumos" + )) +))] fn get_rlimit() -> UResult { use nix::sys::resource::{RLIM_INFINITY, Resource, getrlimit}; @@ -1216,16 +1236,74 @@ fn get_rlimit() -> UResult { .map_err(|_| UUsageError::new(2, translate!("sort-failed-fetch-rlimit"))) } -#[cfg(target_os = "linux")] +#[cfg(all( + unix, + not(any( + target_os = "redox", + target_os = "fuchsia", + target_os = "haiku", + target_os = "solaris", + target_os = "illumos" + )) +))] pub(crate) fn fd_soft_limit() -> Option { get_rlimit().ok() } -#[cfg(not(target_os = "linux"))] +#[cfg(any( + not(unix), + target_os = "redox", + target_os = "fuchsia", + target_os = "haiku", + target_os = "solaris", + target_os = "illumos" +))] pub(crate) fn fd_soft_limit() -> Option { None } +#[cfg(unix)] +pub(crate) fn current_open_fd_count() -> Option { + use nix::libc; + + fn count_dir(path: &str) -> Option { + let entries = std::fs::read_dir(path).ok()?; + let mut count = 0usize; + for entry in entries.flatten() { + let name = entry.file_name(); + let name = name.to_string_lossy(); + if name.parse::().is_ok() { + count = count.saturating_add(1); + } + } + Some(count) + } + + if let Some(count) = count_dir("/proc/self/fd").or_else(|| count_dir("/dev/fd")) { + return Some(count); + } + + let limit = fd_soft_limit()?; + if limit > 16_384 { + return None; + } + + let mut count = 0usize; + for fd in 0..limit { + let fd = fd as libc::c_int; + // Probe with libc::fcntl because the fd may be invalid. + if unsafe { libc::fcntl(fd, libc::F_GETFD) } != -1 { + count = count.saturating_add(1); + } + } + Some(count) +} + +#[cfg(not(unix))] +pub(crate) fn current_open_fd_count() -> Option { + None +} + const STDIN_FILE: &str = "-"; /// Legacy `+POS1 [-POS2]` syntax is permitted unless `_POSIX2_VERSION` is in @@ -1776,6 +1854,9 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { } settings.debug = matches.get_flag(options::DEBUG); + if let Some(path) = matches.get_one::(options::RANDOM_SOURCE) { + settings.random_source = Some(PathBuf::from(path)); + } // check whether user specified a zero terminated list of files for input, otherwise read files from args let mut files: Vec = if matches.contains_id(options::FILES0_FROM) { @@ -2036,9 +2117,6 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { if let Some(values) = matches.get_many::(options::KEY) { for value in values { let selector = FieldSelector::parse(value, &settings)?; - if selector.settings.mode == SortMode::Random && settings.salt.is_none() { - settings.salt = Some(get_rand_string()); - } settings.selectors.push(selector); } } @@ -2060,6 +2138,18 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { ); } + let needs_random = settings.mode == SortMode::Random + || settings + .selectors + .iter() + .any(|selector| selector.settings.mode == SortMode::Random); + if needs_random { + settings.salt = Some(match settings.random_source.as_deref() { + Some(path) => salt_from_random_source(path)?, + None => get_rand_string(), + }); + } + // Verify that we can open all input files. // It is the correct behavior to close all files afterwards, // and to reopen them at a later point. This is different from how the output file is handled, @@ -2158,6 +2248,14 @@ pub fn uu_app() -> Command { 'R', translate!("sort-help-random"), )) + .arg( + Arg::new(options::RANDOM_SOURCE) + .long(options::RANDOM_SOURCE) + .help(translate!("sort-help-random-source")) + .value_name("FILE") + .value_parser(ValueParser::os_string()) + .value_hint(clap::ValueHint::FilePath), + ) .arg( Arg::new(options::DICTIONARY_ORDER) .short('d') @@ -2667,10 +2765,58 @@ fn general_numeric_compare( a.partial_cmp(b).unwrap() } -fn get_rand_string() -> [u8; 16] { +/// Generate a 128-bit salt from a uniform RNG distribution. +fn get_rand_string() -> [u8; SALT_LEN] { rng().sample(rand::distr::StandardUniform) } +const SALT_LEN: usize = 16; // 128-bit salt +const MAX_BYTES: usize = 1024 * 1024; // Read cap: 1 MiB +const BUF_LEN: usize = 8192; // 8 KiB read buffer +const U64_LEN: usize = 8; +const RANDOM_SOURCE_TAG: &[u8] = b"uutils-sort-random-source"; // Domain separation tag + +/// Create a 128-bit salt by hashing up to 1 MiB from the given file. +fn salt_from_random_source(path: &Path) -> UResult<[u8; SALT_LEN]> { + let mut reader = open_with_open_failed_error(path)?; + let mut buf = [0u8; BUF_LEN]; + let mut total = 0usize; + let mut hasher = FnvHasher::default(); + + loop { + let n = reader + .read(&mut buf) + .map_err(|error| SortError::ReadFailed { + path: path.to_owned(), + error, + })?; + if n == 0 { + break; + } + let remaining = MAX_BYTES.saturating_sub(total); + if remaining == 0 { + break; + } + let take = n.min(remaining); + hasher.write(&buf[..take]); + total = total.saturating_add(take); + if take < n { + break; + } + } + + let first = hasher.finish(); + let mut second_hasher = FnvHasher::default(); + second_hasher.write(RANDOM_SOURCE_TAG); + second_hasher.write_u64(first); + let second = second_hasher.finish(); + + let mut out = [0u8; SALT_LEN]; + out[..U64_LEN].copy_from_slice(&first.to_le_bytes()); + out[U64_LEN..].copy_from_slice(&second.to_le_bytes()); + Ok(out) +} + fn get_hash(t: &T) -> u64 { let mut s = FnvHasher::default(); t.hash(&mut s); diff --git a/src/uu/sort/src/tmp_dir.rs b/src/uu/sort/src/tmp_dir.rs index 815ba5109..09168e8ba 100644 --- a/src/uu/sort/src/tmp_dir.rs +++ b/src/uu/sort/src/tmp_dir.rs @@ -15,7 +15,7 @@ use uucore::{ show_error, translate, }; -use crate::SortError; +use crate::{SortError, current_open_fd_count, fd_soft_limit}; /// A wrapper around [`TempDir`] that may only exist once in a process. /// @@ -45,6 +45,17 @@ fn handler_state() -> Arc> { .clone() } +fn should_install_signal_handler() -> bool { + const CTRL_C_FDS: usize = 2; + const RESERVED_FOR_MERGE: usize = 3; // temp output + minimum inputs + let Some(limit) = fd_soft_limit() else { + return true; + }; + let open_fds = current_open_fd_count().unwrap_or(3); + open_fds.saturating_add(CTRL_C_FDS + RESERVED_FOR_MERGE) <= limit +} + +#[cfg(not(target_os = "redox"))] fn ensure_signal_handler_installed(state: Arc>) -> UResult<()> { // This shared state must originate from `handler_state()` so the handler always sees // the current lock/path pair and can clean up the active temp directory on SIGINT. @@ -94,6 +105,11 @@ fn ensure_signal_handler_installed(state: Arc>) -> UR Ok(()) } +#[cfg(target_os = "redox")] +fn ensure_signal_handler_installed(_state: Arc>) -> UResult<()> { + Ok(()) +} + impl TmpDirWrapper { pub fn new(path: PathBuf) -> Self { Self { @@ -124,7 +140,10 @@ impl TmpDirWrapper { guard.path = Some(path); } - ensure_signal_handler_installed(state) + if should_install_signal_handler() { + ensure_signal_handler_installed(state)?; + } + Ok(()) } pub fn next_file(&mut self) -> UResult<(File, PathBuf)> { From 7e126cc8cd90bbc8bccbc7de76d264eed00d8197 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 18 Jan 2026 21:47:44 +0000 Subject: [PATCH 268/425] chore(deps): update rust crate filetime to v0.2.27 --- Cargo.lock | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index dff0506ac..b272cc9f9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1054,14 +1054,13 @@ dependencies = [ [[package]] name = "filetime" -version = "0.2.26" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc0505cd1b6fa6580283f6bdf70a73fcf4aba1184038c90902b92b3dd0df63ed" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" dependencies = [ "cfg-if", "libc", "libredox", - "windows-sys 0.60.2", ] [[package]] From d7ed3c6bbace871d1eac9e0f6ac166a8ac14bb1d Mon Sep 17 00:00:00 2001 From: Rostyslav Toch Date: Sun, 18 Jan 2026 21:59:18 +0000 Subject: [PATCH 269/425] ptx: handle duplicate input files (#9823) --- src/uu/ptx/src/ptx.rs | 23 ++++++++++++++++------- tests/by-util/test_ptx.rs | 8 ++++++++ tests/fixtures/ptx/one_word | 1 + 3 files changed, 25 insertions(+), 7 deletions(-) create mode 100644 tests/fixtures/ptx/one_word diff --git a/src/uu/ptx/src/ptx.rs b/src/uu/ptx/src/ptx.rs index b3cc319c1..2bae65115 100644 --- a/src/uu/ptx/src/ptx.rs +++ b/src/uu/ptx/src/ptx.rs @@ -7,7 +7,7 @@ use std::cmp; use std::cmp::PartialEq; -use std::collections::{BTreeSet, HashMap, HashSet}; +use std::collections::{BTreeSet, HashSet}; use std::ffi::{OsStr, OsString}; use std::fmt::Write as FmtWrite; use std::fs::File; @@ -279,10 +279,10 @@ struct FileContent { offset: usize, } -type FileMap = HashMap; +type FileMap = Vec<(OsString, FileContent)>; fn read_input(input_files: &[OsString], config: &Config) -> std::io::Result { - let mut file_map: FileMap = HashMap::new(); + let mut file_map: FileMap = FileMap::new(); let mut offset: usize = 0; let sentence_splitter = config @@ -304,14 +304,14 @@ fn read_input(input_files: &[OsString], config: &Config) -> std::io::Result, which can be indexed in constant time. let chars_lines: Vec> = lines.iter().map(|x| x.chars().collect()).collect(); let size = lines.len(); - file_map.insert( + file_map.push(( filename.clone(), FileContent { lines, chars_lines, offset, }, - ); + )); offset += size; } Ok(file_map) @@ -793,8 +793,17 @@ fn write_traditional_output( } for word_ref in words { - let file_map_value: &FileContent = file_map - .get(&word_ref.filename) + // Since `ptx` accepts duplicate file arguments (e.g., `ptx file file`), + // simply looking up by filename is ambiguous. + // We use the `global_line_nr` (which is unique across the entire input stream) + // to identify which file covers this line. + let (_, file_map_value) = file_map + .iter() + .find(|(name, content)| { + name == &word_ref.filename + && word_ref.global_line_nr >= content.offset + && word_ref.global_line_nr < content.offset + content.lines.len() + }) .expect("Missing file in file map"); let FileContent { ref lines, diff --git a/tests/by-util/test_ptx.rs b/tests/by-util/test_ptx.rs index 840759f7b..63ac3ca8f 100644 --- a/tests/by-util/test_ptx.rs +++ b/tests/by-util/test_ptx.rs @@ -339,6 +339,14 @@ fn test_unicode_truncation_alignment() { .stdout_only(" / bar\n föö/\n"); } +#[test] +fn test_duplicate_input_files() { + new_ucmd!() + .args(&["one_word", "one_word"]) + .succeeds() + .stdout_is(" rust\n rust\n"); +} + #[test] fn test_narrow_width_with_long_reference_no_panic() { new_ucmd!() diff --git a/tests/fixtures/ptx/one_word b/tests/fixtures/ptx/one_word new file mode 100644 index 000000000..871732e64 --- /dev/null +++ b/tests/fixtures/ptx/one_word @@ -0,0 +1 @@ +rust From 356567edb30c2acebb3b0df74bedfc8e89503c4c Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Mon, 19 Jan 2026 07:26:49 +0900 Subject: [PATCH 270/425] Backport tests/env/env.sh for better multicall support (#10337) --- util/fetch-gnu.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/util/fetch-gnu.sh b/util/fetch-gnu.sh index 064f84f93..f6cc6143d 100755 --- a/util/fetch-gnu.sh +++ b/util/fetch-gnu.sh @@ -16,6 +16,8 @@ sed -i -e 's/no-mtab-status.sh/no-mtab-status-masked-proc.sh/' -e 's/nproc-quota curl -L ${repo}/raw/refs/heads/master/tests/df/no-mtab-status-masked-proc.sh > tests/df/no-mtab-status-masked-proc.sh curl -L ${repo}/raw/refs/heads/master/tests/nproc/nproc-quota-systemd.sh > tests/nproc/nproc-quota-systemd.sh curl -L ${repo}/raw/refs/heads/master/tests/stty/bad-speed.sh > tests/stty/bad-speed.sh +# Better support for single binary +curl -L ${repo}/raw/refs/heads/master/tests/env/env.sh > tests/env/env.sh # Avoid incorrect PASS 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 From 4bfbbdd56aa9f57159a6ff1c35d6c7ca32c0ff1b Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Mon, 19 Jan 2026 16:48:10 +0900 Subject: [PATCH 271/425] CICD.yml: Drop unused apt-get (#10343) --- .github/workflows/CICD.yml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/.github/workflows/CICD.yml b/.github/workflows/CICD.yml index e29bac73c..9504d22bd 100644 --- a/.github/workflows/CICD.yml +++ b/.github/workflows/CICD.yml @@ -493,12 +493,6 @@ jobs: - uses: Swatinem/rust-cache@v2 - name: Run sccache-cache uses: mozilla-actions/sccache-action@v0.0.9 - - name: Install dependencies - shell: bash - run: | - ## Install dependencies - sudo apt-get update - sudo apt-get install libselinux1-dev libsystemd-dev - name: "`make install`" shell: bash run: | From cefe8a1b89f2cb19534d36e82a05bb04ccce5039 Mon Sep 17 00:00:00 2001 From: xtqqczze <45661989+xtqqczze@users.noreply.github.com> Date: Mon, 19 Jan 2026 08:08:55 +0000 Subject: [PATCH 272/425] clippy: fix needless_continue lint (#10340) https://rust-lang.github.io/rust-clippy/master/index.html#needless_continue --- Cargo.toml | 1 - src/uu/cat/src/cat.rs | 2 +- src/uu/comm/src/comm.rs | 4 ++-- src/uu/dd/src/dd.rs | 2 +- src/uu/fmt/src/parasplit.rs | 1 - src/uu/mv/src/hardlink.rs | 1 - src/uu/od/src/partial_reader.rs | 6 +++--- src/uucore/src/lib/features/uptime.rs | 18 +++++------------- 8 files changed, 12 insertions(+), 23 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 7d3ee2462..ce726de0c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -662,7 +662,6 @@ needless_pass_by_value = "allow" # 16 float_cmp = "allow" # 12 items_after_statements = "allow" # 11 return_self_not_must_use = "allow" # 8 -needless_continue = "allow" # 6 inline_always = "allow" # 6 fn_params_excessive_bools = "allow" # 6 used_underscore_items = "allow" # 2 diff --git a/src/uu/cat/src/cat.rs b/src/uu/cat/src/cat.rs index 4e5f07205..ff53df767 100644 --- a/src/uu/cat/src/cat.rs +++ b/src/uu/cat/src/cat.rs @@ -505,7 +505,7 @@ fn write_fast(handle: &mut InputHandle) -> CatResult<()> { .write_all(&buf[..n]) .inspect_err(handle_broken_pipe)?; } - Err(e) if e.kind() == ErrorKind::Interrupted => continue, + Err(e) if e.kind() == ErrorKind::Interrupted => {} Err(e) => return Err(e.into()), } } diff --git a/src/uu/comm/src/comm.rs b/src/uu/comm/src/comm.rs index 37ac4de1c..61424141a 100644 --- a/src/uu/comm/src/comm.rs +++ b/src/uu/comm/src/comm.rs @@ -156,7 +156,7 @@ pub fn are_files_identical(path1: &Path, path2: &Path) -> io::Result { // instead of failing, which is the POSIX-compliant way to handle interrupted I/O let bytes1 = loop { match reader1.read(&mut buffer1) { - Err(e) if e.kind() == io::ErrorKind::Interrupted => continue, + Err(e) if e.kind() == io::ErrorKind::Interrupted => {} result => break result?, } }; @@ -165,7 +165,7 @@ pub fn are_files_identical(path1: &Path, path2: &Path) -> io::Result { // Same retry logic as above for the second file to ensure consistent behavior let bytes2 = loop { match reader2.read(&mut buffer2) { - Err(e) if e.kind() == io::ErrorKind::Interrupted => continue, + Err(e) if e.kind() == io::ErrorKind::Interrupted => {} result => break result?, } }; diff --git a/src/uu/dd/src/dd.rs b/src/uu/dd/src/dd.rs index ebcc737fd..05ada10d2 100644 --- a/src/uu/dd/src/dd.rs +++ b/src/uu/dd/src/dd.rs @@ -201,7 +201,7 @@ fn read_and_discard(reader: &mut R, n: u64, buf_size: usize) -> io::Res total += bytes_read as u64; remaining -= bytes_read as u64; } - Err(e) if e.kind() == io::ErrorKind::Interrupted => continue, + Err(e) if e.kind() == io::ErrorKind::Interrupted => {} Err(e) => return Err(e), } } diff --git a/src/uu/fmt/src/parasplit.rs b/src/uu/fmt/src/parasplit.rs index ab402eac9..4fff132eb 100644 --- a/src/uu/fmt/src/parasplit.rs +++ b/src/uu/fmt/src/parasplit.rs @@ -242,7 +242,6 @@ impl FileLines<'_> { let info = decode_char_info(bytes, idx); indent_len += info.width; idx += info.consumed; - continue; } if indent_end == bytes.len() { indent_end = idx; diff --git a/src/uu/mv/src/hardlink.rs b/src/uu/mv/src/hardlink.rs index 4c3d77cfe..1402047d5 100644 --- a/src/uu/mv/src/hardlink.rs +++ b/src/uu/mv/src/hardlink.rs @@ -192,7 +192,6 @@ impl HardlinkGroupScanner { // For non-verbose mode, silently continue for missing files // This provides graceful degradation - we'll lose hardlink info for this file // but can still preserve hardlinks for other files - continue; } } diff --git a/src/uu/od/src/partial_reader.rs b/src/uu/od/src/partial_reader.rs index bfa601bbc..31367181a 100644 --- a/src/uu/od/src/partial_reader.rs +++ b/src/uu/od/src/partial_reader.rs @@ -55,7 +55,7 @@ impl Read for PartialReader { self.skip -= n as u64; break; } - Err(e) if e.kind() == io::ErrorKind::Interrupted => continue, + Err(e) if e.kind() == io::ErrorKind::Interrupted => {} Err(e) => return Err(e), } } @@ -65,7 +65,7 @@ impl Read for PartialReader { match self.limit { None => loop { match self.inner.read(out) { - Err(e) if e.kind() == io::ErrorKind::Interrupted => continue, + Err(e) if e.kind() == io::ErrorKind::Interrupted => {} result => return result, } }, @@ -82,7 +82,7 @@ impl Read for PartialReader { *limit -= r as u64; return Ok(r); } - Err(e) if e.kind() == io::ErrorKind::Interrupted => continue, + Err(e) if e.kind() == io::ErrorKind::Interrupted => {} Err(e) => return Err(e), } } diff --git a/src/uucore/src/lib/features/uptime.rs b/src/uucore/src/lib/features/uptime.rs index 6b869ef05..2aba7cd27 100644 --- a/src/uucore/src/lib/features/uptime.rs +++ b/src/uucore/src/lib/features/uptime.rs @@ -155,19 +155,11 @@ pub fn get_uptime(boot_time: Option) -> UResult { // Try provided boot_time or derive from utmpx let derived_boot_time = boot_time.or_else(|| { - let records = Utmpx::iter_all_records(); - for line in records { - match line.record_type() { - BOOT_TIME => { - let dt = line.login_time(); - if dt.unix_timestamp() > 0 { - return Some(dt.unix_timestamp() as time_t); - } - } - _ => continue, - } - } - None + Utmpx::iter_all_records() + .filter(|r| r.record_type() == BOOT_TIME) + .map(|r| r.login_time().unix_timestamp()) + .find(|&ts| ts > 0) + .map(|ts| ts as time_t) }); // macOS-specific fallback: use sysctl kern.boottime when utmpx did not provide BOOT_TIME From c720245457e88fba4b1e53c8446ff01db0ac1c06 Mon Sep 17 00:00:00 2001 From: jfinkels Date: Mon, 19 Jan 2026 03:14:02 -0500 Subject: [PATCH 273/425] pr: refactor common code for reading from file (#10334) --- src/uu/pr/src/pr.rs | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/uu/pr/src/pr.rs b/src/uu/pr/src/pr.rs index 05a0eea10..1317f52ff 100644 --- a/src/uu/pr/src/pr.rs +++ b/src/uu/pr/src/pr.rs @@ -762,18 +762,25 @@ fn build_options( }) } +/// Read the entire contents of the given path into memory. +/// +/// If `path` is `"-"`, then read from stdin. +fn read_to_end(path: &str) -> Result, std::io::Error> { + if path == "-" { + let mut f = stdin(); + let mut buf = vec![]; + f.read_to_end(&mut buf)?; + Ok(buf) + } else { + std::fs::read(path) + } +} + fn pr(path: &str, options: &OutputOptions) -> Result { // Read the entire contents of the file into a buffer. // // TODO Read incrementally. - let buf = if path == "-" { - let mut f = stdin(); - let mut buf = vec![]; - f.read_to_end(&mut buf)?; - buf - } else { - std::fs::read(path)? - }; + let buf = read_to_end(path)?; let pages = get_pages(options, 0, &buf)?; @@ -930,14 +937,7 @@ fn get_file_line_groups( // Read the entire contents of the file into a buffer. // // TODO Read incrementally. - let buf = if *path == "-" { - let mut f = stdin(); - let mut buf = vec![]; - f.read_to_end(&mut buf)?; - buf - } else { - std::fs::read(path)? - }; + let buf = read_to_end(path)?; // Split the text into pages and collect each line for // subsequent grouping. From 597361324c62087f6fe0950413514be5c90c1f31 Mon Sep 17 00:00:00 2001 From: jfinkels Date: Mon, 19 Jan 2026 03:14:50 -0500 Subject: [PATCH 274/425] pr: ignore empty line after form feed char (#10331) Fix a bug in `pr` where a newline character (`\n`) immediately following a form feed character (`\f`) was incorrectly rendered as an extra blank line in the output. After this commit, the newline is correctly ignored. --- src/uu/pr/src/pr.rs | 11 ++++++++--- tests/by-util/test_pr.rs | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/uu/pr/src/pr.rs b/src/uu/pr/src/pr.rs index 1317f52ff..4b2853db2 100644 --- a/src/uu/pr/src/pr.rs +++ b/src/uu/pr/src/pr.rs @@ -856,9 +856,14 @@ fn get_pages( } else { // Add everything up to (but not including) the newline // character as one line of the page. - let file_line = FileLine::from_buf(file_id, page_num, line_num, &buf[prev..i])?; - page.push(file_line); - line_num += 1; + if i > 0 && i == prev && buf[i - 1] == FF { + // If the file has the pattern `\f\n`, don't treat the + // `\n` as its own line; instead ignore the empty line. + } else { + let file_line = FileLine::from_buf(file_id, page_num, line_num, &buf[prev..i])?; + page.push(file_line); + line_num += 1; + } // Remember where the last line ended. prev = i + 1; diff --git a/tests/by-util/test_pr.rs b/tests/by-util/test_pr.rs index 80478258c..bf7338d67 100644 --- a/tests/by-util/test_pr.rs +++ b/tests/by-util/test_pr.rs @@ -631,3 +631,22 @@ fn test_form_feed_newlines() { .succeeds() .stdout_matches(®ex); } + +#[test] +fn test_form_feed_followed_by_new_line() { + // Here we define the expected output. + let whitespace = " ".repeat(50); + let datetime_pattern = r"\d\d\d\d-\d\d-\d\d \d\d:\d\d"; + let blank_lines_61 = "\n".repeat(61); + let blank_lines_60 = "\n".repeat(60); + let page1 = format!("\n\n{datetime_pattern}{whitespace}Page 1\n\n\n{blank_lines_61}"); + let page2 = format!("\n\n{datetime_pattern}{whitespace}Page 2\n\n\nabc\n{blank_lines_60}"); + let pattern = format!("{page1}{page2}"); + let regex = Regex::new(&pattern).unwrap(); + + // Command line: `printf "\f\nabc" | pr`. + new_ucmd!() + .pipe_in("\x0c\nabc") + .succeeds() + .stdout_matches(®ex); +} From 364a23539d6d8488773645dbdd853a766ba614cf Mon Sep 17 00:00:00 2001 From: cerdelen <95369756+cerdelen@users.noreply.github.com> Date: Mon, 19 Jan 2026 09:32:14 +0100 Subject: [PATCH 275/425] Rm no abbreviation no preserve root (#10205) --------- Co-authored-by: Sylvestre Ledru --- src/uu/rm/locales/en-US.ftl | 1 + src/uu/rm/locales/fr-FR.ftl | 1 + src/uu/rm/src/rm.rs | 12 +++++++++++- tests/by-util/test_rm.rs | 18 ++++++++++++++++++ 4 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/uu/rm/locales/en-US.ftl b/src/uu/rm/locales/en-US.ftl index 12816693e..2d4486ce2 100644 --- a/src/uu/rm/locales/en-US.ftl +++ b/src/uu/rm/locales/en-US.ftl @@ -43,6 +43,7 @@ rm-error-dangerous-recursive-operation = it is dangerous to operate recursively rm-error-use-no-preserve-root = use --no-preserve-root to override this failsafe rm-error-refusing-to-remove-directory = refusing to remove '.' or '..' directory: skipping {$path} rm-error-cannot-remove = cannot remove {$file} +rm-error-may-not-abbreviate-no-preserve-root = you may not abbreviate the --no-preserve-root option # Verbose messages rm-verbose-removed = removed {$file} diff --git a/src/uu/rm/locales/fr-FR.ftl b/src/uu/rm/locales/fr-FR.ftl index e1ee8ec23..52d881d02 100644 --- a/src/uu/rm/locales/fr-FR.ftl +++ b/src/uu/rm/locales/fr-FR.ftl @@ -43,6 +43,7 @@ rm-error-dangerous-recursive-operation = il est dangereux d'opérer récursiveme rm-error-use-no-preserve-root = utilisez --no-preserve-root pour outrepasser cette protection rm-error-refusing-to-remove-directory = refus de supprimer le répertoire '.' ou '..' : ignorer {$path} rm-error-cannot-remove = impossible de supprimer {$file} +rm-error-may-not-abbreviate-no-preserve-root = Vous ne pouvez pas abréger l'option --no-preserve-root # Messages verbeux rm-verbose-removed = {$file} supprimé diff --git a/src/uu/rm/src/rm.rs b/src/uu/rm/src/rm.rs index 32bf94fd0..252c72340 100644 --- a/src/uu/rm/src/rm.rs +++ b/src/uu/rm/src/rm.rs @@ -45,6 +45,8 @@ enum RmError { UseNoPreserveRoot, #[error("{}", translate!("rm-error-refusing-to-remove-directory", "path" => _0.quote()))] RefusingToRemoveDirectory(OsString), + #[error("{}", translate!("rm-error-may-not-abbreviate-no-preserve-root"))] + MayNotAbbreviateNoPreserveRoot, } impl UError for RmError {} @@ -200,7 +202,8 @@ static ARG_FILES: &str = "files"; #[uucore::main] pub fn uumain(args: impl uucore::Args) -> UResult<()> { - let matches = uucore::clap_localization::handle_clap_result(uu_app(), args)?; + let args: Vec = args.collect(); + let matches = uucore::clap_localization::handle_clap_result(uu_app(), args.iter())?; let files: Vec<_> = matches .get_many::(ARG_FILES) @@ -253,6 +256,13 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { None }, }; + + // manually parse all args to verify --no-preserve-root did not get abbreviated (clap does + // allow this) + if !options.preserve_root && !args.iter().any(|arg| arg == "--no-preserve-root") { + return Err(RmError::MayNotAbbreviateNoPreserveRoot.into()); + } + if options.interactive == InteractiveMode::Once && (options.recursive || files.len() > 3) { let msg: String = format!( "remove {} {}{}", diff --git a/tests/by-util/test_rm.rs b/tests/by-util/test_rm.rs index d0a8bca2e..3d3b1a9b1 100644 --- a/tests/by-util/test_rm.rs +++ b/tests/by-util/test_rm.rs @@ -1218,6 +1218,24 @@ fn test_progress_no_output_on_error() { .stderr_contains("No such file or directory"); } +#[test] +fn no_preserve_root_may_not_be_abbreviated() { + let (at, _ucmd) = at_and_ucmd!(); + let file = "test_file_123"; + + at.touch(file); + + for arg in ["--n", "--no-pre", "--no-preserve-ro"] { + new_ucmd!() + .arg(arg) + .arg(file) + .fails() + .stderr_contains("you may not abbreviate the --no-preserve-root option"); + } + + assert!(at.file_exists(file)); +} + #[cfg(unix)] #[test] fn test_symlink_to_readonly_no_prompt() { From 8a7c3fb3e310bbfed04c3d6d315081dae17c2238 Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Mon, 19 Jan 2026 18:00:38 +0900 Subject: [PATCH 276/425] CICD.yml: Upload individual bins to release (#10338) Co-authored-by: Sylvestre Ledru --- .github/workflows/CICD.yml | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/.github/workflows/CICD.yml b/.github/workflows/CICD.yml index 9504d22bd..f690f78c2 100644 --- a/.github/workflows/CICD.yml +++ b/.github/workflows/CICD.yml @@ -1,7 +1,7 @@ name: CICD # spell-checker:ignore (abbrev/names) CACHEDIR CICD CodeCOV MacOS MinGW MSVC musl taiki -# spell-checker:ignore (env/flags) Awarnings Ccodegen Coverflow Cpanic Dwarnings RUSTDOCFLAGS RUSTFLAGS Zpanic CARGOFLAGS +# spell-checker:ignore (env/flags) Awarnings Ccodegen Coverflow Cpanic Dwarnings RUSTDOCFLAGS RUSTFLAGS Zpanic CARGOFLAGS CLEVEL # 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 zstd @@ -477,6 +477,8 @@ jobs: name: Binary sizes needs: [ min_version, deps ] runs-on: ${{ matrix.job.os }} + permissions: + contents: write env: SCCACHE_GHA_ENABLED: "true" RUSTC_WRAPPER: "sccache" @@ -493,12 +495,25 @@ jobs: - uses: Swatinem/rust-cache@v2 - name: Run sccache-cache uses: mozilla-actions/sccache-action@v0.0.9 - - name: "`make install`" + - name: "`make install PROFILE=release-fast`" shell: bash run: | - ## `make install` - RUSTFLAGS="${RUSTFLAGS} -C strip=symbols" make install DESTDIR=target/size-release/ - RUSTFLAGS="${RUSTFLAGS} -C strip=symbols" make install MULTICALL=y LN="ln -vf" DESTDIR=target/size-multi-release/ + export CARGO_TARGET_DIR=cargo-target RUSTFLAGS="${RUSTFLAGS} -C strip=symbols" PROFILE=release-fast MANPAGES=n COMPLETIONS=n LOCALES=n + mkdir -p "${CARGO_TARGET_DIR}" && sudo mount -t tmpfs -o noatime,size=16G tmpfs "${CARGO_TARGET_DIR}" + make install DESTDIR=target/size-release/ + make install COMPLETIONS=n MULTICALL=y LN="ln -vf" DESTDIR=target/size-multi-release/ + ZSTD_CLEVEL=19 tar --zstd -caf individual-x86_64-unknown-linux-gnu.tar.zst -C target/size-release/usr/local bin + - name: Publish + uses: softprops/action-gh-release@v2 + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + with: + tag_name: latest-commit + draft: false + prerelease: true + files: | + individual-x86_64-unknown-linux-gnu.tar.zst + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Test for hardlinks shell: bash run: | From 425b232a508b377d9cd8ec391ef11f945b00832a Mon Sep 17 00:00:00 2001 From: jfinkels Date: Mon, 19 Jan 2026 06:20:35 -0500 Subject: [PATCH 277/425] pr: ignore empty line after newline char (#10332) Fix a bug in `pr` where a form feed character (`\f`) immediately following a newline character (`\n`) was incorrectly rendered as an extra blank line in the output when running with the `-f` option. After this commit, the empty line is correctly ignored. Co-authored-by: Sylvestre Ledru --- src/uu/pr/src/pr.rs | 9 +++++++-- tests/by-util/test_pr.rs | 16 ++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/uu/pr/src/pr.rs b/src/uu/pr/src/pr.rs index 4b2853db2..b37f3a883 100644 --- a/src/uu/pr/src/pr.rs +++ b/src/uu/pr/src/pr.rs @@ -836,8 +836,13 @@ fn get_pages( if buf[i] == FF { // Treat everything up to (but not including) the form feed // character as the last line of the page. - let file_line = FileLine::from_buf(file_id, page_num, line_num, &buf[prev..i])?; - page.push(file_line); + if i > 0 && i == prev && buf[i - 1] == NL { + // If the file has the pattern `\n\f`, don't treat the + // `\f` as its own line; instead ignore the empty line. + } else { + let file_line = FileLine::from_buf(file_id, page_num, line_num, &buf[prev..i])?; + page.push(file_line); + } // Remember where the last line ended. prev = i + 1; diff --git a/tests/by-util/test_pr.rs b/tests/by-util/test_pr.rs index bf7338d67..1beed2305 100644 --- a/tests/by-util/test_pr.rs +++ b/tests/by-util/test_pr.rs @@ -632,6 +632,22 @@ fn test_form_feed_newlines() { .stdout_matches(®ex); } +#[test] +fn test_new_line_followed_by_form_feed() { + // Here we define the expected output. + let whitespace = " ".repeat(50); + let datetime_pattern = r"\d\d\d\d-\d\d-\d\d \d\d:\d\d"; + let pattern = format!("\n\n{datetime_pattern}{whitespace}Page 1\n\n\nabc\n\x0c"); + let regex = Regex::new(&pattern).unwrap(); + + // Command line: `printf "abc\n\f" | pr -f`. + new_ucmd!() + .arg("-f") + .pipe_in("abc\n\x0c") + .succeeds() + .stdout_matches(®ex); +} + #[test] fn test_form_feed_followed_by_new_line() { // Here we define the expected output. From 8d027624b29a46d7dc1a864a85b2be5060477df6 Mon Sep 17 00:00:00 2001 From: Andrus Suvalau Date: Mon, 19 Jan 2026 18:05:13 +0100 Subject: [PATCH 278/425] du: count links based on inode (#10313) * du: count links based on metadata Count links based on metadata in order to produce the same output as GNU dd --- src/uu/du/src/du.rs | 12 ++++++--- tests/by-util/test_du.rs | 55 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/src/uu/du/src/du.rs b/src/uu/du/src/du.rs index 3ad6e07f9..a70a0269c 100644 --- a/src/uu/du/src/du.rs +++ b/src/uu/du/src/du.rs @@ -1080,6 +1080,9 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { let (print_tx, rx) = mpsc::channel::>(); let printing_thread = thread::spawn(move || stat_printer.print_stats(&rx)); + // Check existence of path provided in argument + let mut seen_inodes: HashSet = HashSet::new(); + 'loop_file: for path in files { // Skip if we don't want to ignore anything if !&traversal_options.excludes.is_empty() { @@ -1098,9 +1101,6 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { } } - // Check existence of path provided in argument - let mut seen_inodes: HashSet = HashSet::new(); - // Determine which traversal method to use #[cfg(all(unix, not(target_os = "redox")))] let use_safe_traversal = traversal_options.dereference != Deref::All; @@ -1114,6 +1114,9 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { // Pre-populate seen_inodes with the starting directory to detect cycles if let Ok(stat) = Stat::new(&path, None, &traversal_options) { if let Some(inode) = stat.inode { + if !traversal_options.count_links && seen_inodes.contains(&inode) { + continue 'loop_file; + } seen_inodes.insert(inode); } } @@ -1147,6 +1150,9 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { // Use regular traversal (non-Linux or when -L is used) if let Ok(stat) = Stat::new(&path, None, &traversal_options) { if let Some(inode) = stat.inode { + if !traversal_options.count_links && seen_inodes.contains(&inode) { + continue 'loop_file; + } seen_inodes.insert(inode); } let stat = du_regular( diff --git a/tests/by-util/test_du.rs b/tests/by-util/test_du.rs index 38d64d5b8..c89cd4667 100644 --- a/tests/by-util/test_du.rs +++ b/tests/by-util/test_du.rs @@ -2003,3 +2003,58 @@ fn test_du_long_path_from_unreadable() { perms.set_mode(0o755); fs::set_permissions(&inaccessible_path, perms).unwrap(); } + +#[test] +#[cfg(target_os = "linux")] +fn test_du_hard_links_multiple_dirs_in_args() { + let ts = TestScenario::new(util_name!()); + let at = &ts.fixtures; + + at.mkdir("dir1"); + at.mkdir("dir2"); + at.write("dir1/file", "hello world"); + at.hard_link("dir1/file", "dir2/link"); + + let result = ts.ucmd().args(&["dir1", "dir2"]).succeeds(); + let lines: Vec<&str> = result.stdout_str().lines().collect(); + let size = |i: usize| lines[i].split_once('\t').unwrap().0.parse::().unwrap(); + assert!(size(0) > size(1)); +} + +#[test] +#[cfg(target_os = "linux")] +fn test_du_hard_links_multiple_links_in_args() { + let ts = TestScenario::new(util_name!()); + let at = &ts.fixtures; + + at.mkdir("dir1"); + at.write("dir1/file", "hello world"); + at.hard_link("dir1/file", "dir1/link"); + + let result = ts.ucmd().args(&["dir1/file", "dir1/link"]).succeeds(); + result.stdout_contains("dir1/file"); + result.stdout_does_not_contain("dir1/link"); + + let result = ts.ucmd().args(&["-L", "dir1/file", "dir1/link"]).succeeds(); + result.stdout_contains("dir1/file"); + result.stdout_does_not_contain("dir1/link"); +} + +#[test] +#[cfg(target_os = "linux")] +fn test_du_symlinks_multiple_links_in_args() { + let ts = TestScenario::new(util_name!()); + let at = &ts.fixtures; + + at.mkdir("dir1"); + at.write("dir1/file", "hello world"); + at.symlink_file("dir1/file", "dir1/link"); + + let result = ts.ucmd().args(&["dir1/file", "dir1/link"]).succeeds(); + result.stdout_contains("dir1/file"); + result.stdout_contains("dir1/link"); + + let result = ts.ucmd().args(&["-L", "dir1/file", "dir1/link"]).succeeds(); + result.stdout_contains("dir1/file"); + result.stdout_does_not_contain("dir1/link"); +} From 2fb45c188e51f8558d6c72c4968486ee9f061232 Mon Sep 17 00:00:00 2001 From: Chris Dryden Date: Mon, 19 Jan 2026 16:39:25 -0500 Subject: [PATCH 279/425] rm: fix error reporting for -r on Linux fixing #9011 (#10111) * rm: fix error reporting for -r on Linux * rm: add stricter assertion for error tracking test --------- Co-authored-by: Alex Lyon --- src/uu/rm/src/platform/unix.rs | 12 ++++++------ tests/by-util/test_rm.rs | 7 ++++--- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/uu/rm/src/platform/unix.rs b/src/uu/rm/src/platform/unix.rs index 5c8e0981b..e890ab158 100644 --- a/src/uu/rm/src/platform/unix.rs +++ b/src/uu/rm/src/platform/unix.rs @@ -371,7 +371,7 @@ pub fn safe_remove_dir_recursive_impl(path: &Path, dir_fd: &DirFd, options: &Opt let entry_stat = match dir_fd.stat_at(&entry_name, false) { Ok(stat) => stat, Err(e) => { - error = handle_error_with_force(e, &entry_path, options); + error |= handle_error_with_force(e, &entry_path, options); continue; } }; @@ -395,21 +395,21 @@ pub fn safe_remove_dir_recursive_impl(path: &Path, dir_fd: &DirFd, options: &Opt // If we can't open the subdirectory for safe traversal, // try to handle it as best we can with safe operations if e.kind() == std::io::ErrorKind::PermissionDenied { - error = handle_permission_denied( + error |= handle_permission_denied( dir_fd, entry_name.as_ref(), &entry_path, options, ); } else { - error = handle_error_with_force(e, &entry_path, options); + error |= handle_error_with_force(e, &entry_path, options); } continue; } }; let child_error = safe_remove_dir_recursive_impl(&entry_path, &child_dir_fd, options); - error = error || child_error; + error |= child_error; // Ask user permission if needed for this subdirectory if !child_error @@ -421,12 +421,12 @@ pub fn safe_remove_dir_recursive_impl(path: &Path, dir_fd: &DirFd, options: &Opt // Remove the now-empty subdirectory using safe unlinkat if !child_error { - error = handle_unlink(dir_fd, entry_name.as_ref(), &entry_path, true, options); + error |= handle_unlink(dir_fd, entry_name.as_ref(), &entry_path, true, options); } } else { // Remove file - check if user wants to remove it first if prompt_file_with_stat(&entry_path, &entry_stat, options) { - error = handle_unlink(dir_fd, entry_name.as_ref(), &entry_path, false, options); + error |= handle_unlink(dir_fd, entry_name.as_ref(), &entry_path, false, options); } } } diff --git a/tests/by-util/test_rm.rs b/tests/by-util/test_rm.rs index 3d3b1a9b1..20d4a9357 100644 --- a/tests/by-util/test_rm.rs +++ b/tests/by-util/test_rm.rs @@ -1140,9 +1140,10 @@ fn test_rm_directory_not_writable() { // Check for expected error message // When the parent directory (b/a) doesn't have write permission, - // we get "Permission denied" when trying to remove the subdirectory - let stderr = result.stderr_str(); - assert!(stderr.contains("rm: cannot remove 'b/a/p': Permission denied")); + // we get "Permission denied" when trying to remove the subdirectory. + // The error tracking must be correct so we don't attempt to remove the parent + // directory after child failure (which would produce extra "Directory not empty" errors). + result.stderr_only("rm: cannot remove 'b/a/p': Permission denied\n"); // Check which directories still exist assert!(at.dir_exists("b/a/p")); // Should still exist (parent not writable) From 2d7a3bf2e037bac1283c67879d7a9759b2038a6e Mon Sep 17 00:00:00 2001 From: Chris Dryden Date: Mon, 19 Jan 2026 17:28:29 -0500 Subject: [PATCH 280/425] fix: detect closed stdin before Rust sanitizes it to /dev/null (#9664) Co-authored-by: Sylvestre Ledru --- Cargo.lock | 1 + src/uu/dd/Cargo.toml | 1 + src/uu/dd/src/dd.rs | 8 +++ src/uu/dd/src/progress.rs | 14 +++-- src/uu/seq/src/seq.rs | 2 +- src/uu/tac/Cargo.toml | 3 +- src/uu/tac/src/tac.rs | 19 +++++- src/uu/tail/src/paths.rs | 15 +++-- src/uu/tail/src/tail.rs | 20 +++++-- src/uu/timeout/src/timeout.rs | 44 +++++++++----- src/uucore/src/lib/features/signals.rs | 80 ++++++++++++++++++++++---- 11 files changed, 159 insertions(+), 48 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 81839c662..b9e81b1dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3997,6 +3997,7 @@ version = "0.6.0" dependencies = [ "clap", "fluent", + "libc", "memchr", "memmap2", "regex", diff --git a/src/uu/dd/Cargo.toml b/src/uu/dd/Cargo.toml index 6dbc6c2ff..f7941f5d3 100644 --- a/src/uu/dd/Cargo.toml +++ b/src/uu/dd/Cargo.toml @@ -26,6 +26,7 @@ uucore = { workspace = true, features = [ "parser-size", "quoting-style", "fs", + "signals", ] } thiserror = { workspace = true } fluent = { workspace = true } diff --git a/src/uu/dd/src/dd.rs b/src/uu/dd/src/dd.rs index 05ada10d2..45fdf6f3d 100644 --- a/src/uu/dd/src/dd.rs +++ b/src/uu/dd/src/dd.rs @@ -5,6 +5,9 @@ // spell-checker:ignore fname, ftype, tname, fpath, specfile, testfile, unspec, ifile, ofile, outfile, fullblock, urand, fileio, atoe, atoibm, behaviour, bmax, bremain, cflags, creat, ctable, ctty, datastructures, doesnt, etoa, fileout, fname, gnudd, iconvflags, iseek, nocache, noctty, noerror, nofollow, nolinks, nonblock, oconvflags, oseek, outfile, parseargs, rlen, rmax, rremain, rsofar, rstat, sigusr, wlen, wstat seekable oconv canonicalized fadvise Fadvise FADV DONTNEED ESPIPE bufferedoutput, SETFL +#[cfg(unix)] +uucore::init_startup_state_capture!(); + mod blocks; mod bufferedoutput; mod conversion_tables; @@ -1521,6 +1524,11 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { .unwrap_or_default(), )?; + #[cfg(unix)] + if uucore::signals::stderr_was_closed() && settings.status != Some(StatusLevel::None) { + return Err(USimpleError::new(1, "write error")); + } + let i = match settings.infile { #[cfg(unix)] Some(ref infile) if is_fifo(infile) => Input::new_fifo(Path::new(&infile), &settings)?, diff --git a/src/uu/dd/src/progress.rs b/src/uu/dd/src/progress.rs index 2ad61cf1b..416c95f27 100644 --- a/src/uu/dd/src/progress.rs +++ b/src/uu/dd/src/progress.rs @@ -18,7 +18,7 @@ use std::time::Duration; #[cfg(target_os = "linux")] use signal_hook::iterator::Handle; use uucore::{ - error::UResult, + error::{UResult, set_exit_code}, format::num_format::{FloatVariant, Formatter}, locale::setup_localization, translate, @@ -231,7 +231,9 @@ impl ProgUpdate { /// See [`ProgUpdate::write_io_lines`] for more information. pub(crate) fn print_io_lines(&self) { let mut stderr = std::io::stderr(); - self.write_io_lines(&mut stderr).unwrap(); + if self.write_io_lines(&mut stderr).is_err() { + set_exit_code(1); + } } /// Re-print the number of bytes written, duration, and throughput. @@ -240,7 +242,9 @@ impl ProgUpdate { pub(crate) fn reprint_prog_line(&self) { let mut stderr = std::io::stderr(); let rewrite = true; - self.write_prog_line(&mut stderr, rewrite).unwrap(); + if self.write_prog_line(&mut stderr, rewrite).is_err() { + set_exit_code(1); + } } /// Write all summary statistics. @@ -248,7 +252,9 @@ impl ProgUpdate { /// See [`ProgUpdate::write_transfer_stats`] for more information. pub(crate) fn print_transfer_stats(&self, new_line: bool) { let mut stderr = std::io::stderr(); - self.write_transfer_stats(&mut stderr, new_line).unwrap(); + if self.write_transfer_stats(&mut stderr, new_line).is_err() { + set_exit_code(1); + } } /// Write all the final statistics. diff --git a/src/uu/seq/src/seq.rs b/src/uu/seq/src/seq.rs index 1e94a9901..b931cc8b1 100644 --- a/src/uu/seq/src/seq.rs +++ b/src/uu/seq/src/seq.rs @@ -94,7 +94,7 @@ fn select_precision( // Initialize SIGPIPE state capture at process startup (Unix only) #[cfg(unix)] -uucore::init_sigpipe_capture!(); +uucore::init_startup_state_capture!(); #[uucore::main] pub fn uumain(args: impl uucore::Args) -> UResult<()> { diff --git a/src/uu/tac/Cargo.toml b/src/uu/tac/Cargo.toml index bd1652935..82ec5c5bc 100644 --- a/src/uu/tac/Cargo.toml +++ b/src/uu/tac/Cargo.toml @@ -24,7 +24,8 @@ memchr = { workspace = true } memmap2 = { workspace = true } regex = { workspace = true } clap = { workspace = true } -uucore = { workspace = true } +libc = { workspace = true } +uucore = { workspace = true, features = ["signals"] } thiserror = { workspace = true } fluent = { workspace = true } tempfile = { workspace = true } diff --git a/src/uu/tac/src/tac.rs b/src/uu/tac/src/tac.rs index 15e34baf8..e1686f459 100644 --- a/src/uu/tac/src/tac.rs +++ b/src/uu/tac/src/tac.rs @@ -4,6 +4,9 @@ // file that was distributed with this source code. // spell-checker:ignore (ToDO) sbytes slen dlen memmem memmap Mmap mmap SIGBUS +#[cfg(unix)] +uucore::init_startup_state_capture!(); + mod error; use clap::{Arg, ArgAction, Command}; @@ -16,8 +19,9 @@ use std::{ io::copy, path::Path, }; -use uucore::error::UError; -use uucore::error::UResult; +#[cfg(unix)] +use uucore::error::set_exit_code; +use uucore::error::{UError, UResult}; use uucore::{format_usage, show}; use crate::error::TacError; @@ -238,6 +242,17 @@ fn tac(filenames: &[OsString], before: bool, regex: bool, separator: &str) -> UR let buf; let data: &[u8] = if filename == "-" { + #[cfg(unix)] + if uucore::signals::stdin_was_closed() { + let e: Box = TacError::ReadError( + OsString::from("-"), + std::io::Error::from_raw_os_error(libc::EBADF), + ) + .into(); + show!(e); + set_exit_code(1); + continue; + } if let Some(mmap1) = try_mmap_stdin() { mmap = mmap1; &mmap diff --git a/src/uu/tail/src/paths.rs b/src/uu/tail/src/paths.rs index 3f37091d8..6eaeae980 100644 --- a/src/uu/tail/src/paths.rs +++ b/src/uu/tail/src/paths.rs @@ -229,14 +229,13 @@ pub fn path_is_tailable(path: &Path) -> bool { } #[inline] +#[cfg(unix)] +pub fn stdin_is_bad_fd() -> bool { + uucore::signals::stdin_was_closed() +} + +#[inline] +#[cfg(not(unix))] pub fn stdin_is_bad_fd() -> bool { - // FIXME : Rust's stdlib is reopening fds as /dev/null - // see also: https://github.com/uutils/coreutils/issues/2873 - // (gnu/tests/tail-2/follow-stdin.sh fails because of this) - //#[cfg(unix)] - { - //platform::stdin_is_bad_fd() - } - //#[cfg(not(unix))] false } diff --git a/src/uu/tail/src/tail.rs b/src/uu/tail/src/tail.rs index 17b09013a..c1cfb333a 100644 --- a/src/uu/tail/src/tail.rs +++ b/src/uu/tail/src/tail.rs @@ -33,11 +33,14 @@ use std::fs::File; use std::io::{self, BufReader, BufWriter, ErrorKind, Read, Seek, SeekFrom, Write, stdin, stdout}; use std::path::{Path, PathBuf}; use uucore::display::Quotable; -use uucore::error::{FromIo, UResult, USimpleError, get_exit_code, set_exit_code}; +use uucore::error::{FromIo, UResult, USimpleError, set_exit_code}; use uucore::translate; use uucore::{show, show_error}; +#[cfg(unix)] +uucore::init_startup_state_capture!(); + #[uucore::main] pub fn uumain(args: impl uucore::Args) -> UResult<()> { // When we receive a SIGPIPE signal, we want to terminate the process so @@ -113,10 +116,6 @@ fn uu_tail(settings: &Settings) -> UResult<()> { } } - if get_exit_code() > 0 && paths::stdin_is_bad_fd() { - show_error!("{}: {}", text::DASH, translate!("tail-bad-fd")); - } - Ok(()) } @@ -277,6 +276,17 @@ fn tail_stdin( } } + // Check if stdin was closed before Rust reopened it as /dev/null + if paths::stdin_is_bad_fd() { + set_exit_code(1); + show_error!( + "{}", + translate!("tail-error-cannot-fstat", "file" => translate!("tail-stdin-header").quote(), "error" => translate!("tail-bad-fd")) + ); + show_error!("{}", translate!("tail-no-files-remaining")); + return Ok(()); + } + match input.resolve() { // fifo Some(path) => { diff --git a/src/uu/timeout/src/timeout.rs b/src/uu/timeout/src/timeout.rs index b74673d5a..22c839c42 100644 --- a/src/uu/timeout/src/timeout.rs +++ b/src/uu/timeout/src/timeout.rs @@ -4,12 +4,15 @@ // file that was distributed with this source code. // spell-checker:ignore (ToDO) tstr sigstr cmdname setpgid sigchld getpid +#[cfg(unix)] +uucore::init_startup_state_capture!(); + mod status; use crate::status::ExitStatus; use clap::{Arg, ArgAction, Command}; use std::io::ErrorKind; -use std::os::unix::process::ExitStatusExt; +use std::os::unix::process::{CommandExt, ExitStatusExt}; use std::process::{self, Child, Stdio}; use std::sync::atomic::{self, AtomicBool}; use std::time::Duration; @@ -334,23 +337,34 @@ fn timeout( #[cfg(unix)] enable_pipe_errors()?; - let process = &mut process::Command::new(&cmd[0]) + let mut command = process::Command::new(&cmd[0]); + command .args(&cmd[1..]) .stdin(Stdio::inherit()) .stdout(Stdio::inherit()) - .stderr(Stdio::inherit()) - .spawn() - .map_err(|err| { - let status_code = match err.kind() { - ErrorKind::NotFound => ExitStatus::CommandNotFound.into(), - ErrorKind::PermissionDenied => ExitStatus::CannotInvoke.into(), - _ => ExitStatus::CannotInvoke.into(), - }; - USimpleError::new( - status_code, - translate!("timeout-error-failed-to-execute-process", "error" => err), - ) - })?; + .stderr(Stdio::inherit()); + + // If stdin was closed before Rust reopened it as /dev/null, close it in child + if uucore::signals::stdin_was_closed() { + unsafe { + command.pre_exec(|| { + libc::close(libc::STDIN_FILENO); + Ok(()) + }); + } + } + + let process = &mut command.spawn().map_err(|err| { + let status_code = match err.kind() { + ErrorKind::NotFound => ExitStatus::CommandNotFound.into(), + ErrorKind::PermissionDenied => ExitStatus::CannotInvoke.into(), + _ => ExitStatus::CannotInvoke.into(), + }; + USimpleError::new( + status_code, + translate!("timeout-error-failed-to-execute-process", "error" => err), + ) + })?; unblock_sigchld(); catch_sigterm(); // Wait for the child process for the specified time period. diff --git a/src/uucore/src/lib/features/signals.rs b/src/uucore/src/lib/features/signals.rs index 8f2c822cb..25b91d585 100644 --- a/src/uucore/src/lib/features/signals.rs +++ b/src/uucore/src/lib/features/signals.rs @@ -3,7 +3,7 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore (vars/api) fcntl setrlimit setitimer rubout pollable sysconf pgrp pfds revents POLLRDBAND POLLERR +// spell-checker:ignore (vars/api) fcntl setrlimit setitimer rubout pollable sysconf pgrp GETFD pfds revents POLLRDBAND POLLERR // spell-checker:ignore (vars/signals) ABRT ALRM CHLD SEGV SIGABRT SIGALRM SIGBUS SIGCHLD SIGCONT SIGDANGER SIGEMT SIGFPE SIGHUP SIGILL SIGINFO SIGINT SIGIO SIGIOT SIGKILL SIGMIGRATE SIGMSG SIGPIPE SIGPRE SIGPROF SIGPWR SIGQUIT SIGSEGV SIGSTOP SIGSYS SIGTALRM SIGTERM SIGTRAP SIGTSTP SIGTHR SIGTTIN SIGTTOU SIGURG SIGUSR SIGVIRT SIGVTALRM SIGWINCH SIGXCPU SIGXFSZ STKFLT PWR THR TSTP TTIN TTOU VIRT VTALRM XCPU XFSZ SIGCLD SIGPOLL SIGWAITING SIGAIOCANCEL SIGLWP SIGFREEZE SIGTHAW SIGCANCEL SIGLOST SIGXRES SIGJVM SIGRTMIN SIGRT SIGRTMAX TALRM AIOCANCEL XRES RTMIN RTMAX LTOSTOP //! This module provides a way to handle signals in a platform-independent way. @@ -426,23 +426,49 @@ pub fn ignore_interrupts() -> Result<(), Errno> { unsafe { signal(SIGINT, SigIgn) }.map(|_| ()) } -// SIGPIPE state capture - captures whether SIGPIPE was ignored at process startup +// Detect closed stdin/stdout before Rust reopens them as /dev/null (see issue #2873) #[cfg(unix)] use std::sync::atomic::{AtomicBool, Ordering}; +#[cfg(unix)] +static STDIN_WAS_CLOSED: AtomicBool = AtomicBool::new(false); +#[cfg(unix)] +static STDOUT_WAS_CLOSED: AtomicBool = AtomicBool::new(false); +#[cfg(unix)] +static STDERR_WAS_CLOSED: AtomicBool = AtomicBool::new(false); + +// SIGPIPE state capture - captures whether SIGPIPE was ignored at process startup #[cfg(unix)] static SIGPIPE_WAS_IGNORED: AtomicBool = AtomicBool::new(false); -/// Captures SIGPIPE state at process initialization, before main() runs. +/// Captures stdio and SIGPIPE state at process initialization, before main() runs. /// /// # Safety -/// Called from `.init_array` before main(). Only reads current SIGPIPE handler state. +/// Called from `.init_array` before main(). Only reads current state. #[cfg(unix)] -pub unsafe extern "C" fn capture_sigpipe_state() { +#[allow(clippy::missing_safety_doc)] +pub unsafe extern "C" fn capture_startup_state() { use nix::libc; use std::mem::MaybeUninit; use std::ptr; + // Capture stdio state + unsafe { + STDIN_WAS_CLOSED.store( + libc::fcntl(libc::STDIN_FILENO, libc::F_GETFD) == -1, + Ordering::Relaxed, + ); + STDOUT_WAS_CLOSED.store( + libc::fcntl(libc::STDOUT_FILENO, libc::F_GETFD) == -1, + Ordering::Relaxed, + ); + STDERR_WAS_CLOSED.store( + libc::fcntl(libc::STDERR_FILENO, libc::F_GETFD) == -1, + Ordering::Relaxed, + ); + } + + // Capture SIGPIPE state let mut current = MaybeUninit::::uninit(); // SAFETY: sigaction with null new-action just queries current state if unsafe { libc::sigaction(libc::SIGPIPE, ptr::null(), current.as_mut_ptr()) } == 0 { @@ -452,31 +478,61 @@ pub unsafe extern "C" fn capture_sigpipe_state() { } } -/// Initializes SIGPIPE state capture. Call once at crate root level. +/// Initializes startup state capture. Call once at crate root level. #[macro_export] #[cfg(unix)] -macro_rules! init_sigpipe_capture { +macro_rules! init_startup_state_capture { () => { #[cfg(not(target_os = "macos"))] #[used] #[unsafe(link_section = ".init_array")] - static CAPTURE_SIGPIPE_STATE: unsafe extern "C" fn() = - $crate::signals::capture_sigpipe_state; + static CAPTURE_STARTUP_STATE: unsafe extern "C" fn() = + $crate::signals::capture_startup_state; #[cfg(target_os = "macos")] #[used] #[unsafe(link_section = "__DATA,__mod_init_func")] - static CAPTURE_SIGPIPE_STATE: unsafe extern "C" fn() = - $crate::signals::capture_sigpipe_state; + static CAPTURE_STARTUP_STATE: unsafe extern "C" fn() = + $crate::signals::capture_startup_state; }; } #[macro_export] #[cfg(not(unix))] -macro_rules! init_sigpipe_capture { +macro_rules! init_startup_state_capture { () => {}; } +#[cfg(unix)] +pub fn stdin_was_closed() -> bool { + STDIN_WAS_CLOSED.load(Ordering::Relaxed) +} + +#[cfg(not(unix))] +pub const fn stdin_was_closed() -> bool { + false +} + +#[cfg(unix)] +pub fn stdout_was_closed() -> bool { + STDOUT_WAS_CLOSED.load(Ordering::Relaxed) +} + +#[cfg(not(unix))] +pub const fn stdout_was_closed() -> bool { + false +} + +#[cfg(unix)] +pub fn stderr_was_closed() -> bool { + STDERR_WAS_CLOSED.load(Ordering::Relaxed) +} + +#[cfg(not(unix))] +pub const fn stderr_was_closed() -> bool { + false +} + /// Returns whether SIGPIPE was ignored at process startup. #[cfg(unix)] pub fn sigpipe_was_ignored() -> bool { From fa8de55f49d820de58a1e9182762c3631435073e Mon Sep 17 00:00:00 2001 From: xtqqczze <45661989+xtqqczze@users.noreply.github.com> Date: Mon, 19 Jan 2026 22:48:54 +0000 Subject: [PATCH 281/425] deps: remove unused rust crate linux-raw-sys maybe unused since 61ac1932860368dbfc11aa354f3a5fa9f98929d4 --- Cargo.lock | 9 +-------- Cargo.toml | 1 - deny.toml | 2 -- src/uu/cp/Cargo.toml | 1 - 4 files changed, 1 insertion(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 81839c662..044c4f9f2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1725,12 +1725,6 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" -[[package]] -name = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - [[package]] name = "litemap" version = "0.8.1" @@ -2490,7 +2484,7 @@ dependencies = [ "bitflags 2.10.0", "errno", "libc", - "linux-raw-sys 0.11.0", + "linux-raw-sys", "windows-sys 0.61.2", ] @@ -3214,7 +3208,6 @@ dependencies = [ "fluent", "indicatif", "libc", - "linux-raw-sys 0.12.1", "selinux", "tempfile", "thiserror 2.0.18", diff --git a/Cargo.toml b/Cargo.toml index ce726de0c..e1f7083cd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -338,7 +338,6 @@ indicatif = "0.18.0" itertools = "0.14.0" jiff = "0.2.18" libc = "0.2.172" -linux-raw-sys = "0.12" lscolors = { version = "0.21.0", default-features = false, features = [ "gnu_legacy", ] } diff --git a/deny.toml b/deny.toml index 027b3723e..73d56567b 100644 --- a/deny.toml +++ b/deny.toml @@ -107,8 +107,6 @@ skip = [ { name = "zerocopy", version = "0.7.35" }, # zerocopy { name = "zerocopy-derive", version = "0.7.35" }, - # rustix - { name = "linux-raw-sys", version = "0.11.0" }, # crossterm { name = "signal-hook", version = "0.3.18" }, ] diff --git a/src/uu/cp/Cargo.toml b/src/uu/cp/Cargo.toml index 6e6921bf9..8a2391e55 100644 --- a/src/uu/cp/Cargo.toml +++ b/src/uu/cp/Cargo.toml @@ -21,7 +21,6 @@ path = "src/cp.rs" clap = { workspace = true } filetime = { workspace = true } libc = { workspace = true } -linux-raw-sys = { workspace = true, features = ["ioctl"] } selinux = { workspace = true, optional = true } uucore = { workspace = true, features = [ "backup-control", From b302bbec169f78db15887ba4503708a129509101 Mon Sep 17 00:00:00 2001 From: xtqqczze <45661989+xtqqczze@users.noreply.github.com> Date: Tue, 20 Jan 2026 01:37:57 +0000 Subject: [PATCH 282/425] clippy: remove assigning_clones lint suppression (#10377) --- src/uu/cp/src/cp.rs | 1 - src/uu/df/src/filesystem.rs | 1 - src/uu/tail/src/follow/watch.rs | 1 - 3 files changed, 3 deletions(-) diff --git a/src/uu/cp/src/cp.rs b/src/uu/cp/src/cp.rs index 4fd3fba47..22134f0d6 100644 --- a/src/uu/cp/src/cp.rs +++ b/src/uu/cp/src/cp.rs @@ -1307,7 +1307,6 @@ fn parse_path_args( }; if options.strip_trailing_slashes { - #[allow(clippy::assigning_clones)] for source in &mut paths { *source = source.components().as_path().to_owned(); } diff --git a/src/uu/df/src/filesystem.rs b/src/uu/df/src/filesystem.rs index 7d9e7cf5e..bfd982646 100644 --- a/src/uu/df/src/filesystem.rs +++ b/src/uu/df/src/filesystem.rs @@ -291,7 +291,6 @@ mod tests { } #[test] - #[allow(clippy::assigning_clones)] fn test_dev_name_match() { let tmp = tempfile::TempDir::new().expect("Failed to create temp dir"); let dev_name = std::fs::canonicalize(tmp.path()) diff --git a/src/uu/tail/src/follow/watch.rs b/src/uu/tail/src/follow/watch.rs index a5dd53897..b195ab0a4 100644 --- a/src/uu/tail/src/follow/watch.rs +++ b/src/uu/tail/src/follow/watch.rs @@ -49,7 +49,6 @@ impl WatcherRx { Tested for notify::InotifyWatcher and for notify::PollWatcher. */ if let Some(parent) = path.parent() { - #[allow(clippy::assigning_clones)] if parent.is_dir() { path = parent.to_owned(); } else { From 5f5b83818b00e6b0ccdfafe802a353f25eb5c1d7 Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Tue, 20 Jan 2026 15:20:32 +0900 Subject: [PATCH 283/425] CICD.yml: Drop a toolchain outside of VM --- .github/workflows/CICD.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/CICD.yml b/.github/workflows/CICD.yml index f690f78c2..bf79a2d6e 100644 --- a/.github/workflows/CICD.yml +++ b/.github/workflows/CICD.yml @@ -1286,7 +1286,6 @@ jobs: - uses: actions/checkout@v6 with: persist-credentials: false - - uses: dtolnay/rust-toolchain@stable - name: Setup Lima uses: lima-vm/lima-actions/setup@v1 id: lima-actions-setup @@ -1301,7 +1300,7 @@ jobs: - name: Setup Rust and other build deps in VM run: | lima sudo dnf install gcc g++ git rustup libselinux-devel clang-devel attr -y - lima rustup-init -y --default-toolchain stable + lima rustup-init -y --default-toolchain stable --profile minimal -c clippy - name: Verify SELinux Status run: | lima getenforce From 56ec4bebbee079db7f6c613de3b0a63ee31f1e55 Mon Sep 17 00:00:00 2001 From: Daniel Hofstetter Date: Tue, 20 Jan 2026 11:33:09 +0100 Subject: [PATCH 284/425] ci: move -no-metrics to COMMON_EMULATOR_OPTIONS (#10385) --- .github/workflows/android.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index a4a9b3bd0..434313fe7 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -22,7 +22,7 @@ concurrency: env: TERMUX: v0.118.0 KEY_POSTFIX: nextest+rustc-hash+adb+sshd+upgrade+XGB+inc18 - COMMON_EMULATOR_OPTIONS: -no-window -noaudio -no-boot-anim -camera-back none -gpu off + COMMON_EMULATOR_OPTIONS: -no-metrics -no-window -noaudio -no-boot-anim -camera-back none -gpu off EMULATOR_DISK_SIZE: 12GB EMULATOR_HEAP_SIZE: 2048M EMULATOR_BOOT_TIMEOUT: 1200 # 20min @@ -166,7 +166,7 @@ jobs: disk-size: ${{ env.EMULATOR_DISK_SIZE }} cores: ${{ env.EMULATOR_CORES }} force-avd-creation: false - emulator-options: ${{ env.COMMON_EMULATOR_OPTIONS }} -no-metrics -no-snapshot-save -snapshot ${{ env.AVD_CACHE_KEY }} + emulator-options: ${{ env.COMMON_EMULATOR_OPTIONS }} -no-snapshot-save -snapshot ${{ env.AVD_CACHE_KEY }} emulator-boot-timeout: ${{ env.EMULATOR_BOOT_TIMEOUT }} # This is not a usual script. Every line is executed in a separate shell with `sh -c`. If # one of the lines returns with error the whole script is failed (like running a script with From bcd4ab1154939212a40e6e7faad3c93154ef184c Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Tue, 20 Jan 2026 19:40:56 +0900 Subject: [PATCH 285/425] CICD.yml: Filter-out non-SELinux progs on SELinux test (#10382) --- .github/workflows/CICD.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/CICD.yml b/.github/workflows/CICD.yml index bf79a2d6e..c6760554b 100644 --- a/.github/workflows/CICD.yml +++ b/.github/workflows/CICD.yml @@ -1308,9 +1308,9 @@ jobs: - name: Build and Test with SELinux run: | lima ls - lima bash -c "cd work && cargo test --features 'feat_selinux'" + lima bash -c "cd work && cargo test --features 'feat_selinux' --no-default-features" - name: Lint with SELinux - run: lima bash -c "cd work && cargo clippy --all-targets --features 'feat_selinux' -- -D warnings" + run: lima bash -c "cd work && cargo clippy --all-targets --features 'feat_selinux' --no-default-features -- -D warnings" test_selinux_stubs: name: Build/SELinux-Stubs (Non-Linux) From 16119970d9b6d16dc663a3fb1491a0cf6853f9de Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Tue, 20 Jan 2026 19:52:37 +0900 Subject: [PATCH 286/425] Merge pull request #10367 from oech3/patch-9 l10n.yml: Use git clone --depth=1 --- .github/workflows/l10n.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/l10n.yml b/.github/workflows/l10n.yml index c7154f490..3ccbda776 100644 --- a/.github/workflows/l10n.yml +++ b/.github/workflows/l10n.yml @@ -735,7 +735,7 @@ jobs: run: | ## Download additional locale files from coreutils-l10n repository echo "Downloading additional locale files from coreutils-l10n..." - git clone https://github.com/uutils/coreutils-l10n.git coreutils-l10n-repo + git clone --depth=1 https://github.com/uutils/coreutils-l10n.git coreutils-l10n-repo # Create installation directory CARGO_INSTALL_DIR="$PWD/cargo-install-dir" From 7ebe7cd095070d68bbf6a9c3409b95d574f5d6cb Mon Sep 17 00:00:00 2001 From: xtqqczze <45661989+xtqqczze@users.noreply.github.com> Date: Tue, 20 Jan 2026 10:54:20 +0000 Subject: [PATCH 287/425] benchmarks: reduce allocations in run_util_function (#10378) --- src/uucore/Cargo.toml | 3 ++- src/uucore/src/lib/features/benchmark.rs | 7 +++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/uucore/Cargo.toml b/src/uucore/Cargo.toml index 22e463f7a..1cbc276e4 100644 --- a/src/uucore/Cargo.toml +++ b/src/uucore/Cargo.toml @@ -96,6 +96,7 @@ nix = { workspace = true, features = [ "poll", ] } xattr = { workspace = true, optional = true } +itertools = { workspace = true, optional = true } [dev-dependencies] tempfile = { workspace = true } @@ -186,4 +187,4 @@ wide = [] tty = [] time = ["jiff"] uptime = ["jiff", "libc", "windows-sys", "utmpx", "utmp-classic"] -benchmark = ["divan", "tempfile"] +benchmark = ["divan", "itertools", "tempfile"] diff --git a/src/uucore/src/lib/features/benchmark.rs b/src/uucore/src/lib/features/benchmark.rs index 8be0baf72..29f2c1a59 100644 --- a/src/uucore/src/lib/features/benchmark.rs +++ b/src/uucore/src/lib/features/benchmark.rs @@ -12,6 +12,8 @@ use std::fs::File; use std::io::{BufWriter, Write}; use std::path::{Path, PathBuf}; +use itertools::Itertools as _; + /// Create a temporary file with test data pub fn create_test_file(data: &[u8], temp_dir: &Path) -> PathBuf { let file_path = temp_dir.join("test_data.txt"); @@ -32,8 +34,9 @@ where F: FnOnce(std::vec::IntoIter) -> i32, { // Prepend a dummy program name as argv[0] since clap expects it - let mut os_args: Vec = vec!["benchmark".into()]; - os_args.extend(args.iter().map(|s| (*s).into())); + let os_args = std::iter::once("benchmark".into()) + .chain(args.iter().map(Into::into)) + .collect_vec(); util_func(os_args.into_iter()) } From da4d846b63d1487772905ac1e9bbdf0a1884087e Mon Sep 17 00:00:00 2001 From: Fan Mo Date: Tue, 20 Jan 2026 04:55:17 -0600 Subject: [PATCH 288/425] dir: add "about" and "usage" instead of being "ls" (#10369) * fix: add "about" and "usage" for dir and vdir * fix import * revert changes on vdir. focus on dir first * revert import on vdir * add test * Update en-US.ftl * fix fmt * revert vdir * precommit --- src/uu/dir/src/dir.rs | 5 +++-- src/uu/ls/locales/en-US.ftl | 3 +++ src/uu/ls/locales/fr-FR.ftl | 3 +++ tests/by-util/test_dir.rs | 18 ++++++++++++++++++ 4 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/uu/dir/src/dir.rs b/src/uu/dir/src/dir.rs index 099ae8bf9..c758a26e4 100644 --- a/src/uu/dir/src/dir.rs +++ b/src/uu/dir/src/dir.rs @@ -7,8 +7,7 @@ use clap::Command; use std::ffi::OsString; use std::path::Path; use uu_ls::{Config, Format, options}; -use uucore::error::UResult; -use uucore::quoting_style::QuotingStyle; +use uucore::{error::UResult, format_usage, quoting_style::QuotingStyle, translate}; #[uucore::main] pub fn uumain(args: impl uucore::Args) -> UResult<()> { @@ -63,4 +62,6 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { // an uu_app function, so we return the `ls` app. pub fn uu_app() -> Command { uu_ls::uu_app() + .override_usage(format_usage(&translate!("dir-usage"))) + .about(translate!("dir-about")) } diff --git a/src/uu/ls/locales/en-US.ftl b/src/uu/ls/locales/en-US.ftl index 004243c5e..0ff418309 100644 --- a/src/uu/ls/locales/en-US.ftl +++ b/src/uu/ls/locales/en-US.ftl @@ -1,6 +1,9 @@ ls-about = List directory contents. Ignore files and directories starting with a '.' by default +dir-about = List directory contents. + Ignore files and directories starting with a '.' by default ls-usage = ls [OPTION]... [FILE]... +dir-usage = dir [OPTION]... [FILE]... ls-after-help = The TIME_STYLE argument can be full-iso, long-iso, iso, locale or +FORMAT. FORMAT is interpreted like in date. Also the TIME_STYLE environment variable sets the default style to use. # Error messages diff --git a/src/uu/ls/locales/fr-FR.ftl b/src/uu/ls/locales/fr-FR.ftl index 0ae8b06c9..a655535f7 100644 --- a/src/uu/ls/locales/fr-FR.ftl +++ b/src/uu/ls/locales/fr-FR.ftl @@ -1,6 +1,9 @@ ls-about = Lister le contenu des répertoires. Ignorer les fichiers et répertoires commençant par un '.' par défaut +dir-about = Lister le contenu des répertoires. + Ignorer les fichiers et répertoires commençant par un '.' par défaut ls-usage = ls [OPTION]... [FICHIER]... +dir-usage = dir [OPTION]... [FICHIER]... ls-after-help = L'argument TIME_STYLE peut être full-iso, long-iso, iso, locale ou +FORMAT. FORMAT est interprété comme dans date. De plus, la variable d'environnement TIME_STYLE définit le style par défaut à utiliser. # Messages d'erreur diff --git a/tests/by-util/test_dir.rs b/tests/by-util/test_dir.rs index 0d77de7a0..c28fa51ee 100644 --- a/tests/by-util/test_dir.rs +++ b/tests/by-util/test_dir.rs @@ -56,3 +56,21 @@ fn test_long_output() { fn test_invalid_option_exit_code() { new_ucmd!().arg("-/").fails().code_is(2); } + +#[test] +fn test_help_shows_dir_not_ls() { + let result = new_ucmd!().arg("--help").succeeds(); + let output = result.stdout_str(); + + // Verify help text contains "dir" in the usage line + assert!( + output.contains("dir [OPTION]"), + "Help should show 'dir [OPTION]'" + ); + + // Verify help text does not incorrectly show "ls" + assert!( + !output.contains("ls [OPTION]"), + "Help should not show 'ls [OPTION]'" + ); +} From b2b584ec5f7b7bf2ea575c7a683450741d0adff3 Mon Sep 17 00:00:00 2001 From: xtqqczze <45661989+xtqqczze@users.noreply.github.com> Date: Tue, 20 Jan 2026 10:55:54 +0000 Subject: [PATCH 289/425] uutests: replace unsafe `libc::stat` with `nix::stat` (#10364) No functional change intended. --- tests/uutests/src/lib/util.rs | 24 ++++++------------------ 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/tests/uutests/src/lib/util.rs b/tests/uutests/src/lib/util.rs index 5c5ed3ef4..0c4bd2553 100644 --- a/tests/uutests/src/lib/util.rs +++ b/tests/uutests/src/lib/util.rs @@ -20,6 +20,8 @@ use libc::mode_t; use nix::pty::OpenptyResult; #[cfg(unix)] use nix::sys; +#[cfg(not(windows))] +use nix::sys::stat::{self, SFlag}; use pretty_assertions::assert_eq; #[cfg(unix)] use rlimit::setrlimit; @@ -1144,28 +1146,14 @@ impl AtPath { #[cfg(not(windows))] pub fn is_fifo(&self, fifo: &str) -> bool { - unsafe { - let name = CString::new(self.plus_as_string(fifo)).unwrap(); - let mut stat: libc::stat = std::mem::zeroed(); - if libc::stat(name.as_ptr(), &raw mut stat) >= 0 { - libc::S_IFIFO & stat.st_mode as libc::mode_t != 0 - } else { - false - } - } + stat::stat(&self.plus(fifo)) + .is_ok_and(|s| SFlag::from_bits_truncate(s.st_mode).contains(SFlag::S_IFIFO)) } #[cfg(not(windows))] pub fn is_char_device(&self, char_dev: &str) -> bool { - unsafe { - let name = CString::new(self.plus_as_string(char_dev)).unwrap(); - let mut stat: libc::stat = std::mem::zeroed(); - if libc::stat(name.as_ptr(), &raw mut stat) >= 0 { - libc::S_IFCHR & stat.st_mode as libc::mode_t != 0 - } else { - false - } - } + stat::stat(&self.plus(char_dev)) + .is_ok_and(|s| SFlag::from_bits_truncate(s.st_mode).contains(SFlag::S_IFCHR)) } pub fn hard_link(&self, original: &str, link: &str) { From ea3526c5e9dcae39d19f6c938ef8bd8798b82814 Mon Sep 17 00:00:00 2001 From: Daniel Hofstetter Date: Tue, 20 Jan 2026 13:09:05 +0100 Subject: [PATCH 290/425] deny.toml: remove wasi from skip list (#10355) --- deny.toml | 2 -- 1 file changed, 2 deletions(-) diff --git a/deny.toml b/deny.toml index 73d56567b..51bf577cf 100644 --- a/deny.toml +++ b/deny.toml @@ -95,8 +95,6 @@ skip = [ { name = "nom", version = "7.1.3" }, # const-random-macro, rand_core { name = "getrandom", version = "0.2.15" }, - # getrandom, mio - { name = "wasi", version = "0.11.0+wasi-snapshot-preview1" }, # num-bigint, num-prime, phf_generator { name = "rand", version = "0.8.5" }, # rand From 7ea3dd9b5b1574a0fa12ea7f9c2462d7e2611931 Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Tue, 20 Jan 2026 21:09:34 +0900 Subject: [PATCH 291/425] Don't build coreutils without MULTICALL=y (#10359) --- .github/workflows/CICD.yml | 2 ++ GNUmakefile | 6 +++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/CICD.yml b/.github/workflows/CICD.yml index c6760554b..a96bf2130 100644 --- a/.github/workflows/CICD.yml +++ b/.github/workflows/CICD.yml @@ -298,6 +298,8 @@ jobs: fi # Check that we don't cross-build uudoc env CARGO_BUILD_TARGET=aarch64-unknown-linux-gnu make install-manpages PREFIX=/tmp/usr UTILS=true + # We don't build coreutils without MULTICALL=y + ! test -e target/debug/coreutils # build (host) make build echo "Check that target directory will be ignored by backup tools" diff --git a/GNUmakefile b/GNUmakefile index 3382fa74d..fa824d626 100644 --- a/GNUmakefile +++ b/GNUmakefile @@ -327,7 +327,11 @@ endif build-coreutils: ${CARGO} build ${CARGOFLAGS} --features "${EXES} $(BUILD_SPEC_FEATURE)" ${PROFILE_CMD} --no-default-features -build: build-coreutils build-pkgs locales +ifeq (${MULTICALL}, y) +build: build-coreutils locales +else +build: build-pkgs locales +endif $(foreach test,$(UTILS),$(eval $(call TEST_BUSYBOX,$(test)))) From 555271d8a97dc0e9fe3f4ee7acc5d2912c10d18d Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Tue, 20 Jan 2026 21:10:00 +0900 Subject: [PATCH 292/425] runcon, chcon: Don't fetch crates at unsupported target (#10360) --- src/uu/chcon/Cargo.toml | 2 +- src/uu/runcon/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/uu/chcon/Cargo.toml b/src/uu/chcon/Cargo.toml index ab05ed53c..110a70492 100644 --- a/src/uu/chcon/Cargo.toml +++ b/src/uu/chcon/Cargo.toml @@ -17,7 +17,7 @@ workspace = true [lib] path = "src/chcon.rs" -[dependencies] +[target.'cfg(target_os = "linux")'.dependencies] # todo: block fetching crates without feat_selinux clap = { workspace = true } uucore = { workspace = true, features = ["entries", "fs", "perms"] } selinux = { workspace = true } diff --git a/src/uu/runcon/Cargo.toml b/src/uu/runcon/Cargo.toml index a7c235dca..f358c31ec 100644 --- a/src/uu/runcon/Cargo.toml +++ b/src/uu/runcon/Cargo.toml @@ -17,7 +17,7 @@ workspace = true [lib] path = "src/runcon.rs" -[dependencies] +[target.'cfg(target_os = "linux")'.dependencies] # todo: block fetching crates without feat_selinux clap = { workspace = true } uucore = { workspace = true, features = ["entries", "fs", "perms", "selinux"] } selinux = { workspace = true } From 98d3dbad4ec2508d77aada6bf9e774b2f1993567 Mon Sep 17 00:00:00 2001 From: WaterWhisperer Date: Tue, 20 Jan 2026 21:24:30 +0800 Subject: [PATCH 293/425] join: consider locale collation in field comparison (#9982) Co-authored-by: Sylvestre Ledru --- src/uu/join/Cargo.toml | 2 +- src/uu/join/src/join.rs | 20 ++++++++++++++++++-- src/uucore/src/lib/features/i18n/collator.rs | 5 +++++ tests/by-util/test_join.rs | 19 +++++++++++++++++++ 4 files changed, 43 insertions(+), 3 deletions(-) diff --git a/src/uu/join/Cargo.toml b/src/uu/join/Cargo.toml index 401cb3bb5..8599c5c6a 100644 --- a/src/uu/join/Cargo.toml +++ b/src/uu/join/Cargo.toml @@ -19,7 +19,7 @@ path = "src/join.rs" [dependencies] clap = { workspace = true } -uucore = { workspace = true } +uucore = { workspace = true, features = ["i18n-collator"] } memchr = { workspace = true } thiserror = { workspace = true } fluent = { workspace = true } diff --git a/src/uu/join/src/join.rs b/src/uu/join/src/join.rs index 1360e4a6a..5d2dd2cc9 100644 --- a/src/uu/join/src/join.rs +++ b/src/uu/join/src/join.rs @@ -19,6 +19,9 @@ use thiserror::Error; use uucore::display::Quotable; use uucore::error::{FromIo, UError, UResult, USimpleError, set_exit_code}; use uucore::format_usage; +use uucore::i18n::collator::{ + AlternateHandling, CollatorOptions, locale_cmp, should_use_locale_collation, try_init_collator, +}; use uucore::line_ending::LineEnding; use uucore::translate; @@ -311,14 +314,16 @@ struct Input { separator: Sep, ignore_case: bool, check_order: CheckOrder, + use_locale: bool, } impl Input { - fn new(separator: Sep, ignore_case: bool, check_order: CheckOrder) -> Self { + fn new(separator: Sep, ignore_case: bool, check_order: CheckOrder, use_locale: bool) -> Self { Self { separator, ignore_case, check_order, + use_locale, } } @@ -328,6 +333,8 @@ impl Input { let field1 = CaseInsensitiveSlice { v: field1 }; let field2 = CaseInsensitiveSlice { v: field2 }; field1.cmp(&field2) + } else if self.use_locale { + locale_cmp(field1, field2) } else { field1.cmp(field2) } @@ -823,6 +830,10 @@ fn parse_settings(matches: &clap::ArgMatches) -> UResult { pub fn uumain(args: impl uucore::Args) -> UResult<()> { let matches = uucore::clap_localization::handle_clap_result(uu_app(), args)?; + let mut opts = CollatorOptions::default(); + opts.alternate_handling = Some(AlternateHandling::Shifted); + let _ = try_init_collator(opts); + let settings = parse_settings(&matches)?; let file1 = matches.get_one::("file1").unwrap(); @@ -989,7 +1000,12 @@ fn exec( settings.print_unpaired2, )?; - let input = Input::new(sep.clone(), settings.ignore_case, settings.check_order); + let input = Input::new( + sep.clone(), + settings.ignore_case, + settings.check_order, + should_use_locale_collation(), + ); let format = if settings.autoformat { let mut format = vec![Spec::Key]; diff --git a/src/uucore/src/lib/features/i18n/collator.rs b/src/uucore/src/lib/features/i18n/collator.rs index f0a9e6b35..37868ed3b 100644 --- a/src/uucore/src/lib/features/i18n/collator.rs +++ b/src/uucore/src/lib/features/i18n/collator.rs @@ -30,6 +30,11 @@ pub fn init_collator(opts: CollatorOptions) { .expect("Collator already initialized"); } +/// Check if locale collation should be used. +pub fn should_use_locale_collation() -> bool { + get_collating_locale().0 != DEFAULT_LOCALE +} + /// Initialize the collator for locale-aware string comparison if needed. /// /// This function checks if the current locale requires locale-aware collation diff --git a/tests/by-util/test_join.rs b/tests/by-util/test_join.rs index 9041cb560..a0a061b6b 100644 --- a/tests/by-util/test_join.rs +++ b/tests/by-util/test_join.rs @@ -580,3 +580,22 @@ fn join_emoji_delim_inner_key() { .succeeds() .stdout_only("b🗿a🗿u\n"); } + +#[cfg(unix)] +#[test] +fn test_locale_collation() { + let ts = TestScenario::new(util_name!()); + let at = &ts.fixtures; + + at.write("f1.sorted", "abc:d 2\nab:d 1\n"); + at.write("f2.sorted", "abc:d y\nab:d x\n"); + + ts.ucmd() + .env("LC_ALL", "en_US.UTF-8") + .arg("--check-order") + .arg("f1.sorted") + .arg("f2.sorted") + .succeeds() + .stdout_contains("abc:d 2 y") + .stdout_contains("ab:d 1 x"); +} From a74f72f390e0fc6a9a13d747a0bdfd2a66938770 Mon Sep 17 00:00:00 2001 From: Dhruv <62135445+dhr412@users.noreply.github.com> Date: Tue, 20 Jan 2026 20:48:55 +0530 Subject: [PATCH 294/425] wc: respect POSIXLY_CORRECT for word counting (#10344) * wc: respect POSIXLY_CORRECT for word counting * wc: Add test for POSIXLY_CORRECT word counting --- src/uu/wc/src/wc.rs | 12 +++++++++++- tests/by-util/test_wc.rs | 20 ++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/uu/wc/src/wc.rs b/src/uu/wc/src/wc.rs index 4ae07fe2b..854849182 100644 --- a/src/uu/wc/src/wc.rs +++ b/src/uu/wc/src/wc.rs @@ -13,6 +13,7 @@ mod word_count; use std::{ borrow::{Borrow, Cow}, cmp::max, + env, ffi::{OsStr, OsString}, fs::{self, File}, io::{self, Write}, @@ -578,10 +579,17 @@ fn process_chunk< text: &str, current_len: &mut usize, in_word: &mut bool, + posixly_correct: bool, ) { for ch in text.chars() { if SHOW_WORDS { - if ch.is_whitespace() { + let is_space = if posixly_correct { + matches!(ch, '\t'..='\r' | ' ') + } else { + ch.is_whitespace() + }; + + if is_space { *in_word = false; } else if !(*in_word) { // This also counts control characters! (As of GNU coreutils 9.5) @@ -639,6 +647,7 @@ fn word_count_from_reader_specialized< let mut reader = BufReadDecoder::new(reader.buffered()); let mut in_word = false; let mut current_len = 0; + let posixly_correct = env::var_os("POSIXLY_CORRECT").is_some(); while let Some(chunk) = reader.next_strict() { match chunk { Ok(text) => { @@ -647,6 +656,7 @@ fn word_count_from_reader_specialized< text, &mut current_len, &mut in_word, + posixly_correct, ); } Err(e) => { diff --git a/tests/by-util/test_wc.rs b/tests/by-util/test_wc.rs index be2374283..6901cf1e4 100644 --- a/tests/by-util/test_wc.rs +++ b/tests/by-util/test_wc.rs @@ -891,3 +891,23 @@ fn test_simd_respects_glibc_tunables() { ); } } + +#[test] +fn test_posixly_correct_whitespace() { + let input = "word\u{00A0}word"; // Non-breaking space + + // Default: Unicode whitespace is respected + new_ucmd!() + .arg("-w") + .pipe_in(input) + .succeeds() + .stdout_is("2\n"); + + // POSIXLY_CORRECT: Only ASCII whitespace + new_ucmd!() + .arg("-w") + .env("POSIXLY_CORRECT", "1") + .pipe_in(input) + .succeeds() + .stdout_is("1\n"); +} From 5a909d877e7c09dc7ca439dd5196b459879b819a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 20 Jan 2026 15:20:19 +0000 Subject: [PATCH 295/425] chore(deps): update rust crate zip to v7.2.0 --- Cargo.lock | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 40dfe4e48..be10693f6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2952,6 +2952,12 @@ dependencies = [ "rustc-hash", ] +[[package]] +name = "typed-path" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7922f2cdc51280d47b491af9eafc41eb0cdab85eabcb390c854412fcbf26dbe8" + [[package]] name = "typenum" version = "1.19.0" @@ -4883,14 +4889,15 @@ dependencies = [ [[package]] name = "zip" -version = "7.1.0" +version = "7.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9013f1222db8a6d680f13a7ccdc60a781199cd09c2fa4eff58e728bb181757fc" +checksum = "c42e33efc22a0650c311c2ef19115ce232583abbe80850bc8b66509ebef02de0" dependencies = [ "crc32fast", "flate2", "indexmap", "memchr", + "typed-path", "zopfli", ] From b9372e509ea9b278fe13763237067a261bb8c946 Mon Sep 17 00:00:00 2001 From: Paol0B <52996310+Paol0B@users.noreply.github.com> Date: Tue, 20 Jan 2026 16:38:10 +0100 Subject: [PATCH 296/425] Fixes #10192 - fix(comm): improve stdout handling and add test for lossy UTF-8 output (#10206) * fix(comm): improve stdout handling and add test for lossy UTF-8 output * run cargo fmt * perf(comm): use BufWriter for buffered stdout output Wrap stdout in BufWriter to improve performance and avoid duplicate error messages, matching GNU comm behavior. * fix: refactor write operations in comm to use a dedicated function * comm: use translate! --------- Co-authored-by: Sylvestre Ledru --- src/uu/comm/src/comm.rs | 30 ++++++++++++++++++++++++------ tests/by-util/test_comm.rs | 24 ++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 6 deletions(-) diff --git a/src/uu/comm/src/comm.rs b/src/uu/comm/src/comm.rs index 61424141a..4e05678ef 100644 --- a/src/uu/comm/src/comm.rs +++ b/src/uu/comm/src/comm.rs @@ -8,7 +8,7 @@ use std::cmp::Ordering; use std::ffi::OsString; use std::fs::{File, metadata}; -use std::io::{self, BufRead, BufReader, Read, StdinLock, stdin}; +use std::io::{self, BufRead, BufReader, BufWriter, Read, StdinLock, Write, stdin}; use std::path::Path; use uucore::display::Quotable; use uucore::error::{FromIo, UResult, USimpleError}; @@ -184,6 +184,16 @@ pub fn are_files_identical(path1: &Path, path2: &Path) -> io::Result { } } +fn write_line_with_delimiter(writer: &mut W, delim: &[u8], line: &[u8]) -> UResult<()> { + writer + .write_all(delim) + .map_err_context(|| translate!("comm-error-write"))?; + writer + .write_all(line) + .map_err_context(|| translate!("comm-error-write"))?; + Ok(()) +} + fn comm(a: &mut LineReader, b: &mut LineReader, delim: &str, opts: &ArgMatches) -> UResult<()> { let width_col_1 = usize::from(!opts.get_flag(options::COLUMN_1)); let width_col_2 = usize::from(!opts.get_flag(options::COLUMN_2)); @@ -191,6 +201,8 @@ fn comm(a: &mut LineReader, b: &mut LineReader, delim: &str, opts: &ArgMatches) let delim_col_2 = delim.repeat(width_col_1); let delim_col_3 = delim.repeat(width_col_1 + width_col_2); + let mut writer = BufWriter::new(io::stdout().lock()); + let ra = &mut Vec::new(); let mut na = a.read_line(ra); let rb = &mut Vec::new(); @@ -239,7 +251,9 @@ fn comm(a: &mut LineReader, b: &mut LineReader, delim: &str, opts: &ArgMatches) break; } if !opts.get_flag(options::COLUMN_1) { - print!("{}", String::from_utf8_lossy(ra)); + writer + .write_all(ra) + .map_err_context(|| translate!("comm-error-write"))?; } ra.clear(); na = a.read_line(ra); @@ -250,7 +264,7 @@ fn comm(a: &mut LineReader, b: &mut LineReader, delim: &str, opts: &ArgMatches) break; } if !opts.get_flag(options::COLUMN_2) { - print!("{delim_col_2}{}", String::from_utf8_lossy(rb)); + write_line_with_delimiter(&mut writer, delim_col_2.as_bytes(), rb)?; } rb.clear(); nb = b.read_line(rb); @@ -262,7 +276,7 @@ fn comm(a: &mut LineReader, b: &mut LineReader, delim: &str, opts: &ArgMatches) break; } if !opts.get_flag(options::COLUMN_3) { - print!("{delim_col_3}{}", String::from_utf8_lossy(ra)); + write_line_with_delimiter(&mut writer, delim_col_3.as_bytes(), ra)?; } ra.clear(); rb.clear(); @@ -280,12 +294,16 @@ fn comm(a: &mut LineReader, b: &mut LineReader, delim: &str, opts: &ArgMatches) if opts.get_flag(options::TOTAL) { let line_ending = LineEnding::from_zero_flag(opts.get_flag(options::ZERO_TERMINATED)); - print!( + write!( + writer, "{total_col_1}{delim}{total_col_2}{delim}{total_col_3}{delim}{}{line_ending}", translate!("comm-total") - ); + ) + .map_err_context(|| translate!("comm-error-write"))?; } + writer.flush().ok(); + if should_check_order && (checker1.has_error || checker2.has_error) { // Print the input error message once at the end if input_error { diff --git a/tests/by-util/test_comm.rs b/tests/by-util/test_comm.rs index 3194d270e..dbcee0598 100644 --- a/tests/by-util/test_comm.rs +++ b/tests/by-util/test_comm.rs @@ -649,6 +649,30 @@ fn test_comm_eintr_handling() { .stdout_contains("line3"); } +#[test] +fn test_output_lossy_utf8() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + // Create files with invalid UTF-8 + // A: \xfe\n\xff\n + // B: \xff\n\xfe\n + at.write_bytes("a", b"\xfe\n\xff\n"); + at.write_bytes("b", b"\xff\n\xfe\n"); + + // GNU comm output (and uutils with fix): + // \xfe\n (col 1) + // \t\t\xff\n (col 3) + // \t\xfe\n (col 2) + // Hex: fe 0a 09 09 ff 0a 09 fe 0a + + scene + .ucmd() + .args(&["a", "b"]) + .fails() // Fails because of unsorted input + .stdout_is_bytes(b"\xfe\n\t\t\xff\n\t\xfe\n"); +} + #[test] #[cfg(any(target_os = "linux", target_os = "android"))] fn test_comm_anonymous_pipes() { From f39081d25c24175b2c244d93f65370db75b4f2b7 Mon Sep 17 00:00:00 2001 From: Chris Dryden Date: Tue, 20 Jan 2026 13:24:45 -0500 Subject: [PATCH 297/425] gnu: patch inotify-race tests to use Rust gdb breakpoints (#9908) --- util/build-gnu.sh | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/util/build-gnu.sh b/util/build-gnu.sh index 5c506cdc2..7a9f45c38 100755 --- a/util/build-gnu.sh +++ b/util/build-gnu.sh @@ -216,6 +216,17 @@ sed -i -e "s|rm: cannot remove 'a/1': Permission denied|rm: cannot remove 'a/1/2 # however there's a bug because `---dis` is an alias for: `---disable-inotify` sed -i -e "s|---dis ||g" tests/tail/overlay-headers.sh +# Patch inotify-race tests to use Rust source lines for gdb breakpoints. +# GNU test checks for race between initial read and watch setup. Rust sets up +# watchers before initial read, so no exact equivalent exists. We break at +# watch_with_parent as the closest semantic match. -iex suppresses Rust debug +# script auto-load warnings that would cause the test to skip. +"${SED}" -i \ + -e "s|break_src=\"\$abs_top_srcdir/src/tail.c\"|break_src=\"${path_UUTILS}/src/uu/tail/src/follow/watch.rs\"|" \ + -e 's|break_line=$(grep -n ^tail_forever_inotify "$break_src")|break_line=$(grep -n "watcher_rx.watch_with_parent" "$break_src")|' \ + -e 's|gdb -nx --batch-silent|gdb -nx --batch-silent -iex "set auto-load no"|g' \ + tests/tail/inotify-race.sh tests/tail/inotify-race2.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 From 456252fc5da1c6b16024ddc429d8a90d63646dc5 Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Tue, 20 Jan 2026 19:27:05 +0100 Subject: [PATCH 298/425] join: Benchmark join with actual Unicode data requiring locale collation (#10391) --- src/uu/join/benches/join_bench.rs | 43 ++++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/src/uu/join/benches/join_bench.rs b/src/uu/join/benches/join_bench.rs index 798f4344f..800bfa96d 100644 --- a/src/uu/join/benches/join_bench.rs +++ b/src/uu/join/benches/join_bench.rs @@ -110,7 +110,7 @@ fn join_custom_separator(bencher: Bencher) { }); } -/// Benchmark join with French locale (fr_FR.UTF-8) +/// Benchmark join with French locale (fr_FR.UTF-8) - ASCII data (fast path) #[divan::bench] fn join_french_locale(bencher: Bencher) { let num_lines = 10000; @@ -126,6 +126,47 @@ fn join_french_locale(bencher: Bencher) { }); } +/// Create files with Unicode data that requires locale collation +fn create_unicode_join_files(temp_dir: &TempDir, num_lines: usize) -> (String, String) { + let file1_path = temp_dir.path().join("file1.txt"); + let file2_path = temp_dir.path().join("file2.txt"); + + let mut file1 = File::create(&file1_path).unwrap(); + let mut file2 = File::create(&file2_path).unwrap(); + + // Create data with accented characters that require locale collation + let accented_chars = [ + "àbc", "àbd", "abc", "abd", "èfg", "efg", "çar", "car", "öst", "ost", + ]; + + for i in 0..num_lines { + let key = &accented_chars[i % accented_chars.len()]; + writeln!(file1, "{key}:{i:06} field1_{i}").unwrap(); + writeln!(file2, "{key}:{i:06} data1_{i}").unwrap(); + } + + ( + file1_path.to_str().unwrap().to_string(), + file2_path.to_str().unwrap().to_string(), + ) +} + +/// Benchmark join with actual Unicode data requiring locale collation +#[divan::bench] +fn join_unicode_locale(bencher: Bencher) { + let num_lines = 1000; // Smaller due to complexity + let temp_dir = TempDir::new().unwrap(); + let (file1, file2) = create_unicode_join_files(&temp_dir, num_lines); + + bencher + .with_inputs(|| unsafe { + std::env::set_var("LC_ALL", "fr_FR.UTF-8"); + }) + .bench_values(|_| { + black_box(run_util_function(uumain, &[&file1, &file2])); + }); +} + fn main() { divan::main(); } From 0f7a7c40b65f39fd837f5c9a9f20c2f37ee00d67 Mon Sep 17 00:00:00 2001 From: Chris Dryden Date: Tue, 20 Jan 2026 14:28:51 -0500 Subject: [PATCH 299/425] fetch-gnu: add tests/tail/inotify-dir-recreate.sh (#10392) --- util/fetch-gnu.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/util/fetch-gnu.sh b/util/fetch-gnu.sh index f6cc6143d..7765bf544 100755 --- a/util/fetch-gnu.sh +++ b/util/fetch-gnu.sh @@ -21,6 +21,7 @@ curl -L ${repo}/raw/refs/heads/master/tests/env/env.sh > tests/env/env.sh # Avoid incorrect PASS 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 +curl -L ${repo}/raw/refs/heads/master/tests/tail/inotify-dir-recreate.sh > tests/tail/inotify-dir-recreate.sh # Add tac-continue.sh to root tests (it requires root to mount tmpfs) # Use sed -i.bak for macOS sed -i.bak 's|tests/split/l-chunk-root.sh.*|tests/split/l-chunk-root.sh\t\t\t\\\n tests/tac/tac-continue.sh\t\t\t\\|' tests/local.mk From 035671c98107a449fd58bec5a7e3de4890cce59e Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Tue, 20 Jan 2026 22:31:52 +0100 Subject: [PATCH 300/425] ci: generate the french locale for benchmark (#10398) --- .github/workflows/benchmarks.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index ec97e294e..ed6570571 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -62,6 +62,14 @@ jobs: - name: Run sccache-cache uses: mozilla-actions/sccache-action@v0.0.9 + - name: Install locales + shell: bash + run: | + sudo apt-get update + sudo apt-get install -y locales + sudo locale-gen fr_FR.UTF-8 + sudo update-locale + - name: Install cargo-codspeed shell: bash run: cargo install cargo-codspeed --locked From 5929dec5aceb951891e2e59ac1148a1e95c49e66 Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Tue, 20 Jan 2026 22:32:09 +0100 Subject: [PATCH 301/425] ci: disable memory profiling in benchmarks due to variance (#10399) --- .github/workflows/benchmarks.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index ed6570571..0f4b5881a 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -22,7 +22,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - type: [performance, memory] + type: [performance] # , memory] # memory profile disabled due to variance package: [ uu_base64, uu_cksum, From 5cb884fffe52471602aafb7419f93f34a705662b Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Wed, 21 Jan 2026 06:54:39 +0900 Subject: [PATCH 302/425] check-safe-traversal.sh: Support any profile (#10401) --- .github/workflows/CICD.yml | 2 +- util/check-safe-traversal.sh | 15 +++++++++------ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/.github/workflows/CICD.yml b/.github/workflows/CICD.yml index a96bf2130..263036fbf 100644 --- a/.github/workflows/CICD.yml +++ b/.github/workflows/CICD.yml @@ -1355,6 +1355,6 @@ jobs: - name: Install strace run: sudo apt-get update && sudo apt-get install -y strace - name: Build utilities with safe traversal - run: cargo build --release -p uu_rm -p uu_chmod -p uu_chown -p uu_chgrp -p uu_mv -p uu_du + run: cargo build --profile=release-small -p uu_rm -p uu_chmod -p uu_chown -p uu_chgrp -p uu_mv -p uu_du - name: Run safe traversal verification run: ./util/check-safe-traversal.sh diff --git a/util/check-safe-traversal.sh b/util/check-safe-traversal.sh index 3ce1574aa..0462f6b9e 100755 --- a/util/check-safe-traversal.sh +++ b/util/check-safe-traversal.sh @@ -6,6 +6,9 @@ set -e +: ${PROFILE:=release-small} +export PROFILE + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" TEMP_DIR=$(mktemp -d) @@ -27,15 +30,15 @@ echo "=== Safe Traversal Verification ===" # Assume binaries are already built (for CI usage) # Prefer individual binaries for more accurate testing -if [ -f "$PROJECT_ROOT/target/release/rm" ]; then +if [ -f "$PROJECT_ROOT/target/${PROFILE}/rm" ]; then echo "Using individual binaries" USE_MULTICALL=0 -elif [ -f "$PROJECT_ROOT/target/release/coreutils" ]; then +elif [ -f "$PROJECT_ROOT/target/${PROFILE}/coreutils" ]; then echo "Using multicall binary" USE_MULTICALL=1 - COREUTILS_BIN="$PROJECT_ROOT/target/release/coreutils" + COREUTILS_BIN="$PROJECT_ROOT/target/${PROFILE}/coreutils" else - echo "Error: No binaries found. Please build first with 'cargo build --release'" + echo "Error: No binaries found. Please build first with 'cargo build --profile=${PROFILE}'" exit 1 fi @@ -64,7 +67,7 @@ check_utility() { if [ "$USE_MULTICALL" -eq 1 ]; then local util_cmd="$COREUTILS_BIN $util" else - local util_path="$PROJECT_ROOT/target/release/$util" + local util_path="$PROJECT_ROOT/target/${PROFILE}/$util" if [ ! -f "$util_path" ]; then fail_immediately "$util binary not found at $util_path" fi @@ -157,7 +160,7 @@ if [ "$USE_MULTICALL" -eq 1 ]; then else AVAILABLE_UTILS="" for util in rm chmod chown chgrp du mv; do - if [ -f "$PROJECT_ROOT/target/release/$util" ]; then + if [ -f "$PROJECT_ROOT/target/${PROFILE}/$util" ]; then AVAILABLE_UTILS="$AVAILABLE_UTILS $util" fi done From fa24992b6d3f9b63866612db9aeca7974afbd9f3 Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Wed, 21 Jan 2026 02:41:31 +0100 Subject: [PATCH 303/425] unexpand: add support for extended tab stop syntax (+N and /N) (#9265) should fix tests/misc/unexpand.pl --- src/uu/unexpand/src/unexpand.rs | 320 ++++++++++++++++++++++++++++---- tests/by-util/test_unexpand.rs | 22 +++ 2 files changed, 306 insertions(+), 36 deletions(-) diff --git a/src/uu/unexpand/src/unexpand.rs b/src/uu/unexpand/src/unexpand.rs index 896318484..2ca0e8eae 100644 --- a/src/uu/unexpand/src/unexpand.rs +++ b/src/uu/unexpand/src/unexpand.rs @@ -34,27 +34,92 @@ enum ParseError { impl UError for ParseError {} -fn tabstops_parse(s: &str) -> Result, ParseError> { +fn parse_tab_num(word: &str, allow_zero: bool) -> Result { + match word.parse::() { + Ok(0) if !allow_zero => Err(ParseError::TabSizeCannotBeZero), + Ok(num) => Ok(num), + Err(e) => match e.kind() { + IntErrorKind::PosOverflow => Err(ParseError::TabSizeTooLarge), + _ => Err(ParseError::InvalidCharacter( + word.trim_start_matches(char::is_numeric).to_string(), + )), + }, + } +} + +fn parse_tabstops(s: &str) -> Result { let words = s.split(','); let mut nums = Vec::new(); + let mut increment_size: Option = None; + let mut extend_size: Option = None; for word in words { - match word.parse::() { - Ok(num) => nums.push(num), - Err(e) => { - return match e.kind() { - IntErrorKind::PosOverflow => Err(ParseError::TabSizeTooLarge), - _ => Err(ParseError::InvalidCharacter( - word.trim_start_matches(char::is_numeric).to_string(), - )), - }; + if word.is_empty() { + continue; + } + + // Handle extended syntax: +N (increment) and /N (repeat) + if let Some(word) = word.strip_prefix('+') { + // +N means N positions after the last tab stop (only allowed at end) + if increment_size.is_some() || extend_size.is_some() { + return Err(ParseError::InvalidCharacter("+".to_string())); } + let value = parse_tab_num(word, true)?; + if nums.is_empty() { + // Standalone +N: treat as tab stops at multiples of N + if value == 0 { + return Err(ParseError::TabSizeCannotBeZero); + } + return Ok(TabConfig { + tabstops: vec![value], + increment_size: None, + extend_size: None, + }); + } + increment_size = Some(value); + } else if let Some(word) = word.strip_prefix('/') { + // /N means repeat every N positions after the last tab stop + if increment_size.is_some() || extend_size.is_some() { + return Err(ParseError::InvalidCharacter("/".to_string())); + } + let value = parse_tab_num(word, true)?; + if nums.is_empty() { + // Standalone /N: treat as tab stops at multiples of N + if value == 0 { + return Err(ParseError::TabSizeCannotBeZero); + } + return Ok(TabConfig { + tabstops: vec![value], + increment_size: None, + extend_size: None, + }); + } + extend_size = Some(value); + } else { + // Regular number + if increment_size.is_some() || extend_size.is_some() { + return Err(ParseError::InvalidCharacter(word.to_string())); + } + nums.push(parse_tab_num(word, false)?); } } - if nums.contains(&0) { - return Err(ParseError::TabSizeCannotBeZero); + if nums.is_empty() && increment_size.is_none() && extend_size.is_none() { + return Ok(TabConfig { + tabstops: vec![DEFAULT_TABSTOP], + increment_size: None, + extend_size: None, + }); + } + + // Handle the increment if specified + // Only add an extra tab stop if increment is non-zero + if let Some(inc) = increment_size { + if inc > 0 { + let last = *nums.last().unwrap(); + nums.push(last + inc); + } } if let (false, _) = nums @@ -64,7 +129,11 @@ fn tabstops_parse(s: &str) -> Result, ParseError> { return Err(ParseError::TabSizesMustBeAscending); } - Ok(nums) + Ok(TabConfig { + tabstops: nums, + increment_size, + extend_size, + }) } mod options { @@ -75,18 +144,28 @@ mod options { pub const NO_UTF8: &str = "no-utf8"; } +struct TabConfig { + tabstops: Vec, + increment_size: Option, + extend_size: Option, +} + struct Options { files: Vec, - tabstops: Vec, + tab_config: TabConfig, aflag: bool, uflag: bool, } impl Options { fn new(matches: &clap::ArgMatches) -> Result { - let tabstops = match matches.get_many::(options::TABS) { - None => vec![DEFAULT_TABSTOP], - Some(s) => tabstops_parse(&s.map(|s| s.as_str()).collect::>().join(","))?, + let tab_config = match matches.get_many::(options::TABS) { + None => TabConfig { + tabstops: vec![DEFAULT_TABSTOP], + increment_size: None, + extend_size: None, + }, + Some(s) => parse_tabstops(&s.map(|s| s.as_str()).collect::>().join(","))?, }; let aflag = (matches.get_flag(options::ALL) || matches.contains_id(options::TABS)) @@ -100,7 +179,7 @@ impl Options { Ok(Self { files, - tabstops, + tab_config, aflag, uflag, }) @@ -216,19 +295,58 @@ fn open(path: &OsString) -> UResult>> { } } -fn next_tabstop(tabstops: &[usize], col: usize) -> Option { - if tabstops.len() == 1 { +fn next_tabstop(tab_config: &TabConfig, col: usize) -> Option { + let tabstops = &tab_config.tabstops; + + if tabstops.is_empty() { + return None; + } + + if tabstops.len() == 1 + && tab_config.increment_size.is_none() + && tab_config.extend_size.is_none() + { + // Simple case: single tab stop, repeat at that interval Some(tabstops[0] - col % tabstops[0]) } else { - // find next larger tab - // if there isn't one in the list, tab becomes a single space - tabstops.iter().find(|&&t| t > col).map(|t| t - col) + // Find next larger tab + if let Some(&next_tab) = tabstops.iter().find(|&&t| t > col) { + Some(next_tab - col) + } else { + // We're past the last explicit tab stop + if let Some(&last_tab) = tabstops.last() { + if let Some(extend_size) = tab_config.extend_size { + // /N: tab stops at multiples of N + if extend_size == 0 { + return None; + } + Some(extend_size - (col % extend_size)) + } else if let Some(increment_size) = tab_config.increment_size { + // +N: continue with increment after last tab stop + if increment_size == 0 || col < last_tab { + return None; + } + let distance_from_last = col - last_tab; + let remainder = distance_from_last % increment_size; + Some(if remainder == 0 { + increment_size + } else { + increment_size - remainder + }) + } else { + // No more tabs + None + } + } else { + None + } + } } } fn write_tabs( output: &mut BufWriter, - tabstops: &[usize], + tab_config: &TabConfig, mut scol: usize, col: usize, prevtab: bool, @@ -240,7 +358,7 @@ fn write_tabs( // a tab, unless it's at the start of the line. let ai = init || amode; if (ai && !prevtab && col > scol + 1) || (col > scol && (init || ai && prevtab)) { - while let Some(nts) = next_tabstop(tabstops, scol) { + while let Some(nts) = next_tabstop(tab_config, scol) { if col < scol + nts { break; } @@ -311,7 +429,7 @@ fn unexpand_line( output: &mut BufWriter, options: &Options, lastcol: usize, - ts: &[usize], + tab_config: &TabConfig, ) -> UResult<()> { // Fast path: if we're not converting all spaces (-a flag not set) // and the line doesn't start with spaces, just write it directly @@ -338,7 +456,7 @@ fn unexpand_line( byte += 1; } b'\t' => { - col += next_tabstop(ts, col).unwrap_or(1); + col += next_tabstop(tab_config, col).unwrap_or(1); byte += 1; pctype = CharType::Tab; } @@ -348,7 +466,15 @@ fn unexpand_line( // If we found spaces/tabs, write them as tabs if byte > 0 { - write_tabs(output, ts, 0, col, pctype == CharType::Tab, true, true)?; + write_tabs( + output, + tab_config, + 0, + col, + pctype == CharType::Tab, + true, + true, + )?; } // Write the rest of the line directly (no more tab conversion needed) @@ -362,7 +488,15 @@ fn unexpand_line( while byte < buf.len() { // when we have a finite number of columns, never convert past the last column if lastcol > 0 && col >= lastcol { - write_tabs(output, ts, scol, col, pctype == CharType::Tab, init, true)?; + write_tabs( + output, + tab_config, + scol, + col, + pctype == CharType::Tab, + init, + true, + )?; output.write_all(&buf[byte..])?; scol = col; break; @@ -379,7 +513,7 @@ fn unexpand_line( col += if ctype == CharType::Space { 1 } else { - next_tabstop(ts, col).unwrap_or(1) + next_tabstop(tab_config, col).unwrap_or(1) }; if !tabs_buffered { @@ -391,7 +525,7 @@ fn unexpand_line( // always write_tabs( output, - ts, + tab_config, scol, col, pctype == CharType::Tab, @@ -418,7 +552,15 @@ fn unexpand_line( } // write out anything remaining - write_tabs(output, ts, scol, col, pctype == CharType::Tab, init, true)?; + write_tabs( + output, + tab_config, + scol, + col, + pctype == CharType::Tab, + init, + true, + )?; buf.truncate(0); // clear out the buffer Ok(()) @@ -426,9 +568,16 @@ fn unexpand_line( fn unexpand(options: &Options) -> UResult<()> { let mut output = BufWriter::new(stdout()); - let ts = &options.tabstops[..]; + let tab_config = &options.tab_config; let mut buf = Vec::new(); - let lastcol = if ts.len() > 1 { *ts.last().unwrap() } else { 0 }; + let lastcol = if tab_config.tabstops.len() > 1 + && tab_config.increment_size.is_none() + && tab_config.extend_size.is_none() + { + *tab_config.tabstops.last().unwrap() + } else { + 0 + }; for file in &options.files { let mut fh = match open(file) { @@ -443,7 +592,7 @@ fn unexpand(options: &Options) -> UResult<()> { Ok(s) => s > 0, Err(_) => !buf.is_empty(), } { - unexpand_line(&mut buf, &mut output, options, lastcol, ts)?; + unexpand_line(&mut buf, &mut output, options, lastcol, tab_config)?; } } output.flush()?; @@ -452,7 +601,7 @@ fn unexpand(options: &Options) -> UResult<()> { #[cfg(test)] mod tests { - use crate::is_digit_or_comma; + use crate::{ParseError, is_digit_or_comma, parse_tab_num, parse_tabstops}; #[test] fn test_is_digit_or_comma() { @@ -460,4 +609,103 @@ mod tests { assert!(is_digit_or_comma(',')); assert!(!is_digit_or_comma('a')); } + + #[test] + fn test_parse_tab_num() { + assert_eq!(parse_tab_num("6", false).unwrap(), 6); + assert_eq!(parse_tab_num("12", false).unwrap(), 12); + assert_eq!(parse_tab_num("9", false).unwrap(), 9); + assert_eq!(parse_tab_num("4", false).unwrap(), 4); + } + + #[test] + fn test_parse_tab_num_errors() { + // Zero is not allowed when allow_zero is false + assert!(matches!( + parse_tab_num("0", false), + Err(ParseError::TabSizeCannotBeZero) + )); + + // Zero is allowed when allow_zero is true + assert_eq!(parse_tab_num("0", true).unwrap(), 0); + + // Invalid character + assert!(matches!( + parse_tab_num("6x", false), + Err(ParseError::InvalidCharacter(_)) + )); + + // Invalid character + assert!(matches!( + parse_tab_num("9y", false), + Err(ParseError::InvalidCharacter(_)) + )); + } + + #[test] + fn test_parse_tabstops_extended_syntax() { + // Standalone +N is now allowed (treated as multiples of N) + let config = parse_tabstops("+6").unwrap(); + assert_eq!(config.tabstops, vec![6]); + assert_eq!(config.increment_size, None); + assert_eq!(config.extend_size, None); + + // Standalone /N is now allowed (treated as multiples of N) + let config = parse_tabstops("/9").unwrap(); + assert_eq!(config.tabstops, vec![9]); + assert_eq!(config.increment_size, None); + assert_eq!(config.extend_size, None); + + // +0 and /0 are not allowed as standalone + assert!(matches!( + parse_tabstops("+0"), + Err(ParseError::TabSizeCannotBeZero) + )); + assert!(matches!( + parse_tabstops("/0"), + Err(ParseError::TabSizeCannotBeZero) + )); + + // Valid +N with previous tab stop + let config = parse_tabstops("3,+6").unwrap(); + assert_eq!(config.tabstops, vec![3, 9]); + assert_eq!(config.increment_size, Some(6)); + + // Valid /N with previous tab stop + let config = parse_tabstops("3,/4").unwrap(); + assert_eq!(config.tabstops, vec![3]); + assert_eq!(config.extend_size, Some(4)); + + // +0 with previous tab stop should be allowed + let config = parse_tabstops("3,+0").unwrap(); + assert_eq!(config.tabstops, vec![3]); + assert_eq!(config.increment_size, Some(0)); + + // /0 with previous tab stop should be allowed + let config = parse_tabstops("3,/0").unwrap(); + assert_eq!(config.tabstops, vec![3]); + assert_eq!(config.extend_size, Some(0)); + } + + #[test] + fn test_next_tabstop_with_increment() { + use crate::{next_tabstop, parse_tabstops}; + + // Test with "3,+6" configuration + let config = parse_tabstops("3,+6").unwrap(); + + // Verify the parsed configuration + assert_eq!(config.tabstops, vec![3, 9]); + assert_eq!(config.increment_size, Some(6)); + + // Tab stops should be at 3, 9, 15, 21, ... + assert_eq!(next_tabstop(&config, 0), Some(3)); // 0 → 3 + assert_eq!(next_tabstop(&config, 1), Some(2)); // 1 → 3 + assert_eq!(next_tabstop(&config, 2), Some(1)); // 2 → 3 + assert_eq!(next_tabstop(&config, 3), Some(6)); // 3 → 9 + assert_eq!(next_tabstop(&config, 4), Some(5)); // 4 → 9 + assert_eq!(next_tabstop(&config, 8), Some(1)); // 8 → 9 + assert_eq!(next_tabstop(&config, 9), Some(6)); // 9 → 15 + assert_eq!(next_tabstop(&config, 15), Some(6)); // 15 → 21 + } } diff --git a/tests/by-util/test_unexpand.rs b/tests/by-util/test_unexpand.rs index 0720dabb0..1029d67e6 100644 --- a/tests/by-util/test_unexpand.rs +++ b/tests/by-util/test_unexpand.rs @@ -307,3 +307,25 @@ fn unexpand_multibyte_utf8_gnu_compat() { .succeeds() .stdout_is("1ΔΔΔ5 99999\n"); } + +#[test] +fn test_blanks_ext1() { + // Test case from GNU test suite: blanks-ext1 + // ['blanks-ext1', '-t', '3,+6', {IN=> "\t "}, {OUT=> "\t\t"}], + new_ucmd!() + .args(&["-t", "3,+6"]) + .pipe_in("\t ") + .succeeds() + .stdout_is("\t\t"); +} + +#[test] +fn test_blanks_ext2() { + // Test case from GNU test suite: blanks-ext2 + // ['blanks-ext2', '-t', '3,/9', {IN=> "\t "}, {OUT=> "\t\t"}], + new_ucmd!() + .args(&["-t", "3,/9"]) + .pipe_in("\t ") + .succeeds() + .stdout_is("\t\t"); +} From 00f77cc7081f8e6837271d21b4fe8ca6d2f047c3 Mon Sep 17 00:00:00 2001 From: Fan Mo Date: Tue, 20 Jan 2026 20:09:01 -0600 Subject: [PATCH 304/425] mkfifo: do not change permission when failed to create (#10376) * mkfifo: do not change permission when failed to create --------- Co-authored-by: Sylvestre Ledru --- src/uu/mkfifo/src/mkfifo.rs | 1 + tests/by-util/test_mkfifo.rs | 34 +++++++++++++++++++++++++++++----- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/src/uu/mkfifo/src/mkfifo.rs b/src/uu/mkfifo/src/mkfifo.rs index 3586eb7c3..607b8a9aa 100644 --- a/src/uu/mkfifo/src/mkfifo.rs +++ b/src/uu/mkfifo/src/mkfifo.rs @@ -44,6 +44,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { 1, translate!("mkfifo-error-cannot-create-fifo", "path" => f.quote()), )); + continue; } // Explicitly set the permissions to ignore umask diff --git a/tests/by-util/test_mkfifo.rs b/tests/by-util/test_mkfifo.rs index ac0b78b3a..35869fe5d 100644 --- a/tests/by-util/test_mkfifo.rs +++ b/tests/by-util/test_mkfifo.rs @@ -137,11 +137,9 @@ fn test_create_fifo_permission_denied() { at.mkdir(no_exec_dir); at.set_mode(no_exec_dir, 0o644); - let err_msg = format!( - "mkfifo: cannot create fifo '{named_pipe}': File exists -mkfifo: cannot set permissions on '{named_pipe}': Permission denied (os error 13) -" - ); + // We no longer attempt to modify file permission if the file was failed to be created. + // Therefore the error message should only contain "cannot create". + let err_msg = format!("mkfifo: cannot create fifo '{named_pipe}': File exists\n"); scene .ucmd() @@ -199,3 +197,29 @@ fn test_mkfifo_selinux_invalid() { } } } + +#[test] +fn test_mkfifo_permission_unchanged_when_failed() { + use uucore::fs::display_permissions; + + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + let file_name = "test_file"; + at.write(file_name, "content"); + at.set_mode(file_name, 0o600); + + let err_msg = format!("mkfifo: cannot create fifo '{file_name}': File exists\n"); + + scene + .ucmd() + .arg(file_name) + .arg("-m") + .arg("666") + .fails() + .stderr_is(err_msg.as_str()); + let metadata = std::fs::metadata(at.subdir.join(file_name)).unwrap(); + let permissions = display_permissions(&metadata, true); + let expected = "-rw-------"; + assert_eq!(permissions, expected.to_string()); +} From 0eb2ade7e2418cdbd7428d3b0cc7a749b8b4e453 Mon Sep 17 00:00:00 2001 From: Chris Dryden Date: Wed, 21 Jan 2026 01:54:05 -0500 Subject: [PATCH 305/425] unexpand: fix +0 and /0 handling, add integration tests (#10406) --- src/uu/unexpand/src/unexpand.rs | 4 ++-- tests/by-util/test_unexpand.rs | 28 ++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/uu/unexpand/src/unexpand.rs b/src/uu/unexpand/src/unexpand.rs index 2ca0e8eae..1840f659c 100644 --- a/src/uu/unexpand/src/unexpand.rs +++ b/src/uu/unexpand/src/unexpand.rs @@ -303,8 +303,8 @@ fn next_tabstop(tab_config: &TabConfig, col: usize) -> Option { } if tabstops.len() == 1 - && tab_config.increment_size.is_none() - && tab_config.extend_size.is_none() + && !matches!(tab_config.increment_size, Some(n) if n > 0) + && !matches!(tab_config.extend_size, Some(n) if n > 0) { // Simple case: single tab stop, repeat at that interval Some(tabstops[0] - col % tabstops[0]) diff --git a/tests/by-util/test_unexpand.rs b/tests/by-util/test_unexpand.rs index 1029d67e6..fdba510c3 100644 --- a/tests/by-util/test_unexpand.rs +++ b/tests/by-util/test_unexpand.rs @@ -329,3 +329,31 @@ fn test_blanks_ext2() { .succeeds() .stdout_is("\t\t"); } + +#[test] +fn test_extended_tabstop_syntax() { + let test_cases = [ + // Standalone /N: tabs at multiples of N + ("-t /9", " ", "\t"), // 9 spaces -> 1 tab + ("-t /9", " ", "\t\t"), // 18 spaces -> 2 tabs + // Standalone +N: tabs at multiples of N + ("-t +6", " ", "\t"), // 6 spaces -> 1 tab + ("-t +6", " ", "\t\t"), // 12 spaces -> 2 tabs + // 3,/0 and 3,+0 should behave like just 3 + ("-t 3,/0", " ", "\t\t\t "), // 10 spaces -> 3 tabs + 1 space + ("-t 3,+0", " ", "\t\t\t "), // 10 spaces -> 3 tabs + 1 space + ("-t 3", " ", "\t\t\t "), // 10 spaces -> 3 tabs + 1 space + // 3,/0 with text + ("-t 3,/0", " test", "\ttest"), // 3 spaces + text -> 1 tab + text + // 3,+6 means tab stops at 3, 9, 15, 21, ... + ("-t 3,+6", " ", "\t\t\t "), // 20 spaces -> 3 tabs + 5 spaces + ]; + + for (args, input, expected) in test_cases { + new_ucmd!() + .args(&args.split_whitespace().collect::>()) + .pipe_in(input) + .succeeds() + .stdout_is(expected); + } +} From 3026d0d1592a8a22bff78349de18f20aedae5a56 Mon Sep 17 00:00:00 2001 From: Fan Mo Date: Wed, 21 Jan 2026 01:05:31 -0600 Subject: [PATCH 306/425] mkfifo: error when non-file permission mode set (#10372) --------- Co-authored-by: Chris Dryden --- src/uu/mkfifo/locales/en-US.ftl | 1 + src/uu/mkfifo/locales/fr-FR.ftl | 1 + src/uu/mkfifo/src/mkfifo.rs | 9 +++++++++ tests/by-util/test_mkfifo.rs | 16 ++++++++++++++++ 4 files changed, 27 insertions(+) diff --git a/src/uu/mkfifo/locales/en-US.ftl b/src/uu/mkfifo/locales/en-US.ftl index 2a02e7d0d..c6dcae831 100644 --- a/src/uu/mkfifo/locales/en-US.ftl +++ b/src/uu/mkfifo/locales/en-US.ftl @@ -11,3 +11,4 @@ mkfifo-error-invalid-mode = invalid mode: { $error } mkfifo-error-missing-operand = missing operand mkfifo-error-cannot-create-fifo = cannot create fifo { $path }: File exists mkfifo-error-cannot-set-permissions = cannot set permissions on { $path }: { $error } +mkfifo-error-non-file-permission = mode must specify only file permission bits diff --git a/src/uu/mkfifo/locales/fr-FR.ftl b/src/uu/mkfifo/locales/fr-FR.ftl index d47722463..14cfc6dd5 100644 --- a/src/uu/mkfifo/locales/fr-FR.ftl +++ b/src/uu/mkfifo/locales/fr-FR.ftl @@ -11,3 +11,4 @@ mkfifo-error-invalid-mode = mode invalide : { $error } mkfifo-error-missing-operand = opérande manquant mkfifo-error-cannot-create-fifo = impossible de créer le fifo { $path } : Le fichier existe mkfifo-error-cannot-set-permissions = impossible de définir les permissions sur { $path } : { $error } +mkfifo-error-non-file-permission = le mode ne doit spécifier que des bits de permission de fichier diff --git a/src/uu/mkfifo/src/mkfifo.rs b/src/uu/mkfifo/src/mkfifo.rs index 607b8a9aa..82c5ec1c6 100644 --- a/src/uu/mkfifo/src/mkfifo.rs +++ b/src/uu/mkfifo/src/mkfifo.rs @@ -28,6 +28,15 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { let mode = calculate_mode(matches.get_one::(options::MODE)) .map_err(|e| USimpleError::new(1, translate!("mkfifo-error-invalid-mode", "error" => e)))?; + // Check if mode contains special bits + let non_file_permission_bits = 0o7000; // setuid, setgid, sticky bits + if mode & non_file_permission_bits != 0 { + return Err(USimpleError::new( + 1, + translate!("mkfifo-error-non-file-permission"), + )); + } + let fifos: Vec = match matches.get_many::(options::FIFO) { Some(v) => v.cloned().collect(), None => { diff --git a/tests/by-util/test_mkfifo.rs b/tests/by-util/test_mkfifo.rs index 35869fe5d..4d19da636 100644 --- a/tests/by-util/test_mkfifo.rs +++ b/tests/by-util/test_mkfifo.rs @@ -43,6 +43,22 @@ fn test_create_one_fifo_with_invalid_mode() { .stderr_contains("invalid mode"); } +#[test] +fn test_create_one_fifo_with_non_file_permission_mode() { + new_ucmd!() + .arg("abcd") + .arg("-m") + .arg("1777") + .fails() + .stderr_is("mkfifo: mode must specify only file permission bits\n"); + new_ucmd!() + .arg("abcd") + .arg("-m") + .arg("1999") + .fails() + .stderr_contains("invalid mode"); +} + #[test] fn test_create_multiple_fifos() { new_ucmd!() From cbbff3014c3034d359644daf63362eafdc011ea4 Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Wed, 21 Jan 2026 19:48:05 +0900 Subject: [PATCH 307/425] Merge pull request #10353 from oech3/patch-7 android.yml: Drop x86 --- .github/workflows/android.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 434313fe7..174c6e7cc 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -39,7 +39,7 @@ jobs: ram: [4096] api-level: [28] target: [google_apis_playstore] - arch: [x86, x86_64] # , arm64-v8a + arch: [x86_64] # ,x86 ,arm64-v8a runs-on: ${{ matrix.os }} env: EMULATOR_RAM_SIZE: ${{ matrix.ram }} From 53d3176f6ca8d4eff5454ebea5b89ba2e3c066df Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Wed, 21 Jan 2026 18:36:19 +0900 Subject: [PATCH 308/425] Reduce duplicated management of supported utils --- Cargo.toml | 1 - GNUmakefile | 99 ++-------------------------------------------- util/show-utils.sh | 4 +- 3 files changed, 6 insertions(+), 98 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index e1f7083cd..7af13f510 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -154,7 +154,6 @@ feat_common_core = [ # "feat_Tier1" == expanded set of utilities which can be built/run on the usual rust "Tier 1" target platforms (ref: ) feat_Tier1 = [ "feat_common_core", - # "arch", "hostname", "nproc", diff --git a/GNUmakefile b/GNUmakefile index fa824d626..cae34803b 100644 --- a/GNUmakefile +++ b/GNUmakefile @@ -78,106 +78,15 @@ endif LN ?= ln -sf # Possible programs -PROGS := \ - arch \ - base32 \ - base64 \ - basenc \ - basename \ - cat \ - cksum \ - comm \ - cp \ - csplit \ - cut \ - date \ - dd \ - df \ - dir \ - dircolors \ - dirname \ - du \ - echo \ - env \ - expand \ - expr \ - factor \ - false \ - fmt \ - fold \ - hashsum \ - head \ - hostname \ - join \ - link \ - ln \ - ls \ - mkdir \ - mktemp \ - more \ - mv \ - nl \ - numfmt \ - nproc \ - od \ - paste \ - pr \ - printenv \ - printf \ - ptx \ - pwd \ - readlink \ - realpath \ - rm \ - rmdir \ - seq \ - shred \ - shuf \ - sleep \ - sort \ - split \ - sum \ - sync \ - tac \ - tail \ - tee \ - test \ - touch \ - tr \ - true \ - truncate \ - tsort \ - uname \ - unexpand \ - uniq \ - unlink \ - vdir \ - wc \ - whoami \ - yes +PROGS := \ + $(shell sed -n '/feat_Tier1 = \[/,/\]/p' Cargo.toml | sed '1d;2d' |tr -d '],"\n')\ + $(shell sed -n '/feat_common_core = \[/,/\]/p' Cargo.toml | sed '1d' |tr -d '],"\n') UNIX_PROGS := \ - chgrp \ - chmod \ - chown \ - chroot \ - groups \ + $(shell sed -n '/feat_require_unix_core = \[/,/\]/p' Cargo.toml | sed '1d' |tr -d '],"\n') \ hostid \ - id \ - install \ - kill \ - logname \ - mkfifo \ - mknod \ - nice \ - nohup \ - pathchk \ pinky \ - stat \ stdbuf \ - stty \ - timeout \ - tty \ uptime \ users \ who diff --git a/util/show-utils.sh b/util/show-utils.sh index 3cc487940..66266e7a9 100755 --- a/util/show-utils.sh +++ b/util/show-utils.sh @@ -14,8 +14,8 @@ ME_parent_dir_abs="$("${REALPATH}" -mP -- "${ME_parent_dir}" || "${REALPATH}" -- # refs: , -# default ("Tier 1" cross-platform) utility list -default_utils="base32 base64 basename cat cksum comm cp cut date dircolors dirname echo env expand expr factor false fmt fold hashsum head join link ln ls mkdir mktemp more mv nl od paste printenv printf ptx pwd readlink realpath rm rmdir seq shred shuf sleep sort split sum tac tail tee test tr true truncate tsort unexpand uniq wc yes" +# default utility list +default_utils=$(sed -n '/feat_common_core = \[/,/\]/p' Cargo.toml | sed '1d' |tr -d '],"\n') # $(sed -n '/feat_Tier1 = \[/,/\]/p' Cargo.toml | sed '1d;2d' |tr -d '],"\n') too? project_main_dir="${ME_parent_dir_abs}" # printf 'project_main_dir="%s"\n' "${project_main_dir}" From f388214d4753a53dd5afdd368bc550741fee9e64 Mon Sep 17 00:00:00 2001 From: Dhruv <62135445+dhr412@users.noreply.github.com> Date: Wed, 21 Jan 2026 19:56:53 +0530 Subject: [PATCH 309/425] wc: fix word undercount with invalid byte sequences (#10348) * wc: fix word undercount with invalid byte sequences * wc: update utf8 test counts to account invalid byte sequences * wc: remove unnecessary borrow in test --- src/uu/wc/src/wc.rs | 12 ++++++++++-- tests/by-util/test_wc.rs | 12 +++++++++++- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/uu/wc/src/wc.rs b/src/uu/wc/src/wc.rs index 854849182..62bf5c77f 100644 --- a/src/uu/wc/src/wc.rs +++ b/src/uu/wc/src/wc.rs @@ -624,10 +624,18 @@ fn process_chunk< total.max_line_length = max(*current_len, total.max_line_length); } -fn handle_error(error: BufReadDecoderError<'_>, total: &mut WordCount) -> Option { +fn handle_error( + error: BufReadDecoderError<'_>, + total: &mut WordCount, + in_word: &mut bool, +) -> Option { match error { BufReadDecoderError::InvalidByteSequence(bytes) => { total.bytes += bytes.len(); + if !(*in_word) { + *in_word = true; + total.words += 1; + } } BufReadDecoderError::Io(e) => return Some(e), } @@ -660,7 +668,7 @@ fn word_count_from_reader_specialized< ); } Err(e) => { - if let Some(e) = handle_error(e, &mut total) { + if let Some(e) = handle_error(e, &mut total, &mut in_word) { return (total, Some(e)); } } diff --git a/tests/by-util/test_wc.rs b/tests/by-util/test_wc.rs index 6901cf1e4..d62c1da6e 100644 --- a/tests/by-util/test_wc.rs +++ b/tests/by-util/test_wc.rs @@ -65,7 +65,7 @@ fn test_utf8() { .args(&["-lwmcL"]) .pipe_in_fixture("UTF_8_test.txt") .succeeds() - .stdout_is(" 303 2119 22457 23025 79\n"); + .stdout_is(" 303 2178 22457 23025 79\n"); } #[test] @@ -826,6 +826,16 @@ fn wc_w_words_with_emoji_separator() { .stdout_contains("3"); } +#[test] +fn test_invalid_byte_sequence_word_count() { + // wc should count invalid byte sequences as words + // Input: "a \xff b\n" should produce: 1 line, 3 words, 6 bytes + new_ucmd!() + .pipe_in([b'a', b' ', 0xff, b' ', b'b', b'\n']) + .succeeds() + .stdout_is(" 1 3 6\n"); +} + #[cfg(unix)] #[test] fn test_simd_respects_glibc_tunables() { From 97c3d7e2402a561468764ac568dcb1b7f4c9d325 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 21 Jan 2026 14:28:23 +0000 Subject: [PATCH 310/425] chore(deps): update rust crate divan to v4.3.0 --- Cargo.lock | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index be10693f6..e92defb61 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -374,9 +374,9 @@ dependencies = [ [[package]] name = "codspeed" -version = "4.2.1" +version = "4.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0d98d97fd75ca4489a1a0997820a6521531085e7c8a98941bd0e1264d567dd" +checksum = "38c2eb3388ebe26b5a0ab6bf4969d9c4840143d7f6df07caa3cc851b0606cef6" dependencies = [ "anyhow", "cc", @@ -392,9 +392,9 @@ dependencies = [ [[package]] name = "codspeed-divan-compat" -version = "4.2.1" +version = "4.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4179ec5518e79efcd02ed50aa483ff807902e43c85146e87fff58b9cffc06078" +checksum = "b2de65b7489a59709724d489070c6d05b7744039e4bf751d0a2006b90bb5593d" dependencies = [ "clap", "codspeed", @@ -405,9 +405,9 @@ dependencies = [ [[package]] name = "codspeed-divan-compat-macros" -version = "4.2.1" +version = "4.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15eaee97aa5bceb32cc683fe25cd6373b7fc48baee5c12471996b58b6ddf0d7c" +checksum = "56ca01ce4fd22b8dcc6c770dcd6b74343642e842482b94e8920d14e10c57638d" dependencies = [ "divan-macros", "itertools 0.14.0", @@ -419,9 +419,9 @@ dependencies = [ [[package]] name = "codspeed-divan-compat-walltime" -version = "4.2.1" +version = "4.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c38671153aa73be075d6019cab5ab1e6b31d36644067c1ac4cef73bf9723ce33" +checksum = "720ab9d0714718afe5f5832be6e5f5eb5ce97836e24ca7bf7042eea4308b9fb8" dependencies = [ "cfg-if", "clap", From 401eb89d183e63ea58035b802e8c136d073603c1 Mon Sep 17 00:00:00 2001 From: not-matthias Date: Wed, 21 Jan 2026 16:20:51 +0100 Subject: [PATCH 311/425] fix(ci): remove outdated `analysis` mode --- .github/workflows/benchmarks.yml | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index 0f4b5881a..8643721f3 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -22,7 +22,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - type: [performance] # , memory] # memory profile disabled due to variance + type: [simulation] # , memory] # memory profile disabled due to variance package: [ uu_base64, uu_cksum, @@ -78,18 +78,14 @@ jobs: shell: bash run: | echo "Building ${{ matrix.type }} benchmarks for ${{ matrix.package }}" - if [ "${{ matrix.type }}" = "memory" ]; then - cargo codspeed build -m analysis -p ${{ matrix.package }} - else - cargo codspeed build -p ${{ matrix.package }} - fi + cargo codspeed build -m ${{ matrix.type }} -p ${{ matrix.package }} - name: Run ${{ matrix.type }} benchmarks for ${{ matrix.package }} uses: CodSpeedHQ/action@v4 env: CODSPEED_LOG: debug with: - mode: ${{ matrix.type == 'memory' && 'memory' || 'simulation' }} + mode: ${{ matrix.type }} run: | echo "Running ${{ matrix.type }} benchmarks for ${{ matrix.package }}" cargo codspeed run -p ${{ matrix.package }} > /dev/null From 376d5b26c7db4d661a5a28ba4c0090cf0e33114a Mon Sep 17 00:00:00 2001 From: Chris Dryden Date: Wed, 21 Jan 2026 11:54:38 -0500 Subject: [PATCH 312/425] uucore: centralize SIGPIPE handling in main macro (#10354) Instead of adding this code to every utility, hoping to make it centralized and then addressing all of the special cases in the individual utilities. There's definitely a chance that a special case is missed and could be a regression, but this should solve a much larger number of issues for all utilities. Fixes https://github.com/uutils/coreutils/issues/10325 Fixes https://github.com/uutils/coreutils/issues/10260 Fixes https://github.com/uutils/coreutils/issues/10230 Fixes https://github.com/uutils/coreutils/issues/10214 Fixes https://github.com/uutils/coreutils/issues/9936 Fixes https://github.com/uutils/coreutils/issues/8919 Fixes https://github.com/uutils/coreutils/issues/7252 Fixes https://github.com/uutils/coreutils/issues/4965 --- src/uu/cat/src/cat.rs | 7 ------- src/uu/dd/src/dd.rs | 3 --- src/uu/env/src/env.rs | 4 ---- src/uu/seq/src/seq.rs | 14 -------------- src/uu/split/src/split.rs | 11 ++++++++++- src/uu/tac/src/tac.rs | 2 -- src/uu/tail/src/tail.rs | 10 ---------- src/uu/tee/src/tee.rs | 6 +++--- src/uu/timeout/src/timeout.rs | 7 ------- src/uu/tr/src/tr.rs | 7 ------- src/uu/tty/src/tty.rs | 5 +++++ src/uu/yes/src/yes.rs | 4 ---- src/uucore/Cargo.toml | 2 +- src/uucore/src/lib/features/signals.rs | 11 ++++++++++- src/uucore_procs/src/lib.rs | 20 ++++++++++++++++++++ 15 files changed, 49 insertions(+), 64 deletions(-) diff --git a/src/uu/cat/src/cat.rs b/src/uu/cat/src/cat.rs index ff53df767..eeec89025 100644 --- a/src/uu/cat/src/cat.rs +++ b/src/uu/cat/src/cat.rs @@ -218,13 +218,6 @@ mod options { #[uucore::main] pub fn uumain(args: impl uucore::Args) -> UResult<()> { - // When we receive a SIGPIPE signal, we want to terminate the process so - // that we don't print any error messages to stderr. Rust ignores SIGPIPE - // (see https://github.com/rust-lang/rust/issues/62569), so we restore it's - // default action here. - #[cfg(not(target_os = "windows"))] - let _ = uucore::signals::enable_pipe_errors(); - let matches = uucore::clap_localization::handle_clap_result(uu_app(), args)?; let number_mode = if matches.get_flag(options::NUMBER_NONBLANK) { diff --git a/src/uu/dd/src/dd.rs b/src/uu/dd/src/dd.rs index 45fdf6f3d..fc1adc537 100644 --- a/src/uu/dd/src/dd.rs +++ b/src/uu/dd/src/dd.rs @@ -5,9 +5,6 @@ // spell-checker:ignore fname, ftype, tname, fpath, specfile, testfile, unspec, ifile, ofile, outfile, fullblock, urand, fileio, atoe, atoibm, behaviour, bmax, bremain, cflags, creat, ctable, ctty, datastructures, doesnt, etoa, fileout, fname, gnudd, iconvflags, iseek, nocache, noctty, noerror, nofollow, nolinks, nonblock, oconvflags, oseek, outfile, parseargs, rlen, rmax, rremain, rsofar, rstat, sigusr, wlen, wstat seekable oconv canonicalized fadvise Fadvise FADV DONTNEED ESPIPE bufferedoutput, SETFL -#[cfg(unix)] -uucore::init_startup_state_capture!(); - mod blocks; mod bufferedoutput; mod conversion_tables; diff --git a/src/uu/env/src/env.rs b/src/uu/env/src/env.rs index 40f32b765..a5dd8a8d7 100644 --- a/src/uu/env/src/env.rs +++ b/src/uu/env/src/env.rs @@ -1095,10 +1095,6 @@ fn list_signal_handling(log: &SignalActionLog) { #[uucore::main] pub fn uumain(args: impl uucore::Args) -> UResult<()> { - // Rust ignores SIGPIPE (see https://github.com/rust-lang/rust/issues/62569). - // We restore its default action here. - #[cfg(unix)] - let _ = uucore::signals::enable_pipe_errors(); EnvAppData::default().run_env(args) } diff --git a/src/uu/seq/src/seq.rs b/src/uu/seq/src/seq.rs index b931cc8b1..29373a511 100644 --- a/src/uu/seq/src/seq.rs +++ b/src/uu/seq/src/seq.rs @@ -92,22 +92,8 @@ fn select_precision( } } -// Initialize SIGPIPE state capture at process startup (Unix only) -#[cfg(unix)] -uucore::init_startup_state_capture!(); - #[uucore::main] pub fn uumain(args: impl uucore::Args) -> UResult<()> { - // Restore SIGPIPE to default if it wasn't explicitly ignored by parent. - // The Rust runtime ignores SIGPIPE, but we need to respect the parent's - // signal disposition for proper pipeline behavior (GNU compatibility). - #[cfg(unix)] - if !signals::sigpipe_was_ignored() { - // Ignore the return value: if setting signal handler fails, we continue anyway. - // The worst case is we don't get proper SIGPIPE behavior, but seq will still work. - let _ = signals::enable_pipe_errors(); - } - let matches = uucore::clap_localization::handle_clap_result(uu_app(), split_short_args_with_value(args))?; diff --git a/src/uu/split/src/split.rs b/src/uu/split/src/split.rs index 6f290a7d5..c40a6a07e 100644 --- a/src/uu/split/src/split.rs +++ b/src/uu/split/src/split.rs @@ -54,7 +54,16 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { let matches = uucore::clap_localization::handle_clap_result(uu_app(), args)?; match Settings::from(&matches, obs_lines.as_deref()) { - Ok(settings) => split(&settings), + Ok(settings) => { + // When using --filter, we write to a child process's stdin which may + // close early. Disable SIGPIPE so we get EPIPE errors instead of + // being terminated, allowing graceful handling of broken pipes. + #[cfg(unix)] + if settings.filter.is_some() { + let _ = uucore::signals::disable_pipe_errors(); + } + split(&settings) + } Err(e) if e.requires_usage() => Err(UUsageError::new(1, format!("{e}"))), Err(e) => Err(USimpleError::new(1, format!("{e}"))), } diff --git a/src/uu/tac/src/tac.rs b/src/uu/tac/src/tac.rs index e1686f459..cba911a7e 100644 --- a/src/uu/tac/src/tac.rs +++ b/src/uu/tac/src/tac.rs @@ -4,8 +4,6 @@ // file that was distributed with this source code. // spell-checker:ignore (ToDO) sbytes slen dlen memmem memmap Mmap mmap SIGBUS -#[cfg(unix)] -uucore::init_startup_state_capture!(); mod error; diff --git a/src/uu/tail/src/tail.rs b/src/uu/tail/src/tail.rs index c1cfb333a..7b82e9566 100644 --- a/src/uu/tail/src/tail.rs +++ b/src/uu/tail/src/tail.rs @@ -38,18 +38,8 @@ use uucore::translate; use uucore::{show, show_error}; -#[cfg(unix)] -uucore::init_startup_state_capture!(); - #[uucore::main] pub fn uumain(args: impl uucore::Args) -> UResult<()> { - // When we receive a SIGPIPE signal, we want to terminate the process so - // that we don't print any error messages to stderr. Rust ignores SIGPIPE - // (see https://github.com/rust-lang/rust/issues/62569), so we restore it's - // default action here. - #[cfg(not(target_os = "windows"))] - let _ = uucore::signals::enable_pipe_errors(); - let settings = parse_args(args)?; settings.check_warnings(); diff --git a/src/uu/tee/src/tee.rs b/src/uu/tee/src/tee.rs index cf3d89c0a..026f7fd95 100644 --- a/src/uu/tee/src/tee.rs +++ b/src/uu/tee/src/tee.rs @@ -19,7 +19,7 @@ use uucore::{format_usage, show_error}; #[cfg(target_os = "linux")] use uucore::signals::ensure_stdout_not_broken; #[cfg(unix)] -use uucore::signals::{enable_pipe_errors, ignore_interrupts}; +use uucore::signals::{disable_pipe_errors, ignore_interrupts}; mod options { pub const APPEND: &str = "append"; @@ -163,8 +163,8 @@ fn tee(options: &Options) -> Result<()> { if options.ignore_interrupts { ignore_interrupts().map_err(|_| Error::from(ErrorKind::Other))?; } - if options.output_error.is_none() { - enable_pipe_errors().map_err(|_| Error::from(ErrorKind::Other))?; + if options.output_error.is_some() { + disable_pipe_errors().map_err(|_| Error::from(ErrorKind::Other))?; } } let mut writers: Vec = options diff --git a/src/uu/timeout/src/timeout.rs b/src/uu/timeout/src/timeout.rs index 22c839c42..2a917ae79 100644 --- a/src/uu/timeout/src/timeout.rs +++ b/src/uu/timeout/src/timeout.rs @@ -4,8 +4,6 @@ // file that was distributed with this source code. // spell-checker:ignore (ToDO) tstr sigstr cmdname setpgid sigchld getpid -#[cfg(unix)] -uucore::init_startup_state_capture!(); mod status; @@ -22,9 +20,6 @@ use uucore::parser::parse_time; use uucore::process::ChildExt; use uucore::translate; -#[cfg(unix)] -use uucore::signals::enable_pipe_errors; - use uucore::{ format_usage, show_error, signals::{signal_by_name_or_value, signal_name_by_value}, @@ -334,8 +329,6 @@ fn timeout( if !foreground { let _ = setpgid(Pid::from_raw(0), Pid::from_raw(0)); } - #[cfg(unix)] - enable_pipe_errors()?; let mut command = process::Command::new(&cmd[0]); command diff --git a/src/uu/tr/src/tr.rs b/src/uu/tr/src/tr.rs index 2b20d29ce..3a0ee6253 100644 --- a/src/uu/tr/src/tr.rs +++ b/src/uu/tr/src/tr.rs @@ -31,13 +31,6 @@ mod options { #[uucore::main] pub fn uumain(args: impl uucore::Args) -> UResult<()> { - // When we receive a SIGPIPE signal, we want to terminate the process so - // that we don't print any error messages to stderr. Rust ignores SIGPIPE - // (see https://github.com/rust-lang/rust/issues/62569), so we restore it's - // default action here. - #[cfg(not(target_os = "windows"))] - let _ = uucore::signals::enable_pipe_errors(); - let matches = uucore::clap_localization::handle_clap_result(uu_app(), args)?; let delete_flag = matches.get_flag(options::DELETE); diff --git a/src/uu/tty/src/tty.rs b/src/uu/tty/src/tty.rs index 1469948b8..5bf5199a0 100644 --- a/src/uu/tty/src/tty.rs +++ b/src/uu/tty/src/tty.rs @@ -19,6 +19,11 @@ mod options { #[uucore::main] pub fn uumain(args: impl uucore::Args) -> UResult<()> { + // Disable SIGPIPE so we can handle broken pipe errors gracefully + // and exit with code 3 instead of being killed by the signal. + #[cfg(unix)] + let _ = uucore::signals::disable_pipe_errors(); + let matches = uucore::clap_localization::handle_clap_result_with_exit_code(uu_app(), args, 2)?; let silent = matches.get_flag(options::SILENT); diff --git a/src/uu/yes/src/yes.rs b/src/uu/yes/src/yes.rs index a5aaa18a8..98ee5550c 100644 --- a/src/uu/yes/src/yes.rs +++ b/src/uu/yes/src/yes.rs @@ -11,8 +11,6 @@ use std::ffi::OsString; use std::io::{self, Write}; use uucore::error::{UResult, USimpleError}; use uucore::format_usage; -#[cfg(unix)] -use uucore::signals::enable_pipe_errors; use uucore::translate; // it's possible that using a smaller or larger buffer might provide better performance on some @@ -113,8 +111,6 @@ fn prepare_buffer(buf: &mut Vec) { pub fn exec(bytes: &[u8]) -> io::Result<()> { let stdout = io::stdout(); let mut stdout = stdout.lock(); - #[cfg(unix)] - enable_pipe_errors()?; loop { stdout.write_all(bytes)?; diff --git a/src/uucore/Cargo.toml b/src/uucore/Cargo.toml index 1cbc276e4..06b192dfe 100644 --- a/src/uucore/Cargo.toml +++ b/src/uucore/Cargo.toml @@ -120,7 +120,7 @@ windows-sys = { workspace = true, optional = true, default-features = false, fea utmp-classic = { workspace = true, optional = true } [features] -default = [] +default = ["signals"] # * non-default features backup-control = [] colors = [] diff --git a/src/uucore/src/lib/features/signals.rs b/src/uucore/src/lib/features/signals.rs index 25b91d585..6d0956b39 100644 --- a/src/uucore/src/lib/features/signals.rs +++ b/src/uucore/src/lib/features/signals.rs @@ -410,7 +410,7 @@ pub fn signal_name_by_value(signal_value: usize) -> Option<&'static str> { ALL_SIGNALS.get(signal_value).copied() } -/// Returns the default signal value. +/// Restores SIGPIPE to default behavior (process terminates on broken pipe). #[cfg(unix)] pub fn enable_pipe_errors() -> Result<(), Errno> { // We pass the error as is, the return value would just be Ok(SigDfl), so we can safely ignore it. @@ -418,6 +418,15 @@ pub fn enable_pipe_errors() -> Result<(), Errno> { unsafe { signal(SIGPIPE, SigDfl) }.map(|_| ()) } +/// Ignores SIGPIPE signal (broken pipe errors are returned instead of terminating). +/// Use this to override the default SIGPIPE handling when you need to handle +/// broken pipe errors gracefully (e.g., tee with --output-error). +#[cfg(unix)] +pub fn disable_pipe_errors() -> Result<(), Errno> { + // SAFETY: this function is safe as long as we do not use a custom SigHandler -- we use the default one. + unsafe { signal(SIGPIPE, SigIgn) }.map(|_| ()) +} + /// Ignores the SIGINT signal. #[cfg(unix)] pub fn ignore_interrupts() -> Result<(), Errno> { diff --git a/src/uucore_procs/src/lib.rs b/src/uucore_procs/src/lib.rs index e60e2b822..c73f542a9 100644 --- a/src/uucore_procs/src/lib.rs +++ b/src/uucore_procs/src/lib.rs @@ -16,14 +16,34 @@ use quote::quote; //* ref: [path construction from LitStr](https://oschwald.github.io/maxminddb-rust/syn/struct.LitStr.html) @@ /// A procedural macro to define the main function of a uutils binary. +/// +/// This macro handles: +/// - SIGPIPE state capture at process startup (before Rust runtime overrides it) +/// - SIGPIPE restoration to default if parent didn't explicitly ignore it +/// - Disabling Rust signal handlers for proper core dumps +/// - Error handling and exit code management #[proc_macro_attribute] pub fn main(_args: TokenStream, stream: TokenStream) -> TokenStream { let stream = proc_macro2::TokenStream::from(stream); let new = quote!( + // Initialize SIGPIPE state capture at process startup (Unix only). + // This must be at module level to set up the .init_array static that runs + // before main() to capture whether SIGPIPE was ignored by the parent process. + #[cfg(unix)] + uucore::init_startup_state_capture!(); + pub fn uumain(args: impl uucore::Args) -> i32 { #stream + // Restore SIGPIPE to default if it wasn't explicitly ignored by parent. + // The Rust runtime ignores SIGPIPE, but we need to respect the parent's + // signal disposition for proper pipeline behavior (GNU compatibility). + #[cfg(unix)] + if !uucore::signals::sigpipe_was_ignored() { + let _ = uucore::signals::enable_pipe_errors(); + } + // disable rust signal handlers (otherwise processes don't dump core after e.g. one SIGSEGV) #[cfg(unix)] uucore::disable_rust_signal_handlers().expect("Disabling rust signal handlers failed"); From 786a33dd2cf322bd03ffd268ed466d1b50f3c691 Mon Sep 17 00:00:00 2001 From: xtqqczze <45661989+xtqqczze@users.noreply.github.com> Date: Wed, 21 Jan 2026 17:29:29 +0000 Subject: [PATCH 313/425] Merge pull request #10404 from xtqqczze/cargo-shear deps: remove unused rust deps --- Cargo.lock | 21 --------------------- Cargo.toml | 4 +++- fuzz/Cargo.lock | 27 --------------------------- src/uu/base32/Cargo.toml | 1 - src/uu/cksum/Cargo.toml | 1 - src/uu/cp/Cargo.toml | 1 - src/uu/cut/Cargo.toml | 1 - src/uu/factor/Cargo.toml | 1 - src/uu/hashsum/Cargo.toml | 2 -- src/uu/mkfifo/Cargo.toml | 1 - src/uu/more/Cargo.toml | 1 - src/uu/numfmt/Cargo.toml | 1 - src/uu/seq/Cargo.toml | 1 - src/uu/shuf/Cargo.toml | 1 - src/uu/sort/Cargo.toml | 1 - src/uu/tail/Cargo.toml | 1 - src/uu/tsort/Cargo.toml | 1 - src/uu/unexpand/Cargo.toml | 1 - src/uu/uniq/Cargo.toml | 1 - src/uu/uptime/Cargo.toml | 1 - src/uu/yes/Cargo.toml | 1 - src/uucore/Cargo.toml | 1 - 22 files changed, 3 insertions(+), 69 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e92defb61..1a976a105 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -538,7 +538,6 @@ dependencies = [ "rlimit", "rstest", "selinux", - "serde", "sha1", "tempfile", "textwrap", @@ -3080,7 +3079,6 @@ dependencies = [ name = "uu_base32" version = "0.6.0" dependencies = [ - "base64-simd", "clap", "fluent", "uucore", @@ -3190,7 +3188,6 @@ dependencies = [ "clap", "codspeed-divan-compat", "fluent", - "tempfile", "uucore", ] @@ -3219,7 +3216,6 @@ dependencies = [ "thiserror 2.0.18", "uucore", "walkdir", - "xattr", ] [[package]] @@ -3242,7 +3238,6 @@ dependencies = [ "codspeed-divan-compat", "fluent", "memchr", - "tempfile", "uucore", ] @@ -3388,7 +3383,6 @@ dependencies = [ "num-bigint", "num-prime", "num-traits", - "rand 0.9.2", "uucore", ] @@ -3439,9 +3433,7 @@ name = "uu_hashsum" version = "0.6.0" dependencies = [ "clap", - "codspeed-divan-compat", "fluent", - "tempfile", "uucore", ] @@ -3588,7 +3580,6 @@ version = "0.6.0" dependencies = [ "clap", "fluent", - "libc", "nix", "uucore", ] @@ -3622,7 +3613,6 @@ dependencies = [ "clap", "crossterm", "fluent", - "nix", "tempfile", "uucore", ] @@ -3694,7 +3684,6 @@ dependencies = [ "clap", "codspeed-divan-compat", "fluent", - "tempfile", "thiserror 2.0.18", "uucore", ] @@ -3855,7 +3844,6 @@ dependencies = [ "fluent", "num-bigint", "num-traits", - "tempfile", "thiserror 2.0.18", "uucore", ] @@ -3880,7 +3868,6 @@ dependencies = [ "fluent", "rand 0.9.2", "rand_core 0.9.5", - "tempfile", "uucore", ] @@ -3913,7 +3900,6 @@ dependencies = [ "self_cell", "tempfile", "thiserror 2.0.18", - "unicode-width 0.2.2", "uucore", ] @@ -4018,7 +4004,6 @@ dependencies = [ "rstest", "same-file", "uucore", - "winapi-util", "windows-sys 0.61.2", ] @@ -4106,7 +4091,6 @@ dependencies = [ "fluent", "nix", "string-interner", - "tempfile", "thiserror 2.0.18", "uucore", ] @@ -4140,7 +4124,6 @@ dependencies = [ "fluent", "tempfile", "thiserror 2.0.18", - "unicode-width 0.2.2", "uucore", ] @@ -4151,7 +4134,6 @@ dependencies = [ "clap", "codspeed-divan-compat", "fluent", - "tempfile", "uucore", ] @@ -4172,7 +4154,6 @@ dependencies = [ "fluent", "jiff", "thiserror 2.0.18", - "utmp-classic", "uucore", ] @@ -4237,7 +4218,6 @@ dependencies = [ "clap", "fluent", "itertools 0.14.0", - "nix", "uucore", ] @@ -4275,7 +4255,6 @@ dependencies = [ "nix", "num-traits", "os_display", - "phf", "procfs", "selinux", "sha1", diff --git a/Cargo.toml b/Cargo.toml index e1f7083cd..773d4518b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -571,7 +571,6 @@ xattr.workspace = true # Used in test_uptime::test_uptime_with_file_containing_valid_boot_time_utmpx_record # to deserialize an utmpx struct into a binary file [target.'cfg(all(target_family= "unix",not(target_os = "macos")))'.dev-dependencies] -serde = { version = "1.0.202", features = ["derive"] } wincode = "0.2.5" wincode-derive = "0.2.3" @@ -678,3 +677,6 @@ format_push_string = "allow" flat_map_option = "allow" from_iter_instead_of_collect = "allow" large_types_passed_by_value = "allow" + +[workspace.metadata.cargo-shear] +ignored = ["fluent", "libstdbuf"] diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index 1ec35d314..8bf1a6e84 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -1107,25 +1107,6 @@ dependencies = [ "winnow", ] -[[package]] -name = "phf" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" -dependencies = [ - "phf_shared", - "serde", -] - -[[package]] -name = "phf_shared" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" -dependencies = [ - "siphasher", -] - [[package]] name = "pkg-config" version = "0.3.32" @@ -1391,12 +1372,6 @@ version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" -[[package]] -name = "siphasher" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" - [[package]] name = "sm3" version = "0.4.2" @@ -1692,7 +1667,6 @@ dependencies = [ "self_cell", "tempfile", "thiserror", - "unicode-width", "uucore", ] @@ -1773,7 +1747,6 @@ dependencies = [ "nix", "num-traits", "os_display", - "phf", "procfs", "sha1", "sha2", diff --git a/src/uu/base32/Cargo.toml b/src/uu/base32/Cargo.toml index fe51e6865..2318911b5 100644 --- a/src/uu/base32/Cargo.toml +++ b/src/uu/base32/Cargo.toml @@ -21,7 +21,6 @@ path = "src/base32.rs" clap = { workspace = true } uucore = { workspace = true, features = ["encoding"] } fluent = { workspace = true } -base64-simd = "0.8" [[bin]] name = "base32" diff --git a/src/uu/cksum/Cargo.toml b/src/uu/cksum/Cargo.toml index 840397273..1c343181c 100644 --- a/src/uu/cksum/Cargo.toml +++ b/src/uu/cksum/Cargo.toml @@ -29,7 +29,6 @@ fluent = { workspace = true } [dev-dependencies] divan = { workspace = true } -tempfile = { workspace = true } uucore = { workspace = true, features = ["benchmark"] } [[bin]] diff --git a/src/uu/cp/Cargo.toml b/src/uu/cp/Cargo.toml index 8a2391e55..592b9cba9 100644 --- a/src/uu/cp/Cargo.toml +++ b/src/uu/cp/Cargo.toml @@ -39,7 +39,6 @@ thiserror = { workspace = true } fluent = { workspace = true } [target.'cfg(unix)'.dependencies] -xattr = { workspace = true } exacl = { workspace = true, optional = true } [[bin]] diff --git a/src/uu/cut/Cargo.toml b/src/uu/cut/Cargo.toml index 0133180f0..f7ea5b203 100644 --- a/src/uu/cut/Cargo.toml +++ b/src/uu/cut/Cargo.toml @@ -26,7 +26,6 @@ fluent = { workspace = true } [dev-dependencies] divan = { workspace = true } -tempfile = { workspace = true } uucore = { workspace = true, features = ["benchmark"] } [[bin]] diff --git a/src/uu/factor/Cargo.toml b/src/uu/factor/Cargo.toml index ef672bf93..15d09f7a0 100644 --- a/src/uu/factor/Cargo.toml +++ b/src/uu/factor/Cargo.toml @@ -31,7 +31,6 @@ path = "src/main.rs" [dev-dependencies] divan = { workspace = true } -rand = { workspace = true } uucore = { workspace = true, features = ["benchmark"] } [lib] diff --git a/src/uu/hashsum/Cargo.toml b/src/uu/hashsum/Cargo.toml index f77c2c52d..4c28a1588 100644 --- a/src/uu/hashsum/Cargo.toml +++ b/src/uu/hashsum/Cargo.toml @@ -27,6 +27,4 @@ name = "hashsum" path = "src/main.rs" [dev-dependencies] -divan = { workspace = true } -tempfile = { workspace = true } uucore = { workspace = true, features = ["benchmark"] } diff --git a/src/uu/mkfifo/Cargo.toml b/src/uu/mkfifo/Cargo.toml index 900614344..c75638482 100644 --- a/src/uu/mkfifo/Cargo.toml +++ b/src/uu/mkfifo/Cargo.toml @@ -19,7 +19,6 @@ path = "src/mkfifo.rs" [dependencies] clap = { workspace = true } -libc = { workspace = true } nix = { workspace = true, features = ["fs"] } uucore = { workspace = true, features = ["fs", "mode"] } fluent = { workspace = true } diff --git a/src/uu/more/Cargo.toml b/src/uu/more/Cargo.toml index cd65f7412..e296dcf80 100644 --- a/src/uu/more/Cargo.toml +++ b/src/uu/more/Cargo.toml @@ -24,7 +24,6 @@ crossterm = { workspace = true } fluent = { workspace = true } [target.'cfg(all(unix, not(target_os = "fuchsia")))'.dependencies] -nix = { workspace = true } [target.'cfg(target_os = "macos")'.dependencies] crossterm = { workspace = true, features = ["use-dev-tty"] } diff --git a/src/uu/numfmt/Cargo.toml b/src/uu/numfmt/Cargo.toml index 177f2e3b8..fed39ad68 100644 --- a/src/uu/numfmt/Cargo.toml +++ b/src/uu/numfmt/Cargo.toml @@ -25,7 +25,6 @@ fluent = { workspace = true } [dev-dependencies] divan = { workspace = true } -tempfile = { workspace = true } uucore = { workspace = true, features = ["benchmark"] } [[bin]] diff --git a/src/uu/seq/Cargo.toml b/src/uu/seq/Cargo.toml index 534b675e1..cdc1c29af 100644 --- a/src/uu/seq/Cargo.toml +++ b/src/uu/seq/Cargo.toml @@ -40,7 +40,6 @@ path = "src/main.rs" [dev-dependencies] divan = { workspace = true } -tempfile = { workspace = true } uucore = { workspace = true, features = ["benchmark"] } [[bench]] diff --git a/src/uu/shuf/Cargo.toml b/src/uu/shuf/Cargo.toml index b67b1d808..26a270b88 100644 --- a/src/uu/shuf/Cargo.toml +++ b/src/uu/shuf/Cargo.toml @@ -34,5 +34,4 @@ harness = false [dev-dependencies] divan = { workspace = true } -tempfile = { workspace = true } uucore = { workspace = true, features = ["benchmark"] } diff --git a/src/uu/sort/Cargo.toml b/src/uu/sort/Cargo.toml index ad1dcc118..ae537ed02 100644 --- a/src/uu/sort/Cargo.toml +++ b/src/uu/sort/Cargo.toml @@ -35,7 +35,6 @@ rayon = { workspace = true } self_cell = { workspace = true } tempfile = { workspace = true } thiserror = { workspace = true } -unicode-width = { workspace = true } uucore = { workspace = true, features = [ "fs", "parser-size", diff --git a/src/uu/tail/Cargo.toml b/src/uu/tail/Cargo.toml index 055b62400..f01b4f603 100644 --- a/src/uu/tail/Cargo.toml +++ b/src/uu/tail/Cargo.toml @@ -35,7 +35,6 @@ windows-sys = { workspace = true, features = [ "Win32_System_Threading", "Win32_Foundation", ] } -winapi-util = { workspace = true } [dev-dependencies] rstest = { workspace = true } diff --git a/src/uu/tsort/Cargo.toml b/src/uu/tsort/Cargo.toml index 72559199c..a7e2eb014 100644 --- a/src/uu/tsort/Cargo.toml +++ b/src/uu/tsort/Cargo.toml @@ -32,7 +32,6 @@ path = "src/main.rs" [dev-dependencies] divan = { workspace = true } -tempfile = { workspace = true } uucore = { workspace = true, features = ["benchmark"] } [[bench]] diff --git a/src/uu/unexpand/Cargo.toml b/src/uu/unexpand/Cargo.toml index 19128ad03..d7ea1533b 100644 --- a/src/uu/unexpand/Cargo.toml +++ b/src/uu/unexpand/Cargo.toml @@ -20,7 +20,6 @@ path = "src/unexpand.rs" [dependencies] thiserror = { workspace = true } clap = { workspace = true } -unicode-width = { workspace = true } uucore = { workspace = true } fluent = { workspace = true } diff --git a/src/uu/uniq/Cargo.toml b/src/uu/uniq/Cargo.toml index 59a463071..0bd197827 100644 --- a/src/uu/uniq/Cargo.toml +++ b/src/uu/uniq/Cargo.toml @@ -24,7 +24,6 @@ fluent = { workspace = true } [dev-dependencies] divan = { workspace = true } -tempfile = { workspace = true } uucore = { workspace = true, features = ["benchmark", "parser"] } [[bin]] diff --git a/src/uu/uptime/Cargo.toml b/src/uu/uptime/Cargo.toml index 651b342cd..026039026 100644 --- a/src/uu/uptime/Cargo.toml +++ b/src/uu/uptime/Cargo.toml @@ -30,7 +30,6 @@ fluent = { workspace = true } jiff = { workspace = true } [target.'cfg(target_os = "openbsd")'.dependencies] -utmp-classic = { workspace = true } [[bin]] name = "uptime" diff --git a/src/uu/yes/Cargo.toml b/src/uu/yes/Cargo.toml index 3b6e8d08f..33623c7c9 100644 --- a/src/uu/yes/Cargo.toml +++ b/src/uu/yes/Cargo.toml @@ -24,7 +24,6 @@ fluent = { workspace = true } [target.'cfg(unix)'.dependencies] uucore = { workspace = true, features = ["pipes", "signals"] } -nix = { workspace = true } [target.'cfg(not(unix))'.dependencies] uucore = { workspace = true, features = ["pipes"] } diff --git a/src/uucore/Cargo.toml b/src/uucore/Cargo.toml index 06b192dfe..e27e1d70e 100644 --- a/src/uucore/Cargo.toml +++ b/src/uucore/Cargo.toml @@ -26,7 +26,6 @@ bstr = { workspace = true, optional = true } clap = { workspace = true } uucore_procs = { workspace = true } unit-prefix = { workspace = true, optional = true } -phf = { workspace = true } dns-lookup = { workspace = true, optional = true } dunce = { version = "1.0.4", optional = true } glob = { workspace = true, optional = true } From 1a64417aae5d266578e691f6ec70fcdf6dbc0510 Mon Sep 17 00:00:00 2001 From: Kaua Klassmann <131408936+Kaua-Klassmann@users.noreply.github.com> Date: Wed, 21 Jan 2026 17:48:50 -0300 Subject: [PATCH 314/425] fix: numeric sort (-n) does not recognize thousand separators (#10339) --- src/uu/sort/src/sort.rs | 13 +++++- src/uucore/src/lib/features/i18n/decimal.rs | 36 ++++++++++++++- tests/by-util/test_sort.rs | 16 +++++-- .../mixed_floats_ints_chars_numeric.expected | 4 +- ...d_floats_ints_chars_numeric.expected.debug | 12 ++--- ..._floats_ints_chars_numeric_stable.expected | 4 +- ...s_ints_chars_numeric_stable.expected.debug | 8 ++-- ..._floats_ints_chars_numeric_unique.expected | 3 +- ...s_ints_chars_numeric_unique.expected.debug | 6 ++- ...ints_chars_numeric_unique_reverse.expected | 3 +- ...hars_numeric_unique_reverse.expected.debug | 6 ++- .../sort/multiple_decimals_numeric.expected | 4 +- .../multiple_decimals_numeric.expected.debug | 12 ++--- .../sort/multiple_groupings_numeric.expected | 15 +++++++ .../multiple_groupings_numeric.expected.debug | 45 +++++++++++++++++++ .../sort/multiple_groupings_numeric.txt | 15 +++++++ 16 files changed, 167 insertions(+), 35 deletions(-) create mode 100644 tests/fixtures/sort/multiple_groupings_numeric.expected create mode 100644 tests/fixtures/sort/multiple_groupings_numeric.expected.debug create mode 100644 tests/fixtures/sort/multiple_groupings_numeric.txt diff --git a/src/uu/sort/src/sort.rs b/src/uu/sort/src/sort.rs index 8c27910dc..01ddc63fb 100644 --- a/src/uu/sort/src/sort.rs +++ b/src/uu/sort/src/sort.rs @@ -47,7 +47,6 @@ use uucore::display::Quotable; 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; @@ -59,6 +58,7 @@ use uucore::posix::{MODERN, TRADITIONAL}; use uucore::show_error; use uucore::translate; use uucore::version_cmp::version_cmp; +use uucore::{format_usage, i18n}; use crate::buffer_hint::automatic_buffer_size; use crate::tmp_dir::TmpDirWrapper; @@ -1086,11 +1086,22 @@ impl FieldSelector { }; let mut range_str = &line[self.get_range(line, tokens)]; if self.settings.mode == SortMode::Numeric || self.settings.mode == SortMode::HumanNumeric { + // Get the thousands separator from the locale, handling cases where the separator is empty or multi-character + let locale_thousands_separator = i18n::decimal::locale_grouping_separator().as_bytes(); + + // Upstream GNU coreutils ignore multibyte thousands separators + // (FIXME in C source). We keep the same single-byte behavior. + let thousands_separator = match locale_thousands_separator { + [b] => Some(*b), + _ => None, + }; + // Parse NumInfo for this number. let (info, num_range) = NumInfo::parse( range_str, &NumInfoParseSettings { accept_si_units: self.settings.mode == SortMode::HumanNumeric, + thousands_separator, ..Default::default() }, ); diff --git a/src/uucore/src/lib/features/i18n/decimal.rs b/src/uucore/src/lib/features/i18n/decimal.rs index 9fa2d8d7b..0a901143c 100644 --- a/src/uucore/src/lib/features/i18n/decimal.rs +++ b/src/uucore/src/lib/features/i18n/decimal.rs @@ -37,15 +37,47 @@ pub fn locale_decimal_separator() -> &'static str { DECIMAL_SEP.get_or_init(|| get_decimal_separator(get_numeric_locale().0.clone())) } +/// Return the grouping separator for the given locale +fn get_grouping_separator(loc: Locale) -> String { + let data_locale = DataLocale::from(loc); + + let request = DataRequest { + id: DataIdentifierBorrowed::for_locale(&data_locale), + metadata: DataRequestMetadata::default(), + }; + + let response: DataResponse = + icu_decimal::provider::Baked.load(request).unwrap(); + + response.payload.get().grouping_separator().to_string() +} + +/// Return the grouping separator from the language we're working with. +/// Example: +/// Say we need to format 1,000 +/// en_US: 1,000 -> grouping separator is ',' +/// fr_FR: 1 000 -> grouping separator is '\u{202f}' +pub fn locale_grouping_separator() -> &'static str { + static GROUPING_SEP: OnceLock = OnceLock::new(); + + GROUPING_SEP.get_or_init(|| get_grouping_separator(get_numeric_locale().0.clone())) +} + #[cfg(test)] mod tests { use icu_locale::locale; - use super::get_decimal_separator; + use super::{get_decimal_separator, get_grouping_separator}; #[test] - fn test_simple_separator() { + fn test_simple_decimal_separator() { assert_eq!(get_decimal_separator(locale!("en")), "."); assert_eq!(get_decimal_separator(locale!("fr")), ","); } + + #[test] + fn test_simple_grouping_separator() { + assert_eq!(get_grouping_separator(locale!("en")), ","); + assert_eq!(get_grouping_separator(locale!("fr")), "\u{202f}"); + } } diff --git a/tests/by-util/test_sort.rs b/tests/by-util/test_sort.rs index 0106d719f..e794898a2 100644 --- a/tests/by-util/test_sort.rs +++ b/tests/by-util/test_sort.rs @@ -258,6 +258,14 @@ fn test_multiple_decimals_numeric() { ); } +#[test] +fn test_multiple_groupings_numeric() { + test_helper( + "multiple_groupings_numeric", + &["-n", "--numeric-sort", "--sort=numeric", "--sort=n"], + ); +} + #[test] fn test_numeric_with_trailing_invalid_chars() { test_helper( @@ -2359,18 +2367,18 @@ _ __ 1 _ -2,5 -_ 2.4 ___ +2,5 +_ 2.,,3 __ 2.4 ___ -2,,3 -_ 2.4 ___ +2,,3 +_ 1a _ 2b diff --git a/tests/fixtures/sort/mixed_floats_ints_chars_numeric.expected b/tests/fixtures/sort/mixed_floats_ints_chars_numeric.expected index 59541af32..a781a36bb 100644 --- a/tests/fixtures/sort/mixed_floats_ints_chars_numeric.expected +++ b/tests/fixtures/sort/mixed_floats_ints_chars_numeric.expected @@ -21,10 +21,10 @@ CARAvan 8.013 45 46.89 -576,446.88800000 -576,446.890 4567. 37800 +576,446.88800000 +576,446.890 4798908.340000000000 4798908.45 4798908.8909800 diff --git a/tests/fixtures/sort/mixed_floats_ints_chars_numeric.expected.debug b/tests/fixtures/sort/mixed_floats_ints_chars_numeric.expected.debug index b7b76e589..a00067b1e 100644 --- a/tests/fixtures/sort/mixed_floats_ints_chars_numeric.expected.debug +++ b/tests/fixtures/sort/mixed_floats_ints_chars_numeric.expected.debug @@ -67,18 +67,18 @@ __ 46.89 _____ _____ -576,446.88800000 -___ -________________ -576,446.890 -___ -___________ 4567. _____ ____________________ >>>>37800 _____ _________ +576,446.88800000 +___ +________________ +576,446.890 +___ +___________ 4798908.340000000000 ____________________ ____________________ diff --git a/tests/fixtures/sort/mixed_floats_ints_chars_numeric_stable.expected b/tests/fixtures/sort/mixed_floats_ints_chars_numeric_stable.expected index 0ccdd84c0..36eeda637 100644 --- a/tests/fixtures/sort/mixed_floats_ints_chars_numeric_stable.expected +++ b/tests/fixtures/sort/mixed_floats_ints_chars_numeric_stable.expected @@ -24,10 +24,10 @@ CARAvan 8.013 45 46.89 -576,446.890 -576,446.88800000 4567. 37800 +576,446.88800000 +576,446.890 4798908.340000000000 4798908.45 4798908.8909800 diff --git a/tests/fixtures/sort/mixed_floats_ints_chars_numeric_stable.expected.debug b/tests/fixtures/sort/mixed_floats_ints_chars_numeric_stable.expected.debug index 66a98b208..3fba89030 100644 --- a/tests/fixtures/sort/mixed_floats_ints_chars_numeric_stable.expected.debug +++ b/tests/fixtures/sort/mixed_floats_ints_chars_numeric_stable.expected.debug @@ -50,14 +50,14 @@ _____ __ 46.89 _____ -576,446.890 -___ -576,446.88800000 -___ 4567. _____ >>>>37800 _____ +576,446.88800000 +___ +576,446.890 +___ 4798908.340000000000 ____________________ 4798908.45 diff --git a/tests/fixtures/sort/mixed_floats_ints_chars_numeric_unique.expected b/tests/fixtures/sort/mixed_floats_ints_chars_numeric_unique.expected index cd4256c5f..cb27c6664 100644 --- a/tests/fixtures/sort/mixed_floats_ints_chars_numeric_unique.expected +++ b/tests/fixtures/sort/mixed_floats_ints_chars_numeric_unique.expected @@ -11,9 +11,10 @@ 8.013 45 46.89 -576,446.890 4567. 37800 +576,446.88800000 +576,446.890 4798908.340000000000 4798908.45 4798908.8909800 diff --git a/tests/fixtures/sort/mixed_floats_ints_chars_numeric_unique.expected.debug b/tests/fixtures/sort/mixed_floats_ints_chars_numeric_unique.expected.debug index 663a4b3a9..dd6e8dfcc 100644 --- a/tests/fixtures/sort/mixed_floats_ints_chars_numeric_unique.expected.debug +++ b/tests/fixtures/sort/mixed_floats_ints_chars_numeric_unique.expected.debug @@ -24,12 +24,14 @@ _____ __ 46.89 _____ -576,446.890 -___ 4567. _____ >>>>37800 _____ +576,446.88800000 +___ +576,446.890 +___ 4798908.340000000000 ____________________ 4798908.45 diff --git a/tests/fixtures/sort/mixed_floats_ints_chars_numeric_unique_reverse.expected b/tests/fixtures/sort/mixed_floats_ints_chars_numeric_unique_reverse.expected index 97e261f14..bbce16934 100644 --- a/tests/fixtures/sort/mixed_floats_ints_chars_numeric_unique_reverse.expected +++ b/tests/fixtures/sort/mixed_floats_ints_chars_numeric_unique_reverse.expected @@ -1,9 +1,10 @@ 4798908.8909800 4798908.45 4798908.340000000000 +576,446.890 +576,446.88800000 37800 4567. -576,446.890 46.89 45 8.013 diff --git a/tests/fixtures/sort/mixed_floats_ints_chars_numeric_unique_reverse.expected.debug b/tests/fixtures/sort/mixed_floats_ints_chars_numeric_unique_reverse.expected.debug index 01f7abf5b..4b01a8406 100644 --- a/tests/fixtures/sort/mixed_floats_ints_chars_numeric_unique_reverse.expected.debug +++ b/tests/fixtures/sort/mixed_floats_ints_chars_numeric_unique_reverse.expected.debug @@ -4,12 +4,14 @@ _______________ __________ 4798908.340000000000 ____________________ +576,446.890 +___ +576,446.88800000 +___ >>>>37800 _____ 4567. _____ -576,446.890 -___ 46.89 _____ 45 diff --git a/tests/fixtures/sort/multiple_decimals_numeric.expected b/tests/fixtures/sort/multiple_decimals_numeric.expected index 8f42e7ce5..3ef4d22e8 100644 --- a/tests/fixtures/sort/multiple_decimals_numeric.expected +++ b/tests/fixtures/sort/multiple_decimals_numeric.expected @@ -21,8 +21,6 @@ CARAvan 8.013 45 46.89 -576,446.88800000 -576,446.890 4567..457 4567. 4567.1 @@ -30,6 +28,8 @@ CARAvan 37800 45670.89079.098 45670.89079.1 +576,446.88800000 +576,446.890 4798908.340000000000 4798908.45 4798908.8909800 diff --git a/tests/fixtures/sort/multiple_decimals_numeric.expected.debug b/tests/fixtures/sort/multiple_decimals_numeric.expected.debug index 948c4869c..0ae6d2958 100644 --- a/tests/fixtures/sort/multiple_decimals_numeric.expected.debug +++ b/tests/fixtures/sort/multiple_decimals_numeric.expected.debug @@ -67,12 +67,6 @@ __ 46.89 _____ _____ -576,446.88800000 -___ -________________ -576,446.890 -___ -___________ >>>>>>>>>>4567..457 _____ ___________________ @@ -94,6 +88,12 @@ _____________________ >>>>>>45670.89079.1 ___________ ___________________ +576,446.88800000 +___ +________________ +576,446.890 +___ +___________ 4798908.340000000000 ____________________ ____________________ diff --git a/tests/fixtures/sort/multiple_groupings_numeric.expected b/tests/fixtures/sort/multiple_groupings_numeric.expected new file mode 100644 index 000000000..a6daab836 --- /dev/null +++ b/tests/fixtures/sort/multiple_groupings_numeric.expected @@ -0,0 +1,15 @@ + + + +CARAvan + 1.234 +2.000 +2.000,50 +22 +23,. +111 + 210 +1,234 +12,34 + 1,999.99 + 2,000 diff --git a/tests/fixtures/sort/multiple_groupings_numeric.expected.debug b/tests/fixtures/sort/multiple_groupings_numeric.expected.debug new file mode 100644 index 000000000..57a4ae01b --- /dev/null +++ b/tests/fixtures/sort/multiple_groupings_numeric.expected.debug @@ -0,0 +1,45 @@ + +^ no match for key +^ no match for key + +^ no match for key +^ no match for key + +^ no match for key +^ no match for key +CARAvan +^ no match for key +_______ +>1.234 + _____ +______ +2.000 +_____ +_____ +2.000,50 +_____ +________ +22 +__ +__ +23,. +__ +____ +111 +___ +___ +>210 + ___ +____ +1,234 +_ +_____ +12,34 +__ +_____ +>>1,999.99 + _ +__________ +>>>2,000 + _ +________ diff --git a/tests/fixtures/sort/multiple_groupings_numeric.txt b/tests/fixtures/sort/multiple_groupings_numeric.txt new file mode 100644 index 000000000..264403a79 --- /dev/null +++ b/tests/fixtures/sort/multiple_groupings_numeric.txt @@ -0,0 +1,15 @@ +1,234 +12,34 + + 1.234 +2.000 + 2,000 +111 + + +CARAvan +22 +23,. + 210 + 1,999.99 +2.000,50 \ No newline at end of file From fa58060050b135a1951ca8cffc3069b01c225ee9 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 21 Jan 2026 22:48:39 +0000 Subject: [PATCH 315/425] chore(deps): update rust crate proc-macro2 to v1.0.106 --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1a976a105..49bf30262 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2215,9 +2215,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.105" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] From 1997c5c2e1bd749d9f38129cb590f72ec52a01b2 Mon Sep 17 00:00:00 2001 From: xtqqczze <45661989+xtqqczze@users.noreply.github.com> Date: Wed, 21 Jan 2026 22:22:24 +0000 Subject: [PATCH 316/425] fix: allow selinux on android --- src/uu/chcon/Cargo.toml | 3 ++- src/uu/chcon/src/chcon.rs | 4 +++- src/uu/chcon/src/errors.rs | 3 ++- src/uu/chcon/src/fts.rs | 3 ++- src/uu/chcon/src/main.rs | 19 +++++++++++++------ src/uu/cp/src/cp.rs | 4 ++-- src/uu/install/src/install.rs | 24 ++++++++++++------------ src/uu/ls/src/ls.rs | 6 +++--- src/uu/mkfifo/src/mkfifo.rs | 2 +- src/uu/runcon/Cargo.toml | 3 ++- src/uu/runcon/src/errors.rs | 3 ++- src/uu/runcon/src/main.rs | 19 +++++++++++++------ src/uu/runcon/src/runcon.rs | 4 +++- src/uu/stat/src/stat.rs | 10 ++++++++-- src/uucore/src/lib/features.rs | 2 +- src/uucore/src/lib/lib.rs | 2 +- 16 files changed, 70 insertions(+), 41 deletions(-) diff --git a/src/uu/chcon/Cargo.toml b/src/uu/chcon/Cargo.toml index 110a70492..b18da48f8 100644 --- a/src/uu/chcon/Cargo.toml +++ b/src/uu/chcon/Cargo.toml @@ -17,7 +17,8 @@ workspace = true [lib] path = "src/chcon.rs" -[target.'cfg(target_os = "linux")'.dependencies] # todo: block fetching crates without feat_selinux +# TODO: block fetching crates without feat_selinux +[target.'cfg(any(target_os = "linux", target_os = "android"))'.dependencies] clap = { workspace = true } uucore = { workspace = true, features = ["entries", "fs", "perms"] } selinux = { workspace = true } diff --git a/src/uu/chcon/src/chcon.rs b/src/uu/chcon/src/chcon.rs index 6069b8d2b..cd3985826 100644 --- a/src/uu/chcon/src/chcon.rs +++ b/src/uu/chcon/src/chcon.rs @@ -2,8 +2,10 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:ignore (vars) RFILE -#![cfg(target_os = "linux")] + +#![cfg(any(target_os = "linux", target_os = "android"))] #![allow(clippy::upper_case_acronyms)] use clap::builder::ValueParser; diff --git a/src/uu/chcon/src/errors.rs b/src/uu/chcon/src/errors.rs index 76ffeeb6a..fa4ae6fee 100644 --- a/src/uu/chcon/src/errors.rs +++ b/src/uu/chcon/src/errors.rs @@ -2,7 +2,8 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -#![cfg(target_os = "linux")] + +#![cfg(any(target_os = "linux", target_os = "android"))] use std::ffi::OsString; use std::fmt::Write; diff --git a/src/uu/chcon/src/fts.rs b/src/uu/chcon/src/fts.rs index b60ac7d3a..8214058a7 100644 --- a/src/uu/chcon/src/fts.rs +++ b/src/uu/chcon/src/fts.rs @@ -2,7 +2,8 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -#![cfg(target_os = "linux")] + +#![cfg(any(target_os = "linux", target_os = "android"))] use std::ffi::{CStr, CString, OsStr}; use std::marker::PhantomData; diff --git a/src/uu/chcon/src/main.rs b/src/uu/chcon/src/main.rs index c143ebf88..bd5025095 100644 --- a/src/uu/chcon/src/main.rs +++ b/src/uu/chcon/src/main.rs @@ -1,11 +1,18 @@ -// On non-Linux targets, provide a stub main to keep the binary target present -// and the workspace buildable. Using item-level cfg avoids excluding the crate -// entirely (via #![cfg(...)]), which can break tooling and cross builds that -// expect this binary to exist even when it's a no-op off Linux. -#[cfg(target_os = "linux")] +// This file is part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +//! This package is specific to Android and some Linux distributions. On other +//! targets, provide a stub main to keep the binary target present and the +//! workspace buildable. Using item-level cfg avoids excluding the crate +//! entirely (via #![cfg(...)]), which can break tooling and cross builds that +//! expect this binary to exist even when it's a no-op off Linux. + +#[cfg(any(target_os = "linux", target_os = "android"))] uucore::bin!(uu_chcon); -#[cfg(not(target_os = "linux"))] +#[cfg(not(any(target_os = "linux", target_os = "android")))] fn main() { eprintln!("chcon: SELinux is not supported on this platform"); std::process::exit(1); diff --git a/src/uu/cp/src/cp.rs b/src/uu/cp/src/cp.rs index 22134f0d6..62b8b7a7b 100644 --- a/src/uu/cp/src/cp.rs +++ b/src/uu/cp/src/cp.rs @@ -1790,7 +1790,7 @@ pub(crate) fn copy_attributes( Ok(()) })?; - #[cfg(all(feature = "selinux", target_os = "linux"))] + #[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] handle_preserve(&attributes.context, || -> CopyResult<()> { // Get the source context and apply it to the destination if let Ok(context) = selinux::SecurityContext::of_path(source, false, false) { @@ -2586,7 +2586,7 @@ fn copy_file( copy_attributes(source, dest, &options.attributes)?; } - #[cfg(all(feature = "selinux", target_os = "linux"))] + #[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] if options.set_selinux_context && uucore::selinux::is_selinux_enabled() { // Set the given selinux permissions on the copied file. if let Err(e) = diff --git a/src/uu/install/src/install.rs b/src/uu/install/src/install.rs index e128470fc..7dde478b4 100644 --- a/src/uu/install/src/install.rs +++ b/src/uu/install/src/install.rs @@ -10,7 +10,7 @@ mod mode; use clap::{Arg, ArgAction, ArgMatches, Command}; use file_diff::diff; use filetime::{FileTime, set_file_times}; -#[cfg(all(feature = "selinux", target_os = "linux"))] +#[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] use selinux::SecurityContext; use std::ffi::OsString; use std::fmt::Debug; @@ -27,7 +27,7 @@ use uucore::error::{FromIo, UError, UResult, UUsageError}; use uucore::fs::dir_strip_dot_for_creation; use uucore::perms::{Verbosity, VerbosityLevel, wrap_chown}; use uucore::process::{getegid, geteuid}; -#[cfg(all(feature = "selinux", target_os = "linux"))] +#[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] use uucore::selinux::{ SeLinuxError, contexts_differ, get_selinux_security_context, is_selinux_enabled, selinux_error_description, set_selinux_security_context, @@ -118,7 +118,7 @@ enum InstallError { #[error("{}", translate!("install-error-extra-operand", "operand" => .0.quote(), "usage" => .1.clone()))] ExtraOperand(OsString, String), - #[cfg(all(feature = "selinux", target_os = "linux"))] + #[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] #[error("{}", .0)] SelinuxContextFailed(String), } @@ -1004,7 +1004,7 @@ fn copy(from: &Path, to: &Path, b: &Behavior) -> UResult<()> { Ok(()) } -#[cfg(all(feature = "selinux", target_os = "linux"))] +#[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] fn get_context_for_selinux(b: &Behavior) -> Option<&String> { if b.default_context { None @@ -1139,7 +1139,7 @@ fn need_copy(from: &Path, to: &Path, b: &Behavior) -> bool { false } -#[cfg(all(feature = "selinux", target_os = "linux"))] +#[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] /// Sets the `SELinux` security context for install's -Z flag behavior. /// /// This function implements the specific behavior needed for install's -Z flag, @@ -1173,7 +1173,7 @@ pub fn set_selinux_default_context(path: &Path) -> Result<(), SeLinuxError> { } } -#[cfg(all(feature = "selinux", target_os = "linux"))] +#[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] /// Gets the default `SELinux` context for a path based on the system's security policy. /// /// This function attempts to determine what the "correct" `SELinux` context should be @@ -1229,7 +1229,7 @@ fn get_default_context_for_path(path: &Path) -> Result, SeLinuxEr Ok(None) } -#[cfg(all(feature = "selinux", target_os = "linux"))] +#[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] /// Derives an appropriate `SELinux` context based on a parent directory context. /// /// This is a heuristic function that attempts to generate an appropriate @@ -1267,7 +1267,7 @@ fn derive_context_from_parent(parent_context: &str) -> String { } } -#[cfg(all(feature = "selinux", target_os = "linux"))] +#[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] /// Helper function to collect paths that need `SELinux` context setting. /// /// Traverses from the given starting path up to existing parent directories. @@ -1281,7 +1281,7 @@ fn collect_paths_for_context_setting(starting_path: &Path) -> Vec<&Path> { paths } -#[cfg(all(feature = "selinux", target_os = "linux"))] +#[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] /// Sets the `SELinux` security context for a directory hierarchy. /// /// This function traverses from the given starting path up to existing parent directories @@ -1321,7 +1321,7 @@ fn set_selinux_context_for_directories(target_path: &Path, context: Option<&Stri } } -#[cfg(all(feature = "selinux", target_os = "linux"))] +#[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] /// Sets `SELinux` context for created directories using install's -Z default behavior. /// /// Similar to `set_selinux_context_for_directories` but uses install's @@ -1345,10 +1345,10 @@ pub fn set_selinux_context_for_directories_install(target_path: &Path, context: #[cfg(test)] mod tests { - #[cfg(all(feature = "selinux", target_os = "linux"))] + #[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] use super::derive_context_from_parent; - #[cfg(all(feature = "selinux", target_os = "linux"))] + #[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] #[test] fn test_derive_context_from_parent() { // Test cases: (input_context, file_type, expected_output, description) diff --git a/src/uu/ls/src/ls.rs b/src/uu/ls/src/ls.rs index 6694d7bca..1bad30023 100644 --- a/src/uu/ls/src/ls.rs +++ b/src/uu/ls/src/ls.rs @@ -373,7 +373,7 @@ pub struct Config { time_format_recent: String, // Time format for recent dates time_format_older: Option, // Time format for older dates (optional, if not present, time_format_recent is used) context: bool, - #[cfg(all(feature = "selinux", target_os = "linux"))] + #[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] selinux_supported: bool, #[cfg(all(feature = "smack", target_os = "linux"))] smack_supported: bool, @@ -1233,7 +1233,7 @@ impl Config { time_format_recent, time_format_older, context, - #[cfg(all(feature = "selinux", target_os = "linux"))] + #[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] selinux_supported: uucore::selinux::is_selinux_enabled(), #[cfg(all(feature = "smack", target_os = "linux"))] smack_supported: uucore::smack::is_smack_enabled(), @@ -3531,7 +3531,7 @@ fn get_security_context<'a>( } } - #[cfg(all(feature = "selinux", target_os = "linux"))] + #[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] if config.selinux_supported { match selinux::SecurityContext::of_path(path, must_dereference, false) { Err(_r) => { diff --git a/src/uu/mkfifo/src/mkfifo.rs b/src/uu/mkfifo/src/mkfifo.rs index 82c5ec1c6..740e8cdb4 100644 --- a/src/uu/mkfifo/src/mkfifo.rs +++ b/src/uu/mkfifo/src/mkfifo.rs @@ -65,7 +65,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { } // Apply SELinux context if requested - #[cfg(all(feature = "selinux", target_os = "linux"))] + #[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] { // Extract the SELinux related flags and options let set_security_context = matches.get_flag(options::SECURITY_CONTEXT); diff --git a/src/uu/runcon/Cargo.toml b/src/uu/runcon/Cargo.toml index f358c31ec..fdb2f5174 100644 --- a/src/uu/runcon/Cargo.toml +++ b/src/uu/runcon/Cargo.toml @@ -17,7 +17,8 @@ workspace = true [lib] path = "src/runcon.rs" -[target.'cfg(target_os = "linux")'.dependencies] # todo: block fetching crates without feat_selinux +# TODO: block fetching crates without feat_selinux +[target.'cfg(any(target_os = "linux", target_os = "android"))'.dependencies] clap = { workspace = true } uucore = { workspace = true, features = ["entries", "fs", "perms", "selinux"] } selinux = { workspace = true } diff --git a/src/uu/runcon/src/errors.rs b/src/uu/runcon/src/errors.rs index 4fa3135ca..49dc83d16 100644 --- a/src/uu/runcon/src/errors.rs +++ b/src/uu/runcon/src/errors.rs @@ -2,7 +2,8 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -#![cfg(target_os = "linux")] + +#![cfg(any(target_os = "linux", target_os = "android"))] use std::ffi::OsString; use std::fmt::{Display, Formatter, Write}; diff --git a/src/uu/runcon/src/main.rs b/src/uu/runcon/src/main.rs index dde0f2394..947934af1 100644 --- a/src/uu/runcon/src/main.rs +++ b/src/uu/runcon/src/main.rs @@ -1,11 +1,18 @@ -// On non-Linux targets, provide a stub main to keep the binary target present -// and the workspace buildable. Using item-level cfg avoids excluding the crate -// entirely (via #![cfg(...)]), which can break tooling and cross builds that -// expect this binary to exist even when it's a no-op off Linux. -#[cfg(target_os = "linux")] +// This file is part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +//! This package is specific to Android and some Linux distributions. On other +//! targets, provide a stub main to keep the binary target present and the +//! workspace buildable. Using item-level cfg avoids excluding the crate +//! entirely (via #![cfg(...)]), which can break tooling and cross builds that +//! expect this binary to exist even when it's a no-op off Linux. + +#[cfg(any(target_os = "linux", target_os = "android"))] uucore::bin!(uu_runcon); -#[cfg(not(target_os = "linux"))] +#[cfg(not(any(target_os = "linux", target_os = "android")))] fn main() { eprintln!("runcon: SELinux is not supported on this platform"); std::process::exit(1); diff --git a/src/uu/runcon/src/runcon.rs b/src/uu/runcon/src/runcon.rs index 60c71d1dc..128d0dce3 100644 --- a/src/uu/runcon/src/runcon.rs +++ b/src/uu/runcon/src/runcon.rs @@ -2,8 +2,10 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:ignore (vars) RFILE execv execvp -#![cfg(target_os = "linux")] + +#![cfg(any(target_os = "linux", target_os = "android"))] use clap::builder::ValueParser; use uucore::error::{UError, UResult}; diff --git a/src/uu/stat/src/stat.rs b/src/uu/stat/src/stat.rs index a7a876b08..24982e6a4 100644 --- a/src/uu/stat/src/stat.rs +++ b/src/uu/stat/src/stat.rs @@ -1044,7 +1044,10 @@ impl Stater { 'B' => OutputType::Unsigned(512), // SELinux security context string 'C' => { - #[cfg(all(feature = "selinux", target_os = "linux"))] + #[cfg(all( + feature = "selinux", + any(target_os = "linux", target_os = "android") + ))] { if uucore::selinux::is_selinux_enabled() { match uucore::selinux::get_selinux_security_context( @@ -1060,7 +1063,10 @@ impl Stater { OutputType::Str(translate!("stat-selinux-unsupported-system")) } } - #[cfg(not(all(feature = "selinux", target_os = "linux")))] + #[cfg(not(all( + feature = "selinux", + any(target_os = "linux", target_os = "android") + )))] { OutputType::Str(translate!("stat-selinux-unsupported-os")) } diff --git a/src/uucore/src/lib/features.rs b/src/uucore/src/lib/features.rs index cd2ce405f..03d410160 100644 --- a/src/uucore/src/lib/features.rs +++ b/src/uucore/src/lib/features.rs @@ -81,7 +81,7 @@ pub mod tty; pub mod fsxattr; #[cfg(feature = "hardware")] pub mod hardware; -#[cfg(all(target_os = "linux", feature = "selinux"))] +#[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] pub mod selinux; #[cfg(all(unix, not(target_os = "fuchsia"), feature = "signals"))] pub mod signals; diff --git a/src/uucore/src/lib/lib.rs b/src/uucore/src/lib/lib.rs index c1ece8bff..03ae3d955 100644 --- a/src/uucore/src/lib/lib.rs +++ b/src/uucore/src/lib/lib.rs @@ -122,7 +122,7 @@ pub use crate::features::fsext; #[cfg(all(unix, feature = "fsxattr"))] pub use crate::features::fsxattr; -#[cfg(all(target_os = "linux", feature = "selinux"))] +#[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] pub use crate::features::selinux; #[cfg(all(target_os = "linux", feature = "smack"))] From 7598f8efa8da7c1fe2819967a742a0242da4b501 Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Thu, 22 Jan 2026 16:40:09 +0900 Subject: [PATCH 317/425] Merge pull request #10424 from oech3/patch-12 CICD.yml: Merge 2 tests --- .github/workflows/CICD.yml | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/.github/workflows/CICD.yml b/.github/workflows/CICD.yml index 263036fbf..0ff6caeb3 100644 --- a/.github/workflows/CICD.yml +++ b/.github/workflows/CICD.yml @@ -848,16 +848,8 @@ jobs: run: | ## Test ${{ steps.vars.outputs.CARGO_CMD }} ${{ steps.vars.outputs.CARGO_CMD_OPTIONS }} test --target=${{ matrix.job.target }} \ - ${{ steps.vars.outputs.CARGO_TEST_OPTIONS}} ${{ matrix.job.cargo-options }} ${{ steps.vars.outputs.CARGO_FEATURES_OPTION }} ${{ steps.vars.outputs.CARGO_DEFAULT_FEATURES_OPTION }} - env: - RUST_BACKTRACE: "1" - - name: Test individual utilities - if: matrix.job.skip-tests != true - shell: bash - run: | - ## 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 }} + ${{ steps.vars.outputs.CARGO_TEST_OPTIONS}} ${{ matrix.job.cargo-options }} ${{ steps.vars.outputs.CARGO_FEATURES_OPTION }} ${{ steps.vars.outputs.CARGO_DEFAULT_FEATURES_OPTION }} \ + ${{ steps.dep_vars.outputs.CARGO_UTILITY_LIST_OPTIONS }} -p coreutils env: RUST_BACKTRACE: "1" - name: Archive executable artifacts From fd9390cacc4589a27f72b3687e517fc18be7fab0 Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Thu, 22 Jan 2026 16:41:13 +0900 Subject: [PATCH 318/425] openbsf.yml: Try to reduce time (#10423) --- .github/workflows/openbsd.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/openbsd.yml b/.github/workflows/openbsd.yml index 7a14240c8..be27d6222 100644 --- a/.github/workflows/openbsd.yml +++ b/.github/workflows/openbsd.yml @@ -47,10 +47,10 @@ jobs: prepare: | # Clean up disk space before installing packages df -h - rm -rf /usr/share/relink/* /usr/X11R6/* /usr/share/doc/* /usr/share/man/* || : pkg_add curl sudo-- jq coreutils bash rust rust-clippy rust-rustfmt llvm-- + rm -rf /usr/share/relink/* /usr/X11R6/* /usr/share/doc/* /usr/share/man/* & # Clean up package cache after installation - pkg_delete -a || true + pkg_delete -a & df -h run: | ## Prepare, build, and test @@ -137,15 +137,15 @@ jobs: usesh: true sync: rsync copyback: false - mem: 4096 + mem: 6144 # Install rust and build dependencies from OpenBSD packages (llvm provides libclang for bindgen) prepare: | # Clean up disk space before installing packages df -h - rm -rf /usr/share/relink/* /usr/X11R6/* /usr/share/doc/* /usr/share/man/* || : + rm -rf /usr/share/relink/* /usr/X11R6/* /usr/share/doc/* /usr/share/man/* & pkg_add curl gmake sudo-- jq rust llvm-- # Clean up package cache after installation - pkg_delete -a || : + pkg_delete -a & df -h run: | ## Prepare, build, and test @@ -194,7 +194,7 @@ jobs: set +e cd "${WORKSPACE}" unset FAULT - cargo build || FAULT=1 + # openbsd is very slow. Omit duplicated cargo build and do test only export PATH=~/.cargo/bin:${PATH} export RUST_BACKTRACE=1 export CARGO_TERM_COLOR=always @@ -208,7 +208,7 @@ jobs: cargo test --features "\$UUCORE_FEATURES" -p uucore || FAULT=1 fi # Test building with make - if (test -z "\$FAULT"); then make || FAULT=1 ; fi + if (test -z "\$FAULT"); then make MULTICALL=Y || FAULT=1 ; fi # Clean to avoid to rsync back the files and free up disk space cargo clean # Additional cleanup to free disk space From 805390d6580d2c7b94f532f7db426e104f085d7a Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Thu, 22 Jan 2026 18:15:35 +0900 Subject: [PATCH 319/425] freebsd.yml: Drop useless cache actions (#10362) * freebsd.yml: Drop useless cache actions * frebsd.yml: Drop sccache --- .github/workflows/freebsd.yml | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index 549f2ba85..3020105c6 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -1,6 +1,6 @@ name: FreeBSD -# spell-checker:ignore sshfs usesh vmactions taiki Swatinem esac fdescfs fdesc sccache nextest copyback logind +# spell-checker:ignore sshfs usesh vmactions taiki Swatinem esac fdescfs fdesc nextest copyback logind env: # * style job configuration @@ -30,18 +30,10 @@ jobs: matrix: job: - { os: ubuntu-24.04 , features: unix } - env: - SCCACHE_GHA_ENABLED: "true" - RUSTC_WRAPPER: "sccache" steps: - uses: actions/checkout@v6 with: persist-credentials: false - - uses: Swatinem/rust-cache@v2 - - name: Run sccache-cache - uses: mozilla-actions/sccache-action@v0.0.9 - with: - disable_annotations: true - name: Prepare, build and test uses: vmactions/freebsd-vm@v1.2.9 with: @@ -127,19 +119,12 @@ jobs: - { os: ubuntu-24.04 , features: unix } env: mem: 4096 - SCCACHE_GHA_ENABLED: "true" - RUSTC_WRAPPER: "sccache" steps: - uses: actions/checkout@v6 with: persist-credentials: false - name: Avoid no space left on device (Ubuntu runner) 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 - with: - disable_annotations: true - name: Prepare, build and test uses: vmactions/freebsd-vm@v1.2.9 with: From 75b9ad4a32d027a40748ad5af4b6d9a5e954d1b3 Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Thu, 22 Jan 2026 13:48:39 +0100 Subject: [PATCH 320/425] date: handle parentheses as comments like GNU date (#10133) * date: handle parentheses as comments like GNU date * simplify the code Co-authored-by: Daniel Hofstetter --------- Co-authored-by: Daniel Hofstetter --- src/uu/date/src/date.rs | 76 +++++++++++++++++++++++++++++++++++++- tests/by-util/test_date.rs | 37 +++++++++++++++++++ 2 files changed, 112 insertions(+), 1 deletion(-) diff --git a/src/uu/date/src/date.rs b/src/uu/date/src/date.rs index b63b04bf4..d1156f061 100644 --- a/src/uu/date/src/date.rs +++ b/src/uu/date/src/date.rs @@ -3,7 +3,7 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore strtime ; (format) DATEFILE MMDDhhmm ; (vars) datetime datetimes getres AWST ACST AEST +// spell-checker:ignore strtime ; (format) DATEFILE MMDDhhmm ; (vars) datetime datetimes getres AWST ACST AEST foobarbaz mod locale; @@ -11,6 +11,7 @@ use clap::{Arg, ArgAction, Command}; use jiff::fmt::strtime::{self, BrokenDownTime, Config, PosixCustom}; use jiff::tz::{TimeZone, TimeZoneDatabase}; use jiff::{Timestamp, Zoned}; +use std::borrow::Cow; use std::collections::HashMap; use std::fs::File; use std::io::{BufRead, BufReader, BufWriter, Write}; @@ -130,6 +131,42 @@ enum DayDelta { Next, } +/// Strip parenthesized comments from a date string. +/// +/// GNU date removes balanced parentheses and their content, treating them as comments. +/// If parentheses are unbalanced, everything from the unmatched '(' onwards is ignored. +/// +/// Examples: +/// - "2026(comment)-01-05" -> "2026-01-05" +/// - "1(ignore comment to eol" -> "1" +/// - "(" -> "" +/// - "((foo)2026-01-05)" -> "" +fn strip_parenthesized_comments(input: &str) -> Cow<'_, str> { + if !input.contains('(') { + return Cow::Borrowed(input); + } + + let mut result = String::with_capacity(input.len()); + let mut depth = 0; + + for c in input.chars() { + match c { + '(' => { + depth += 1; + } + ')' if depth > 0 => { + depth -= 1; + } + _ if depth == 0 => { + result.push(c); + } + _ => {} + } + } + + Cow::Owned(result) +} + /// Parse military timezone with optional hour offset. /// Pattern: single letter (a-z except j) optionally followed by 1-2 digits. /// Returns Some(total_hours_in_utc) or None if pattern doesn't match. @@ -286,7 +323,10 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { // Iterate over all dates - whether it's a single date or a file. let dates: Box> = match settings.date_source { DateSource::Human(ref input) => { + // GNU compatibility (Comments in parentheses) + let input = strip_parenthesized_comments(input); let input = input.trim(); + // GNU compatibility (Empty string): // An empty string (or whitespace-only) should be treated as midnight today. let is_empty_or_whitespace = input.is_empty(); @@ -887,4 +927,38 @@ mod tests { assert_eq!(parse_military_timezone_with_offset("m999"), None); // Too long assert_eq!(parse_military_timezone_with_offset("9m"), None); // Starts with digit } + + #[test] + fn test_strip_parenthesized_comments() { + assert_eq!(strip_parenthesized_comments("hello"), "hello"); + assert_eq!(strip_parenthesized_comments("2026-01-05"), "2026-01-05"); + assert_eq!(strip_parenthesized_comments("("), ""); + assert_eq!(strip_parenthesized_comments("1(comment"), "1"); + assert_eq!( + strip_parenthesized_comments("2026-01-05(this is a comment"), + "2026-01-05" + ); + assert_eq!( + strip_parenthesized_comments("2026(comment)-01-05"), + "2026-01-05" + ); + assert_eq!(strip_parenthesized_comments("()"), ""); + assert_eq!(strip_parenthesized_comments("((foo)2026-01-05)"), ""); + + // These cases test the balanced parentheses removal feature + // which extends beyond what GNU date strictly supports + assert_eq!(strip_parenthesized_comments("a(b)c"), "ac"); + assert_eq!(strip_parenthesized_comments("a(b)c(d)e"), "ace"); + assert_eq!(strip_parenthesized_comments("(a)(b)"), ""); + + // When parentheses are unmatched, processing stops at the unmatched opening paren + // In this case "a(b)c(d", the (b) is balanced but (d is unmatched + // We process "a(b)c" and stop at the unmatched "(d" + assert_eq!(strip_parenthesized_comments("a(b)c(d"), "ac"); + + // Additional edge cases for nested and complex parentheses + assert_eq!(strip_parenthesized_comments("a(b(c)d)e"), "ae"); // Nested balanced + assert_eq!(strip_parenthesized_comments("a(b(c)d"), "a"); // Nested unbalanced + assert_eq!(strip_parenthesized_comments("a(b)c(d)e(f"), "ace"); // Multiple groups, last unmatched + } } diff --git a/tests/by-util/test_date.rs b/tests/by-util/test_date.rs index 336d07a1c..2f064aaf2 100644 --- a/tests/by-util/test_date.rs +++ b/tests/by-util/test_date.rs @@ -1497,3 +1497,40 @@ fn test_date_format_x_locale_aware() { .succeeds() .stdout_is("19/01/1997\n"); } + +#[test] +fn test_date_parenthesis_comment() { + // GNU compatibility: Text in parentheses is treated as a comment and removed. + let cases = [ + // (input, format, expected_output) + ("(", "+%H:%M:%S", "00:00:00\n"), + ("1(ignore comment to eol", "+%H:%M:%S", "01:00:00\n"), + ("2026-01-05(this is a comment", "+%Y-%m-%d", "2026-01-05\n"), + ("2026(this is a comment)-01-05", "+%Y-%m-%d", "2026-01-05\n"), + ("((foo)2026-01-05)", "+%H:%M:%S", "00:00:00\n"), // Nested/unbalanced case + ("(2026-01-05(foo))", "+%H:%M:%S", "00:00:00\n"), // Balanced parentheses removed (empty result) + ]; + + for (input, format, expected) in cases { + new_ucmd!() + .env("TZ", "UTC") + .arg("-d") + .arg(input) + .arg("-u") + .arg(format) + .succeeds() + .stdout_only(expected); + } +} + +#[test] +fn test_date_parenthesis_vs_other_special_chars() { + // Ensure parentheses are special but other chars like [, ., ^ are still rejected + for special_char in ["[", ".", "^"] { + new_ucmd!() + .arg("-d") + .arg(special_char) + .fails() + .stderr_contains("invalid date"); + } +} From 08789257f60cfac9501f1f2965084e0ef25d5017 Mon Sep 17 00:00:00 2001 From: 0xferrous <0xferrous@proton.me> Date: Thu, 22 Jan 2026 18:19:02 +0530 Subject: [PATCH 321/425] chore: add test for setgid bit inheritance in mkdir -p (#10412) --- tests/by-util/test_mkdir.rs | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/by-util/test_mkdir.rs b/tests/by-util/test_mkdir.rs index 5d68fadfd..0756cb5d6 100644 --- a/tests/by-util/test_mkdir.rs +++ b/tests/by-util/test_mkdir.rs @@ -908,6 +908,33 @@ fn test_mkdir_parent_mode_with_explicit_mode() { ); } +/// Test that nested directories inherit the setgid bit with mkdir -p. +#[test] +#[cfg(target_os = "linux")] +fn test_mkdir_parent_inherits_setgid() { + let (at, mut ucmd) = at_and_ucmd!(); + + at.mkdir("parent"); + at.set_mode("parent", 0o2755); + + ucmd.arg("-p") + .arg("parent/child/grandchild") + .succeeds() + .no_stderr() + .no_stdout(); + + // All descendants should inherit the setgid bit (0o2000) + assert_eq!(at.metadata("parent").permissions().mode() & 0o2000, 0o2000); + assert_eq!( + at.metadata("parent/child").permissions().mode() & 0o2000, + 0o2000 + ); + assert_eq!( + at.metadata("parent/child/grandchild").permissions().mode() & 0o2000, + 0o2000 + ); +} + #[test] fn test_mkdir_concurrent_creation() { // Test concurrent mkdir -p operations: 10 iterations, 8 threads, 40 levels nesting From 2de0a5b51e0e663b0bc9cd0bfdd29601691167b7 Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Thu, 22 Jan 2026 14:16:29 +0100 Subject: [PATCH 322/425] tail: fix test suspension by redirecting stdin to null (#10431) Caused: zsh: suspended (tty input) cargo test --- tests/by-util/test_tail.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/by-util/test_tail.rs b/tests/by-util/test_tail.rs index 9d4a270e2..369ac9ee6 100644 --- a/tests/by-util/test_tail.rs +++ b/tests/by-util/test_tail.rs @@ -2742,12 +2742,12 @@ fn test_fifo() { not(target_os = "openbsd") ))] fn test_fifo_with_pid() { - use std::process::Command; + use std::process::{Command, Stdio}; let (at, mut ucmd) = at_and_ucmd!(); at.mkfifo("FIFO"); - let mut dummy = Command::new("sh").spawn().unwrap(); + let mut dummy = Command::new("sh").stdin(Stdio::null()).spawn().unwrap(); let pid = dummy.id(); let mut child = ucmd From df04d0280a7cacfbb56e79ba3dfa7ade9045d961 Mon Sep 17 00:00:00 2001 From: Aaron Ang <67321817+aaron-ang@users.noreply.github.com> Date: Thu, 22 Jan 2026 10:17:17 -0500 Subject: [PATCH 323/425] fix: clippy unnecessary unwrap --- src/uu/split/src/split.rs | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/uu/split/src/split.rs b/src/uu/split/src/split.rs index c40a6a07e..fcab096e2 100644 --- a/src/uu/split/src/split.rs +++ b/src/uu/split/src/split.rs @@ -1028,14 +1028,16 @@ impl ManageOutFiles for OutFiles { // Could have hit system limit for open files. // Try to close one previously instantiated writer first for (i, out_file) in self.iter_mut().enumerate() { - if i != idx && out_file.maybe_writer.is_some() { - out_file.maybe_writer.as_mut().unwrap().flush()?; - out_file.maybe_writer = None; - out_file.is_new = false; - count += 1; + if i != idx { + if let Some(writer) = out_file.maybe_writer.as_mut() { + writer.flush()?; + out_file.maybe_writer = None; + out_file.is_new = false; + count += 1; - // And then try to instantiate the writer again - continue 'loop1; + // And then try to instantiate the writer again + continue 'loop1; + } } } From 26bb25929816733b12a6fba2a3cb7f7579492caa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marco=20Trevisan=20=28Trevi=C3=B1o=29?= Date: Thu, 22 Jan 2026 17:59:26 +0100 Subject: [PATCH 324/425] test-mv: Use temporary directory in /dev/shm The test is using a predictable path which implies that running the test multiple times fails --- tests/by-util/test_mv.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/by-util/test_mv.rs b/tests/by-util/test_mv.rs index 5592f9c1e..d756cea7d 100644 --- a/tests/by-util/test_mv.rs +++ b/tests/by-util/test_mv.rs @@ -626,11 +626,17 @@ fn test_mv_symlink_into_target() { #[cfg(target_os = "linux")] #[test] fn test_mv_broken_symlink_to_another_fs() { + use tempfile::TempDir; + let scene = TestScenario::new(util_name!()); scene.fixtures.mkdir("foo"); scene.fixtures.symlink_file("missing", "foo/dangling"); - let dest = "/dev/shm/foo"; + + let other_fs_tempdir = + TempDir::new_in("/dev/shm/").expect("Unable to create temp directory in /dev/shm"); + let dest = other_fs_tempdir.path().join("foo"); + scene .ucmd() .arg("foo") From 6ca2cb4922f89b50e09453002fb2ed53dd7b3ee5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marco=20Trevisan=20=28Trevi=C3=B1o=29?= Date: Thu, 22 Jan 2026 16:57:39 +0100 Subject: [PATCH 325/425] df/table: Sum scaled values to compute the totals In order to show the total row we are summing intermediate values as they are and eventually scaling them. Now, while this provides a valid output, it also implies that the total value that we show is not matching the sum of the previously listed values, since the sum of scaled values may different from the sum of the original values that gets eventually scaled. To be consistent with the output: - Use a new BytesCell struct to track the bytes values - Keep a scaled value tracked - Show the sum of the scaled values as total Closes: #10436 --- src/uu/df/src/table.rs | 132 ++++++++++++++++++++++++++++------------- 1 file changed, 90 insertions(+), 42 deletions(-) diff --git a/src/uu/df/src/table.rs b/src/uu/df/src/table.rs index a50861758..8a6e29926 100644 --- a/src/uu/df/src/table.rs +++ b/src/uu/df/src/table.rs @@ -18,7 +18,7 @@ use uucore::translate; use std::ffi::OsString; use std::iter; -use std::ops::AddAssign; +use std::ops::{Add, AddAssign}; /// A row in the filesystem usage data table. /// @@ -38,13 +38,13 @@ pub(crate) struct Row { fs_mount: OsString, /// Total number of bytes in the filesystem regardless of whether they are used. - bytes: u64, + bytes: BytesCell, /// Number of used bytes. - bytes_used: u64, + bytes_used: BytesCell, /// Number of available bytes. - bytes_avail: u64, + bytes_avail: BytesCell, /// Percentage of bytes that are used, given as a float between 0 and 1. /// @@ -81,9 +81,9 @@ impl Row { fs_device: source.into(), fs_type: "-".into(), fs_mount: "-".into(), - bytes: 0, - bytes_used: 0, - bytes_avail: 0, + bytes: BytesCell::default(), + bytes_used: BytesCell::default(), + bytes_avail: BytesCell::default(), bytes_usage: None, #[cfg(target_os = "macos")] bytes_capacity: None, @@ -114,13 +114,13 @@ impl AddAssign for Row { bytes, bytes_used, bytes_avail, - bytes_usage: if bytes == 0 { + bytes_usage: if bytes.bytes == 0 { None } else { // We use "(bytes_used + bytes_avail)" instead of "bytes" because on some filesystems (e.g. // ext4) "bytes" also includes reserved blocks we ignore for the usage calculation. // https://www.gnu.org/software/coreutils/faq/coreutils-faq.html#df-Size-and-Used-and-Available-do-not-add-up - Some(bytes_used as f64 / (bytes_used + bytes_avail) as f64) + Some(bytes_used.bytes as f64 / (bytes_used.bytes + bytes_avail.bytes) as f64) }, // TODO Figure out how to compute this. #[cfg(target_os = "macos")] @@ -137,8 +137,8 @@ impl AddAssign for Row { } } -impl From for Row { - fn from(fs: Filesystem) -> Self { +impl Row { + fn from_filesystem(fs: Filesystem, row_block_size: &BlockSize) -> Self { let MountInfo { dev_name, fs_type, @@ -163,9 +163,9 @@ impl From for Row { fs_device: dev_name, fs_type, fs_mount: mount_dir, - bytes: blocksize * blocks, - bytes_used: blocksize * bused, - bytes_avail: blocksize * bavail, + bytes: BytesCell::new(blocks * blocksize, row_block_size), + bytes_used: BytesCell::new(bused * blocksize, row_block_size), + bytes_avail: BytesCell::new(bavail * blocksize, row_block_size), bytes_usage: if blocks == 0 { None } else { @@ -192,6 +192,48 @@ impl From for Row { } } +#[derive(Debug, Copy, Clone)] +struct BytesCell { + bytes: u64, + scaled: u64, +} + +/// A bytes column in the filesystem usage data table. +/// +/// This is used to keep track of the scaled values to properly compute +/// the total values. +impl Default for BytesCell { + fn default() -> Self { + Self { + bytes: 0, + scaled: 0, + } + } +} + +impl BytesCell { + fn new(bytes: u64, block_size: &BlockSize) -> Self { + Self { + bytes, + scaled: { + let BlockSize::Bytes(d) = block_size; + (bytes as f64 / *d as f64).ceil() as u64 + }, + } + } +} + +impl Add for BytesCell { + type Output = Self; + + fn add(self, rhs: Self) -> Self { + Self { + bytes: self.bytes + rhs.bytes, + scaled: self.scaled + rhs.scaled, + } + } +} + /// A `Cell` in the table. We store raw `bytes` as the data (e.g. directory name /// may be non-Unicode). We also record the printed `width` for alignment purpose, /// as it is easier to compute on the original string. @@ -262,12 +304,18 @@ impl<'a> RowFormatter<'a> { /// Get a string giving the scaled version of the input number. /// /// The scaling factor is defined in the `options` field. - fn scaled_bytes(&self, size: u64) -> Cell { + fn scaled_bytes(&self, bytes_column: &BytesCell) -> Cell { + let size = bytes_column.scaled; let s = if let Some(h) = self.options.human_readable { + let size = if self.is_total_row { + let BlockSize::Bytes(d) = self.options.block_size; + d * size + } else { + bytes_column.bytes + }; to_magnitude_and_suffix(size.into(), SuffixType::HumanReadable(h), true) } else { - let BlockSize::Bytes(d) = self.options.block_size; - (size as f64 / d as f64).ceil().to_string() + size.to_string() }; Cell::from_ascii_string(s) } @@ -308,9 +356,9 @@ impl<'a> RowFormatter<'a> { Cell::from_string(&self.row.fs_device) } } - Column::Size => self.scaled_bytes(self.row.bytes), - Column::Used => self.scaled_bytes(self.row.bytes_used), - Column::Avail => self.scaled_bytes(self.row.bytes_avail), + Column::Size => self.scaled_bytes(&self.row.bytes), + Column::Used => self.scaled_bytes(&self.row.bytes_used), + Column::Avail => self.scaled_bytes(&self.row.bytes_avail), Column::Pcent => Self::percentage(self.row.bytes_usage), Column::Target => { @@ -442,7 +490,7 @@ impl Table { // showing all filesystems, then print the data as a row in // the output table. if options.show_all_fs || filesystem.usage.blocks > 0 { - let row = Row::from(filesystem); + let row = Row::from_filesystem(filesystem, &options.block_size); let fmt = RowFormatter::new(&row, options, false); let values = fmt.get_cells(); total += row; @@ -527,7 +575,7 @@ mod tests { use crate::blocks::HumanReadable; use crate::columns::Column; - use crate::table::{Cell, Header, HeaderMode, Row, RowFormatter, Table}; + use crate::table::{BytesCell, Cell, Header, HeaderMode, Row, RowFormatter, Table}; use crate::{BlockSize, Options}; fn init() { @@ -563,9 +611,9 @@ mod tests { fs_type: "my_type".to_string(), fs_mount: "my_mount".into(), - bytes: 100, - bytes_used: 25, - bytes_avail: 75, + bytes: BytesCell::new(100, &BlockSize::Bytes(1)), + bytes_used: BytesCell::new(25, &BlockSize::Bytes(1)), + bytes_avail: BytesCell::new(75, &BlockSize::Bytes(1)), bytes_usage: Some(0.25), #[cfg(target_os = "macos")] @@ -729,9 +777,9 @@ mod tests { fs_device: "my_device".to_string(), fs_mount: "my_mount".into(), - bytes: 100, - bytes_used: 25, - bytes_avail: 75, + bytes: BytesCell::new(100, &BlockSize::Bytes(1)), + bytes_used: BytesCell::new(25, &BlockSize::Bytes(1)), + bytes_avail: BytesCell::new(75, &BlockSize::Bytes(1)), bytes_usage: Some(0.25), ..Default::default() @@ -756,9 +804,9 @@ mod tests { fs_type: "my_type".to_string(), fs_mount: "my_mount".into(), - bytes: 100, - bytes_used: 25, - bytes_avail: 75, + bytes: BytesCell::new(100, &BlockSize::Bytes(1)), + bytes_used: BytesCell::new(25, &BlockSize::Bytes(1)), + bytes_avail: BytesCell::new(75, &BlockSize::Bytes(1)), bytes_usage: Some(0.25), ..Default::default() @@ -805,7 +853,7 @@ mod tests { ..Default::default() }; let row = Row { - bytes: 100, + bytes: BytesCell::new(100, &BlockSize::Bytes(100)), inodes: 10, ..Default::default() }; @@ -826,9 +874,9 @@ mod tests { fs_type: "my_type".to_string(), fs_mount: "my_mount".into(), - bytes: 40000, - bytes_used: 1000, - bytes_avail: 39000, + bytes: BytesCell::new(40000, &BlockSize::default()), + bytes_used: BytesCell::new(1000, &BlockSize::default()), + bytes_avail: BytesCell::new(39000, &BlockSize::default()), bytes_usage: Some(0.025), ..Default::default() @@ -861,9 +909,9 @@ mod tests { fs_type: "my_type".to_string(), fs_mount: "my_mount".into(), - bytes: 4096, - bytes_used: 1024, - bytes_avail: 3072, + bytes: BytesCell::new(4096, &BlockSize::default()), + bytes_used: BytesCell::new(1024, &BlockSize::default()), + bytes_avail: BytesCell::new(3072, &BlockSize::default()), bytes_usage: Some(0.25), ..Default::default() @@ -909,9 +957,9 @@ mod tests { }; let row = Row { - bytes, - bytes_used, - bytes_avail, + bytes: BytesCell::new(bytes, &BlockSize::Bytes(1000)), + bytes_used: BytesCell::new(bytes_used, &BlockSize::Bytes(1000)), + bytes_avail: BytesCell::new(bytes_avail, &BlockSize::Bytes(1000)), ..Default::default() }; RowFormatter::new(&row, &options, false).get_cells() @@ -962,7 +1010,7 @@ mod tests { }, }; - let row = Row::from(d); + let row = Row::from_filesystem(d, &BlockSize::default()); assert_eq!(row.inodes_used, 0); } From 9e1ab7b48952416b34681124295e6735ca461ee2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marco=20Trevisan=20=28Trevi=C3=B1o=29?= Date: Thu, 22 Jan 2026 17:20:34 +0100 Subject: [PATCH 326/425] df/table: Only compute total row if the user requested for it It adds extra (tiny, but still...) computation for no reason --- src/uu/df/src/table.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/uu/df/src/table.rs b/src/uu/df/src/table.rs index 8a6e29926..f4d83c3aa 100644 --- a/src/uu/df/src/table.rs +++ b/src/uu/df/src/table.rs @@ -493,7 +493,9 @@ impl Table { let row = Row::from_filesystem(filesystem, &options.block_size); let fmt = RowFormatter::new(&row, options, false); let values = fmt.get_cells(); - total += row; + if options.show_total { + total += row; + } rows.push(values); } From a00c12c0922e9563a7e170bf96c9692ae3b2ca8d Mon Sep 17 00:00:00 2001 From: xtqqczze <45661989+xtqqczze@users.noreply.github.com> Date: Thu, 22 Jan 2026 19:07:35 +0000 Subject: [PATCH 327/425] fix(deps): remove duplicate itertools package --- src/uucore/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/uucore/Cargo.toml b/src/uucore/Cargo.toml index e27e1d70e..1e52717d2 100644 --- a/src/uucore/Cargo.toml +++ b/src/uucore/Cargo.toml @@ -83,6 +83,7 @@ fluent-syntax = { workspace = true } unic-langid = { workspace = true } fluent-bundle = { workspace = true } thiserror = { workspace = true } + [target.'cfg(unix)'.dependencies] walkdir = { workspace = true, optional = true } nix = { workspace = true, features = [ @@ -95,7 +96,6 @@ nix = { workspace = true, features = [ "poll", ] } xattr = { workspace = true, optional = true } -itertools = { workspace = true, optional = true } [dev-dependencies] tempfile = { workspace = true } From 6db319ee12bc5a0748a4050e5f9d628aee05d99c Mon Sep 17 00:00:00 2001 From: mattsu <35655889+mattsu2020@users.noreply.github.com> Date: Fri, 23 Jan 2026 06:48:42 +0900 Subject: [PATCH 328/425] fold:fix gnu test fold-zero-width.sh (#9274) --------- Co-authored-by: Sylvestre Ledru --- Cargo.lock | 2 + Cargo.toml | 2 + src/uu/fold/src/fold.rs | 182 ++++++++++++++++------ tests/by-util/test_fold.rs | 299 +++++++++++++++++++++++++++++++++++++ 4 files changed, 443 insertions(+), 42 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 49bf30262..96e33704a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -518,6 +518,7 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" name = "coreutils" version = "0.6.0" dependencies = [ + "bytecount", "clap", "clap_complete", "clap_mangen", @@ -542,6 +543,7 @@ dependencies = [ "tempfile", "textwrap", "time", + "unicode-width 0.2.2", "unindent", "uu_arch", "uu_base32", diff --git a/Cargo.toml b/Cargo.toml index 3c8fea771..e4704a65b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -535,6 +535,7 @@ filetime.workspace = true glob.workspace = true jiff.workspace = true libc.workspace = true +bytecount.workspace = true num-prime.workspace = true pretty_assertions = "1.4.0" rand.workspace = true @@ -542,6 +543,7 @@ regex.workspace = true sha1 = { workspace = true, features = ["std"] } tempfile.workspace = true time = { workspace = true, features = ["local-offset"] } +unicode-width.workspace = true unindent = "0.2.3" uutests.workspace = true uucore = { workspace = true, features = [ diff --git a/src/uu/fold/src/fold.rs b/src/uu/fold/src/fold.rs index 2eb979331..d79d6d422 100644 --- a/src/uu/fold/src/fold.rs +++ b/src/uu/fold/src/fold.rs @@ -19,6 +19,10 @@ const TAB_WIDTH: usize = 8; const NL: u8 = b'\n'; const CR: u8 = b'\r'; const TAB: u8 = b'\t'; +// Implementation threshold (8 KiB) to prevent unbounded buffer growth during streaming. +// Chosen as a small, fixed cap: large enough to avoid excessive flushes, but +// small enough to keep memory bounded when the input has no fold points. +const STREAMING_FLUSH_THRESHOLD: usize = 8 * 1024; mod options { pub const BYTES: &str = "bytes"; @@ -288,6 +292,10 @@ fn compute_col_count(buffer: &[u8], mode: WidthMode) -> usize { } fn emit_output(ctx: &mut FoldContext<'_, W>) -> UResult<()> { + // Emit one folded line: + // - with `-s`, cut at the last remembered whitespace when possible + // - otherwise, cut at the current buffer end + // The remainder (if any) stays in the buffer for the next line. let consume = match *ctx.last_space { Some(index) => index + 1, None => ctx.output.len(), @@ -309,6 +317,7 @@ fn emit_output(ctx: &mut FoldContext<'_, W>) -> UResult<()> { *ctx.col_count = compute_col_count(ctx.output, ctx.mode); if ctx.spaces { + // Rebase the remembered whitespace position into the remaining buffer. *ctx.last_space = last_space.and_then(|idx| { if idx < consume { None @@ -322,6 +331,36 @@ fn emit_output(ctx: &mut FoldContext<'_, W>) -> UResult<()> { Ok(()) } +fn maybe_flush_unbroken_output(ctx: &mut FoldContext<'_, W>) -> UResult<()> { + // In streaming mode without `-s`, avoid unbounded buffering by periodically + // flushing long unbroken segments. With `-s` we must keep the buffer so we + // can still break at the last whitespace boundary. + if ctx.spaces || ctx.output.len() < STREAMING_FLUSH_THRESHOLD { + return Ok(()); + } + + // Write raw bytes without inserting a newline; folding will continue + // based on updated column tracking in the caller. + ctx.writer.write_all(ctx.output)?; + ctx.output.clear(); + Ok(()) +} + +fn push_byte(ctx: &mut FoldContext<'_, W>, byte: u8) -> UResult<()> { + // Append a single byte to the buffer. + ctx.output.push(byte); + maybe_flush_unbroken_output(ctx) +} + +fn push_bytes(ctx: &mut FoldContext<'_, W>, bytes: &[u8]) -> UResult<()> { + // Append a byte slice to the buffer and flush if it grows too large. + if bytes.is_empty() { + return Ok(()); + } + ctx.output.extend_from_slice(bytes); + maybe_flush_unbroken_output(ctx) +} + fn process_ascii_line(line: &[u8], ctx: &mut FoldContext<'_, W>) -> UResult<()> { let mut idx = 0; let len = line.len(); @@ -331,15 +370,15 @@ fn process_ascii_line(line: &[u8], ctx: &mut FoldContext<'_, W>) -> UR NL => { *ctx.last_space = None; emit_output(ctx)?; - break; + idx += 1; } CR => { - ctx.output.push(CR); + push_byte(ctx, CR)?; *ctx.col_count = 0; idx += 1; } 0x08 => { - ctx.output.push(0x08); + push_byte(ctx, 0x08)?; *ctx.col_count = ctx.col_count.saturating_sub(1); idx += 1; } @@ -358,16 +397,23 @@ fn process_ascii_line(line: &[u8], ctx: &mut FoldContext<'_, W>) -> UR } else { *ctx.last_space = None; } - ctx.output.push(TAB); + push_byte(ctx, TAB)?; idx += 1; } 0x00..=0x07 | 0x0B..=0x0C | 0x0E..=0x1F | 0x7F => { - ctx.output.push(line[idx]); + push_byte(ctx, line[idx])?; if ctx.spaces && line[idx].is_ascii_whitespace() && line[idx] != CR { *ctx.last_space = Some(ctx.output.len() - 1); } else if !ctx.spaces { *ctx.last_space = None; } + + if ctx.mode == WidthMode::Characters { + *ctx.col_count = ctx.col_count.saturating_add(1); + if *ctx.col_count >= ctx.width { + emit_output(ctx)?; + } + } idx += 1; } _ => { @@ -405,7 +451,7 @@ fn push_ascii_segment(segment: &[u8], ctx: &mut FoldContext<'_, W>) -> let take = remaining.len().min(available); let base_len = ctx.output.len(); - ctx.output.extend_from_slice(&remaining[..take]); + push_bytes(ctx, &remaining[..take])?; *ctx.col_count += take; if ctx.spaces { @@ -430,16 +476,26 @@ fn process_utf8_line(line: &str, ctx: &mut FoldContext<'_, W>) -> URes return process_ascii_line(line.as_bytes(), ctx); } + process_utf8_chars(line, ctx) +} + +fn process_utf8_chars(line: &str, ctx: &mut FoldContext<'_, W>) -> UResult<()> { let line_bytes = line.as_bytes(); let mut iter = line.char_indices().peekable(); while let Some((byte_idx, ch)) = iter.next() { - // Include combining characters with the base character - while let Some(&(_, next_ch)) = iter.peek() { - if unicode_width::UnicodeWidthChar::width(next_ch).unwrap_or(1) == 0 { - iter.next(); - } else { - break; + // Include combining characters with the base character when we are + // measuring by display columns. In character-counting mode every + // scalar value must advance the counter to match `chars().count()` + // semantics (see `fold_characters_reference` in the tests), so we do + // not coalesce zero-width scalars there. + if ctx.mode == WidthMode::Columns { + while let Some(&(_, next_ch)) = iter.peek() { + if unicode_width::UnicodeWidthChar::width(next_ch).unwrap_or(1) == 0 { + iter.next(); + } else { + break; + } } } @@ -448,7 +504,7 @@ fn process_utf8_line(line: &str, ctx: &mut FoldContext<'_, W>) -> URes if ch == '\n' { *ctx.last_space = None; emit_output(ctx)?; - break; + continue; } if *ctx.col_count >= ctx.width { @@ -456,15 +512,13 @@ fn process_utf8_line(line: &str, ctx: &mut FoldContext<'_, W>) -> URes } if ch == '\r' { - ctx.output - .extend_from_slice(&line_bytes[byte_idx..next_idx]); + push_bytes(ctx, &line_bytes[byte_idx..next_idx])?; *ctx.col_count = 0; continue; } if ch == '\x08' { - ctx.output - .extend_from_slice(&line_bytes[byte_idx..next_idx]); + push_bytes(ctx, &line_bytes[byte_idx..next_idx])?; *ctx.col_count = ctx.col_count.saturating_sub(1); continue; } @@ -484,8 +538,7 @@ fn process_utf8_line(line: &str, ctx: &mut FoldContext<'_, W>) -> URes } else { *ctx.last_space = None; } - ctx.output - .extend_from_slice(&line_bytes[byte_idx..next_idx]); + push_bytes(ctx, &line_bytes[byte_idx..next_idx])?; continue; } @@ -506,8 +559,7 @@ fn process_utf8_line(line: &str, ctx: &mut FoldContext<'_, W>) -> URes *ctx.last_space = Some(ctx.output.len()); } - ctx.output - .extend_from_slice(&line_bytes[byte_idx..next_idx]); + push_bytes(ctx, &line_bytes[byte_idx..next_idx])?; *ctx.col_count = ctx.col_count.saturating_add(added); } @@ -519,7 +571,7 @@ fn process_non_utf8_line(line: &[u8], ctx: &mut FoldContext<'_, W>) -> if byte == NL { *ctx.last_space = None; emit_output(ctx)?; - break; + continue; } if *ctx.col_count >= ctx.width { @@ -539,7 +591,7 @@ fn process_non_utf8_line(line: &[u8], ctx: &mut FoldContext<'_, W>) -> } else { None }; - ctx.output.push(byte); + push_byte(ctx, byte)?; continue; } 0x08 => *ctx.col_count = ctx.col_count.saturating_sub(1), @@ -550,7 +602,46 @@ fn process_non_utf8_line(line: &[u8], ctx: &mut FoldContext<'_, W>) -> _ => *ctx.col_count = ctx.col_count.saturating_add(1), } - ctx.output.push(byte); + push_byte(ctx, byte)?; + } + + Ok(()) +} + +/// Process buffered bytes, emitting output for valid UTF-8 prefixes and +/// deferring incomplete sequences until more input arrives. +/// +/// If the buffer contains invalid UTF-8, it is handled in non-UTF-8 mode and +/// the buffer is fully consumed. +fn process_pending_chunk( + pending: &mut Vec, + ctx: &mut FoldContext<'_, W>, +) -> UResult<()> { + while !pending.is_empty() { + match std::str::from_utf8(pending) { + Ok(valid) => { + process_utf8_line(valid, ctx)?; + pending.clear(); + break; + } + Err(err) => { + if err.error_len().is_some() { + let res = process_non_utf8_line(pending, ctx); + pending.clear(); + res?; + break; + } + + let valid_up_to = err.valid_up_to(); + if valid_up_to == 0 { + break; + } + + let valid = std::str::from_utf8(&pending[..valid_up_to]).expect("valid prefix"); + process_utf8_line(valid, ctx)?; + pending.drain(..valid_up_to); + } + } } Ok(()) @@ -572,20 +663,12 @@ fn fold_file( mode: WidthMode, writer: &mut W, ) -> UResult<()> { - let mut line = Vec::new(); let mut output = Vec::new(); let mut col_count = 0; let mut last_space = None; + let mut pending = Vec::with_capacity(8 * 1024); - loop { - if file - .read_until(NL, &mut line) - .map_err_context(|| translate!("fold-error-readline"))? - == 0 - { - break; - } - + { let mut ctx = FoldContext { spaces, width, @@ -596,17 +679,32 @@ fn fold_file( last_space: &mut last_space, }; - match std::str::from_utf8(&line) { - Ok(s) => process_utf8_line(s, &mut ctx)?, - Err(_) => process_non_utf8_line(&line, &mut ctx)?, + loop { + let buffer = file + .fill_buf() + .map_err_context(|| translate!("fold-error-readline"))?; + if buffer.is_empty() { + break; + } + pending.extend_from_slice(buffer); + let consumed = buffer.len(); + file.consume(consumed); + + process_pending_chunk(&mut pending, &mut ctx)?; } - line.clear(); - } + if !pending.is_empty() { + match std::str::from_utf8(&pending) { + Ok(s) => process_utf8_line(s, &mut ctx)?, + Err(_) => process_non_utf8_line(&pending, &mut ctx)?, + } + pending.clear(); + } - if !output.is_empty() { - writer.write_all(&output)?; - output.clear(); + if !ctx.output.is_empty() { + ctx.writer.write_all(ctx.output)?; + ctx.output.clear(); + } } Ok(()) diff --git a/tests/by-util/test_fold.rs b/tests/by-util/test_fold.rs index 9497044c9..1fe466ba5 100644 --- a/tests/by-util/test_fold.rs +++ b/tests/by-util/test_fold.rs @@ -4,7 +4,11 @@ // file that was distributed with this source code. // spell-checker:ignore fullwidth +use bytecount::count; +use unicode_width::UnicodeWidthChar; use uutests::new_ucmd; +use uutests::util::TestScenario; +use uutests::util_name; #[test] fn test_invalid_arg() { @@ -61,6 +65,301 @@ fn test_wide_characters_with_characters_option() { .stdout_is("\u{B250}\u{B250}\u{B250}\n"); } +#[test] +fn test_multiple_wide_characters_in_column_mode() { + let wide = '\u{FF1A}'; + let mut input = wide.to_string().repeat(50); + input.push('\n'); + + let mut expected = String::new(); + for i in 1..=50 { + expected.push(wide); + if i % 5 == 0 { + expected.push('\n'); + } + } + + new_ucmd!() + .args(&["-w", "10"]) + .pipe_in(input) + .succeeds() + .stdout_is(expected); +} + +#[test] +fn test_multiple_wide_characters_in_character_mode() { + let wide = '\u{FF1A}'; + let mut input = wide.to_string().repeat(50); + input.push('\n'); + + let mut expected = String::new(); + for i in 1..=50 { + expected.push(wide); + if i % 10 == 0 { + expected.push('\n'); + } + } + + new_ucmd!() + .args(&["--characters", "-w", "10"]) + .pipe_in(input) + .succeeds() + .stdout_is(expected); +} + +#[test] +fn test_unicode_on_reader_buffer_boundary_in_character_mode() { + let boundary = buf_reader_capacity().saturating_sub(1); + assert!(boundary > 0, "BufReader capacity must be greater than 1"); + + let mut input = "a".repeat(boundary); + input.push('\u{B250}'); + input.push_str(&"a".repeat(100)); + input.push('\n'); + + let expected_tail = tail_inclusive(&fold_characters_reference(&input, 80), 4); + + let result = new_ucmd!().arg("--characters").pipe_in(input).succeeds(); + + let actual_tail = tail_inclusive(result.stdout_str(), 4); + + assert_eq!(actual_tail, expected_tail); +} + +#[test] +fn test_fold_preserves_invalid_utf8_sequences() { + let bad_input: &[u8] = b"\xC3|\xED\xBA\xAD|\x00|\x89|\xED\xA6\xBF\xED\xBF\xBF\n"; + + new_ucmd!() + .pipe_in(bad_input.to_vec()) + .succeeds() + .stdout_is_bytes(bad_input); +} + +#[test] +fn test_fold_preserves_incomplete_utf8_at_eof() { + let trailing_byte: &[u8] = b"\xC3"; + + new_ucmd!() + .pipe_in(trailing_byte.to_vec()) + .succeeds() + .stdout_is_bytes(trailing_byte); +} + +#[test] +fn test_zero_width_bytes_in_column_mode() { + let len = io_buf_size_times_two(); + let input = vec![0u8; len]; + + new_ucmd!() + .pipe_in(input.clone()) + .succeeds() + .stdout_is_bytes(input); +} + +#[test] +fn test_zero_width_bytes_in_character_mode() { + let len = io_buf_size_times_two(); + let input = vec![0u8; len]; + let expected = fold_characters_reference_bytes(&input, 80); + + new_ucmd!() + .args(&["--characters"]) + .pipe_in(input) + .succeeds() + .stdout_is_bytes(expected); +} + +#[test] +fn test_zero_width_spaces_in_column_mode() { + let len = io_buf_size_times_two(); + let input = "\u{200B}".repeat(len); + + new_ucmd!() + .pipe_in(input.clone()) + .succeeds() + .stdout_is(&input); +} + +#[test] +fn test_zero_width_spaces_in_character_mode() { + let len = io_buf_size_times_two(); + let input = "\u{200B}".repeat(len); + let expected = fold_characters_reference(&input, 80); + + new_ucmd!() + .args(&["--characters"]) + .pipe_in(input) + .succeeds() + .stdout_is(&expected); +} + +#[test] +fn test_zero_width_bytes_from_file() { + let len = io_buf_size_times_two(); + let input = vec![0u8; len]; + let expected = fold_characters_reference_bytes(&input, 80); + + let ts = TestScenario::new(util_name!()); + let path = "zeros.bin"; + ts.fixtures.write_bytes(path, &input); + + ts.ucmd().arg(path).succeeds().stdout_is_bytes(&input); + + ts.ucmd() + .args(&["--characters", path]) + .succeeds() + .stdout_is_bytes(expected); +} + +#[test] +fn test_zero_width_spaces_from_file() { + let len = io_buf_size_times_two(); + let input = "\u{200B}".repeat(len); + let expected = fold_characters_reference(&input, 80); + + let ts = TestScenario::new(util_name!()); + let path = "zero-width.txt"; + ts.fixtures.write(path, &input); + + ts.ucmd().arg(path).succeeds().stdout_is(&input); + + ts.ucmd() + .args(&["--characters", path]) + .succeeds() + .stdout_is(&expected); +} + +#[test] +fn test_zero_width_data_line_counts() { + let len = io_buf_size_times_two(); + + let zero_bytes = vec![0u8; len]; + let column_bytes = new_ucmd!().pipe_in(zero_bytes.clone()).succeeds(); + assert_eq!( + newline_count(column_bytes.stdout()), + 0, + "fold should not wrap zero-width bytes in column mode", + ); + + let characters_bytes = new_ucmd!() + .args(&["--characters"]) + .pipe_in(zero_bytes) + .succeeds(); + assert_eq!( + newline_count(characters_bytes.stdout()), + len / 80, + "fold --characters should wrap zero-width bytes every 80 bytes", + ); + + if UnicodeWidthChar::width('\u{200B}') != Some(0) { + eprintln!("skip zero width space checks because width != 0"); + return; + } + + let zero_width_spaces = "\u{200B}".repeat(len); + let column_spaces = new_ucmd!().pipe_in(zero_width_spaces.clone()).succeeds(); + assert_eq!( + newline_count(column_spaces.stdout()), + 0, + "fold should keep zero-width spaces on a single line in column mode", + ); + + let characters_spaces = new_ucmd!() + .args(&["--characters"]) + .pipe_in(zero_width_spaces) + .succeeds(); + assert_eq!( + newline_count(characters_spaces.stdout()), + len / 80, + "fold --characters should wrap zero-width spaces every 80 characters", + ); +} + +#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "netbsd"))] +#[test] +fn test_fold_reports_no_space_left_on_dev_full() { + use std::fs::OpenOptions; + use std::process::Stdio; + + for &byte in &[b'\n', b'\0', 0xC3u8] { + let dev_full = OpenOptions::new() + .write(true) + .open("/dev/full") + .expect("/dev/full must exist on supported targets"); + + new_ucmd!() + .pipe_in(vec![byte; 1024]) + .set_stdout(Stdio::from(dev_full)) + .fails() + .stderr_contains("No space left"); + } +} + +fn buf_reader_capacity() -> usize { + std::io::BufReader::new(&b""[..]).capacity() +} + +fn io_buf_size_times_two() -> usize { + buf_reader_capacity() + .checked_mul(2) + .expect("BufReader capacity overflow") +} + +fn fold_characters_reference(input: &str, width: usize) -> String { + let mut output = String::with_capacity(input.len()); + let mut col_count = 0usize; + + for ch in input.chars() { + if ch == '\n' { + output.push('\n'); + col_count = 0; + continue; + } + + if col_count >= width { + output.push('\n'); + col_count = 0; + } + + output.push(ch); + col_count += 1; + } + + output +} + +fn fold_characters_reference_bytes(input: &[u8], width: usize) -> Vec { + let mut output = Vec::with_capacity(input.len() + input.len() / width + 1); + + for chunk in input.chunks(width) { + output.extend_from_slice(chunk); + if chunk.len() == width { + output.push(b'\n'); + } + } + + output +} + +fn newline_count(bytes: &[u8]) -> usize { + count(bytes, b'\n') +} + +fn tail_inclusive(text: &str, lines: usize) -> String { + if lines == 0 { + return String::new(); + } + + let segments: Vec<&str> = text.split_inclusive('\n').collect(); + if segments.is_empty() { + return text.to_owned(); + } + + let start = segments.len().saturating_sub(lines); + segments[start..].concat() +} + #[test] fn test_should_preserve_empty_line_without_final_newline() { new_ucmd!() From f19b9f00666652166f63b44f536e4703215f8034 Mon Sep 17 00:00:00 2001 From: xtqqczze <45661989+xtqqczze@users.noreply.github.com> Date: Thu, 22 Jan 2026 17:55:40 +0000 Subject: [PATCH 329/425] fix(deps): refactor nix package configuration --- Cargo.toml | 2 +- src/uu/dd/Cargo.toml | 2 +- src/uu/env/Cargo.toml | 1 - src/uu/kill/Cargo.toml | 4 +++- src/uu/mkfifo/Cargo.toml | 4 +++- src/uu/nice/Cargo.toml | 4 +++- src/uu/stty/Cargo.toml | 4 +++- src/uu/timeout/Cargo.toml | 4 +++- src/uu/tsort/Cargo.toml | 4 +++- src/uu/tty/Cargo.toml | 4 +++- src/uucore/Cargo.toml | 12 ++++++------ tests/uutests/Cargo.toml | 2 +- 12 files changed, 30 insertions(+), 17 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index e4704a65b..52c7b5ed5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -563,8 +563,8 @@ nix = { workspace = true, features = [ "process", "signal", "socket", - "user", "term", + "user", ] } rlimit = "0.10.1" xattr.workspace = true diff --git a/src/uu/dd/Cargo.toml b/src/uu/dd/Cargo.toml index f7941f5d3..5d5819c30 100644 --- a/src/uu/dd/Cargo.toml +++ b/src/uu/dd/Cargo.toml @@ -32,8 +32,8 @@ thiserror = { workspace = true } fluent = { workspace = true } [target.'cfg(any(target_os = "linux", target_os = "android"))'.dependencies] -signal-hook = { workspace = true } nix = { workspace = true, features = ["fs"] } +signal-hook = { workspace = true } [[bin]] name = "dd" diff --git a/src/uu/env/Cargo.toml b/src/uu/env/Cargo.toml index 80fe1f412..b2e4208b9 100644 --- a/src/uu/env/Cargo.toml +++ b/src/uu/env/Cargo.toml @@ -27,7 +27,6 @@ fluent = { workspace = true } [target.'cfg(unix)'.dependencies] nix = { workspace = true, features = ["signal"] } - [[bin]] name = "env" path = "src/main.rs" diff --git a/src/uu/kill/Cargo.toml b/src/uu/kill/Cargo.toml index 1813084af..10de8379d 100644 --- a/src/uu/kill/Cargo.toml +++ b/src/uu/kill/Cargo.toml @@ -19,10 +19,12 @@ path = "src/kill.rs" [dependencies] clap = { workspace = true } -nix = { workspace = true, features = ["signal"] } uucore = { workspace = true, features = ["signals"] } fluent = { workspace = true } +[target.'cfg(unix)'.dependencies] +nix = { workspace = true, features = ["signal"] } + [[bin]] name = "kill" path = "src/main.rs" diff --git a/src/uu/mkfifo/Cargo.toml b/src/uu/mkfifo/Cargo.toml index c75638482..ece483810 100644 --- a/src/uu/mkfifo/Cargo.toml +++ b/src/uu/mkfifo/Cargo.toml @@ -19,10 +19,12 @@ path = "src/mkfifo.rs" [dependencies] clap = { workspace = true } -nix = { workspace = true, features = ["fs"] } uucore = { workspace = true, features = ["fs", "mode"] } fluent = { workspace = true } +[target.'cfg(unix)'.dependencies] +nix = { workspace = true, features = ["fs"] } + [features] selinux = ["uucore/selinux"] smack = ["uucore/smack"] diff --git a/src/uu/nice/Cargo.toml b/src/uu/nice/Cargo.toml index f58c7f3d7..00f96718e 100644 --- a/src/uu/nice/Cargo.toml +++ b/src/uu/nice/Cargo.toml @@ -20,10 +20,12 @@ path = "src/nice.rs" [dependencies] clap = { workspace = true } libc = { workspace = true } -nix = { workspace = true } uucore = { workspace = true } fluent = { workspace = true } +[target.'cfg(unix)'.dependencies] +nix = { workspace = true } + [[bin]] name = "nice" path = "src/main.rs" diff --git a/src/uu/stty/Cargo.toml b/src/uu/stty/Cargo.toml index f05a4cc5b..40173b1df 100644 --- a/src/uu/stty/Cargo.toml +++ b/src/uu/stty/Cargo.toml @@ -20,9 +20,11 @@ path = "src/stty.rs" [dependencies] clap = { workspace = true } uucore = { workspace = true, features = ["parser"] } -nix = { workspace = true, features = ["term", "ioctl"] } fluent = { workspace = true } +[target.'cfg(unix)'.dependencies] +nix = { workspace = true, features = ["ioctl", "term"] } + [[bin]] name = "stty" path = "src/main.rs" diff --git a/src/uu/timeout/Cargo.toml b/src/uu/timeout/Cargo.toml index c6b795628..e0f3db171 100644 --- a/src/uu/timeout/Cargo.toml +++ b/src/uu/timeout/Cargo.toml @@ -20,10 +20,12 @@ path = "src/timeout.rs" [dependencies] clap = { workspace = true } libc = { workspace = true } -nix = { workspace = true, features = ["signal"] } uucore = { workspace = true, features = ["parser", "process", "signals"] } fluent = { workspace = true } +[target.'cfg(unix)'.dependencies] +nix = { workspace = true, features = ["signal"] } + [[bin]] name = "timeout" path = "src/main.rs" diff --git a/src/uu/tsort/Cargo.toml b/src/uu/tsort/Cargo.toml index a7e2eb014..8819596e0 100644 --- a/src/uu/tsort/Cargo.toml +++ b/src/uu/tsort/Cargo.toml @@ -23,9 +23,11 @@ clap = { workspace = true } fluent = { workspace = true } string-interner = { workspace = true } thiserror = { workspace = true } -nix = { workspace = true, features = ["fs"] } uucore = { workspace = true } +[target.'cfg(unix)'.dependencies] +nix = { workspace = true, features = ["fs"] } + [[bin]] name = "tsort" path = "src/main.rs" diff --git a/src/uu/tty/Cargo.toml b/src/uu/tty/Cargo.toml index 407e8b0d1..77165c605 100644 --- a/src/uu/tty/Cargo.toml +++ b/src/uu/tty/Cargo.toml @@ -19,10 +19,12 @@ path = "src/tty.rs" [dependencies] clap = { workspace = true } -nix = { workspace = true, features = ["term"] } uucore = { workspace = true, features = ["fs"] } fluent = { workspace = true } +[target.'cfg(unix)'.dependencies] +nix = { workspace = true, features = ["term"] } + [[bin]] name = "tty" path = "src/main.rs" diff --git a/src/uucore/Cargo.toml b/src/uucore/Cargo.toml index 1e52717d2..3cb1880d5 100644 --- a/src/uucore/Cargo.toml +++ b/src/uucore/Cargo.toml @@ -85,16 +85,16 @@ fluent-bundle = { workspace = true } thiserror = { workspace = true } [target.'cfg(unix)'.dependencies] -walkdir = { workspace = true, optional = true } nix = { workspace = true, features = [ - "fs", - "uio", - "zerocopy", - "signal", "dir", - "user", + "fs", "poll", + "signal", + "uio", + "user", + "zerocopy", ] } +walkdir = { workspace = true, optional = true } xattr = { workspace = true, optional = true } [dev-dependencies] diff --git a/tests/uutests/Cargo.toml b/tests/uutests/Cargo.toml index e73ea5902..e84214ace 100644 --- a/tests/uutests/Cargo.toml +++ b/tests/uutests/Cargo.toml @@ -36,6 +36,6 @@ uucore = { workspace = true, features = [ [target.'cfg(any(target_os = "linux", target_os = "android"))'.dependencies] [target.'cfg(unix)'.dependencies] -nix = { workspace = true, features = ["process", "signal", "user", "term"] } +nix = { workspace = true, features = ["process", "signal", "term", "user"] } rlimit = "0.10.1" xattr = { workspace = true } From f7d8ceeb5831d83d4bfc23cf089320e309b78f6a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 23 Jan 2026 09:54:55 +0000 Subject: [PATCH 330/425] chore(deps): update rust crate quote to v1.0.44 --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 96e33704a..a7f8fd2d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2250,9 +2250,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.43" +version = "1.0.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" dependencies = [ "proc-macro2", ] From bd7e523ef393ff68f2aac372a6eef25db2348740 Mon Sep 17 00:00:00 2001 From: xtqqczze <45661989+xtqqczze@users.noreply.github.com> Date: Fri, 23 Jan 2026 17:34:05 +0000 Subject: [PATCH 331/425] fix(deps): refactor xattr package configuration --- Cargo.lock | 1 - Cargo.toml | 1 - tests/uutests/Cargo.toml | 2 ++ 3 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 96e33704a..da9dda8d1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -652,7 +652,6 @@ dependencies = [ "walkdir", "wincode", "wincode-derive", - "xattr", "zip", ] diff --git a/Cargo.toml b/Cargo.toml index 52c7b5ed5..bc11ed80b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -567,7 +567,6 @@ nix = { workspace = true, features = [ "user", ] } rlimit = "0.10.1" -xattr.workspace = true # Used in test_uptime::test_uptime_with_file_containing_valid_boot_time_utmpx_record # to deserialize an utmpx struct into a binary file diff --git a/tests/uutests/Cargo.toml b/tests/uutests/Cargo.toml index e84214ace..1b523ee7e 100644 --- a/tests/uutests/Cargo.toml +++ b/tests/uutests/Cargo.toml @@ -38,4 +38,6 @@ uucore = { workspace = true, features = [ [target.'cfg(unix)'.dependencies] nix = { workspace = true, features = ["process", "signal", "term", "user"] } rlimit = "0.10.1" + +[target.'cfg(all(unix, not(any(target_os = "macos", target_os = "openbsd"))))'.dependencies] xattr = { workspace = true } From 710fc1be3daee692437e975480dfc686b58c70c2 Mon Sep 17 00:00:00 2001 From: xtqqczze <45661989+xtqqczze@users.noreply.github.com> Date: Sat, 24 Jan 2026 10:50:52 +0000 Subject: [PATCH 332/425] fix(deps): refactor rlimit package configuration --- Cargo.toml | 3 ++- tests/uutests/Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index bc11ed80b..2f57496a5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -358,6 +358,7 @@ rand = { version = "0.9.0", features = ["small_rng"] } rand_core = "0.9.0" rayon = "1.10" regex = "1.10.4" +rlimit = "0.10.1" rstest = "0.26.0" rust-ini = "0.21.0" same-file = "1.0.6" @@ -566,7 +567,7 @@ nix = { workspace = true, features = [ "term", "user", ] } -rlimit = "0.10.1" +rlimit = { workspace = true } # Used in test_uptime::test_uptime_with_file_containing_valid_boot_time_utmpx_record # to deserialize an utmpx struct into a binary file diff --git a/tests/uutests/Cargo.toml b/tests/uutests/Cargo.toml index 1b523ee7e..57eea11ae 100644 --- a/tests/uutests/Cargo.toml +++ b/tests/uutests/Cargo.toml @@ -37,7 +37,7 @@ uucore = { workspace = true, features = [ [target.'cfg(unix)'.dependencies] nix = { workspace = true, features = ["process", "signal", "term", "user"] } -rlimit = "0.10.1" +rlimit = { workspace = true } [target.'cfg(all(unix, not(any(target_os = "macos", target_os = "openbsd"))))'.dependencies] xattr = { workspace = true } From 03c566eca7bbb7bf55e98460e0939fec3cd2da9c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 24 Jan 2026 14:20:54 +0000 Subject: [PATCH 333/425] chore(deps): update rust crate signal-hook to v0.4.2 --- Cargo.lock | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d22cedba4..5450edf53 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -77,7 +77,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -88,7 +88,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -1014,7 +1014,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -1612,7 +1612,7 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde_core", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -1895,7 +1895,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2485,7 +2485,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2641,9 +2641,9 @@ dependencies = [ [[package]] name = "signal-hook" -version = "0.4.1" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a37d01603c37b5466f808de79f845c7116049b0579adb70a6b7d47c1fa3a952" +checksum = "e772e5ae4e7b3ee5244e46c83bd9c97d2ee93211c61b808acb1dfbf4a0af8f76" dependencies = [ "libc", "signal-hook-registry", @@ -2795,7 +2795,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3267,7 +3267,7 @@ dependencies = [ "gcd", "libc", "nix", - "signal-hook 0.4.1", + "signal-hook 0.4.2", "tempfile", "thiserror 2.0.18", "uucore", @@ -4443,7 +4443,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] From ddd037952383a7178df5401e0dd3138b65d62de3 Mon Sep 17 00:00:00 2001 From: RustyJack Date: Sat, 24 Jan 2026 19:33:05 +0100 Subject: [PATCH 334/425] rm: fix for -rf ./ and variants silently delete current directoryFix/rm fr (#9924) --- src/uu/rm/src/rm.rs | 2 ++ tests/by-util/test_rm.rs | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/src/uu/rm/src/rm.rs b/src/uu/rm/src/rm.rs index 252c72340..a4fb32bcb 100644 --- a/src/uu/rm/src/rm.rs +++ b/src/uu/rm/src/rm.rs @@ -838,7 +838,9 @@ fn path_is_current_or_parent_directory(path: &Path) -> bool { let dir_separator = MAIN_SEPARATOR as u8; if let Ok(path_bytes) = path_str { return path_bytes == ([b'.']) + || path_bytes == ([b'.', dir_separator]) || path_bytes == ([b'.', b'.']) + || path_bytes == ([b'.', b'.', dir_separator]) || path_bytes.ends_with(&[dir_separator, b'.']) || path_bytes.ends_with(&[dir_separator, b'.', b'.']) || path_bytes.ends_with(&[dir_separator, b'.', dir_separator]) diff --git a/tests/by-util/test_rm.rs b/tests/by-util/test_rm.rs index 20d4a9357..e262e9612 100644 --- a/tests/by-util/test_rm.rs +++ b/tests/by-util/test_rm.rs @@ -767,12 +767,22 @@ fn test_current_or_parent_dir_rm4() { at.mkdir("d"); + let file_1 = "file1"; + let file_2 = "d/file2"; + + at.touch(file_1); + at.touch(file_2); + let answers = [ "rm: refusing to remove '.' or '..' directory: skipping 'd/.'", "rm: refusing to remove '.' or '..' directory: skipping 'd/./'", "rm: refusing to remove '.' or '..' directory: skipping 'd/./'", "rm: refusing to remove '.' or '..' directory: skipping 'd/..'", "rm: refusing to remove '.' or '..' directory: skipping 'd/../'", + "rm: refusing to remove '.' or '..' directory: skipping '.'", + "rm: refusing to remove '.' or '..' directory: skipping './'", + "rm: refusing to remove '.' or '..' directory: skipping '../'", + "rm: refusing to remove '.' or '..' directory: skipping '..'", ]; let std_err_str = ts .ucmd() @@ -782,12 +792,20 @@ fn test_current_or_parent_dir_rm4() { .arg("d/.////") .arg("d/..") .arg("d/../") + .arg(".") + .arg("./") + .arg("../") + .arg("..") .fails() .stderr_move_str(); for (idx, line) in std_err_str.lines().enumerate() { assert_eq!(line, answers[idx]); } + // checks that no file was silently removed + assert!(at.dir_exists("d")); + assert!(at.file_exists(file_1)); + assert!(at.file_exists(file_2)); } #[test] @@ -798,12 +816,22 @@ fn test_current_or_parent_dir_rm4_windows() { at.mkdir("d"); + let file_1 = "file1"; + let file_2 = "d/file2"; + + at.touch(file_1); + at.touch(file_2); + let answers = [ "rm: refusing to remove '.' or '..' directory: skipping 'd\\.'", "rm: refusing to remove '.' or '..' directory: skipping 'd\\.\\'", "rm: refusing to remove '.' or '..' directory: skipping 'd\\.\\'", "rm: refusing to remove '.' or '..' directory: skipping 'd\\..'", "rm: refusing to remove '.' or '..' directory: skipping 'd\\..\\'", + "rm: refusing to remove '.' or '..' directory: skipping '.'", + "rm: refusing to remove '.' or '..' directory: skipping '.\\'", + "rm: refusing to remove '.' or '..' directory: skipping '..'", + "rm: refusing to remove '.' or '..' directory: skipping '..\\'", ]; let std_err_str = ts .ucmd() @@ -813,12 +841,21 @@ fn test_current_or_parent_dir_rm4_windows() { .arg("d\\.\\\\\\\\") .arg("d\\..") .arg("d\\..\\") + .arg(".") + .arg(".\\") + .arg("..") + .arg("..\\") .fails() .stderr_move_str(); for (idx, line) in std_err_str.lines().enumerate() { assert_eq!(line, answers[idx]); } + + // checks that no file was silently removed + assert!(at.dir_exists("d")); + assert!(at.file_exists(file_1)); + assert!(at.file_exists(file_2)); } #[test] From 1ce899014186b054b5887875f4dcff751cd58de1 Mon Sep 17 00:00:00 2001 From: Daniel Hofstetter Date: Sat, 24 Jan 2026 19:33:55 +0100 Subject: [PATCH 335/425] ci/android: use --locked when installing nextest (#10467) --- util/android-commands.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/util/android-commands.sh b/util/android-commands.sh index b87d7050b..63adf0ec4 100755 --- a/util/android-commands.sh +++ b/util/android-commands.sh @@ -534,7 +534,7 @@ snapshot() { # We need to install nextest via cargo currently, since there is no pre-built binary for android x86 # explicitly set CARGO_TARGET_DIR as otherwise a random generated tmp directory is used, # which prevents incremental build for the retries. - command="export CARGO_TERM_COLOR=always && export CARGO_TARGET_DIR=\"cargo_install_target_dir\" && cargo install cargo-nextest" + command="export CARGO_TERM_COLOR=always && export CARGO_TARGET_DIR=\"cargo_install_target_dir\" && cargo install cargo-nextest --locked" run_with_retry 3 run_command_via_ssh "$command" return_code=$? From 938039e9ac14042eb9d705651733ab20bb41e851 Mon Sep 17 00:00:00 2001 From: xtqqczze <45661989+xtqqczze@users.noreply.github.com> Date: Sat, 24 Jan 2026 18:34:35 +0000 Subject: [PATCH 336/425] fuzz: add cargo clippy check for fuzz directory (#10466) --- .github/workflows/code-quality.yml | 6 ++++++ fuzz/.cargo/config.toml | 2 ++ fuzz/fuzz_targets/fuzz_non_utf8_paths.rs | 6 +++--- fuzz/fuzz_targets/fuzz_test.rs | 4 ++-- 4 files changed, 13 insertions(+), 5 deletions(-) create mode 100644 fuzz/.cargo/config.toml diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index dcd81133c..b3c0a385a 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -147,6 +147,12 @@ jobs: CARGO_UTILITY_LIST_OPTIONS="$(for u in ${UTILITY_LIST}; do echo -n "-puu_${u} "; done;)" S=$(cargo clippy --all-targets $extra --tests --benches -pcoreutils ${CARGO_UTILITY_LIST_OPTIONS} -- -D warnings 2>&1) && printf "%s\n" "$S" || { printf "%s\n" "$S" ; printf "%s" "$S" | sed -E -n -e '/^error:/{' -e "N; s/^error:[[:space:]]+(.*)\\n[[:space:]]+-->[[:space:]]+(.*):([0-9]+):([0-9]+).*$/::${fault_type} file=\2,line=\3,col=\4::${fault_prefix}: \`cargo clippy\`: \1 (file:'\2', line:\3)/p;" -e '}' ; fault=true ; } if [ -n "${{ steps.vars.outputs.FAIL_ON_FAULT }}" ] && [ -n "$fault" ]; then exit 1 ; fi + - name: "cargo clippy on fuzz dir" + if: runner.os != 'Windows' + shell: bash + run: | + cd fuzz + cargo clippy --workspace --all-targets --all-features -- -D warnings style_spellcheck: name: Style/spelling diff --git a/fuzz/.cargo/config.toml b/fuzz/.cargo/config.toml new file mode 100644 index 000000000..5d1a2a27f --- /dev/null +++ b/fuzz/.cargo/config.toml @@ -0,0 +1,2 @@ +[build] +rustflags = ["--cfg", "fuzzing"] diff --git a/fuzz/fuzz_targets/fuzz_non_utf8_paths.rs b/fuzz/fuzz_targets/fuzz_non_utf8_paths.rs index ac7480f32..82e537484 100644 --- a/fuzz/fuzz_targets/fuzz_non_utf8_paths.rs +++ b/fuzz/fuzz_targets/fuzz_non_utf8_paths.rs @@ -14,7 +14,7 @@ use std::env::temp_dir; use std::ffi::{OsStr, OsString}; use std::fs; use std::os::unix::ffi::{OsStrExt, OsStringExt}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use uufuzz::{CommandResult, run_gnu_cmd}; // Programs that typically take file/path arguments and should be tested @@ -148,7 +148,7 @@ fn setup_test_files() -> Result<(PathBuf, Vec), std::io::Error> { // Try to create the file - this may fail on some filesystems if let Ok(mut file) = fs::File::create(&file_path) { use std::io::Write; - let _ = write!(file, "test content for file {}\n", i); + let _ = writeln!(file, "test content for file {}", i); test_files.push(file_path); } } @@ -156,7 +156,7 @@ fn setup_test_files() -> Result<(PathBuf, Vec), std::io::Error> { Ok((temp_root, test_files)) } -fn test_program_with_non_utf8_path(program: &str, path: &PathBuf) -> CommandResult { +fn test_program_with_non_utf8_path(program: &str, path: &Path) -> CommandResult { let path_os = path.as_os_str(); // Use the locally built uutils binary instead of system PATH diff --git a/fuzz/fuzz_targets/fuzz_test.rs b/fuzz/fuzz_targets/fuzz_test.rs index 894a1dcd5..176ab9aba 100644 --- a/fuzz/fuzz_targets/fuzz_test.rs +++ b/fuzz/fuzz_targets/fuzz_test.rs @@ -135,9 +135,9 @@ fn generate_test_arg() -> String { if test_arg.arg_type == ArgType::INTEGER { arg.push_str(&format!( "{} {} {}", - rng.random_range(-100..=100).to_string(), + rng.random_range(-100..=100), test_arg.arg, - rng.random_range(-100..=100).to_string() + rng.random_range(-100..=100) )); } else if test_arg.arg_type == ArgType::STRINGSTRING { let random_str = generate_random_string(rng.random_range(1..=10)); From debb0a90b2070ae29287da394309449fc62a8927 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 24 Jan 2026 18:36:23 +0000 Subject: [PATCH 337/425] chore(deps): update rust crate signal-hook to v0.4.3 --- Cargo.lock | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5450edf53..0674ccaad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1014,7 +1014,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -1612,7 +1612,7 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde_core", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -1895,7 +1895,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -2485,7 +2485,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -2641,9 +2641,9 @@ dependencies = [ [[package]] name = "signal-hook" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e772e5ae4e7b3ee5244e46c83bd9c97d2ee93211c61b808acb1dfbf4a0af8f76" +checksum = "3b57709da74f9ff9f4a27dce9526eec25ca8407c45a7887243b031a58935fb8e" dependencies = [ "libc", "signal-hook-registry", @@ -2795,7 +2795,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -3267,7 +3267,7 @@ dependencies = [ "gcd", "libc", "nix", - "signal-hook 0.4.2", + "signal-hook 0.4.3", "tempfile", "thiserror 2.0.18", "uucore", @@ -4443,7 +4443,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] From 1034c3b6363cec9767adebf02510145ffc53706d Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Sat, 24 Jan 2026 20:20:37 +0100 Subject: [PATCH 338/425] fuzz_parse_size doesn't pass all the time --- .github/workflows/fuzzing.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/fuzzing.yml b/.github/workflows/fuzzing.yml index 19a10523e..f86ce71f0 100644 --- a/.github/workflows/fuzzing.yml +++ b/.github/workflows/fuzzing.yml @@ -92,7 +92,7 @@ jobs: - { name: fuzz_env, should_pass: false } - { name: fuzz_cksum, should_pass: false } - { name: fuzz_parse_glob, should_pass: true } - - { name: fuzz_parse_size, should_pass: true } + - { name: fuzz_parse_size, should_pass: false } - { name: fuzz_parse_time, should_pass: true } - { name: fuzz_seq_parse_number, should_pass: false } - { name: fuzz_non_utf8_paths, should_pass: true } From fcca316887a8922fcc0e8b9deed632c2bf6eec0e Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Sat, 24 Jan 2026 21:08:38 +0100 Subject: [PATCH 339/425] fuzz_parse_time might not pass --- .github/workflows/fuzzing.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/fuzzing.yml b/.github/workflows/fuzzing.yml index f86ce71f0..e1b85a1e8 100644 --- a/.github/workflows/fuzzing.yml +++ b/.github/workflows/fuzzing.yml @@ -93,7 +93,7 @@ jobs: - { name: fuzz_cksum, should_pass: false } - { name: fuzz_parse_glob, should_pass: true } - { name: fuzz_parse_size, should_pass: false } - - { name: fuzz_parse_time, should_pass: true } + - { name: fuzz_parse_time, should_pass: false } - { name: fuzz_seq_parse_number, should_pass: false } - { name: fuzz_non_utf8_paths, should_pass: true } - { name: fuzz_dirname, should_pass: true } From 676363a4f7cfe1a216d71d8520ade7ff6f71c64e Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Tue, 30 Dec 2025 22:12:57 +0100 Subject: [PATCH 340/425] expand: address a cognitive_complexity warnings --- src/uu/expand/src/expand.rs | 127 +++++++++++++++++++++--------------- 1 file changed, 76 insertions(+), 51 deletions(-) diff --git a/src/uu/expand/src/expand.rs b/src/uu/expand/src/expand.rs index 294b3bc88..690cbd0ee 100644 --- a/src/uu/expand/src/expand.rs +++ b/src/uu/expand/src/expand.rs @@ -3,7 +3,7 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore (ToDO) ctype cwidth iflag nbytes nspaces nums tspaces uflag Preprocess +// spell-checker:ignore (ToDO) ctype cwidth iflag nbytes nspaces nums tspaces Preprocess use clap::{Arg, ArgAction, ArgMatches, Command}; use std::ffi::OsString; @@ -174,7 +174,7 @@ struct Options { tabstops: Vec, tspaces: String, iflag: bool, - uflag: bool, + utf8: bool, /// Strategy for expanding tabs for columns beyond those specified /// in `tabstops`. @@ -189,7 +189,7 @@ impl Options { }; let iflag = matches.get_flag(options::INITIAL); - let uflag = !matches.get_flag(options::NO_UTF8); + let utf8 = !matches.get_flag(options::NO_UTF8); // avoid allocations when dumping out long sequences of spaces // by precomputing the longest string of spaces we will ever need @@ -214,7 +214,7 @@ impl Options { tabstops, tspaces, iflag, - uflag, + utf8, remaining_mode, }) } @@ -349,7 +349,62 @@ enum CharType { Other, } -#[allow(clippy::cognitive_complexity)] +/// Classify a character and determine its width and byte length. +/// +/// Returns `(CharType, display_width, byte_length)`. +#[inline] +fn classify_char(buf: &[u8], byte: usize, utf8: bool) -> (CharType, usize, usize) { + use self::CharType::{Backspace, Other, Tab}; + + if utf8 { + let nbytes = char::from(buf[byte]).len_utf8(); + + if byte + nbytes > buf.len() { + // don't overrun buffer because of invalid UTF-8 + return (Other, 1, 1); + } + + if let Ok(t) = from_utf8(&buf[byte..byte + nbytes]) { + match t.chars().next() { + Some('\t') => (Tab, 0, 1), + Some('\x08') => (Backspace, 0, 1), + Some(c) => (Other, UnicodeWidthChar::width(c).unwrap_or(0), nbytes), + None => { + // no valid char at start of t, so take 1 byte + (Other, 1, 1) + } + } + } else { + (Other, 1, 1) // implicit assumption: non-UTF-8 char is 1 col wide + } + } else { + ( + match buf.get(byte) { + // always take exactly 1 byte in strict ASCII mode + Some(0x09) => Tab, + Some(0x08) => Backspace, + _ => Other, + }, + 0, + 1, + ) + } +} + +/// Write spaces for a tab expansion. +#[inline] +fn write_tab_spaces( + output: &mut BufWriter, + nts: usize, + tspaces: &str, +) -> std::io::Result<()> { + if nts <= tspaces.len() { + output.write_all(&tspaces.as_bytes()[..nts]) + } else { + output.write_all(" ".repeat(nts).as_bytes()) + } +} + fn expand_line( buf: &mut Vec, output: &mut BufWriter, @@ -360,8 +415,7 @@ fn expand_line( // Fast path: if there are no tabs, backspaces, and (in UTF-8 mode or no carriage returns), // we can write the buffer directly without character-by-character processing - if !buf.contains(&b'\t') && !buf.contains(&b'\x08') && (options.uflag || !buf.contains(&b'\r')) - { + if !buf.contains(&b'\t') && !buf.contains(&b'\x08') && (options.utf8 || !buf.contains(&b'\r')) { output.write_all(buf)?; buf.truncate(0); return Ok(()); @@ -372,37 +426,7 @@ fn expand_line( let mut init = true; while byte < buf.len() { - let (ctype, cwidth, nbytes) = if options.uflag { - let nbytes = char::from(buf[byte]).len_utf8(); - - if byte + nbytes > buf.len() { - // don't overrun buffer because of invalid UTF-8 - (Other, 1, 1) - } else if let Ok(t) = from_utf8(&buf[byte..byte + nbytes]) { - match t.chars().next() { - Some('\t') => (Tab, 0, nbytes), - Some('\x08') => (Backspace, 0, nbytes), - Some(c) => (Other, UnicodeWidthChar::width(c).unwrap_or(0), nbytes), - None => { - // no valid char at start of t, so take 1 byte - (Other, 1, 1) - } - } - } else { - (Other, 1, 1) // implicit assumption: non-UTF-8 char is 1 col wide - } - } else { - ( - match buf.get(byte) { - // always take exactly 1 byte in strict ASCII mode - Some(0x09) => Tab, - Some(0x08) => Backspace, - _ => Other, - }, - 1, - 1, - ) - }; + let (ctype, cwidth, nbytes) = classify_char(buf, byte, options.utf8); // figure out how many columns this char takes up match ctype { @@ -413,23 +437,24 @@ fn expand_line( // now dump out either spaces if we're expanding, or a literal tab if we're not if init || !options.iflag { - if nts <= options.tspaces.len() { - output.write_all(&options.tspaces.as_bytes()[..nts])?; - } else { - output.write_all(" ".repeat(nts).as_bytes())?; - } + write_tab_spaces(output, nts, &options.tspaces)?; } else { output.write_all(&buf[byte..byte + nbytes])?; } } - _ => { - col = if ctype == Other { - col + cwidth - } else if col > 0 { - col - 1 - } else { - 0 - }; + Backspace => { + col = col.saturating_sub(1); + + // if we're writing anything other than a space, then we're + // done with the line's leading spaces + if buf[byte] != 0x20 { + init = false; + } + + output.write_all(&buf[byte..byte + nbytes])?; + } + Other => { + col += cwidth; // if we're writing anything other than a space, then we're // done with the line's leading spaces From 80a6c67d999bbd905121fa2d96d0ce99b3f25c1f Mon Sep 17 00:00:00 2001 From: Jeffrey Finkelstein Date: Wed, 21 Jan 2026 20:32:51 -0500 Subject: [PATCH 341/425] pr: remove unused lines_printed variable --- src/uu/pr/src/pr.rs | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/uu/pr/src/pr.rs b/src/uu/pr/src/pr.rs index b37f3a883..e5faac408 100644 --- a/src/uu/pr/src/pr.rs +++ b/src/uu/pr/src/pr.rs @@ -989,7 +989,7 @@ fn print_page( lines: &[FileLine], options: &OutputOptions, page: usize, -) -> Result { +) -> Result<(), std::io::Error> { let line_separator = options.line_separator.as_bytes(); let page_separator = options.page_separator_char.as_bytes(); @@ -1004,7 +1004,7 @@ fn print_page( out.write_all(line_separator)?; } - let lines_written = write_columns(lines, options, &mut out)?; + write_columns(lines, options, &mut out)?; for (index, x) in trailer_content.iter().enumerate() { out.write_all(x.as_bytes())?; @@ -1014,7 +1014,7 @@ fn print_page( } out.write_all(page_separator)?; out.flush()?; - Ok(lines_written) + Ok(()) } #[allow(clippy::cognitive_complexity)] @@ -1022,7 +1022,7 @@ fn write_columns( lines: &[FileLine], options: &OutputOptions, out: &mut impl Write, -) -> Result { +) -> Result<(), std::io::Error> { let line_separator = options.content_line_separator.as_bytes(); let content_lines_per_page = if options.double_space { @@ -1035,7 +1035,6 @@ fn write_columns( .merge_files_print .unwrap_or_else(|| get_columns(options)); let line_width = options.line_width; - let mut lines_printed = 0; let feed_line_present = options.form_feed_used; let mut not_found_break = false; @@ -1101,7 +1100,6 @@ fn write_columns( get_line_for_printing(options, file_line, columns, i, line_width, indexes) .as_bytes(), )?; - lines_printed += 1; } } if not_found_break && feed_line_present { @@ -1110,7 +1108,7 @@ fn write_columns( out.write_all(line_separator)?; } - Ok(lines_printed) + Ok(()) } fn get_line_for_printing( From 1f50a04c63abc630caa7929c2c0306857bf6579a Mon Sep 17 00:00:00 2001 From: Jan Verbeek Date: Thu, 20 Mar 2025 20:33:28 +0100 Subject: [PATCH 342/425] shuf: Move NonrepeatingIterator to own module --- src/uu/shuf/src/nonrepeating_iterator.rs | 182 +++++++++++++++++++++++ src/uu/shuf/src/shuf.rs | 175 +--------------------- 2 files changed, 185 insertions(+), 172 deletions(-) create mode 100644 src/uu/shuf/src/nonrepeating_iterator.rs diff --git a/src/uu/shuf/src/nonrepeating_iterator.rs b/src/uu/shuf/src/nonrepeating_iterator.rs new file mode 100644 index 000000000..dfefd1178 --- /dev/null +++ b/src/uu/shuf/src/nonrepeating_iterator.rs @@ -0,0 +1,182 @@ +// spell-checker:ignore nonrepeating + +use std::{collections::HashSet, ops::RangeInclusive}; + +use rand::{Rng, seq::SliceRandom}; + +use crate::WrappedRng; + +enum NumberSet { + AlreadyListed(HashSet), + Remaining(Vec), +} + +pub(crate) struct NonrepeatingIterator<'a> { + range: RangeInclusive, + rng: &'a mut WrappedRng, + remaining_count: usize, + buf: NumberSet, +} + +impl<'a> NonrepeatingIterator<'a> { + pub(crate) fn new( + range: RangeInclusive, + rng: &'a mut WrappedRng, + amount: usize, + ) -> Self { + let capped_amount = if range.start() > range.end() { + 0 + } else if range == (0..=usize::MAX) { + amount + } else { + amount.min(range.end() - range.start() + 1) + }; + NonrepeatingIterator { + range, + rng, + remaining_count: capped_amount, + buf: NumberSet::AlreadyListed(HashSet::default()), + } + } + + fn produce(&mut self) -> usize { + debug_assert!(self.range.start() <= self.range.end()); + match &mut self.buf { + NumberSet::AlreadyListed(already_listed) => { + let chosen = loop { + let guess = self.rng.random_range(self.range.clone()); + let newly_inserted = already_listed.insert(guess); + if newly_inserted { + break guess; + } + }; + // Once a significant fraction of the interval has already been enumerated, + // the number of attempts to find a number that hasn't been chosen yet increases. + // Therefore, we need to switch at some point from "set of already returned values" to "list of remaining values". + let range_size = (self.range.end() - self.range.start()).saturating_add(1); + if number_set_should_list_remaining(already_listed.len(), range_size) { + let mut remaining = self + .range + .clone() + .filter(|n| !already_listed.contains(n)) + .collect::>(); + assert!(remaining.len() >= self.remaining_count); + remaining.partial_shuffle(&mut self.rng, self.remaining_count); + remaining.truncate(self.remaining_count); + self.buf = NumberSet::Remaining(remaining); + } + chosen + } + NumberSet::Remaining(remaining_numbers) => { + debug_assert!(!remaining_numbers.is_empty()); + // We only enter produce() when there is at least one actual element remaining, so popping must always return an element. + remaining_numbers.pop().unwrap() + } + } + } +} + +impl Iterator for NonrepeatingIterator<'_> { + type Item = usize; + + fn next(&mut self) -> Option { + if self.range.is_empty() || self.remaining_count == 0 { + return None; + } + self.remaining_count -= 1; + Some(self.produce()) + } +} + +// This could be a method, but it is much easier to test as a stand-alone function. +fn number_set_should_list_remaining(listed_count: usize, range_size: usize) -> bool { + // Arbitrarily determine the switchover point to be around 25%. This is because: + // - HashSet has a large space overhead for the hash table load factor. + // - This means that somewhere between 25-40%, the memory required for a "positive" HashSet and a "negative" Vec should be the same. + // - HashSet has a small but non-negligible overhead for each lookup, so we have a slight preference for Vec anyway. + // - At 25%, on average 1.33 attempts are needed to find a number that hasn't been taken yet. + // - Finally, "24%" is computationally the simplest: + listed_count >= range_size / 4 +} + +#[cfg(test)] +// Since the computed value is a bool, it is more readable to write the expected value out: +#[allow(clippy::bool_assert_comparison)] +mod test_number_set_decision { + use super::number_set_should_list_remaining; + + #[test] + fn test_stay_positive_large_remaining_first() { + assert_eq!(false, number_set_should_list_remaining(0, usize::MAX)); + } + + #[test] + fn test_stay_positive_large_remaining_second() { + assert_eq!(false, number_set_should_list_remaining(1, usize::MAX)); + } + + #[test] + fn test_stay_positive_large_remaining_tenth() { + assert_eq!(false, number_set_should_list_remaining(9, usize::MAX)); + } + + #[test] + fn test_stay_positive_smallish_range_first() { + assert_eq!(false, number_set_should_list_remaining(0, 12345)); + } + + #[test] + fn test_stay_positive_smallish_range_second() { + assert_eq!(false, number_set_should_list_remaining(1, 12345)); + } + + #[test] + fn test_stay_positive_smallish_range_tenth() { + assert_eq!(false, number_set_should_list_remaining(9, 12345)); + } + + #[test] + fn test_stay_positive_small_range_not_too_early() { + assert_eq!(false, number_set_should_list_remaining(1, 10)); + } + + // Don't want to test close to the border, in case we decide to change the threshold. + // However, at 50% coverage, we absolutely should switch: + #[test] + fn test_switch_half() { + assert_eq!(true, number_set_should_list_remaining(1234, 2468)); + } + + // Ensure that the decision is monotonous: + #[test] + fn test_switch_late1() { + assert_eq!(true, number_set_should_list_remaining(12340, 12345)); + } + + #[test] + fn test_switch_late2() { + assert_eq!(true, number_set_should_list_remaining(12344, 12345)); + } + + // Ensure that we are overflow-free: + #[test] + fn test_no_crash_exceed_max_size1() { + assert_eq!(false, number_set_should_list_remaining(12345, usize::MAX)); + } + + #[test] + fn test_no_crash_exceed_max_size2() { + assert_eq!( + true, + number_set_should_list_remaining(usize::MAX - 1, usize::MAX) + ); + } + + #[test] + fn test_no_crash_exceed_max_size3() { + assert_eq!( + true, + number_set_should_list_remaining(usize::MAX, usize::MAX) + ); + } +} diff --git a/src/uu/shuf/src/shuf.rs b/src/uu/shuf/src/shuf.rs index 4fd5ca85a..47e9cd132 100644 --- a/src/uu/shuf/src/shuf.rs +++ b/src/uu/shuf/src/shuf.rs @@ -10,7 +10,6 @@ use clap::{Arg, ArgAction, Command}; use rand::prelude::SliceRandom; use rand::seq::IndexedRandom; use rand::{Rng, RngCore}; -use std::collections::HashSet; use std::ffi::{OsStr, OsString}; use std::fs::File; use std::io::{BufWriter, Error, Read, Write, stdin, stdout}; @@ -22,8 +21,11 @@ use uucore::error::{FromIo, UResult, USimpleError, UUsageError}; use uucore::format_usage; use uucore::translate; +mod nonrepeating_iterator; mod rand_read_adapter; +use nonrepeating_iterator::NonrepeatingIterator; + enum Mode { Default(PathBuf), Echo(Vec), @@ -315,95 +317,6 @@ impl Shufable for RangeInclusive { } } -enum NumberSet { - AlreadyListed(HashSet), - Remaining(Vec), -} - -struct NonrepeatingIterator<'a> { - range: RangeInclusive, - rng: &'a mut WrappedRng, - remaining_count: usize, - buf: NumberSet, -} - -impl<'a> NonrepeatingIterator<'a> { - fn new(range: RangeInclusive, rng: &'a mut WrappedRng, amount: usize) -> Self { - let capped_amount = if range.start() > range.end() { - 0 - } else if range == (0..=usize::MAX) { - amount - } else { - amount.min(range.end() - range.start() + 1) - }; - NonrepeatingIterator { - range, - rng, - remaining_count: capped_amount, - buf: NumberSet::AlreadyListed(HashSet::default()), - } - } - - fn produce(&mut self) -> usize { - debug_assert!(self.range.start() <= self.range.end()); - match &mut self.buf { - NumberSet::AlreadyListed(already_listed) => { - let chosen = loop { - let guess = self.rng.random_range(self.range.clone()); - let newly_inserted = already_listed.insert(guess); - if newly_inserted { - break guess; - } - }; - // Once a significant fraction of the interval has already been enumerated, - // the number of attempts to find a number that hasn't been chosen yet increases. - // Therefore, we need to switch at some point from "set of already returned values" to "list of remaining values". - let range_size = (self.range.end() - self.range.start()).saturating_add(1); - if number_set_should_list_remaining(already_listed.len(), range_size) { - let mut remaining = self - .range - .clone() - .filter(|n| !already_listed.contains(n)) - .collect::>(); - assert!(remaining.len() >= self.remaining_count); - remaining.partial_shuffle(&mut self.rng, self.remaining_count); - remaining.truncate(self.remaining_count); - self.buf = NumberSet::Remaining(remaining); - } - chosen - } - NumberSet::Remaining(remaining_numbers) => { - debug_assert!(!remaining_numbers.is_empty()); - // We only enter produce() when there is at least one actual element remaining, so popping must always return an element. - remaining_numbers.pop().unwrap() - } - } - } -} - -impl Iterator for NonrepeatingIterator<'_> { - type Item = usize; - - fn next(&mut self) -> Option { - if self.range.is_empty() || self.remaining_count == 0 { - return None; - } - self.remaining_count -= 1; - Some(self.produce()) - } -} - -// This could be a method, but it is much easier to test as a stand-alone function. -fn number_set_should_list_remaining(listed_count: usize, range_size: usize) -> bool { - // Arbitrarily determine the switchover point to be around 25%. This is because: - // - HashSet has a large space overhead for the hash table load factor. - // - This means that somewhere between 25-40%, the memory required for a "positive" HashSet and a "negative" Vec should be the same. - // - HashSet has a small but non-negligible overhead for each lookup, so we have a slight preference for Vec anyway. - // - At 25%, on average 1.33 attempts are needed to find a number that hasn't been taken yet. - // - Finally, "24%" is computationally the simplest: - listed_count >= range_size / 4 -} - trait Writable { fn write_all_to(&self, output: &mut impl OsWrite) -> Result<(), Error>; } @@ -543,85 +456,3 @@ mod test_split_seps { assert_eq!(split_seps(b"a\nb\nc", b'\n'), &[b"a", b"b", b"c"]); } } - -#[cfg(test)] -// Since the computed value is a bool, it is more readable to write the expected value out: -#[allow(clippy::bool_assert_comparison)] -mod test_number_set_decision { - use super::number_set_should_list_remaining; - - #[test] - fn test_stay_positive_large_remaining_first() { - assert_eq!(false, number_set_should_list_remaining(0, usize::MAX)); - } - - #[test] - fn test_stay_positive_large_remaining_second() { - assert_eq!(false, number_set_should_list_remaining(1, usize::MAX)); - } - - #[test] - fn test_stay_positive_large_remaining_tenth() { - assert_eq!(false, number_set_should_list_remaining(9, usize::MAX)); - } - - #[test] - fn test_stay_positive_smallish_range_first() { - assert_eq!(false, number_set_should_list_remaining(0, 12345)); - } - - #[test] - fn test_stay_positive_smallish_range_second() { - assert_eq!(false, number_set_should_list_remaining(1, 12345)); - } - - #[test] - fn test_stay_positive_smallish_range_tenth() { - assert_eq!(false, number_set_should_list_remaining(9, 12345)); - } - - #[test] - fn test_stay_positive_small_range_not_too_early() { - assert_eq!(false, number_set_should_list_remaining(1, 10)); - } - - // Don't want to test close to the border, in case we decide to change the threshold. - // However, at 50% coverage, we absolutely should switch: - #[test] - fn test_switch_half() { - assert_eq!(true, number_set_should_list_remaining(1234, 2468)); - } - - // Ensure that the decision is monotonous: - #[test] - fn test_switch_late1() { - assert_eq!(true, number_set_should_list_remaining(12340, 12345)); - } - - #[test] - fn test_switch_late2() { - assert_eq!(true, number_set_should_list_remaining(12344, 12345)); - } - - // Ensure that we are overflow-free: - #[test] - fn test_no_crash_exceed_max_size1() { - assert_eq!(false, number_set_should_list_remaining(12345, usize::MAX)); - } - - #[test] - fn test_no_crash_exceed_max_size2() { - assert_eq!( - true, - number_set_should_list_remaining(usize::MAX - 1, usize::MAX) - ); - } - - #[test] - fn test_no_crash_exceed_max_size3() { - assert_eq!( - true, - number_set_should_list_remaining(usize::MAX, usize::MAX) - ); - } -} From 7b252b8676bc1213295dfdb58332fb506634bbc5 Mon Sep 17 00:00:00 2001 From: Jan Verbeek Date: Sun, 23 Mar 2025 17:57:55 +0100 Subject: [PATCH 343/425] shuf: correctness: Flush output after writing This is important since the output is buffered and errors may end up ignored otherwise. `shuf -e a b c > /dev/full` now errors while it didn't before. --- src/uu/shuf/src/shuf.rs | 1 + tests/by-util/test_shuf.rs | 12 ++++++++++++ 2 files changed, 13 insertions(+) diff --git a/src/uu/shuf/src/shuf.rs b/src/uu/shuf/src/shuf.rs index 47e9cd132..98e035b63 100644 --- a/src/uu/shuf/src/shuf.rs +++ b/src/uu/shuf/src/shuf.rs @@ -386,6 +386,7 @@ fn shuf_exec( output.write_all(&[opts.sep]).map_err_context(ctx)?; } } + output.flush().map_err_context(ctx)?; Ok(()) } diff --git a/tests/by-util/test_shuf.rs b/tests/by-util/test_shuf.rs index 4d3f841ac..83f02f049 100644 --- a/tests/by-util/test_shuf.rs +++ b/tests/by-util/test_shuf.rs @@ -847,3 +847,15 @@ fn test_range_repeat_empty_minus_one() { .no_stdout() .stderr_contains("invalid value '5-3' for '--input-range ': start exceeds end\n"); } + +// This test fails if we forget to flush the `BufWriter`. +#[test] +#[cfg(target_os = "linux")] +fn write_errors_are_reported() { + new_ucmd!() + .arg("-i1-10") + .arg("-o/dev/full") + .fails() + .no_stdout() + .stderr_is("shuf: write failed: No space left on device\n"); +} From 1076088eecab12b466d1fd4776a22531233026aa Mon Sep 17 00:00:00 2001 From: Jan Verbeek Date: Sun, 23 Mar 2025 18:08:42 +0100 Subject: [PATCH 344/425] shuf: perf: Bump output buffer to 64KB --- src/uu/shuf/src/shuf.rs | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/src/uu/shuf/src/shuf.rs b/src/uu/shuf/src/shuf.rs index 98e035b63..c0765e553 100644 --- a/src/uu/shuf/src/shuf.rs +++ b/src/uu/shuf/src/shuf.rs @@ -32,6 +32,8 @@ enum Mode { InputRange(RangeInclusive), } +const BUF_SIZE: usize = 64 * 1024; + struct Options { head_count: usize, output: Option, @@ -100,15 +102,18 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { }, }; - let mut output = BufWriter::new(match options.output { - None => Box::new(stdout()) as Box, - Some(ref s) => { - let file = File::create(s).map_err_context( - || translate!("shuf-error-failed-to-open-for-writing", "file" => s.quote()), - )?; - Box::new(file) as Box - } - }); + let mut output = BufWriter::with_capacity( + BUF_SIZE, + match options.output { + None => Box::new(stdout()) as Box, + Some(ref s) => { + let file = File::create(s).map_err_context( + || translate!("shuf-error-failed-to-open-for-writing", "file" => s.quote()), + )?; + Box::new(file) as Box + } + }, + ); if options.head_count == 0 { // In this case we do want to touch the output file but we can quit immediately. From 6e63312eba7024c5558778ca8bc202bf9b26bd4c Mon Sep 17 00:00:00 2001 From: Jan Verbeek Date: Mon, 24 Mar 2025 19:01:34 +0100 Subject: [PATCH 345/425] shuf: correctness: Do not use panics to report --random-source read errors --- src/uu/shuf/locales/en-US.ftl | 1 + src/uu/shuf/src/rand_read_adapter.rs | 47 ++++++++++++---------------- src/uu/shuf/src/shuf.rs | 24 +++++++++++++- 3 files changed, 44 insertions(+), 28 deletions(-) diff --git a/src/uu/shuf/locales/en-US.ftl b/src/uu/shuf/locales/en-US.ftl index 24876e6a3..913ddaa1d 100644 --- a/src/uu/shuf/locales/en-US.ftl +++ b/src/uu/shuf/locales/en-US.ftl @@ -19,6 +19,7 @@ shuf-error-unexpected-argument = unexpected argument { $arg } found shuf-error-failed-to-open-for-writing = failed to open { $file } for writing shuf-error-failed-to-open-random-source = failed to open random source { $file } shuf-error-read-error = read error +shuf-error-read-random-bytes = reading random bytes failed shuf-error-no-lines-to-repeat = no lines to repeat shuf-error-start-exceeds-end = start exceeds end shuf-error-missing-dash = missing '-' diff --git a/src/uu/shuf/src/rand_read_adapter.rs b/src/uu/shuf/src/rand_read_adapter.rs index 3f504c03d..84c7e8bf2 100644 --- a/src/uu/shuf/src/rand_read_adapter.rs +++ b/src/uu/shuf/src/rand_read_adapter.rs @@ -13,8 +13,9 @@ //! A wrapper around any Read to treat it as an RNG. -use std::fmt; -use std::io::Read; +use std::cell::Cell; +use std::io::{Error, Read}; +use std::rc::Rc; use rand_core::{RngCore, impls}; @@ -30,27 +31,33 @@ use rand_core::{RngCore, impls}; /// /// `ReadRng` uses [`std::io::Read::read_exact`], which retries on interrupts. /// All other errors from the underlying reader, including when it does not -/// have enough data, will only be reported through `try_fill_bytes`. -/// The other [`RngCore`] methods will panic in case of an error. +/// have enough data, will be reported via the public error field (which can +/// be cloned in advance, as it uses [`Rc`]). This field must be checked for +/// errors after every operation. /// /// [`OsRng`]: rand::rngs::OsRng -#[derive(Debug)] pub struct ReadRng { reader: R, + pub error: ErrorCell, } +pub type ErrorCell = Rc>>; + impl ReadRng { /// Create a new `ReadRng` from a `Read`. pub fn new(r: R) -> Self { - Self { reader: r } + Self { + reader: r, + error: Rc::default(), + } } - fn try_fill_bytes(&mut self, dest: &mut [u8]) -> Result<(), ReadError> { + fn try_fill_bytes(&mut self, dest: &mut [u8]) -> Result<(), Error> { if dest.is_empty() { return Ok(()); } // Use `std::io::read_exact`, which retries on `ErrorKind::Interrupted`. - self.reader.read_exact(dest).map_err(ReadError) + self.reader.read_exact(dest) } } @@ -64,25 +71,11 @@ impl RngCore for ReadRng { } fn fill_bytes(&mut self, dest: &mut [u8]) { - self.try_fill_bytes(dest).unwrap_or_else(|err| { - panic!("reading random bytes from Read implementation failed; error: {err}"); - }); - } -} - -/// `ReadRng` error type -#[derive(Debug)] -pub struct ReadError(std::io::Error); - -impl fmt::Display for ReadError { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!(f, "ReadError: {}", self.0) - } -} - -impl std::error::Error for ReadError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - Some(&self.0) + if let Err(err) = self.try_fill_bytes(dest) { + // Failed to deliver random data, so the caller must check the error + // cell before using the result. + self.error.set(Some(err)); + } } } diff --git a/src/uu/shuf/src/shuf.rs b/src/uu/shuf/src/shuf.rs index c0765e553..1cc444283 100644 --- a/src/uu/shuf/src/shuf.rs +++ b/src/uu/shuf/src/shuf.rs @@ -370,7 +370,7 @@ fn shuf_exec( output: &mut BufWriter>, ) -> UResult<()> { let ctx = || translate!("shuf-error-write-failed"); - + let error_cell = rng.get_error_cell(); if opts.repeat { if input.is_empty() { return Err(USimpleError::new( @@ -380,12 +380,15 @@ fn shuf_exec( } for _ in 0..opts.head_count { let r = input.choose(rng); + WrappedRng::check_error(error_cell.as_ref())?; r.write_all_to(output).map_err_context(ctx)?; output.write_all(&[opts.sep]).map_err_context(ctx)?; } } else { let shuffled = input.partial_shuffle(rng, opts.head_count); + WrappedRng::check_error(error_cell.as_ref())?; + for r in shuffled { r.write_all_to(output).map_err_context(ctx)?; output.write_all(&[opts.sep]).map_err_context(ctx)?; @@ -415,6 +418,25 @@ enum WrappedRng { RngDefault(rand::rngs::ThreadRng), } +impl WrappedRng { + fn get_error_cell(&self) -> Option { + if let Self::RngFile(adapter) = self { + Some(adapter.error.clone()) + } else { + None + } + } + + fn check_error(error_cell: Option<&rand_read_adapter::ErrorCell>) -> UResult<()> { + if let Some(cell) = error_cell { + if let Some(err) = cell.take() { + return Err(err.map_err_context(|| translate!("shuf-error-read-random-bytes"))); + } + } + Ok(()) + } +} + impl RngCore for WrappedRng { fn next_u32(&mut self) -> u32 { match self { From 3017ddad9e94bdb2d7dadc91c5e20758cffb5a5f Mon Sep 17 00:00:00 2001 From: Jan Verbeek Date: Wed, 26 Mar 2025 10:34:54 +0100 Subject: [PATCH 346/425] shuf: perf: Use itoa for integer formatting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This gives a 1.8× speedup over a stdlib formatted write for `shuf -r -n1000000 -i1-1024`. The original version of this commit replaced a formatted write, but before it got merged main received optimized manual formatting from another PR. The speedup of itoa over the manual write is around 1.1×, much less dramatic. --- .../workspace.wordlist.txt | 1 + Cargo.lock | 1 + Cargo.toml | 1 + src/uu/shuf/Cargo.toml | 1 + src/uu/shuf/src/shuf.rs | 24 ++++--------------- 5 files changed, 8 insertions(+), 20 deletions(-) diff --git a/.vscode/cspell.dictionaries/workspace.wordlist.txt b/.vscode/cspell.dictionaries/workspace.wordlist.txt index 28c468d4f..30d2bd3e0 100644 --- a/.vscode/cspell.dictionaries/workspace.wordlist.txt +++ b/.vscode/cspell.dictionaries/workspace.wordlist.txt @@ -38,6 +38,7 @@ getrandom globset indicatif itertools +itoa iuse langid lscolors diff --git a/Cargo.lock b/Cargo.lock index 5450edf53..c5e5d61dd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3867,6 +3867,7 @@ dependencies = [ "clap", "codspeed-divan-compat", "fluent", + "itoa", "rand 0.9.2", "rand_core 0.9.5", "uucore", diff --git a/Cargo.toml b/Cargo.toml index 2f57496a5..d3eee72cc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -335,6 +335,7 @@ icu_locale = "2.0.0" icu_provider = "2.0.0" indicatif = "0.18.0" itertools = "0.14.0" +itoa = "1.0.15" jiff = "0.2.18" libc = "0.2.172" lscolors = { version = "0.21.0", default-features = false, features = [ diff --git a/src/uu/shuf/Cargo.toml b/src/uu/shuf/Cargo.toml index 26a270b88..135dc29f4 100644 --- a/src/uu/shuf/Cargo.toml +++ b/src/uu/shuf/Cargo.toml @@ -19,6 +19,7 @@ path = "src/shuf.rs" [dependencies] clap = { workspace = true } +itoa = { workspace = true } rand = { workspace = true } rand_core = { workspace = true } uucore = { workspace = true } diff --git a/src/uu/shuf/src/shuf.rs b/src/uu/shuf/src/shuf.rs index 1cc444283..cc04a3068 100644 --- a/src/uu/shuf/src/shuf.rs +++ b/src/uu/shuf/src/shuf.rs @@ -340,26 +340,10 @@ impl Writable for &OsStr { impl Writable for usize { fn write_all_to(&self, output: &mut impl OsWrite) -> Result<(), Error> { - let mut n = *self; - - // Handle the zero case explicitly - if n == 0 { - return output.write_all(b"0"); - } - - // Maximum number of digits for u64 is 20 (18446744073709551615) - let mut buf = [0u8; 20]; - let mut i = 20; - - // Write digits from right to left - while n > 0 { - i -= 1; - buf[i] = b'0' + (n % 10) as u8; - n /= 10; - } - - // Write the relevant part of the buffer to output - output.write_all(&buf[i..]) + // The itoa crate is surprisingly much more efficient than a formatted write. + // It speeds up `shuf -r -n1000000 -i1-1024` by 1.8×. + let mut buf = itoa::Buffer::new(); + output.write_all(buf.format(*self).as_bytes()) } } From a8fa8529a84fd0626c6624be490ff8f72ce085b6 Mon Sep 17 00:00:00 2001 From: Jan Verbeek Date: Tue, 25 Mar 2025 19:19:26 +0100 Subject: [PATCH 347/425] shuf: correctness: Make --random-source compatible with GNU shuf When the --random-source option is used uutils shuf now gives identical output to GNU shuf in many (but not all) cases. This is helpful to users who use it to get deterministic output, e.g. by combining it with `openssl` as suggested in the GNU info pages. I reverse engineered the algorithm from GNU shuf's output. There may be bugs. Other modes of shuffling still use `rand`'s `ThreadRng`, though they now sample a uniform distribution directly without going through the slice helper trait. Additionally, switch from `usize` to `u64` for `--input-range` and `--head-count`. This way the same range of numbers can be generated on 32-bit platforms as on 64-bit platforms. --- .../cspell.dictionaries/jargon.wordlist.txt | 2 + src/uu/shuf/locales/en-US.ftl | 1 + src/uu/shuf/src/compat_random_source.rs | 107 ++++++++++++ src/uu/shuf/src/nonrepeating_iterator.rs | 57 +++---- src/uu/shuf/src/rand_read_adapter.rs | 135 --------------- src/uu/shuf/src/shuf.rs | 138 +++++++-------- tests/by-util/test_shuf.rs | 159 ++++++++++++++++++ 7 files changed, 360 insertions(+), 239 deletions(-) create mode 100644 src/uu/shuf/src/compat_random_source.rs delete mode 100644 src/uu/shuf/src/rand_read_adapter.rs diff --git a/.vscode/cspell.dictionaries/jargon.wordlist.txt b/.vscode/cspell.dictionaries/jargon.wordlist.txt index fd1352931..2ca152125 100644 --- a/.vscode/cspell.dictionaries/jargon.wordlist.txt +++ b/.vscode/cspell.dictionaries/jargon.wordlist.txt @@ -93,6 +93,7 @@ mergeable microbenchmark microbenchmarks microbenchmarking +monomorphized multibyte multicall nmerge @@ -107,6 +108,7 @@ nolinks nonblock nonportable nonprinting +nonrepeating nonseekable notrunc nowrite diff --git a/src/uu/shuf/locales/en-US.ftl b/src/uu/shuf/locales/en-US.ftl index 913ddaa1d..477684fb2 100644 --- a/src/uu/shuf/locales/en-US.ftl +++ b/src/uu/shuf/locales/en-US.ftl @@ -20,6 +20,7 @@ shuf-error-failed-to-open-for-writing = failed to open { $file } for writing shuf-error-failed-to-open-random-source = failed to open random source { $file } shuf-error-read-error = read error shuf-error-read-random-bytes = reading random bytes failed +shuf-error-end-of-random-bytes = end of random source shuf-error-no-lines-to-repeat = no lines to repeat shuf-error-start-exceeds-end = start exceeds end shuf-error-missing-dash = missing '-' diff --git a/src/uu/shuf/src/compat_random_source.rs b/src/uu/shuf/src/compat_random_source.rs new file mode 100644 index 000000000..9d2d1e3b2 --- /dev/null +++ b/src/uu/shuf/src/compat_random_source.rs @@ -0,0 +1,107 @@ +use std::io::BufRead; + +use uucore::error::{FromIo, UResult, USimpleError}; +use uucore::translate; + +/// A uniform integer generator that tries to exactly match GNU shuf's --random-source. +/// +/// It's not particularly efficient and possibly not quite uniform. It should *only* be +/// used for compatibility with GNU: other modes shouldn't touch this code. +/// +/// All the logic here was black box reverse engineered. It might not match up in all edge +/// cases but it gives identical results on many different large and small inputs. +/// +/// It seems that GNU uses fairly textbook rejection sampling to generate integers, reading +/// one byte at a time until it has enough entropy, and recycling leftover entropy after +/// accepting or rejecting a value. +/// +/// To do your own experiments, start with commands like these: +/// +/// printf '\x01\x02\x03\x04' | shuf -i0-255 -r --random-source=/dev/stdin +/// +/// Then vary the integer range and the input and the input length. It can be useful to +/// see when exactly shuf crashes with an "end of file" error. +/// +/// To spot small inconsistencies it's useful to run: +/// +/// diff -y <(my_shuf ...) <(shuf -i0-{MAX} -r --random-source={INPUT}) | head -n 50 +pub struct RandomSourceAdapter { + reader: R, + state: u64, + entropy: u64, +} + +impl RandomSourceAdapter { + pub fn new(reader: R) -> Self { + Self { + reader, + state: 0, + entropy: 0, + } + } +} + +impl RandomSourceAdapter { + pub fn get_value(&mut self, at_most: u64) -> UResult { + while self.entropy < at_most { + let buf = self + .reader + .fill_buf() + .map_err_context(|| translate!("shuf-error-read-random-bytes"))?; + let Some(&byte) = buf.first() else { + return Err(USimpleError::new( + 1, + translate!("shuf-error-end-of-random-bytes"), + )); + }; + self.reader.consume(1); + // Is overflow OK here? Won't it cause bias? (Seems to work out...) + self.state = self.state.wrapping_mul(256).wrapping_add(byte as u64); + self.entropy = self.entropy.wrapping_mul(256).wrapping_add(255); + } + + if at_most == u64::MAX { + // at_most + 1 would overflow but this case is easy. + let val = self.state; + self.entropy = 0; + self.state = 0; + return Ok(val); + } + + let num_possibilities = at_most + 1; + + // If the generated number falls within this margin at the upper end of the + // range then we retry to avoid modulo bias. + let margin = ((self.entropy as u128 + 1) % num_possibilities as u128) as u64; + let safe_zone = self.entropy - margin; + + if self.state <= safe_zone { + let val = self.state % num_possibilities; + // Reuse the rest of the state. + self.state /= num_possibilities; + // We need this subtraction, otherwise we consume new input slightly more + // slowly than GNU. Not sure if it checks out mathematically. + self.entropy -= at_most; + self.entropy /= num_possibilities; + Ok(val) + } else { + self.state %= num_possibilities; + self.entropy %= num_possibilities; + // I sure hope the compiler optimizes this tail call. + self.get_value(at_most) + } + } + + pub fn shuffle<'a, T>(&mut self, vals: &'a mut [T], amount: usize) -> UResult<&'a mut [T]> { + // Fisher-Yates shuffle. + // TODO: GNU does something different if amount <= vals.len() and the input is stdin. + // The order changes completely and depends on --head-count. + // No clue what they might do differently and why. + let amount = amount.min(vals.len()); + for idx in 0..amount { + let other_idx = self.get_value((vals.len() - idx - 1) as u64)? as usize + idx; + vals.swap(idx, other_idx); + } + Ok(&mut vals[..amount]) + } +} diff --git a/src/uu/shuf/src/nonrepeating_iterator.rs b/src/uu/shuf/src/nonrepeating_iterator.rs index dfefd1178..41a301a0b 100644 --- a/src/uu/shuf/src/nonrepeating_iterator.rs +++ b/src/uu/shuf/src/nonrepeating_iterator.rs @@ -1,32 +1,30 @@ // spell-checker:ignore nonrepeating +// TODO: this iterator is not compatible with GNU when --random-source is used + use std::{collections::HashSet, ops::RangeInclusive}; -use rand::{Rng, seq::SliceRandom}; +use uucore::error::UResult; use crate::WrappedRng; enum NumberSet { - AlreadyListed(HashSet), - Remaining(Vec), + AlreadyListed(HashSet), + Remaining(Vec), } pub(crate) struct NonrepeatingIterator<'a> { - range: RangeInclusive, + range: RangeInclusive, rng: &'a mut WrappedRng, - remaining_count: usize, + remaining_count: u64, buf: NumberSet, } impl<'a> NonrepeatingIterator<'a> { - pub(crate) fn new( - range: RangeInclusive, - rng: &'a mut WrappedRng, - amount: usize, - ) -> Self { + pub(crate) fn new(range: RangeInclusive, rng: &'a mut WrappedRng, amount: u64) -> Self { let capped_amount = if range.start() > range.end() { 0 - } else if range == (0..=usize::MAX) { + } else if range == (0..=u64::MAX) { amount } else { amount.min(range.end() - range.start() + 1) @@ -39,12 +37,12 @@ impl<'a> NonrepeatingIterator<'a> { } } - fn produce(&mut self) -> usize { + fn produce(&mut self) -> UResult { debug_assert!(self.range.start() <= self.range.end()); match &mut self.buf { NumberSet::AlreadyListed(already_listed) => { let chosen = loop { - let guess = self.rng.random_range(self.range.clone()); + let guess = self.rng.choose_from_range(self.range.clone())?; let newly_inserted = already_listed.insert(guess); if newly_inserted { break guess; @@ -54,32 +52,32 @@ impl<'a> NonrepeatingIterator<'a> { // the number of attempts to find a number that hasn't been chosen yet increases. // Therefore, we need to switch at some point from "set of already returned values" to "list of remaining values". let range_size = (self.range.end() - self.range.start()).saturating_add(1); - if number_set_should_list_remaining(already_listed.len(), range_size) { + if number_set_should_list_remaining(already_listed.len() as u64, range_size) { let mut remaining = self .range .clone() .filter(|n| !already_listed.contains(n)) .collect::>(); - assert!(remaining.len() >= self.remaining_count); - remaining.partial_shuffle(&mut self.rng, self.remaining_count); - remaining.truncate(self.remaining_count); + assert!(remaining.len() as u64 >= self.remaining_count); + remaining.truncate(self.remaining_count as usize); + self.rng.shuffle(&mut remaining, usize::MAX)?; self.buf = NumberSet::Remaining(remaining); } - chosen + Ok(chosen) } NumberSet::Remaining(remaining_numbers) => { debug_assert!(!remaining_numbers.is_empty()); // We only enter produce() when there is at least one actual element remaining, so popping must always return an element. - remaining_numbers.pop().unwrap() + Ok(remaining_numbers.pop().unwrap()) } } } } impl Iterator for NonrepeatingIterator<'_> { - type Item = usize; + type Item = UResult; - fn next(&mut self) -> Option { + fn next(&mut self) -> Option> { if self.range.is_empty() || self.remaining_count == 0 { return None; } @@ -89,7 +87,7 @@ impl Iterator for NonrepeatingIterator<'_> { } // This could be a method, but it is much easier to test as a stand-alone function. -fn number_set_should_list_remaining(listed_count: usize, range_size: usize) -> bool { +fn number_set_should_list_remaining(listed_count: u64, range_size: u64) -> bool { // Arbitrarily determine the switchover point to be around 25%. This is because: // - HashSet has a large space overhead for the hash table load factor. // - This means that somewhere between 25-40%, the memory required for a "positive" HashSet and a "negative" Vec should be the same. @@ -107,17 +105,17 @@ mod test_number_set_decision { #[test] fn test_stay_positive_large_remaining_first() { - assert_eq!(false, number_set_should_list_remaining(0, usize::MAX)); + assert_eq!(false, number_set_should_list_remaining(0, u64::MAX)); } #[test] fn test_stay_positive_large_remaining_second() { - assert_eq!(false, number_set_should_list_remaining(1, usize::MAX)); + assert_eq!(false, number_set_should_list_remaining(1, u64::MAX)); } #[test] fn test_stay_positive_large_remaining_tenth() { - assert_eq!(false, number_set_should_list_remaining(9, usize::MAX)); + assert_eq!(false, number_set_should_list_remaining(9, u64::MAX)); } #[test] @@ -161,22 +159,19 @@ mod test_number_set_decision { // Ensure that we are overflow-free: #[test] fn test_no_crash_exceed_max_size1() { - assert_eq!(false, number_set_should_list_remaining(12345, usize::MAX)); + assert_eq!(false, number_set_should_list_remaining(12345, u64::MAX)); } #[test] fn test_no_crash_exceed_max_size2() { assert_eq!( true, - number_set_should_list_remaining(usize::MAX - 1, usize::MAX) + number_set_should_list_remaining(u64::MAX - 1, u64::MAX) ); } #[test] fn test_no_crash_exceed_max_size3() { - assert_eq!( - true, - number_set_should_list_remaining(usize::MAX, usize::MAX) - ); + assert_eq!(true, number_set_should_list_remaining(u64::MAX, u64::MAX)); } } diff --git a/src/uu/shuf/src/rand_read_adapter.rs b/src/uu/shuf/src/rand_read_adapter.rs deleted file mode 100644 index 84c7e8bf2..000000000 --- a/src/uu/shuf/src/rand_read_adapter.rs +++ /dev/null @@ -1,135 +0,0 @@ -// This file is part of the uutils coreutils package. -// -// For the full copyright and license information, please view the LICENSE -// file that was distributed with this source code. -// Copyright 2018 Developers of the Rand project. -// Copyright 2013 The Rust Project Developers. -// -// Licensed under the Apache License, Version 2.0 or the MIT license -// , at your -// option. This file may not be copied, modified, or distributed -// except according to those terms. - -//! A wrapper around any Read to treat it as an RNG. - -use std::cell::Cell; -use std::io::{Error, Read}; -use std::rc::Rc; - -use rand_core::{RngCore, impls}; - -/// An RNG that reads random bytes straight from any type supporting -/// [`std::io::Read`], for example files. -/// -/// This will work best with an infinite reader, but that is not required. -/// -/// This can be used with `/dev/urandom` on Unix but it is recommended to use -/// [`OsRng`] instead. -/// -/// # Panics -/// -/// `ReadRng` uses [`std::io::Read::read_exact`], which retries on interrupts. -/// All other errors from the underlying reader, including when it does not -/// have enough data, will be reported via the public error field (which can -/// be cloned in advance, as it uses [`Rc`]). This field must be checked for -/// errors after every operation. -/// -/// [`OsRng`]: rand::rngs::OsRng -pub struct ReadRng { - reader: R, - pub error: ErrorCell, -} - -pub type ErrorCell = Rc>>; - -impl ReadRng { - /// Create a new `ReadRng` from a `Read`. - pub fn new(r: R) -> Self { - Self { - reader: r, - error: Rc::default(), - } - } - - fn try_fill_bytes(&mut self, dest: &mut [u8]) -> Result<(), Error> { - if dest.is_empty() { - return Ok(()); - } - // Use `std::io::read_exact`, which retries on `ErrorKind::Interrupted`. - self.reader.read_exact(dest) - } -} - -impl RngCore for ReadRng { - fn next_u32(&mut self) -> u32 { - impls::next_u32_via_fill(self) - } - - fn next_u64(&mut self) -> u64 { - impls::next_u64_via_fill(self) - } - - fn fill_bytes(&mut self, dest: &mut [u8]) { - if let Err(err) = self.try_fill_bytes(dest) { - // Failed to deliver random data, so the caller must check the error - // cell before using the result. - self.error.set(Some(err)); - } - } -} - -#[cfg(test)] -mod test { - use std::println; - - use super::ReadRng; - use rand::RngCore; - - #[test] - fn test_reader_rng_u64() { - // transmute from the target to avoid endianness concerns. - #[rustfmt::skip] - let v = [0u8, 0, 0, 0, 0, 0, 0, 1, - 0, 4, 0, 0, 3, 0, 0, 2, - 5, 0, 0, 0, 0, 0, 0, 0]; - let mut rng = ReadRng::new(&v[..]); - - assert_eq!(rng.next_u64(), 1 << 56); - assert_eq!(rng.next_u64(), (2 << 56) + (3 << 32) + (4 << 8)); - assert_eq!(rng.next_u64(), 5); - } - - #[test] - fn test_reader_rng_u32() { - let v = [0u8, 0, 0, 1, 0, 0, 2, 0, 3, 0, 0, 0]; - let mut rng = ReadRng::new(&v[..]); - - assert_eq!(rng.next_u32(), 1 << 24); - assert_eq!(rng.next_u32(), 2 << 16); - assert_eq!(rng.next_u32(), 3); - } - - #[test] - fn test_reader_rng_fill_bytes() { - let v = [1u8, 2, 3, 4, 5, 6, 7, 8]; - let mut w = [0u8; 8]; - - let mut rng = ReadRng::new(&v[..]); - rng.fill_bytes(&mut w); - - assert_eq!(v, w); - } - - #[test] - fn test_reader_rng_insufficient_bytes() { - let v = [1u8, 2, 3, 4, 5, 6, 7, 8]; - let mut w = [0u8; 9]; - - let mut rng = ReadRng::new(&v[..]); - - let result = rng.try_fill_bytes(&mut w); - assert!(result.is_err()); - println!("Error: {}", result.unwrap_err()); - } -} diff --git a/src/uu/shuf/src/shuf.rs b/src/uu/shuf/src/shuf.rs index cc04a3068..9a3e3afd4 100644 --- a/src/uu/shuf/src/shuf.rs +++ b/src/uu/shuf/src/shuf.rs @@ -7,12 +7,11 @@ use clap::builder::ValueParser; use clap::{Arg, ArgAction, Command}; -use rand::prelude::SliceRandom; -use rand::seq::IndexedRandom; -use rand::{Rng, RngCore}; +use rand::Rng; +use rand::seq::{IndexedRandom, SliceRandom}; use std::ffi::{OsStr, OsString}; use std::fs::File; -use std::io::{BufWriter, Error, Read, Write, stdin, stdout}; +use std::io::{BufReader, BufWriter, Error, Read, Write, stdin, stdout}; use std::ops::RangeInclusive; use std::path::{Path, PathBuf}; use std::str::FromStr; @@ -21,21 +20,21 @@ use uucore::error::{FromIo, UResult, USimpleError, UUsageError}; use uucore::format_usage; use uucore::translate; +mod compat_random_source; mod nonrepeating_iterator; -mod rand_read_adapter; use nonrepeating_iterator::NonrepeatingIterator; enum Mode { Default(PathBuf), Echo(Vec), - InputRange(RangeInclusive), + InputRange(RangeInclusive), } const BUF_SIZE: usize = 64 * 1024; struct Options { - head_count: usize, + head_count: u64, output: Option, random_source: Option, repeat: bool, @@ -87,11 +86,11 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { // Busybox takes the final value which is more typical: later // options override earlier options. head_count: matches - .get_many::(options::HEAD_COUNT) + .get_many::(options::HEAD_COUNT) .unwrap_or_default() .copied() .min() - .unwrap_or(usize::MAX), + .unwrap_or(u64::MAX), output: matches.get_one(options::OUTPUT).cloned(), random_source: matches.get_one(options::RANDOM_SOURCE).cloned(), repeat: matches.get_flag(options::REPEAT), @@ -125,7 +124,8 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { let file = File::open(r).map_err_context( || translate!("shuf-error-failed-to-open-random-source", "file" => r.quote()), )?; - WrappedRng::RngFile(rand_read_adapter::ReadRng::new(file)) + let file = BufReader::new(file); + WrappedRng::RngFile(compat_random_source::RandomSourceAdapter::new(file)) } None => WrappedRng::RngDefault(rand::rng()), }; @@ -180,7 +180,7 @@ pub fn uu_app() -> Command { .value_name("COUNT") .action(ArgAction::Append) .help(translate!("shuf-help-head-count")) - .value_parser(usize::from_str), + .value_parser(u64::from_str), ) .arg( Arg::new(options::OUTPUT) @@ -250,12 +250,15 @@ fn split_seps(data: &[u8], sep: u8) -> Vec<&[u8]> { trait Shufable { type Item: Writable; fn is_empty(&self) -> bool; - fn choose(&self, rng: &mut WrappedRng) -> Self::Item; + fn choose(&self, rng: &mut WrappedRng) -> UResult; + // In some modes we shuffle ahead of time and in some as we generate + // so we unfortunately need to double-wrap UResult. + // But it's monomorphized so the optimizer will hopefully Take Care Of It™. fn partial_shuffle<'b>( &'b mut self, rng: &'b mut WrappedRng, - amount: usize, - ) -> impl Iterator; + amount: u64, + ) -> UResult>>; } impl<'a> Shufable for Vec<&'a [u8]> { @@ -265,20 +268,22 @@ impl<'a> Shufable for Vec<&'a [u8]> { (**self).is_empty() } - fn choose(&self, rng: &mut WrappedRng) -> Self::Item { - // Note: "copied()" only copies the reference, not the entire [u8]. - // Returns None if the slice is empty. We checked this before, so - // this is safe. - (**self).choose(rng).unwrap() + fn choose(&self, rng: &mut WrappedRng) -> UResult { + rng.choose(self) } fn partial_shuffle<'b>( &'b mut self, rng: &'b mut WrappedRng, - amount: usize, - ) -> impl Iterator { - // Note: "copied()" only copies the reference, not the entire [u8]. - (**self).partial_shuffle(rng, amount).0.iter().copied() + amount: u64, + ) -> UResult>> { + // On 32-bit platforms it's possible that amount > usize::MAX. + // We saturate as usize::MAX since all of our shuffling modes require storing + // elements in memory so more than usize::MAX elements won't fit anyway. + // (With --repeat an output larger than usize::MAX is possible. But --repeat + // uses `choose()`.) + let amount = usize::try_from(amount).unwrap_or(usize::MAX); + Ok(rng.shuffle(self, amount)?.iter().copied().map(Ok)) } } @@ -289,36 +294,37 @@ impl<'a> Shufable for Vec<&'a OsStr> { (**self).is_empty() } - fn choose(&self, rng: &mut WrappedRng) -> Self::Item { - (**self).choose(rng).unwrap() + fn choose(&self, rng: &mut WrappedRng) -> UResult { + rng.choose(self) } fn partial_shuffle<'b>( &'b mut self, rng: &'b mut WrappedRng, - amount: usize, - ) -> impl Iterator { - (**self).partial_shuffle(rng, amount).0.iter().copied() + amount: u64, + ) -> UResult>> { + let amount = usize::try_from(amount).unwrap_or(usize::MAX); + Ok(rng.shuffle(self, amount)?.iter().copied().map(Ok)) } } -impl Shufable for RangeInclusive { - type Item = usize; +impl Shufable for RangeInclusive { + type Item = u64; fn is_empty(&self) -> bool { self.is_empty() } - fn choose(&self, rng: &mut WrappedRng) -> usize { - rng.random_range(self.clone()) + fn choose(&self, rng: &mut WrappedRng) -> UResult { + rng.choose_from_range(self.clone()) } fn partial_shuffle<'b>( &'b mut self, rng: &'b mut WrappedRng, - amount: usize, - ) -> impl Iterator { - NonrepeatingIterator::new(self.clone(), rng, amount) + amount: u64, + ) -> UResult>> { + Ok(NonrepeatingIterator::new(self.clone(), rng, amount)) } } @@ -338,7 +344,7 @@ impl Writable for &OsStr { } } -impl Writable for usize { +impl Writable for u64 { fn write_all_to(&self, output: &mut impl OsWrite) -> Result<(), Error> { // The itoa crate is surprisingly much more efficient than a formatted write. // It speeds up `shuf -r -n1000000 -i1-1024` by 1.8×. @@ -354,7 +360,6 @@ fn shuf_exec( output: &mut BufWriter>, ) -> UResult<()> { let ctx = || translate!("shuf-error-write-failed"); - let error_cell = rng.get_error_cell(); if opts.repeat { if input.is_empty() { return Err(USimpleError::new( @@ -363,17 +368,16 @@ fn shuf_exec( )); } for _ in 0..opts.head_count { - let r = input.choose(rng); - WrappedRng::check_error(error_cell.as_ref())?; + let r = input.choose(rng)?; r.write_all_to(output).map_err_context(ctx)?; output.write_all(&[opts.sep]).map_err_context(ctx)?; } } else { - let shuffled = input.partial_shuffle(rng, opts.head_count); - WrappedRng::check_error(error_cell.as_ref())?; + let shuffled = input.partial_shuffle(rng, opts.head_count)?; for r in shuffled { + let r = r?; r.write_all_to(output).map_err_context(ctx)?; output.write_all(&[opts.sep]).map_err_context(ctx)?; } @@ -383,10 +387,10 @@ fn shuf_exec( Ok(()) } -fn parse_range(input_range: &str) -> Result, String> { +fn parse_range(input_range: &str) -> Result, String> { if let Some((from, to)) = input_range.split_once('-') { - let begin = from.parse::().map_err(|e| e.to_string())?; - let end = to.parse::().map_err(|e| e.to_string())?; + let begin = from.parse::().map_err(|e| e.to_string())?; + let end = to.parse::().map_err(|e| e.to_string())?; if begin <= end || begin == end + 1 { Ok(begin..=end) } else { @@ -398,48 +402,36 @@ fn parse_range(input_range: &str) -> Result, String> { } enum WrappedRng { - RngFile(rand_read_adapter::ReadRng), RngDefault(rand::rngs::ThreadRng), + RngFile(compat_random_source::RandomSourceAdapter>), } impl WrappedRng { - fn get_error_cell(&self) -> Option { - if let Self::RngFile(adapter) = self { - Some(adapter.error.clone()) - } else { - None - } - } - - fn check_error(error_cell: Option<&rand_read_adapter::ErrorCell>) -> UResult<()> { - if let Some(cell) = error_cell { - if let Some(err) = cell.take() { - return Err(err.map_err_context(|| translate!("shuf-error-read-random-bytes"))); + fn choose(&mut self, vals: &[T]) -> UResult { + match self { + Self::RngDefault(rng) => Ok(*vals.choose(rng).unwrap()), + Self::RngFile(adapter) => { + assert!(!vals.is_empty()); + let idx = adapter.get_value(vals.len() as u64 - 1)? as usize; + Ok(vals[idx]) } } - Ok(()) } -} -impl RngCore for WrappedRng { - fn next_u32(&mut self) -> u32 { + fn shuffle<'a, T>(&mut self, vals: &'a mut [T], amount: usize) -> UResult<&'a mut [T]> { match self { - Self::RngFile(r) => r.next_u32(), - Self::RngDefault(r) => r.next_u32(), + Self::RngDefault(rng) => Ok(vals.partial_shuffle(rng, amount).0), + Self::RngFile(adapter) => adapter.shuffle(vals, amount), } } - fn next_u64(&mut self) -> u64 { + fn choose_from_range(&mut self, range: RangeInclusive) -> UResult { match self { - Self::RngFile(r) => r.next_u64(), - Self::RngDefault(r) => r.next_u64(), - } - } - - fn fill_bytes(&mut self, dest: &mut [u8]) { - match self { - Self::RngFile(r) => r.fill_bytes(dest), - Self::RngDefault(r) => r.fill_bytes(dest), + Self::RngDefault(rng) => Ok(rng.random_range(range)), + Self::RngFile(adapter) => { + let offset = adapter.get_value(*range.end() - *range.start())?; + Ok(*range.start() + offset) + } } } } diff --git a/tests/by-util/test_shuf.rs b/tests/by-util/test_shuf.rs index 83f02f049..1b5d2a99c 100644 --- a/tests/by-util/test_shuf.rs +++ b/tests/by-util/test_shuf.rs @@ -859,3 +859,162 @@ fn write_errors_are_reported() { .no_stdout() .stderr_is("shuf: write failed: No space left on device\n"); } + +// On 32-bit platforms, if we cast carelessly, this will give no output. +#[test] +fn test_head_count_does_not_overflow_file() { + let (at, mut ucmd) = at_and_ucmd!(); + + at.append("input.txt", "hello\n"); + + ucmd.arg(format!("-n{}", u64::from(u32::MAX) + 1)) + .arg("input.txt") + .succeeds() + .stdout_is("hello\n") + .no_stderr(); +} + +#[test] +fn test_head_count_does_not_overflow_args() { + new_ucmd!() + .arg(format!("-n{}", u64::from(u32::MAX) + 1)) + .arg("-e") + .arg("goodbye") + .succeeds() + .stdout_is("goodbye\n") + .no_stderr(); +} + +#[test] +fn test_head_count_does_not_overflow_range() { + new_ucmd!() + .arg(format!("-n{}", u64::from(u32::MAX) + 1)) + .arg("-i1-1") + .succeeds() + .stdout_is("1\n") + .no_stderr(); +} + +// Test reproducibility and compatibility of --random-source. +// These hard-coded results match those of GNU shuf. They should not be changed. + +#[test] +fn test_gnu_compat_range_repeat() { + let (at, mut ucmd) = at_and_ucmd!(); + at.append_bytes( + "random_bytes.bin", + b"\xfb\x83\x8f\x21\x9b\x3c\x2d\xc5\x73\xa5\x58\x6c\x54\x2f\x59\xf8", + ); + + ucmd.arg("--random-source=random_bytes.bin") + .arg("-r") + .arg("-i1-99") + .fails_with_code(1) + .stderr_is("shuf: end of random source\n") + .stdout_is("38\n30\n10\n26\n23\n61\n46\n99\n75\n43\n10\n89\n10\n44\n24\n59\n22\n51\n"); +} + +#[test] +fn test_gnu_compat_args_no_repeat() { + let (at, mut ucmd) = at_and_ucmd!(); + at.append_bytes( + "random_bytes.bin", + b"\xd1\xfd\xb9\x9a\xf5\x81\x71\x42\xf9\x7a\x59\x79\xd4\x9c\x8c\x7d", + ); + + ucmd.arg("--random-source=random_bytes.bin") + .arg("-e") + .args(&["1", "2", "3", "4", "5", "6", "7"][..]) + .succeeds() + .no_stderr() + .stdout_is("7\n1\n2\n5\n3\n4\n6\n"); +} + +#[test] +fn test_gnu_compat_from_stdin() { + let (at, mut ucmd) = at_and_ucmd!(); + at.append_bytes( + "random_bytes.bin", + b"\xd1\xfd\xb9\x9a\xf5\x81\x71\x42\xf9\x7a\x59\x79\xd4\x9c\x8c\x7d", + ); + + at.append("input.txt", "1\n2\n3\n4\n5\n6\n7\n"); + + ucmd.arg("--random-source=random_bytes.bin") + .set_stdin(at.open("input.txt")) + .succeeds() + .no_stderr() + .stdout_is("7\n1\n2\n5\n3\n4\n6\n"); +} + +#[test] +fn test_gnu_compat_from_file() { + let (at, mut ucmd) = at_and_ucmd!(); + at.append_bytes( + "random_bytes.bin", + b"\xd1\xfd\xb9\x9a\xf5\x81\x71\x42\xf9\x7a\x59\x79\xd4\x9c\x8c\x7d", + ); + + at.append("input.txt", "1\n2\n3\n4\n5\n6\n7\n"); + + ucmd.arg("--random-source=random_bytes.bin") + .arg("input.txt") + .succeeds() + .no_stderr() + .stdout_is("7\n1\n2\n5\n3\n4\n6\n"); +} + +#[test] +fn test_gnu_compat_limited_from_file() { + let (at, mut ucmd) = at_and_ucmd!(); + at.append_bytes( + "random_bytes.bin", + b"\xd1\xfd\xb9\x9a\xf5\x81\x71\x42\xf9\x7a\x59\x79\xd4\x9c\x8c\x7d", + ); + + at.append("input.txt", "1\n2\n3\n4\n5\n6\n7\n"); + + ucmd.arg("--random-source=random_bytes.bin") + .arg("-n5") + .arg("input.txt") + .succeeds() + .no_stderr() + .stdout_is("7\n1\n2\n5\n3\n"); +} + +// This specific case causes GNU to give different results than other modes. +#[ignore = "disabled until fixed"] +#[test] +fn test_gnu_compat_limited_from_stdin() { + let (at, mut ucmd) = at_and_ucmd!(); + at.append_bytes( + "random_bytes.bin", + b"\xd1\xfd\xb9\x9a\xf5\x81\x71\x42\xf9\x7a\x59\x79\xd4\x9c\x8c\x7d", + ); + + at.append("input.txt", "1\n2\n3\n4\n5\n6\n7\n"); + + ucmd.arg("--random-source=random_bytes.bin") + .arg("-n7") + .set_stdin(at.open("input.txt")) + .succeeds() + .no_stderr() + .stdout_is("6\n5\n1\n3\n2\n7\n4\n"); +} + +// We haven't reverse-engineered GNU's nonrepeating integer sampling yet. +#[ignore = "disabled until fixed"] +#[test] +fn test_gnu_compat_range_no_repeat() { + let (at, mut ucmd) = at_and_ucmd!(); + at.append_bytes( + "random_bytes.bin", + b"\xd1\xfd\xb9\x9a\xf5\x81\x71\x42\xf9\x7a\x59\x79\xd4\x9c\x8c\x7d", + ); + + ucmd.arg("--random-source=random_bytes.bin") + .arg("-i1-10") + .succeeds() + .no_stderr() + .stdout_is("10\n2\n8\n7\n3\n9\n6\n5\n1\n4\n"); +} From 55e756f67a2b33e5d950005cdde36852a5eb3dde Mon Sep 17 00:00:00 2001 From: Jan Verbeek Date: Wed, 26 Mar 2025 15:51:55 +0100 Subject: [PATCH 348/425] shuf: feature: Add --random-seed option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This adds a new option to get reproducible output from a seed. This was already possible with --random-source, but doing that properly was tricky and had poor performance. Adding this option implies a commitment to keep using the exact same algorithms in the future. For that reason we only use third-party libraries for well-known algorithms and implement our own distributions on top of that. ----- As a teenager on King's Day I once used `shuf` for divination. People paid €0.50 to enter a cramped tent and sat down next to me behind an old netbook. I would ask their name and their sun sign and pipe this information into `shuf --random-source=/dev/stdin`, which selected pseudo-random dictionary words and `tee`d them into `espeak`. If someone's name was too short `shuf` crashed with an end of file error. --random-seed would have worked better. --- .../cspell.dictionaries/jargon.wordlist.txt | 1 + .../cspell.dictionaries/people.wordlist.txt | 3 + Cargo.lock | 2 + Cargo.toml | 1 + src/uu/shuf/Cargo.toml | 2 + src/uu/shuf/locales/en-US.ftl | 1 + src/uu/shuf/src/compat_random_source.rs | 24 +++- src/uu/shuf/src/random_seed.rs | 118 ++++++++++++++++++ src/uu/shuf/src/shuf.rs | 77 ++++++++---- tests/by-util/test_shuf.rs | 49 ++++++++ 10 files changed, 250 insertions(+), 28 deletions(-) create mode 100644 src/uu/shuf/src/random_seed.rs diff --git a/.vscode/cspell.dictionaries/jargon.wordlist.txt b/.vscode/cspell.dictionaries/jargon.wordlist.txt index 2ca152125..7ba13ab80 100644 --- a/.vscode/cspell.dictionaries/jargon.wordlist.txt +++ b/.vscode/cspell.dictionaries/jargon.wordlist.txt @@ -55,6 +55,7 @@ fileio filesystem filesystems flamegraph +footgun freeram fsxattr fullblock diff --git a/.vscode/cspell.dictionaries/people.wordlist.txt b/.vscode/cspell.dictionaries/people.wordlist.txt index 8fe38d885..446c00df4 100644 --- a/.vscode/cspell.dictionaries/people.wordlist.txt +++ b/.vscode/cspell.dictionaries/people.wordlist.txt @@ -37,6 +37,9 @@ Boden Garman Chirag B Jadwani Chirag Jadwani +Daniel Lemire + Daniel + Lemire Derek Chiang Derek Chiang diff --git a/Cargo.lock b/Cargo.lock index c5e5d61dd..591b5c558 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3869,7 +3869,9 @@ dependencies = [ "fluent", "itoa", "rand 0.9.2", + "rand_chacha 0.9.0", "rand_core 0.9.5", + "sha3", "uucore", ] diff --git a/Cargo.toml b/Cargo.toml index d3eee72cc..de95e8fa8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -356,6 +356,7 @@ phf_codegen = "0.13.1" platform-info = "2.0.3" procfs = "0.18" rand = { version = "0.9.0", features = ["small_rng"] } +rand_chacha = { version = "0.9.0" } rand_core = "0.9.0" rayon = "1.10" regex = "1.10.4" diff --git a/src/uu/shuf/Cargo.toml b/src/uu/shuf/Cargo.toml index 135dc29f4..b271d9b9b 100644 --- a/src/uu/shuf/Cargo.toml +++ b/src/uu/shuf/Cargo.toml @@ -21,7 +21,9 @@ path = "src/shuf.rs" clap = { workspace = true } itoa = { workspace = true } rand = { workspace = true } +rand_chacha = { workspace = true } rand_core = { workspace = true } +sha3 = { workspace = true } uucore = { workspace = true } fluent = { workspace = true } diff --git a/src/uu/shuf/locales/en-US.ftl b/src/uu/shuf/locales/en-US.ftl index 477684fb2..de3221179 100644 --- a/src/uu/shuf/locales/en-US.ftl +++ b/src/uu/shuf/locales/en-US.ftl @@ -10,6 +10,7 @@ shuf-help-echo = treat each ARG as an input line shuf-help-input-range = treat each number LO through HI as an input line shuf-help-head-count = output at most COUNT lines shuf-help-output = write result to FILE instead of standard output +shuf-help-random-seed = seed with STRING for reproducible output shuf-help-random-source = get random bytes from FILE shuf-help-repeat = output lines can be repeated shuf-help-zero-terminated = line delimiter is NUL, not newline diff --git a/src/uu/shuf/src/compat_random_source.rs b/src/uu/shuf/src/compat_random_source.rs index 9d2d1e3b2..73a7191be 100644 --- a/src/uu/shuf/src/compat_random_source.rs +++ b/src/uu/shuf/src/compat_random_source.rs @@ -1,4 +1,9 @@ -use std::io::BufRead; +// This file is part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +use std::{io::BufRead, ops::RangeInclusive}; use uucore::error::{FromIo, UResult, USimpleError}; use uucore::translate; @@ -42,7 +47,7 @@ impl RandomSourceAdapter { } impl RandomSourceAdapter { - pub fn get_value(&mut self, at_most: u64) -> UResult { + fn generate_at_most(&mut self, at_most: u64) -> UResult { while self.entropy < at_most { let buf = self .reader @@ -88,10 +93,21 @@ impl RandomSourceAdapter { self.state %= num_possibilities; self.entropy %= num_possibilities; // I sure hope the compiler optimizes this tail call. - self.get_value(at_most) + self.generate_at_most(at_most) } } + pub fn choose_from_range(&mut self, range: RangeInclusive) -> UResult { + let offset = self.generate_at_most(*range.end() - *range.start())?; + Ok(*range.start() + offset) + } + + pub fn choose_from_slice(&mut self, vals: &[T]) -> UResult { + assert!(!vals.is_empty()); + let idx = self.generate_at_most(vals.len() as u64 - 1)? as usize; + Ok(vals[idx]) + } + pub fn shuffle<'a, T>(&mut self, vals: &'a mut [T], amount: usize) -> UResult<&'a mut [T]> { // Fisher-Yates shuffle. // TODO: GNU does something different if amount <= vals.len() and the input is stdin. @@ -99,7 +115,7 @@ impl RandomSourceAdapter { // No clue what they might do differently and why. let amount = amount.min(vals.len()); for idx in 0..amount { - let other_idx = self.get_value((vals.len() - idx - 1) as u64)? as usize + idx; + let other_idx = self.generate_at_most((vals.len() - idx - 1) as u64)? as usize + idx; vals.swap(idx, other_idx); } Ok(&mut vals[..amount]) diff --git a/src/uu/shuf/src/random_seed.rs b/src/uu/shuf/src/random_seed.rs new file mode 100644 index 000000000..f66ad62f8 --- /dev/null +++ b/src/uu/shuf/src/random_seed.rs @@ -0,0 +1,118 @@ +// This file is part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +use std::ops::RangeInclusive; + +use rand::{RngCore as _, SeedableRng as _}; +use rand_chacha::ChaCha12Rng; +use sha3::{Digest as _, Sha3_256}; + +/// Reproducible seeded random number generation. +/// +/// The behavior should stay the same between releases, so don't change it without +/// a very good reason. +/// +/// # How it works +/// +/// - Take a Unicode string as the seed. +/// +/// - Encode this seed as UTF-8. +/// +/// - Take the SHA3-256 hash of the encoded seed. +/// +/// - Use that hash as the input for a [`rand_chacha`] ChaCha12 RNG. +/// (We don't touch the nonce, so that's probably zero.) +/// +/// - Take 64-bit samples from the RNG. +/// +/// - Use Lemire's method to generate uniformly distributed integers and: +/// +/// - With --repeat, use these to pick elements from ranges. +/// +/// - Without --repeat, use these to do left-to-right modern Fisher-Yates. +/// +/// - Or for --input-range without --repeat, do whatever NonrepeatingIterator does. +/// (We may want to change that. Watch this space.) +/// +/// # Why it works like this +/// +/// - Unicode string: Greatest common denominator between platforms. Windows doesn't +/// let you pass raw bytes as a CLI argument and that would be bad practice anyway. +/// A decimal or hex number would work but this is much more flexible without being +/// unmanageable. +/// +/// (Footgun: if the user passes a filename we won't read from the file but the +/// command will run anyway.) +/// +/// - UTF-8: That's what Rust likes and it's the least unreasonable Unicode encoding. +/// +/// - SHA3-256: We want to make good use of the entire user input and SHA-3 is +/// state of the art. ChaCha12 takes a 256-bit seed. +/// +/// - ChaCha12: [`rand`]'s default rng as of writing. Seems state of the art. +/// +/// - 64-bit samples: We could often get away with 32-bit samples but let's keep things +/// simple and only use one width. (There doesn't seem to be much of a performance hit.) +/// +/// - Lemire, Fisher-Yates: These are very easy to implement and maintain ourselves. +/// `rand` provides fancier implementations but only promises reproducibility within +/// patch releases: +/// +/// Strictly speaking even `ChaCha12` is subject to breakage. But since it's a very +/// specific algorithm I assume it's safe in practice. +pub struct SeededRng(Box); + +impl SeededRng { + pub fn new(seed: &str) -> Self { + let mut hasher = Sha3_256::new(); + hasher.update(seed.as_bytes()); + let seed = hasher.finalize(); + let seed = seed.as_slice().try_into().unwrap(); + Self(Box::new(rand_chacha::ChaCha12Rng::from_seed(seed))) + } + + #[allow(clippy::many_single_char_names)] // use original lemire names for easy comparison + fn generate_at_most(&mut self, at_most: u64) -> u64 { + if at_most == u64::MAX { + return self.0.next_u64(); + } + + // https://lemire.me/blog/2019/06/06/nearly-divisionless-random-integer-generation-on-various-systems/ + let s: u64 = at_most + 1; + let mut x: u64 = self.0.next_u64(); + let mut m: u128 = u128::from(x) * u128::from(s); + let mut l: u64 = m as u64; + if l < s { + let t: u64 = s.wrapping_neg() % s; + while l < t { + x = self.0.next_u64(); + m = u128::from(x) * u128::from(s); + l = m as u64; + } + } + (m >> 64) as u64 + } + + pub fn choose_from_range(&mut self, range: RangeInclusive) -> u64 { + let offset = self.generate_at_most(*range.end() - *range.start()); + *range.start() + offset + } + + pub fn choose_from_slice(&mut self, vals: &[T]) -> T { + assert!(!vals.is_empty()); + let idx = self.generate_at_most(vals.len() as u64 - 1) as usize; + vals[idx] + } + + pub fn shuffle<'a, T>(&mut self, vals: &'a mut [T], amount: usize) -> &'a mut [T] { + // Fisher-Yates shuffle. + let amount = amount.min(vals.len()); + for idx in 0..amount { + let other_idx = self.generate_at_most((vals.len() - idx - 1) as u64) as usize + idx; + vals.swap(idx, other_idx); + } + &mut vals[..amount] + } +} diff --git a/src/uu/shuf/src/shuf.rs b/src/uu/shuf/src/shuf.rs index 9a3e3afd4..e2cb2e958 100644 --- a/src/uu/shuf/src/shuf.rs +++ b/src/uu/shuf/src/shuf.rs @@ -5,16 +5,20 @@ // spell-checker:ignore (ToDO) cmdline evec nonrepeating seps shufable rvec fdata -use clap::builder::ValueParser; -use clap::{Arg, ArgAction, Command}; -use rand::Rng; -use rand::seq::{IndexedRandom, SliceRandom}; use std::ffi::{OsStr, OsString}; use std::fs::File; use std::io::{BufReader, BufWriter, Error, Read, Write, stdin, stdout}; use std::ops::RangeInclusive; use std::path::{Path, PathBuf}; use std::str::FromStr; + +use clap::{Arg, ArgAction, Command, builder::ValueParser}; +use rand::rngs::ThreadRng; +use rand::{ + Rng, + seq::{IndexedRandom, SliceRandom}, +}; + use uucore::display::{OsWrite, Quotable}; use uucore::error::{FromIo, UResult, USimpleError, UUsageError}; use uucore::format_usage; @@ -22,8 +26,11 @@ use uucore::translate; mod compat_random_source; mod nonrepeating_iterator; +mod random_seed; +use compat_random_source::RandomSourceAdapter; use nonrepeating_iterator::NonrepeatingIterator; +use random_seed::SeededRng; enum Mode { Default(PathBuf), @@ -36,17 +43,24 @@ const BUF_SIZE: usize = 64 * 1024; struct Options { head_count: u64, output: Option, - random_source: Option, + random_source: RandomSource, repeat: bool, sep: u8, } +enum RandomSource { + None, + Seed(String), + File(PathBuf), +} + mod options { pub static ECHO: &str = "echo"; pub static INPUT_RANGE: &str = "input-range"; pub static HEAD_COUNT: &str = "head-count"; pub static OUTPUT: &str = "output"; pub static RANDOM_SOURCE: &str = "random-source"; + pub static RANDOM_SEED: &str = "random-seed"; pub static REPEAT: &str = "repeat"; pub static ZERO_TERMINATED: &str = "zero-terminated"; pub static FILE_OR_ARGS: &str = "file-or-args"; @@ -80,6 +94,14 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { Mode::Default(file.into()) }; + let random_source = if let Some(filename) = matches.get_one(options::RANDOM_SOURCE).cloned() { + RandomSource::File(filename) + } else if let Some(seed) = matches.get_one(options::RANDOM_SEED).cloned() { + RandomSource::Seed(seed) + } else { + RandomSource::None + }; + let options = Options { // GNU shuf takes the lowest value passed, so we imitate that. // It's probably a bug or an implementation artifact though. @@ -92,7 +114,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { .min() .unwrap_or(u64::MAX), output: matches.get_one(options::OUTPUT).cloned(), - random_source: matches.get_one(options::RANDOM_SOURCE).cloned(), + random_source, repeat: matches.get_flag(options::REPEAT), sep: if matches.get_flag(options::ZERO_TERMINATED) { b'\0' @@ -120,14 +142,15 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { } let mut rng = match options.random_source { - Some(ref r) => { + RandomSource::None => WrappedRng::Default(rand::rng()), + RandomSource::Seed(ref seed) => WrappedRng::Seed(SeededRng::new(seed)), + RandomSource::File(ref r) => { let file = File::open(r).map_err_context( || translate!("shuf-error-failed-to-open-random-source", "file" => r.quote()), )?; let file = BufReader::new(file); - WrappedRng::RngFile(compat_random_source::RandomSourceAdapter::new(file)) + WrappedRng::File(compat_random_source::RandomSourceAdapter::new(file)) } - None => WrappedRng::RngDefault(rand::rng()), }; match mode { @@ -191,6 +214,15 @@ pub fn uu_app() -> Command { .value_parser(ValueParser::path_buf()) .value_hint(clap::ValueHint::FilePath), ) + .arg( + Arg::new(options::RANDOM_SEED) + .long(options::RANDOM_SEED) + .value_name("STRING") + .help(translate!("shuf-help-random-seed")) + .value_parser(ValueParser::string()) + .value_hint(clap::ValueHint::Other) + .conflicts_with(options::RANDOM_SOURCE), + ) .arg( Arg::new(options::RANDOM_SOURCE) .long(options::RANDOM_SOURCE) @@ -402,36 +434,33 @@ fn parse_range(input_range: &str) -> Result, String> { } enum WrappedRng { - RngDefault(rand::rngs::ThreadRng), - RngFile(compat_random_source::RandomSourceAdapter>), + Default(ThreadRng), + Seed(SeededRng), + File(RandomSourceAdapter>), } impl WrappedRng { fn choose(&mut self, vals: &[T]) -> UResult { match self { - Self::RngDefault(rng) => Ok(*vals.choose(rng).unwrap()), - Self::RngFile(adapter) => { - assert!(!vals.is_empty()); - let idx = adapter.get_value(vals.len() as u64 - 1)? as usize; - Ok(vals[idx]) - } + Self::Default(rng) => Ok(*vals.choose(rng).unwrap()), + Self::Seed(rng) => Ok(rng.choose_from_slice(vals)), + Self::File(rng) => rng.choose_from_slice(vals), } } fn shuffle<'a, T>(&mut self, vals: &'a mut [T], amount: usize) -> UResult<&'a mut [T]> { match self { - Self::RngDefault(rng) => Ok(vals.partial_shuffle(rng, amount).0), - Self::RngFile(adapter) => adapter.shuffle(vals, amount), + Self::Default(rng) => Ok(vals.partial_shuffle(rng, amount).0), + Self::Seed(rng) => Ok(rng.shuffle(vals, amount)), + Self::File(rng) => rng.shuffle(vals, amount), } } fn choose_from_range(&mut self, range: RangeInclusive) -> UResult { match self { - Self::RngDefault(rng) => Ok(rng.random_range(range)), - Self::RngFile(adapter) => { - let offset = adapter.get_value(*range.end() - *range.start())?; - Ok(*range.start() + offset) - } + Self::Default(rng) => Ok(rng.random_range(range)), + Self::Seed(rng) => Ok(rng.choose_from_range(range)), + Self::File(rng) => rng.choose_from_range(range), } } } diff --git a/tests/by-util/test_shuf.rs b/tests/by-util/test_shuf.rs index 1b5d2a99c..b419419ec 100644 --- a/tests/by-util/test_shuf.rs +++ b/tests/by-util/test_shuf.rs @@ -1018,3 +1018,52 @@ fn test_gnu_compat_range_no_repeat() { .no_stderr() .stdout_is("10\n2\n8\n7\n3\n9\n6\n5\n1\n4\n"); } + +// Test reproducibility of --random-seed. +// These results are arbitrary but they should not change unless we choose to break compatibility. + +#[test] +fn test_seed_args_repeat() { + new_ucmd!() + .arg("--random-seed=🌱") + .arg("-e") + .arg("-r") + .arg("-n10") + .args(&["foo", "bar", "baz", "qux"]) + .succeeds() + .no_stderr() + .stdout_is("qux\nbar\nbaz\nfoo\nbaz\nqux\nqux\nfoo\nqux\nqux\n"); +} + +#[test] +fn test_seed_args_no_repeat() { + new_ucmd!() + .arg("--random-seed=🌱") + .arg("-e") + .args(&["foo", "bar", "baz", "qux"]) + .succeeds() + .no_stderr() + .stdout_is("qux\nbaz\nfoo\nbar\n"); +} + +#[test] +fn test_seed_range_repeat() { + new_ucmd!() + .arg("--random-seed=🦀") + .arg("-r") + .arg("-i1-99") + .arg("-n10") + .succeeds() + .no_stderr() + .stdout_is("60\n44\n38\n41\n63\n43\n31\n71\n46\n90\n"); +} + +#[test] +fn test_seed_range_no_repeat() { + new_ucmd!() + .arg("--random-seed=12345") + .arg("-i1-10") + .succeeds() + .no_stderr() + .stdout_is("8\n9\n5\n10\n1\n2\n4\n7\n3\n6\n"); +} From b81a018003de1989277acbb4d170d88aca8d824a Mon Sep 17 00:00:00 2001 From: Jan Verbeek Date: Wed, 26 Mar 2025 17:49:49 +0100 Subject: [PATCH 349/425] shuf: correctness: Use Fisher-Yates for nonrepeating integers We used to use a clever homegrown way to sample integers. But GNU shuf with --random-source observably uses Fisher-Yates, and the output of the old version depended on a heuristic (making it dangerous for --random-seed). So now we do Fisher-Yates here, just like we do for other inputs. In deterministic modes the output for --input-range is identical that for piping `seq` into `shuf`. We imitate the old algorithm's method for keeping the resource use in check. The performance of the new version is very close to that of the old version: I haven't found any cases where it's much faster or much slower. --- src/uu/shuf/src/nonrepeating_iterator.rs | 230 ++++++++--------------- src/uu/shuf/src/random_seed.rs | 3 - src/uu/shuf/src/shuf.rs | 3 +- tests/by-util/test_shuf.rs | 51 ++++- 4 files changed, 132 insertions(+), 155 deletions(-) diff --git a/src/uu/shuf/src/nonrepeating_iterator.rs b/src/uu/shuf/src/nonrepeating_iterator.rs index 41a301a0b..d05844ba9 100644 --- a/src/uu/shuf/src/nonrepeating_iterator.rs +++ b/src/uu/shuf/src/nonrepeating_iterator.rs @@ -1,74 +1,85 @@ -// spell-checker:ignore nonrepeating - -// TODO: this iterator is not compatible with GNU when --random-source is used - -use std::{collections::HashSet, ops::RangeInclusive}; +use std::collections::HashMap; +use std::ops::RangeInclusive; use uucore::error::UResult; use crate::WrappedRng; -enum NumberSet { - AlreadyListed(HashSet), - Remaining(Vec), +/// An iterator that samples from an integer range without repetition. +/// +/// This is based on Fisher-Yates, and it's required for backward compatibility +/// that it behaves exactly like Fisher-Yates if --random-source or --random-seed +/// is used. But we have a few tricks: +/// +/// - In the beginning we use a hash table instead of an array. This way we lazily +/// keep track of swaps without allocating the entire range upfront. +/// +/// - When the hash table starts to get big relative to the remaining items +/// we switch over to an array. +/// +/// - We store the array backwards so that we can shrink it as we go and free excess +/// memory every now and then. +/// +/// Both the hash table and the array give the same output. +/// +/// There's room for optimization: +/// +/// - Switching over from the hash table to the array is costly. If we happen to know +/// (through --head-count) that only few draws remain then it would be better not +/// to switch. +/// +/// - If the entire range gets used then we might as well allocate an array to start +/// with. But if the user e.g. pipes through `head` rather than using --head-count +/// we can't know whether that's the case, so there's a tradeoff. +/// +/// GNU decides the other way: --head-count is noticeably faster than | head. +pub(crate) struct NonrepeatingIterator<'a> { + rng: &'a mut WrappedRng, + values: Values, } -pub(crate) struct NonrepeatingIterator<'a> { - range: RangeInclusive, - rng: &'a mut WrappedRng, - remaining_count: u64, - buf: NumberSet, +enum Values { + Full(Vec), + Sparse(RangeInclusive, HashMap), } impl<'a> NonrepeatingIterator<'a> { - pub(crate) fn new(range: RangeInclusive, rng: &'a mut WrappedRng, amount: u64) -> Self { - let capped_amount = if range.start() > range.end() { - 0 - } else if range == (0..=u64::MAX) { - amount - } else { - amount.min(range.end() - range.start() + 1) - }; - NonrepeatingIterator { - range, - rng, - remaining_count: capped_amount, - buf: NumberSet::AlreadyListed(HashSet::default()), - } + pub(crate) fn new(range: RangeInclusive, rng: &'a mut WrappedRng) -> Self { + let values = Values::Sparse(range, HashMap::default()); + NonrepeatingIterator { rng, values } } fn produce(&mut self) -> UResult { - debug_assert!(self.range.start() <= self.range.end()); - match &mut self.buf { - NumberSet::AlreadyListed(already_listed) => { - let chosen = loop { - let guess = self.rng.choose_from_range(self.range.clone())?; - let newly_inserted = already_listed.insert(guess); - if newly_inserted { - break guess; - } - }; - // Once a significant fraction of the interval has already been enumerated, - // the number of attempts to find a number that hasn't been chosen yet increases. - // Therefore, we need to switch at some point from "set of already returned values" to "list of remaining values". - let range_size = (self.range.end() - self.range.start()).saturating_add(1); - if number_set_should_list_remaining(already_listed.len() as u64, range_size) { - let mut remaining = self - .range - .clone() - .filter(|n| !already_listed.contains(n)) - .collect::>(); - assert!(remaining.len() as u64 >= self.remaining_count); - remaining.truncate(self.remaining_count as usize); - self.rng.shuffle(&mut remaining, usize::MAX)?; - self.buf = NumberSet::Remaining(remaining); + match &mut self.values { + Values::Full(items) => { + let this_idx = items.len() - 1; + + let other_idx = self.rng.choose_from_range(0..=items.len() as u64 - 1)? as usize; + // Flip the index to pretend we're going left-to-right + let other_idx = items.len() - other_idx - 1; + + items.swap(this_idx, other_idx); + + let val = items.pop().unwrap(); + if items.len().is_power_of_two() && items.len() >= 512 { + items.shrink_to_fit(); } - Ok(chosen) + Ok(val) } - NumberSet::Remaining(remaining_numbers) => { - debug_assert!(!remaining_numbers.is_empty()); - // We only enter produce() when there is at least one actual element remaining, so popping must always return an element. - Ok(remaining_numbers.pop().unwrap()) + Values::Sparse(range, items) => { + let this_idx = *range.start(); + let this_val = items.remove(&this_idx).unwrap_or(this_idx); + + let other_idx = self.rng.choose_from_range(range.clone())?; + + let val = if this_idx == other_idx { + this_val + } else { + items.insert(other_idx, this_val).unwrap_or(other_idx) + }; + *range = *range.start() + 1..=*range.end(); + + Ok(val) } } } @@ -77,101 +88,24 @@ impl<'a> NonrepeatingIterator<'a> { impl Iterator for NonrepeatingIterator<'_> { type Item = UResult; - fn next(&mut self) -> Option> { - if self.range.is_empty() || self.remaining_count == 0 { - return None; + fn next(&mut self) -> Option { + match &self.values { + Values::Full(items) if items.is_empty() => return None, + Values::Full(_) => (), + Values::Sparse(range, _) if range.is_empty() => return None, + Values::Sparse(range, items) => { + let range_len = range.size_hint().0 as u64; + if items.len() as u64 >= range_len / 8 { + self.values = Values::Full(hashmap_to_vec(range.clone(), items)); + } + } } - self.remaining_count -= 1; + Some(self.produce()) } } -// This could be a method, but it is much easier to test as a stand-alone function. -fn number_set_should_list_remaining(listed_count: u64, range_size: u64) -> bool { - // Arbitrarily determine the switchover point to be around 25%. This is because: - // - HashSet has a large space overhead for the hash table load factor. - // - This means that somewhere between 25-40%, the memory required for a "positive" HashSet and a "negative" Vec should be the same. - // - HashSet has a small but non-negligible overhead for each lookup, so we have a slight preference for Vec anyway. - // - At 25%, on average 1.33 attempts are needed to find a number that hasn't been taken yet. - // - Finally, "24%" is computationally the simplest: - listed_count >= range_size / 4 -} - -#[cfg(test)] -// Since the computed value is a bool, it is more readable to write the expected value out: -#[allow(clippy::bool_assert_comparison)] -mod test_number_set_decision { - use super::number_set_should_list_remaining; - - #[test] - fn test_stay_positive_large_remaining_first() { - assert_eq!(false, number_set_should_list_remaining(0, u64::MAX)); - } - - #[test] - fn test_stay_positive_large_remaining_second() { - assert_eq!(false, number_set_should_list_remaining(1, u64::MAX)); - } - - #[test] - fn test_stay_positive_large_remaining_tenth() { - assert_eq!(false, number_set_should_list_remaining(9, u64::MAX)); - } - - #[test] - fn test_stay_positive_smallish_range_first() { - assert_eq!(false, number_set_should_list_remaining(0, 12345)); - } - - #[test] - fn test_stay_positive_smallish_range_second() { - assert_eq!(false, number_set_should_list_remaining(1, 12345)); - } - - #[test] - fn test_stay_positive_smallish_range_tenth() { - assert_eq!(false, number_set_should_list_remaining(9, 12345)); - } - - #[test] - fn test_stay_positive_small_range_not_too_early() { - assert_eq!(false, number_set_should_list_remaining(1, 10)); - } - - // Don't want to test close to the border, in case we decide to change the threshold. - // However, at 50% coverage, we absolutely should switch: - #[test] - fn test_switch_half() { - assert_eq!(true, number_set_should_list_remaining(1234, 2468)); - } - - // Ensure that the decision is monotonous: - #[test] - fn test_switch_late1() { - assert_eq!(true, number_set_should_list_remaining(12340, 12345)); - } - - #[test] - fn test_switch_late2() { - assert_eq!(true, number_set_should_list_remaining(12344, 12345)); - } - - // Ensure that we are overflow-free: - #[test] - fn test_no_crash_exceed_max_size1() { - assert_eq!(false, number_set_should_list_remaining(12345, u64::MAX)); - } - - #[test] - fn test_no_crash_exceed_max_size2() { - assert_eq!( - true, - number_set_should_list_remaining(u64::MAX - 1, u64::MAX) - ); - } - - #[test] - fn test_no_crash_exceed_max_size3() { - assert_eq!(true, number_set_should_list_remaining(u64::MAX, u64::MAX)); - } +fn hashmap_to_vec(range: RangeInclusive, map: &HashMap) -> Vec { + let lookup = |idx| *map.get(&idx).unwrap_or(&idx); + range.rev().map(lookup).collect() } diff --git a/src/uu/shuf/src/random_seed.rs b/src/uu/shuf/src/random_seed.rs index f66ad62f8..dbc6c728c 100644 --- a/src/uu/shuf/src/random_seed.rs +++ b/src/uu/shuf/src/random_seed.rs @@ -33,9 +33,6 @@ use sha3::{Digest as _, Sha3_256}; /// /// - Without --repeat, use these to do left-to-right modern Fisher-Yates. /// -/// - Or for --input-range without --repeat, do whatever NonrepeatingIterator does. -/// (We may want to change that. Watch this space.) -/// /// # Why it works like this /// /// - Unicode string: Greatest common denominator between platforms. Windows doesn't diff --git a/src/uu/shuf/src/shuf.rs b/src/uu/shuf/src/shuf.rs index e2cb2e958..73290a0fc 100644 --- a/src/uu/shuf/src/shuf.rs +++ b/src/uu/shuf/src/shuf.rs @@ -356,7 +356,8 @@ impl Shufable for RangeInclusive { rng: &'b mut WrappedRng, amount: u64, ) -> UResult>> { - Ok(NonrepeatingIterator::new(self.clone(), rng, amount)) + let amount = usize::try_from(amount).unwrap_or(usize::MAX); + Ok(NonrepeatingIterator::new(self.clone(), rng).take(amount)) } } diff --git a/tests/by-util/test_shuf.rs b/tests/by-util/test_shuf.rs index b419419ec..948b3ed07 100644 --- a/tests/by-util/test_shuf.rs +++ b/tests/by-util/test_shuf.rs @@ -4,6 +4,8 @@ // file that was distributed with this source code. // spell-checker:ignore (ToDO) unwritable +use std::fmt::Write; + use uutests::at_and_ucmd; use uutests::new_ucmd; @@ -1002,8 +1004,6 @@ fn test_gnu_compat_limited_from_stdin() { .stdout_is("6\n5\n1\n3\n2\n7\n4\n"); } -// We haven't reverse-engineered GNU's nonrepeating integer sampling yet. -#[ignore = "disabled until fixed"] #[test] fn test_gnu_compat_range_no_repeat() { let (at, mut ucmd) = at_and_ucmd!(); @@ -1060,10 +1060,55 @@ fn test_seed_range_repeat() { #[test] fn test_seed_range_no_repeat() { + let expected = "8\n9\n1\n5\n2\n6\n4\n3\n10\n7\n"; + new_ucmd!() .arg("--random-seed=12345") .arg("-i1-10") .succeeds() .no_stderr() - .stdout_is("8\n9\n5\n10\n1\n2\n4\n7\n3\n6\n"); + .stdout_is(expected); + + // Piping from e.g. seq gives identical results. + new_ucmd!() + .arg("--random-seed=12345") + .pipe_in("1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n") + .succeeds() + .no_stderr() + .stdout_is(expected); +} + +// Test a longer input to exercise some more code paths in the sparse representation. +#[test] +fn test_seed_long_range_no_repeat() { + let expected = "\ + 1\n3\n35\n37\n36\n45\n72\n17\n18\n40\n67\n74\n81\n77\n14\n90\n\ + 7\n12\n80\n54\n23\n61\n29\n41\n15\n56\n6\n32\n82\n76\n11\n2\n100\n\ + 50\n60\n97\n73\n79\n91\n89\n85\n86\n66\n70\n22\n55\n8\n83\n39\n27\n"; + + new_ucmd!() + .arg("--random-seed=67890") + .arg("-i1-100") + .arg("-n50") + .succeeds() + .no_stderr() + .stdout_is(expected); + + let mut test_input = String::new(); + for n in 1..=100 { + writeln!(&mut test_input, "{n}").unwrap(); + } + + new_ucmd!() + .arg("--random-seed=67890") + .pipe_in(test_input.as_bytes()) + .arg("-n50") + .succeeds() + .no_stderr() + .stdout_is(expected); +} + +#[test] +fn test_empty_range_no_repeat() { + new_ucmd!().arg("-i4-3").succeeds().no_stderr().no_stdout(); } From e1259d984f1cfd98e6154ce79476dd3d3ce05d2b Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Sun, 25 Jan 2026 17:35:34 +0100 Subject: [PATCH 350/425] date: add ICU support for day/months names (#10457) Fixes: https://bugs.launchpad.net/ubuntu/+source/rust-coreutils/+bug/2130859 --- Cargo.lock | 139 +++++++++++++++++++ Cargo.toml | 2 + fuzz/Cargo.lock | 138 ++++++++++++++++++ src/uu/date/Cargo.toml | 6 +- src/uu/date/src/date.rs | 119 ++++++++++++++-- src/uucore/Cargo.toml | 9 +- src/uucore/src/lib/features/i18n/datetime.rs | 131 +++++++++++++++++ src/uucore/src/lib/features/i18n/mod.rs | 4 +- tests/by-util/test_date.rs | 95 ++++++++++++- 9 files changed, 626 insertions(+), 17 deletions(-) create mode 100644 src/uucore/src/lib/features/i18n/datetime.rs diff --git a/Cargo.lock b/Cargo.lock index 300513a18..5e10d0d54 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -272,6 +272,16 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "calendrical_calculations" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a0b39595c6ee54a8d0900204ba4c401d0ab4eb45adaf07178e8d017541529e7" +dependencies = [ + "core_maths", + "displaydoc", +] + [[package]] name = "cc" version = "1.2.52" @@ -514,6 +524,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + [[package]] name = "coreutils" version = "0.6.0" @@ -1348,6 +1367,29 @@ dependencies = [ "cc", ] +[[package]] +name = "icu_calendar" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f0e52e009b6b16ba9c0693578796f2dd4aaa59a7f8f920423706714a89ac4e" +dependencies = [ + "calendrical_calculations", + "displaydoc", + "icu_calendar_data", + "icu_locale", + "icu_locale_core", + "icu_provider", + "ixdtf", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_calendar_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527f04223b17edfe0bd43baf14a0cb1b017830db65f3950dc00224860a9a446d" + [[package]] name = "icu_collator" version = "2.1.1" @@ -1386,6 +1428,35 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icu_datetime" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9d49f41ded8e63761b6b4c3120dfdc289415a1ed10107db6198eb311057ca5" +dependencies = [ + "displaydoc", + "fixed_decimal", + "icu_calendar", + "icu_datetime_data", + "icu_decimal", + "icu_locale", + "icu_locale_core", + "icu_pattern", + "icu_plurals", + "icu_provider", + "icu_time", + "potential_utf", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_datetime_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46597233625417b7c8052a63d916e4fdc73df21614ac0b679492a5d6e3b01aeb" + [[package]] name = "icu_decimal" version = "2.1.1" @@ -1465,6 +1536,38 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" +[[package]] +name = "icu_pattern" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a7ff8c0ff6f61cdce299dcb54f557b0a251adbc78f6f0c35a21332c452b4a1b" +dependencies = [ + "displaydoc", + "either", + "serde", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_plurals" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f9cfe49f5b1d1163cc58db451562339916a9ca5cbcaae83924d41a0bf839474" +dependencies = [ + "fixed_decimal", + "icu_locale", + "icu_plurals_data", + "icu_provider", + "zerovec", +] + +[[package]] +name = "icu_plurals_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f018a98dccf7f0eb02ba06ac0ff67d102d8ded80734724305e924de304e12ff0" + [[package]] name = "icu_properties" version = "2.1.2" @@ -1502,6 +1605,30 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icu_time" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8242b00da3b3b6678f731437a11c8833a43c821ae081eca60ba1b7579d45b6d8" +dependencies = [ + "calendrical_calculations", + "displaydoc", + "icu_calendar", + "icu_locale_core", + "icu_provider", + "icu_time_data", + "ixdtf", + "serde", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_time_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e10b0e5e87a2c84bd5fa407705732052edebe69291d347d0c3033785470edbf" + [[package]] name = "ident_case" version = "1.0.1" @@ -1600,6 +1727,12 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +[[package]] +name = "ixdtf" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84de9d95a6d2547d9b77ee3f25fa0ee32e3c3a6484d47a55adebc0439c077992" + [[package]] name = "jiff" version = "0.2.18" @@ -3249,6 +3382,7 @@ dependencies = [ "clap", "codspeed-divan-compat", "fluent", + "icu_calendar", "jiff", "nix", "parse_datetime", @@ -4247,7 +4381,9 @@ dependencies = [ "fluent-syntax", "glob", "hex", + "icu_calendar", "icu_collator", + "icu_datetime", "icu_decimal", "icu_locale", "icu_provider", @@ -4719,6 +4855,9 @@ name = "writeable" version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +dependencies = [ + "either", +] [[package]] name = "wyz" diff --git a/Cargo.toml b/Cargo.toml index de95e8fa8..6e7600f17 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -329,7 +329,9 @@ gcd = "2.3" glob = "0.3.1" half = "2.4.1" hostname = "0.4" +icu_calendar = "2.0.0" icu_collator = "2.0.0" +icu_datetime = "2.0.0" icu_decimal = "2.0.0" icu_locale = "2.0.0" icu_provider = "2.0.0" diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index f116b39fa..424578d1d 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -194,6 +194,16 @@ version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" +[[package]] +name = "calendrical_calculations" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a0b39595c6ee54a8d0900204ba4c401d0ab4eb45adaf07178e8d017541529e7" +dependencies = [ + "core_maths", + "displaydoc", +] + [[package]] name = "cc" version = "1.2.51" @@ -314,6 +324,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -649,6 +668,29 @@ dependencies = [ "cc", ] +[[package]] +name = "icu_calendar" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f0e52e009b6b16ba9c0693578796f2dd4aaa59a7f8f920423706714a89ac4e" +dependencies = [ + "calendrical_calculations", + "displaydoc", + "icu_calendar_data", + "icu_locale", + "icu_locale_core", + "icu_provider", + "ixdtf", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_calendar_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527f04223b17edfe0bd43baf14a0cb1b017830db65f3950dc00224860a9a446d" + [[package]] name = "icu_collator" version = "2.1.1" @@ -687,6 +729,35 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icu_datetime" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9d49f41ded8e63761b6b4c3120dfdc289415a1ed10107db6198eb311057ca5" +dependencies = [ + "displaydoc", + "fixed_decimal", + "icu_calendar", + "icu_datetime_data", + "icu_decimal", + "icu_locale", + "icu_locale_core", + "icu_pattern", + "icu_plurals", + "icu_provider", + "icu_time", + "potential_utf", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_datetime_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46597233625417b7c8052a63d916e4fdc73df21614ac0b679492a5d6e3b01aeb" + [[package]] name = "icu_decimal" version = "2.1.1" @@ -766,6 +837,38 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" +[[package]] +name = "icu_pattern" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a7ff8c0ff6f61cdce299dcb54f557b0a251adbc78f6f0c35a21332c452b4a1b" +dependencies = [ + "displaydoc", + "either", + "serde", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_plurals" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f9cfe49f5b1d1163cc58db451562339916a9ca5cbcaae83924d41a0bf839474" +dependencies = [ + "fixed_decimal", + "icu_locale", + "icu_plurals_data", + "icu_provider", + "zerovec", +] + +[[package]] +name = "icu_plurals_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f018a98dccf7f0eb02ba06ac0ff67d102d8ded80734724305e924de304e12ff0" + [[package]] name = "icu_properties" version = "2.1.2" @@ -803,6 +906,30 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icu_time" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8242b00da3b3b6678f731437a11c8833a43c821ae081eca60ba1b7579d45b6d8" +dependencies = [ + "calendrical_calculations", + "displaydoc", + "icu_calendar", + "icu_locale_core", + "icu_provider", + "icu_time_data", + "ixdtf", + "serde", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_time_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e10b0e5e87a2c84bd5fa407705732052edebe69291d347d0c3033785470edbf" + [[package]] name = "intl-memoizer" version = "0.5.3" @@ -837,6 +964,12 @@ dependencies = [ "either", ] +[[package]] +name = "ixdtf" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84de9d95a6d2547d9b77ee3f25fa0ee32e3c3a6484d47a55adebc0439c077992" + [[package]] name = "jiff" version = "0.2.18" @@ -1745,7 +1878,9 @@ dependencies = [ "fluent-syntax", "glob", "hex", + "icu_calendar", "icu_collator", + "icu_datetime", "icu_decimal", "icu_locale", "icu_provider", @@ -2073,6 +2208,9 @@ name = "writeable" version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +dependencies = [ + "either", +] [[package]] name = "yoke" diff --git a/src/uu/date/Cargo.toml b/src/uu/date/Cargo.toml index 9bff97696..9b927b1e2 100644 --- a/src/uu/date/Cargo.toml +++ b/src/uu/date/Cargo.toml @@ -18,16 +18,20 @@ workspace = true [lib] path = "src/date.rs" +[features] +i18n-datetime = ["uucore/i18n-datetime", "icu_calendar"] + [dependencies] clap = { workspace = true } fluent = { workspace = true } +icu_calendar = { workspace = true, optional = true } jiff = { workspace = true, features = [ "tzdb-bundle-platform", "tzdb-zoneinfo", "tzdb-concatenated", ] } parse_datetime = { workspace = true } -uucore = { workspace = true, features = ["parser"] } +uucore = { workspace = true, features = ["parser", "i18n-datetime"] } [target.'cfg(unix)'.dependencies] nix = { workspace = true, features = ["time"] } diff --git a/src/uu/date/src/date.rs b/src/uu/date/src/date.rs index d1156f061..cf6732aaa 100644 --- a/src/uu/date/src/date.rs +++ b/src/uu/date/src/date.rs @@ -20,6 +20,9 @@ use std::sync::OnceLock; use uucore::display::Quotable; use uucore::error::FromIo; use uucore::error::{UResult, USimpleError}; +use uucore::i18n::datetime::{ + get_localized_day_name, get_localized_month_name, should_use_icu_locale, +}; use uucore::translate; use uucore::{format_usage, show}; #[cfg(windows)] @@ -474,20 +477,18 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { let config = Config::new().custom(PosixCustom::new()).lenient(true); for date in dates { match date { - Ok(date) => { - match BrokenDownTime::from(&date).to_string_with_config(&config, format_string) { - Ok(s) => writeln!(stdout, "{s}").map_err(|e| { - USimpleError::new(1, translate!("date-error-write", "error" => e)) - })?, - Err(e) => { - let _ = stdout.flush(); - return Err(USimpleError::new( - 1, - translate!("date-error-invalid-format", "format" => format_string, "error" => e), - )); - } + Ok(date) => match format_date_with_locale_aware_months(&date, format_string, &config) { + Ok(s) => writeln!(stdout, "{s}").map_err(|e| { + USimpleError::new(1, translate!("date-error-write", "error" => e)) + })?, + Err(e) => { + let _ = stdout.flush(); + return Err(USimpleError::new( + 1, + translate!("date-error-invalid-format", "format" => format_string, "error" => e), + )); } - } + }, Err((input, _err)) => { let _ = stdout.flush(); show!(USimpleError::new( @@ -612,6 +613,98 @@ pub fn uu_app() -> Command { .arg(Arg::new(OPT_FORMAT).num_args(0..).trailing_var_arg(true)) } +fn format_date_with_locale_aware_months( + date: &Zoned, + format_string: &str, + config: &Config, +) -> Result { + // Only use ICU for non-default locales and when format string contains month or day specifiers + let use_icu = should_use_icu_locale(); + + if (format_string.contains("%B") + || format_string.contains("%b") + || format_string.contains("%A") + || format_string.contains("%a")) + && use_icu + { + let broken_down = BrokenDownTime::from(date); + // Get localized month names if needed + let (full_month, abbrev_month) = + if format_string.contains("%B") || format_string.contains("%b") { + if let Some(month_val) = broken_down.month() { + let month_u8 = if (1..=12).contains(&month_val) { + month_val as u8 + } else { + 1 // fallback to January for invalid values + }; + ( + get_localized_month_name(month_u8, true), + get_localized_month_name(month_u8, false), + ) + } else { + (String::new(), String::new()) + } + } else { + (String::new(), String::new()) + }; + + // Get localized day names if needed + let (full_day, abbrev_day) = if format_string.contains("%A") || format_string.contains("%a") + { + if let (Some(year), Some(month), Some(day)) = + (broken_down.year(), broken_down.month(), broken_down.day()) + { + ( + get_localized_day_name(year.into(), month as u8, day as u8, true), + get_localized_day_name(year.into(), month as u8, day as u8, false), + ) + } else { + (String::new(), String::new()) + } + } else { + (String::new(), String::new()) + }; + + // Replace format specifiers with placeholders for successful ICU translations only + let mut temp_format = format_string.to_string(); + if !full_month.is_empty() { + temp_format = temp_format.replace("%B", "<<>>"); + } + if !abbrev_month.is_empty() { + temp_format = temp_format.replace("%b", "<<>>"); + } + if !full_day.is_empty() { + temp_format = temp_format.replace("%A", "<<>>"); + } + if !abbrev_day.is_empty() { + temp_format = temp_format.replace("%a", "<<>>"); + } + + // Format with the temporary string + let temp_result = broken_down.to_string_with_config(config, &temp_format)?; + + // Replace placeholders with localized names + let mut final_result = temp_result; + if !full_month.is_empty() { + final_result = final_result.replace("<<>>", &full_month); + } + if !abbrev_month.is_empty() { + final_result = final_result.replace("<<>>", &abbrev_month); + } + if !full_day.is_empty() { + final_result = final_result.replace("<<>>", &full_day); + } + if !abbrev_day.is_empty() { + final_result = final_result.replace("<<>>", &abbrev_day); + } + + return Ok(final_result); + } + + // Fallback to regular formatting + BrokenDownTime::from(date).to_string_with_config(config, format_string) +} + /// Return the appropriate format string for the given settings. fn make_format_string(settings: &Settings) -> &str { match settings.format { diff --git a/src/uucore/Cargo.toml b/src/uucore/Cargo.toml index 3cb1880d5..507f7740c 100644 --- a/src/uucore/Cargo.toml +++ b/src/uucore/Cargo.toml @@ -68,9 +68,15 @@ num-traits = { workspace = true, optional = true } selinux = { workspace = true, optional = true } # icu stuff +icu_calendar = { workspace = true, optional = true, features = [ + "compiled_data", +] } icu_collator = { workspace = true, optional = true, features = [ "compiled_data", ] } +icu_datetime = { workspace = true, optional = true, features = [ + "compiled_data", +] } icu_decimal = { workspace = true, optional = true, features = [ "compiled_data", ] } @@ -143,10 +149,11 @@ format = [ "quoting-style", "unit-prefix", ] -i18n-all = ["i18n-collator", "i18n-decimal"] +i18n-all = ["i18n-collator", "i18n-decimal", "i18n-datetime"] i18n-common = ["icu_locale"] i18n-collator = ["i18n-common", "icu_collator"] i18n-decimal = ["i18n-common", "icu_decimal", "icu_provider"] +i18n-datetime = ["i18n-common", "icu_calendar", "icu_datetime"] mode = ["libc"] perms = ["entries", "libc", "walkdir"] buf-copy = [] diff --git a/src/uucore/src/lib/features/i18n/datetime.rs b/src/uucore/src/lib/features/i18n/datetime.rs new file mode 100644 index 000000000..4cca5ed22 --- /dev/null +++ b/src/uucore/src/lib/features/i18n/datetime.rs @@ -0,0 +1,131 @@ +// This file is part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +//! Locale-aware datetime formatting utilities using ICU +// spell-checker:ignore fieldsets janvier + +use icu_calendar::Date; +use icu_datetime::DateTimeFormatter; +use icu_datetime::fieldsets; +use icu_locale::Locale; +use std::sync::OnceLock; + +use crate::i18n::get_locale_from_env; + +/// Get the locale for time/date formatting from LC_TIME environment variable +pub fn get_time_locale() -> &'static (Locale, super::UEncoding) { + static TIME_LOCALE: OnceLock<(Locale, super::UEncoding)> = OnceLock::new(); + + TIME_LOCALE.get_or_init(|| get_locale_from_env("LC_TIME")) +} + +/// Check if we should use ICU for locale-aware time/date formatting +/// +/// Returns true for non-C/POSIX locales, false otherwise +pub fn should_use_icu_locale() -> bool { + use icu_locale::locale; + + let (locale, _encoding) = get_time_locale(); + + // Use ICU for non-default locales (anything other than C/POSIX) + // The default locale is "und" (undefined) representing C/POSIX + *locale != locale!("und") +} + +/// Get a localized month name for the given month number (1-12) +/// +/// # Arguments +/// * `month` - Month number (1 = January, 2 = February, etc.) +/// * `full` - If true, return full month name (e.g., "January"), otherwise abbreviated (e.g., "Jan") +/// +/// # Returns +/// Localized month name, or falls back to English if locale is not supported +pub fn get_localized_month_name(month: u8, full: bool) -> String { + // Get locale from environment + let (locale, _encoding) = get_time_locale(); + + // Create a date with the specified month (use year 2000, day 1 as arbitrary values) + let Ok(date) = Date::try_new_gregorian(2000, month, 1) else { + // Invalid month, return empty string to signal failure + return String::new(); + }; + + // Configure field set for month formatting + // Use Year-Month-Day format to ensure we get textual month names + let field_set = if full { + fieldsets::YMD::long() + } else { + fieldsets::YMD::medium() + }; + + // Create formatter with locale + let Ok(formatter) = DateTimeFormatter::try_new(locale.clone().into(), field_set) else { + // Failed to create formatter, return empty string to signal failure + return String::new(); + }; + + // Format the date to get full date, then extract month + let formatted = formatter.format(&date).to_string(); + // Extract month name from formatted date like "15 janvier 2000" or "2000-01-15" + // Look for a word that contains letters (the month name) + let words: Vec<&str> = formatted.split_whitespace().collect(); + + // Return the month name as extracted from ICU (no further processing needed) + // ICU already handles the full vs abbreviated formatting correctly + words + .iter() + .find(|word| word.chars().any(|c| c.is_alphabetic())) + .map_or_else(String::new, |s| (*s).to_string()) +} + +/// Get a localized day name for the given date components +/// +/// # Arguments +/// * `year` - The year +/// * `month` - The month (1-12) +/// * `day` - The day of the month +/// * `full` - If true, return full day name (e.g., "Monday"), otherwise abbreviated (e.g., "Mon") +/// +/// # Returns +/// Localized day name, or falls back to empty string if locale is not supported +pub fn get_localized_day_name(year: i32, month: u8, day: u8, full: bool) -> String { + // Create ICU Date from components + let Ok(date) = Date::try_new_gregorian(year, month, day) else { + return String::new(); + }; + + // Get locale from environment + let (locale, _encoding) = get_time_locale(); + + // Configure field set for day formatting + let field_set = if full { + fieldsets::E::long() // Full day name + } else { + fieldsets::E::short() // Abbreviated day name + }; + + // Create formatter with locale + let Ok(formatter) = DateTimeFormatter::try_new(locale.clone().into(), field_set) else { + return String::new(); + }; + + // Format the date to get day name + let formatted = formatter.format(&date).to_string(); + formatted.trim().to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_localized_month_name_fallback() { + // This should work even if locale is not available + let name = get_localized_month_name(1, true); + // The function may return empty string if ICU fails, which is fine + // The caller (date.rs) will handle this by falling back to jiff + assert!(name.is_empty() || name.len() >= 3); + } +} diff --git a/src/uucore/src/lib/features/i18n/mod.rs b/src/uucore/src/lib/features/i18n/mod.rs index 79c804a03..e8e0f3f3c 100644 --- a/src/uucore/src/lib/features/i18n/mod.rs +++ b/src/uucore/src/lib/features/i18n/mod.rs @@ -9,6 +9,8 @@ use icu_locale::{Locale, locale}; #[cfg(feature = "i18n-collator")] pub mod collator; +#[cfg(feature = "i18n-datetime")] +pub mod datetime; #[cfg(feature = "i18n-decimal")] pub mod decimal; @@ -31,7 +33,7 @@ const DEFAULT_LOCALE: Locale = locale!("und"); /// 3. LANG /// /// Or fallback on Posix locale, with ASCII encoding. -fn get_locale_from_env(locale_name: &str) -> (Locale, UEncoding) { +pub fn get_locale_from_env(locale_name: &str) -> (Locale, UEncoding) { let locale_var = ["LC_ALL", locale_name, "LANG"] .iter() .find_map(|&key| std::env::var(key).ok()); diff --git a/tests/by-util/test_date.rs b/tests/by-util/test_date.rs index 2f064aaf2..ee2e0addd 100644 --- a/tests/by-util/test_date.rs +++ b/tests/by-util/test_date.rs @@ -3,7 +3,7 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. // -// spell-checker: ignore: AEDT AEST EEST NZDT NZST Kolkata Iseconds +// spell-checker: ignore: AEDT AEST EEST NZDT NZST Kolkata Iseconds févr février janv janvier mercredi samedi sommes use std::cmp::Ordering; @@ -1470,6 +1470,99 @@ fn test_date_posix_format_specifiers() { } } +#[test] +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +fn test_date_format_b_french_locale() { + // Test both %B and %b formats with French locale using a loop + // This test expects localized month names when i18n support is available + let test_cases = [ + ("2025-01-15", "janvier", "janv."), // Wednesday = mercredi, mer. + ("2025-02-15", "février", "févr."), // Saturday = samedi, sam. + ]; + + for (date, expected_full, expected_abbrev) in &test_cases { + let result = new_ucmd!() + .env("LC_TIME", "fr_FR.UTF-8") + .env("TZ", "UTC") + .arg("-d") + .arg(date) + .arg("+%B %b") + .succeeds(); + + let output = result.stdout_str().trim(); + let expected = format!("{expected_full} {expected_abbrev}"); + + if output == expected { + // i18n feature is working - test passed + assert_eq!(output, expected); + } else { + // i18n feature not available, skip test + println!( + "Skipping French locale test for {date} - i18n feature not available, got: {output}" + ); + return; // Exit early if i18n not available + } + } +} + +#[test] +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +fn test_date_format_a_french_locale() { + // Test both %A and %a formats with French locale using a loop + // This test expects localized day names when i18n support is available + let test_cases = [ + ("2025-01-15", "mercredi", "mer."), // Wednesday + ("2025-02-15", "samedi", "sam."), // Saturday + ]; + + for (date, expected_full, expected_abbrev) in &test_cases { + let result = new_ucmd!() + .env("LC_TIME", "fr_FR.UTF-8") + .env("TZ", "UTC") + .arg("-d") + .arg(date) + .arg("+%A %a") + .succeeds(); + + let output = result.stdout_str().trim(); + let expected = format!("{expected_full} {expected_abbrev}"); + + if output == expected { + // i18n feature is working - test passed + assert_eq!(output, expected); + } else { + // i18n feature not available, skip test + println!( + "Skipping French day locale test for {date} - i18n feature not available, got: {output}" + ); + return; // Exit early if i18n not available + } + } +} + +#[test] +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +fn test_date_french_full_sentence() { + let result = new_ucmd!() + .env("LANG", "fr_FR.UTF-8") + .env("TZ", "UTC") + .arg("-d") + .arg("2026-01-21") + .arg("+Nous sommes le %A %d %B %Y") + .succeeds(); + + let output = result.stdout_str().trim(); + let expected = "Nous sommes le mercredi 21 janvier 2026"; + + if output == expected { + // i18n feature is working - test passed + assert_eq!(output, expected); + } else { + // i18n feature not available, skip test + println!("Skipping French full sentence test - i18n feature not available, got: {output}"); + } +} + /// Test that %x format specifier respects locale settings /// This is a regression test for locale-aware date formatting #[test] From c279ede77c71709339d2275e8643d00ea6abc9dc Mon Sep 17 00:00:00 2001 From: Paol0B <52996310+Paol0B@users.noreply.github.com> Date: Sun, 25 Jan 2026 19:59:25 +0100 Subject: [PATCH 351/425] Merge pull request #10481 from Paol0B/fix/10314 FIX 10314 sort: locale-based collation is not supported --- src/uu/sort/Cargo.toml | 1 + tests/by-util/test_sort.rs | 86 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) diff --git a/src/uu/sort/Cargo.toml b/src/uu/sort/Cargo.toml index ae537ed02..e487a1bfe 100644 --- a/src/uu/sort/Cargo.toml +++ b/src/uu/sort/Cargo.toml @@ -20,6 +20,7 @@ workspace = true path = "src/sort.rs" [features] +default = ["i18n-collator"] i18n-collator = ["uucore/i18n-collator"] [dependencies] diff --git a/tests/by-util/test_sort.rs b/tests/by-util/test_sort.rs index e794898a2..f6842969c 100644 --- a/tests/by-util/test_sort.rs +++ b/tests/by-util/test_sort.rs @@ -2525,4 +2525,90 @@ fn test_locale_collation_utf8() { } } +#[test] +fn test_locale_interleaved_en_us_utf8() { + // Test case for issue: locale-based collation support + // In en_US.UTF-8, lowercase and uppercase letters should interleave + // Expected: a, A, b, B (locale-aware) + // Not: A, B, a, b (ASCII byte order) + new_ucmd!() + .env("LC_ALL", "en_US.UTF-8") + .pipe_in("a\nA\nb\nB\n") + .succeeds() + .stdout_is("a\nA\nb\nB\n"); +} + +#[test] +fn test_locale_c_byte_order() { + // Test case for issue: C locale should use ASCII byte order + // In C locale: A < B < a < b (uppercase before lowercase) + new_ucmd!() + .env("LC_ALL", "C") + .pipe_in("a\nA\nb\nB\n") + .succeeds() + .stdout_is("A\nB\na\nb\n"); +} + +#[test] +fn test_locale_posix_byte_order() { + // POSIX locale should behave like C locale + new_ucmd!() + .env("LC_ALL", "POSIX") + .pipe_in("a\nA\nb\nB\n") + .succeeds() + .stdout_is("A\nB\na\nb\n"); +} + +#[test] +fn test_locale_with_ignore_case_flag() { + // When -f (ignore case) is used, the comparison uses custom_str_cmp + // which converts to uppercase for comparison. With -f flag, all letters + // are treated as equivalent regardless of case, so original order is preserved + // for equal keys (stable sort behavior within equal elements). + // Note: This may differ slightly from GNU in tie-breaking behavior. + let result = new_ucmd!() + .env("LC_ALL", "en_US.UTF-8") + .arg("-f") + .pipe_in("a\nA\nb\nB\n") + .succeeds(); + + // Verify that a/A come before b/B (case-insensitive grouping works) + let output = result.stdout_str(); + let lines: Vec<&str> = output.lines().collect(); + assert_eq!(lines.len(), 4); + // a and A should come before b and B + let a_positions: Vec = lines + .iter() + .enumerate() + .filter(|(_, l)| **l == "a" || **l == "A") + .map(|(i, _)| i) + .collect(); + let b_positions: Vec = lines + .iter() + .enumerate() + .filter(|(_, l)| **l == "b" || **l == "B") + .map(|(i, _)| i) + .collect(); + assert!( + a_positions + .iter() + .all(|&a| b_positions.iter().all(|&b| a < b)), + "All 'a'/'A' should come before 'b'/'B' with -f flag" + ); +} + +#[test] +fn test_locale_complex_utf8_sorting() { + // More complex test with mixed case and special characters + // In en_US.UTF-8, should respect locale collation rules + // Locale collation is case-insensitive by default, with lowercase < uppercase for same base letter + let input = "zebra\nApple\napple\nBanana\nbanana\nZebra\n"; + + new_ucmd!() + .env("LC_ALL", "en_US.UTF-8") + .pipe_in(input) + .succeeds() + .stdout_is("apple\nApple\nbanana\nBanana\nzebra\nZebra\n"); +} + /* spell-checker: enable */ From bf12e9c8067547125948cf2a47324f96a7b11cd5 Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Mon, 26 Jan 2026 06:41:37 +0900 Subject: [PATCH 352/425] CI: Disable incremental build for faster CI (#10462) --- .github/workflows/CICD.yml | 10 ++++++++++ .github/workflows/benchmarks.yml | 4 ++++ .github/workflows/code-quality.yml | 1 + .github/workflows/freebsd.yml | 2 ++ .github/workflows/l10n.yml | 8 ++++++++ .github/workflows/openbsd.yml | 2 ++ .github/workflows/wsl2.yml | 1 + 7 files changed, 28 insertions(+) diff --git a/.github/workflows/CICD.yml b/.github/workflows/CICD.yml index 0ff6caeb3..34808af38 100644 --- a/.github/workflows/CICD.yml +++ b/.github/workflows/CICD.yml @@ -98,6 +98,7 @@ jobs: env: SCCACHE_GHA_ENABLED: "true" RUSTC_WRAPPER: "sccache" + CARGO_INCREMENTAL: 0 strategy: fail-fast: false matrix: @@ -163,6 +164,7 @@ jobs: env: SCCACHE_GHA_ENABLED: "true" RUSTC_WRAPPER: "sccache" + CARGO_INCREMENTAL: 0 strategy: matrix: job: @@ -269,6 +271,7 @@ jobs: env: SCCACHE_GHA_ENABLED: "true" RUSTC_WRAPPER: "sccache" + CARGO_INCREMENTAL: 0 strategy: fail-fast: false matrix: @@ -405,6 +408,7 @@ jobs: env: SCCACHE_GHA_ENABLED: "true" RUSTC_WRAPPER: "sccache" + CARGO_INCREMENTAL: 0 strategy: fail-fast: false matrix: @@ -444,6 +448,7 @@ jobs: env: SCCACHE_GHA_ENABLED: "true" RUSTC_WRAPPER: "sccache" + CARGO_INCREMENTAL: 0 strategy: fail-fast: false matrix: @@ -484,6 +489,7 @@ jobs: env: SCCACHE_GHA_ENABLED: "true" RUSTC_WRAPPER: "sccache" + CARGO_INCREMENTAL: 0 strategy: fail-fast: false matrix: @@ -610,6 +616,7 @@ jobs: DOCKER_OPTS: '--volume /etc/passwd:/etc/passwd --volume /etc/group:/etc/group' SCCACHE_GHA_ENABLED: "true" RUSTC_WRAPPER: "sccache" + CARGO_INCREMENTAL: 0 strategy: fail-fast: false matrix: @@ -920,6 +927,7 @@ jobs: env: SCCACHE_GHA_ENABLED: "true" RUSTC_WRAPPER: "sccache" + CARGO_INCREMENTAL: 0 strategy: fail-fast: false matrix: @@ -1001,6 +1009,7 @@ jobs: env: SCCACHE_GHA_ENABLED: "true" RUSTC_WRAPPER: "sccache" + CARGO_INCREMENTAL: 0 strategy: fail-fast: false matrix: @@ -1094,6 +1103,7 @@ jobs: env: SCCACHE_GHA_ENABLED: "true" RUSTC_WRAPPER: "sccache" + CARGO_INCREMENTAL: 0 strategy: fail-fast: false matrix: diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index 8643721f3..eb5392a13 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -20,6 +20,10 @@ jobs: benchmarks: name: Run ${{ matrix.type }} benchmarks for ${{ matrix.package }} (CodSpeed) runs-on: ubuntu-latest + env: + RUSTC_WRAPPER: sccache + CARGO_INCREMENTAL: 0 + SCCACHE_GHA_ENABLED: "true" strategy: matrix: type: [simulation] # , memory] # memory profile disabled due to variance diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index b3c0a385a..7927676ae 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -74,6 +74,7 @@ jobs: env: SCCACHE_GHA_ENABLED: "true" RUSTC_WRAPPER: "sccache" + CARGO_INCREMENTAL: 0 strategy: fail-fast: false matrix: diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index 3020105c6..4b6dcf043 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -93,6 +93,7 @@ jobs: # To ensure that files are cleaned up, we don't want to exit on error set +e unset FAULT + export CARGO_INCREMENTAL=0 ## cargo fmt testing echo "## cargo fmt testing" # * convert any errors/warnings to GHA UI annotations; ref: @@ -179,6 +180,7 @@ jobs: set +e cd "${WORKSPACE}" unset FAULT + export CARGO_INCREMENTAL=0 export RUSTFLAGS="-C strip=symbols" # for disk space cargo build || FAULT=1 export PATH=~/.cargo/bin:${PATH} diff --git a/.github/workflows/l10n.yml b/.github/workflows/l10n.yml index 3ccbda776..e9343b211 100644 --- a/.github/workflows/l10n.yml +++ b/.github/workflows/l10n.yml @@ -28,6 +28,7 @@ jobs: env: SCCACHE_GHA_ENABLED: "true" RUSTC_WRAPPER: "sccache" + CARGO_INCREMENTAL: 0 strategy: fail-fast: false matrix: @@ -130,6 +131,7 @@ jobs: env: SCCACHE_GHA_ENABLED: "true" RUSTC_WRAPPER: "sccache" + CARGO_INCREMENTAL: 0 steps: - uses: actions/checkout@v6 with: @@ -300,6 +302,7 @@ jobs: env: SCCACHE_GHA_ENABLED: "true" RUSTC_WRAPPER: "sccache" + CARGO_INCREMENTAL: 0 steps: - uses: actions/checkout@v6 with: @@ -409,6 +412,7 @@ jobs: env: SCCACHE_GHA_ENABLED: "true" RUSTC_WRAPPER: "sccache" + CARGO_INCREMENTAL: 0 strategy: fail-fast: false matrix: @@ -560,6 +564,7 @@ jobs: env: SCCACHE_GHA_ENABLED: "true" RUSTC_WRAPPER: "sccache" + CARGO_INCREMENTAL: 0 strategy: fail-fast: false matrix: @@ -899,6 +904,7 @@ jobs: env: SCCACHE_GHA_ENABLED: "true" RUSTC_WRAPPER: "sccache" + CARGO_INCREMENTAL: 0 steps: - uses: actions/checkout@v6 with: @@ -1130,6 +1136,7 @@ jobs: env: SCCACHE_GHA_ENABLED: "true" RUSTC_WRAPPER: "sccache" + CARGO_INCREMENTAL: 0 steps: - uses: actions/checkout@v6 with: @@ -1251,6 +1258,7 @@ jobs: env: SCCACHE_GHA_ENABLED: "true" RUSTC_WRAPPER: "sccache" + CARGO_INCREMENTAL: 0 steps: - uses: actions/checkout@v6 with: diff --git a/.github/workflows/openbsd.yml b/.github/workflows/openbsd.yml index be27d6222..dea831b10 100644 --- a/.github/workflows/openbsd.yml +++ b/.github/workflows/openbsd.yml @@ -58,6 +58,7 @@ jobs: # * NOTE: All steps need to be run in this block, otherwise, we are operating back on the mac host set -e # + export CARGO_INCREMENTAL=0 TEST_USER=tester REPO_NAME=${GITHUB_WORKSPACE##*/} WORKSPACE_PARENT="/home/runner/work/${REPO_NAME}" @@ -153,6 +154,7 @@ jobs: # * NOTE: All steps need to be run in this block, otherwise, we are operating back on the mac host set -e # + export CARGO_INCREMENTAL=0 TEST_USER=tester REPO_NAME=${GITHUB_WORKSPACE##*/} WORKSPACE_PARENT="/home/runner/work/${REPO_NAME}" diff --git a/.github/workflows/wsl2.yml b/.github/workflows/wsl2.yml index 1764a03fc..607a80e1d 100644 --- a/.github/workflows/wsl2.yml +++ b/.github/workflows/wsl2.yml @@ -66,4 +66,5 @@ jobs: . "$HOME/.cargo/env" export CARGO_TERM_COLOR=always export RUST_BACKTRACE=1 + CARGO_INCREMENTAL=0 cargo nextest run --hide-progress-bar --profile ci --features '${{ matrix.job.features }}' From ed2e9ebe87afc5f34f275307b127d9b9f565db14 Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Mon, 26 Jan 2026 06:42:21 +0900 Subject: [PATCH 353/425] CICD.yml: Merge 2 "separately" (#10425) --- .github/workflows/CICD.yml | 35 ++++------------------------------- 1 file changed, 4 insertions(+), 31 deletions(-) diff --git a/.github/workflows/CICD.yml b/.github/workflows/CICD.yml index 34808af38..677c2d68a 100644 --- a/.github/workflows/CICD.yml +++ b/.github/workflows/CICD.yml @@ -1228,7 +1228,7 @@ jobs: fail_ci_if_error: false test_separately: - name: Separate Builds + name: Separate Builds (individual and coreutils)# duplicated with other CI, but has better appearance runs-on: ${{ matrix.job.os }} strategy: fail-fast: false @@ -1241,6 +1241,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@stable - uses: Swatinem/rust-cache@v2 - name: build and test all programs individually @@ -1250,36 +1252,7 @@ jobs: for f in $(util/show-utils.sh ${CARGO_FEATURES_OPTION}) do echo "Building and testing $f" - cargo test -p "uu_$f" - done - - test_all_features: - name: Test all features separately - needs: [ min_version, deps ] - runs-on: ${{ matrix.job.os }} - strategy: - fail-fast: false - matrix: - job: - - { os: ubuntu-latest , features: feat_os_unix } - - { os: macos-latest , features: feat_os_macos } - # - { os: windows-latest , features: feat_os_windows } https://github.com/uutils/coreutils/issues/7044 - steps: - - 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: | - CARGO_FEATURES_OPTION='--features=${{ matrix.job.features }}' ; - for f in $(util/show-utils.sh ${CARGO_FEATURES_OPTION}) - do - echo "Running tests with --features=$f and --no-default-features" - cargo test --features=$f --no-default-features + cargo test -p "uu_$f" -p coreutils --features=$f --no-default-features done test_selinux: From cbb7f63893eac33455d9c15d2331a90351c77ce1 Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Mon, 26 Jan 2026 06:43:31 +0900 Subject: [PATCH 354/425] stty: Add cfg_aliases (#10443) --- Cargo.lock | 1 + src/uu/stty/Cargo.toml | 3 ++ src/uu/stty/build.rs | 14 ++++++++ src/uu/stty/src/stty.rs | 72 +++++------------------------------------ 4 files changed, 26 insertions(+), 64 deletions(-) create mode 100644 src/uu/stty/build.rs diff --git a/Cargo.lock b/Cargo.lock index 5e10d0d54..8ef4a55db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4088,6 +4088,7 @@ dependencies = [ name = "uu_stty" version = "0.6.0" dependencies = [ + "cfg_aliases", "clap", "fluent", "nix", diff --git a/src/uu/stty/Cargo.toml b/src/uu/stty/Cargo.toml index 40173b1df..94812b2ac 100644 --- a/src/uu/stty/Cargo.toml +++ b/src/uu/stty/Cargo.toml @@ -28,3 +28,6 @@ nix = { workspace = true, features = ["ioctl", "term"] } [[bin]] name = "stty" path = "src/main.rs" + +[build-dependencies] +cfg_aliases = "0.2.1" diff --git a/src/uu/stty/build.rs b/src/uu/stty/build.rs new file mode 100644 index 000000000..0d26ea321 --- /dev/null +++ b/src/uu/stty/build.rs @@ -0,0 +1,14 @@ +use cfg_aliases::cfg_aliases; + +fn main() { + cfg_aliases! { + bsd: { any( + target_os = "freebsd", + target_os = "dragonfly", + target_os = "ios", + target_os = "macos", + target_os = "netbsd", + target_os = "openbsd" + ) }, + } +} diff --git a/src/uu/stty/src/stty.rs b/src/uu/stty/src/stty.rs index f34f9b498..0c3fea02b 100644 --- a/src/uu/stty/src/stty.rs +++ b/src/uu/stty/src/stty.rs @@ -36,14 +36,7 @@ use uucore::format_usage; use uucore::parser::num_parser::ExtendedParser; use uucore::translate; -#[cfg(not(any( - target_os = "freebsd", - target_os = "dragonfly", - target_os = "ios", - target_os = "macos", - target_os = "netbsd", - target_os = "openbsd" -)))] +#[cfg(not(bsd))] use flags::BAUD_RATES; use flags::{CONTROL_CHARS, CONTROL_FLAGS, INPUT_FLAGS, LOCAL_FLAGS, OUTPUT_FLAGS}; @@ -624,26 +617,12 @@ fn print_terminal_size( let mut printer = WrappedPrinter::new(window_size); // BSDs use a u32 for the baud rate, so we can simply print it. - #[cfg(any( - target_os = "freebsd", - target_os = "dragonfly", - target_os = "ios", - target_os = "macos", - target_os = "netbsd", - target_os = "openbsd" - ))] + #[cfg(bsd)] printer.print(&translate!("stty-output-speed", "speed" => speed)); // Other platforms need to use the baud rate enum, so printing the right value // becomes slightly more complicated. - #[cfg(not(any( - target_os = "freebsd", - target_os = "dragonfly", - target_os = "ios", - target_os = "macos", - target_os = "netbsd", - target_os = "openbsd" - )))] + #[cfg(not(bsd))] for (text, baud_rate) in BAUD_RATES { if *baud_rate == speed { printer.print(&translate!("stty-output-speed", "speed" => (*text))); @@ -752,24 +731,10 @@ fn string_to_baud(arg: &str, baud_type: flags::BaudType) -> Option> let value = parse_baud_with_rounding(normalized)?; // BSDs use a u32 for the baud rate, so any decimal number applies. - #[cfg(any( - target_os = "freebsd", - target_os = "dragonfly", - target_os = "ios", - target_os = "macos", - target_os = "netbsd", - target_os = "openbsd" - ))] + #[cfg(bsd)] return Some(AllFlags::Baud(value, baud_type)); - #[cfg(not(any( - target_os = "freebsd", - target_os = "dragonfly", - target_os = "ios", - target_os = "macos", - target_os = "netbsd", - target_os = "openbsd" - )))] + #[cfg(not(bsd))] { for (text, baud_rate) in BAUD_RATES { if text.parse::().ok() == Some(value) { @@ -1440,14 +1405,7 @@ mod tests { // Tests for string_to_baud #[test] fn test_string_to_baud_valid() { - #[cfg(not(any( - target_os = "freebsd", - target_os = "dragonfly", - target_os = "ios", - target_os = "macos", - target_os = "netbsd", - target_os = "openbsd" - )))] + #[cfg(not(bsd))] { assert!(string_to_baud("9600", flags::BaudType::Both).is_some()); assert!(string_to_baud("115200", flags::BaudType::Both).is_some()); @@ -1455,14 +1413,7 @@ mod tests { assert!(string_to_baud("19200", flags::BaudType::Both).is_some()); } - #[cfg(any( - target_os = "freebsd", - target_os = "dragonfly", - target_os = "ios", - target_os = "macos", - target_os = "netbsd", - target_os = "openbsd" - ))] + #[cfg(bsd)] { assert!(string_to_baud("9600", flags::BaudType::Both).is_some()); assert!(string_to_baud("115200", flags::BaudType::Both).is_some()); @@ -1473,14 +1424,7 @@ mod tests { #[test] fn test_string_to_baud_invalid() { - #[cfg(not(any( - target_os = "freebsd", - target_os = "dragonfly", - target_os = "ios", - target_os = "macos", - target_os = "netbsd", - target_os = "openbsd" - )))] + #[cfg(not(bsd))] { assert_eq!(string_to_baud("995", flags::BaudType::Both), None); assert_eq!(string_to_baud("invalid", flags::BaudType::Both), None); From e061f8e75927d9738bba485a22fac373434c4e83 Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Mon, 26 Jan 2026 06:51:48 +0900 Subject: [PATCH 355/425] shuf: Tune performance for -i 1-1000000 (#10478) --- src/uu/shuf/src/shuf.rs | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/src/uu/shuf/src/shuf.rs b/src/uu/shuf/src/shuf.rs index 73290a0fc..970a623e2 100644 --- a/src/uu/shuf/src/shuf.rs +++ b/src/uu/shuf/src/shuf.rs @@ -378,6 +378,7 @@ impl Writable for &OsStr { } impl Writable for u64 { + #[inline] fn write_all_to(&self, output: &mut impl OsWrite) -> Result<(), Error> { // The itoa crate is surprisingly much more efficient than a formatted write. // It speeds up `shuf -r -n1000000 -i1-1024` by 1.8×. @@ -386,13 +387,22 @@ impl Writable for u64 { } } +#[cold] +#[inline(never)] +fn handle_write_error(e: std::io::Error) -> Box { + use uucore::error::FromIo; + let ctx = translate!("shuf-error-write-failed"); + e.map_err_context(move || ctx) +} + +#[inline(never)] fn shuf_exec( input: &mut impl Shufable, opts: &Options, rng: &mut WrappedRng, output: &mut BufWriter>, ) -> UResult<()> { - let ctx = || translate!("shuf-error-write-failed"); + let sep = [opts.sep]; if opts.repeat { if input.is_empty() { return Err(USimpleError::new( @@ -402,20 +412,19 @@ fn shuf_exec( } for _ in 0..opts.head_count { let r = input.choose(rng)?; - - r.write_all_to(output).map_err_context(ctx)?; - output.write_all(&[opts.sep]).map_err_context(ctx)?; + r.write_all_to(output).map_err(handle_write_error)?; + output.write_all(&sep).map_err(handle_write_error)?; } } else { let shuffled = input.partial_shuffle(rng, opts.head_count)?; for r in shuffled { let r = r?; - r.write_all_to(output).map_err_context(ctx)?; - output.write_all(&[opts.sep]).map_err_context(ctx)?; + r.write_all_to(output).map_err(handle_write_error)?; + output.write_all(&sep).map_err(handle_write_error)?; } } - output.flush().map_err_context(ctx)?; + output.flush().map_err(handle_write_error)?; Ok(()) } From d2db87275c063943b47e81e66be035ebd22c3ba8 Mon Sep 17 00:00:00 2001 From: FidelSch Date: Sun, 25 Jan 2026 19:03:22 -0300 Subject: [PATCH 356/425] tac: add regex flavor translation for compatibility and new test case (#10416) --- src/uu/tac/src/tac.rs | 175 +++++++++++++++++++++++++++++++++++++- tests/by-util/test_tac.rs | 71 +++++++++++++++- 2 files changed, 244 insertions(+), 2 deletions(-) diff --git a/src/uu/tac/src/tac.rs b/src/uu/tac/src/tac.rs index cba911a7e..ec8ae4503 100644 --- a/src/uu/tac/src/tac.rs +++ b/src/uu/tac/src/tac.rs @@ -223,11 +223,99 @@ fn buffer_tac(data: &[u8], before: bool, separator: &str) -> std::io::Result<()> Ok(()) } +/// Make the regex flavor compatible with `regex` crate +/// +/// Concretely: +/// - Toggle escaping of (), |, {} +/// - Escape ^ and $ when not at edges +/// - Leave expressions inside [] unchanged +fn translate_regex_flavor(regex: &str) -> String { + let mut result = String::new(); + let mut chars = regex.chars().peekable(); + let mut inside_brackets = false; + let mut prev_was_backslash = false; + let mut last_char: Option = None; + + while let Some(c) = chars.next() { + let is_escaped = prev_was_backslash; + prev_was_backslash = false; + + match c { + // Unescape escaped (), |, {} when not inside brackets + '\\' if !inside_brackets && !is_escaped => { + if let Some(&next) = chars.peek() { + if matches!(next, '(' | ')' | '|' | '{' | '}') { + result.push(next); + last_char = Some(next); + chars.next(); + continue; + } + } + + result.push('\\'); + last_char = Some('\\'); + prev_was_backslash = true; + } + // Bracket tracking + '[' => { + inside_brackets = true; + result.push(c); + last_char = Some(c); + } + ']' => { + inside_brackets = false; + result.push(c); + last_char = Some(c); + } + // Escape (), |, {} when not escaped and outside brackets + '(' | ')' | '|' | '{' | '}' if !inside_brackets && !is_escaped => { + result.push('\\'); + result.push(c); + last_char = Some(c); + } + '^' if !inside_brackets && !is_escaped => { + let is_anchor_position = result.is_empty() || matches!(last_char, Some('(' | '|')); + if !is_anchor_position { + result.push('\\'); + } + result.push(c); + last_char = Some(c); + } + '$' if !inside_brackets && !is_escaped => { + let next_is_anchor_position = match chars.peek() { + None => true, + Some(&')' | &'|') => true, + Some(&'\\') => { + // Peek two ahead to see if it's \) or \| + let chars_vec: Vec = chars.clone().take(2).collect(); + matches!(chars_vec.get(1), Some(&')' | &'|')) + } + _ => false, + }; + if !next_is_anchor_position { + result.push('\\'); + } + result.push(c); + last_char = Some(c); + } + _ => { + result.push(c); + last_char = Some(c); + } + } + } + + result +} + #[allow(clippy::cognitive_complexity)] fn tac(filenames: &[OsString], before: bool, regex: bool, separator: &str) -> UResult<()> { // Compile the regular expression pattern if it is provided. let maybe_pattern = if regex { - match regex::bytes::Regex::new(separator) { + match regex::bytes::RegexBuilder::new(&translate_regex_flavor(separator)) + .multi_line(true) + .build() + { Ok(p) => Some(p), Err(e) => return Err(TacError::InvalidRegex(e).into()), } @@ -359,3 +447,88 @@ fn try_mmap_path(path: &Path) -> Option { Some(mmap) } + +#[cfg(test)] +mod tests_hybrid_flavor { + use super::translate_regex_flavor; + + #[test] + fn test_grouping_and_alternation() { + assert_eq!(translate_regex_flavor(r"\(abc\)"), r"(abc)"); + + assert_eq!(translate_regex_flavor(r"(abc)"), r"\(abc\)"); + + assert_eq!(translate_regex_flavor(r"a\|b"), r"a|b"); + + assert_eq!(translate_regex_flavor(r"a|b"), r"a\|b"); + } + + #[test] + fn test_quantifiers() { + assert_eq!(translate_regex_flavor("a+"), "a+"); + + assert_eq!(translate_regex_flavor("a*"), "a*"); + + assert_eq!(translate_regex_flavor("a?"), "a?"); + + assert_eq!(translate_regex_flavor(r"a\+"), r"a\+"); + + assert_eq!(translate_regex_flavor(r"a\*"), r"a\*"); + + assert_eq!(translate_regex_flavor(r"a\?"), r"a\?"); + } + + #[test] + fn test_intervals() { + assert_eq!(translate_regex_flavor(r"a\{1,3\}"), r"a{1,3}"); + + assert_eq!(translate_regex_flavor(r"a{1,3}"), r"a\{1,3\}"); + } + + #[test] + fn test_anchors_context() { + assert_eq!(translate_regex_flavor(r"^abc$"), r"^abc$"); + + assert_eq!(translate_regex_flavor(r"a^b"), r"a\^b"); + assert_eq!(translate_regex_flavor(r"a$b"), r"a\$b"); + + // Anchors inside groups (reset by \(...\) regardless of position) + assert_eq!(translate_regex_flavor(r"\(^abc\)"), r"(^abc)"); + assert_eq!(translate_regex_flavor(r"z\(^abc\)"), r"z(^abc)"); + assert_eq!(translate_regex_flavor(r"\(abc$\)"), r"(abc$)"); + assert_eq!(translate_regex_flavor(r"\(abc$\)z"), r"(abc$)z"); + + // Anchors inside alternation (reset by \| regardless of position) + assert_eq!(translate_regex_flavor(r"^a\|^b"), r"^a|^b"); + assert_eq!(translate_regex_flavor(r"x\|^b"), r"x|^b"); + assert_eq!(translate_regex_flavor(r"a$\|b$"), r"a$|b$"); + } + + #[test] + fn test_character_classes() { + assert_eq!(translate_regex_flavor(r"[a-z]"), r"[a-z]"); + + assert_eq!(translate_regex_flavor(r"[.]"), r"[.]"); + assert_eq!(translate_regex_flavor(r"[+]"), r"[+]"); + + assert_eq!(translate_regex_flavor(r"[]abc]"), r"[]abc]"); + + assert_eq!(translate_regex_flavor(r"[^]abc]"), r"[^]abc]"); + } + + #[test] + fn test_complex_strings() { + assert_eq!(translate_regex_flavor(r"(\d+)[+*]"), r"\(\d+\)[+*]"); + + assert_eq!(translate_regex_flavor(r"\(\d+\)\{2\}"), r"(\d+){2}"); + } + + #[test] + fn test_edge_cases() { + assert_eq!(translate_regex_flavor(r"abc\"), r"abc\"); + + assert_eq!(translate_regex_flavor(r"\\"), r"\\"); + + assert_eq!(translate_regex_flavor(r"\^"), r"\^"); + } +} diff --git a/tests/by-util/test_tac.rs b/tests/by-util/test_tac.rs index be2b89cae..1fc42d1c8 100644 --- a/tests/by-util/test_tac.rs +++ b/tests/by-util/test_tac.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 axxbxx bxxaxx axxx axxxx xxaxx xxax xxxxa axyz zyax zyxa +// spell-checker:ignore axxbxx bxxaxx axxx axxxx xxaxx xxax xxxxa axyz zyax zyxa bbaaa aaabc bcdddd cddddaaabc xyzabc abcxyzabc nbbaaa #[cfg(target_os = "linux")] use uutests::at_and_ucmd; use uutests::new_ucmd; @@ -347,3 +347,72 @@ fn test_stdin_bad_tmpdir_fallback() { .succeeds() .stdout_is("c\nb\na\n"); } + +#[test] +fn test_regex_or_operator() { + new_ucmd!() + .args(&["-r", "-s", r"[^x]\|x"]) + .pipe_in("abc") + .succeeds() + .stdout_is("cba"); +} + +#[test] +fn test_unescaped_middle_anchor() { + new_ucmd!() + .args(&["-r", "-s", r"1^2"]) + .pipe_in("111^222") + .succeeds() + .stdout_is("22111^2"); + + new_ucmd!() + .args(&["-r", "-s", r"a$b"]) + .pipe_in("aaa$bbb") + .succeeds() + .stdout_is("bbaaa$b"); +} + +#[test] +fn test_escaped_middle_anchor() { + new_ucmd!() + .args(&["-r", "-s", r"c\^b"]) + .pipe_in("aaabc^bcdddd") + .succeeds() + .stdout_is("cddddaaabc^b"); + + new_ucmd!() + .args(&["-r", "-s", r"c\$b"]) + .pipe_in("aaabc$bcdddd") + .succeeds() + .stdout_is("cddddaaabc$b"); +} + +#[test] +fn test_regular_start_anchor() { + new_ucmd!() + .args(&["-r", "-s", r"^abc"]) + .pipe_in("xyzabc123abc") + .succeeds() + .stdout_is("xyzabc123abc"); + + new_ucmd!() + .args(&["-r", "-s", r"^b"]) + .pipe_in("aaa\nbbb\nccc\n") + .succeeds() + .stdout_is("bb\nccc\naaa\nb"); +} + +#[test] +fn test_regular_end_anchor() { + new_ucmd!() + .args(&["-r", "-s", r"abc$"]) + .pipe_in("123abcxyzabc") + .succeeds() + .stdout_is("123abcxyzabc"); + + new_ucmd!() + .args(&["-r", "-s", r"b$"]) + .pipe_in("aaa\nbbb\nccc\n") + .succeeds() + .stdout_is("\nccc\nbbaaa\nb"); +} From 9f98aecbcb0045d424c1451b0456d4b885e436e0 Mon Sep 17 00:00:00 2001 From: xtqqczze <45661989+xtqqczze@users.noreply.github.com> Date: Mon, 26 Jan 2026 02:02:10 +0000 Subject: [PATCH 357/425] deps: refactor crossterm package configuration --- Cargo.lock | 38 -------------------------------------- Cargo.toml | 2 +- src/uu/more/Cargo.toml | 5 ++++- 3 files changed, 5 insertions(+), 40 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8ef4a55db..df301dbd7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -509,15 +509,6 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" -[[package]] -name = "convert_case" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" -dependencies = [ - "unicode-segmentation", -] - [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -752,7 +743,6 @@ checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ "bitflags 2.10.0", "crossterm_winapi", - "derive_more", "document-features", "filedescriptor", "mio", @@ -885,28 +875,6 @@ dependencies = [ "powerfmt", ] -[[package]] -name = "derive_more" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" -dependencies = [ - "derive_more-impl", -] - -[[package]] -name = "derive_more-impl" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" -dependencies = [ - "convert_case", - "proc-macro2", - "quote", - "rustc_version", - "syn", -] - [[package]] name = "diff" version = "0.1.13" @@ -3127,12 +3095,6 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" -[[package]] -name = "unicode-segmentation" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" - [[package]] name = "unicode-width" version = "0.1.14" diff --git a/Cargo.toml b/Cargo.toml index 6e7600f17..398c60017 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -314,7 +314,7 @@ clap = { version = "4.5", features = ["wrap_help", "cargo", "color"] } clap_complete = "4.4" clap_mangen = "0.2" compare = "0.1.0" -crossterm = "0.29.0" +crossterm = { version = "0.29.0", default-features = false } ctor = "0.6.0" ctrlc = { version = "3.4.7", features = ["termination"] } divan = { package = "codspeed-divan-compat", version = "4.0.5" } diff --git a/src/uu/more/Cargo.toml b/src/uu/more/Cargo.toml index e296dcf80..bee3ff755 100644 --- a/src/uu/more/Cargo.toml +++ b/src/uu/more/Cargo.toml @@ -19,12 +19,15 @@ path = "src/more.rs" [dependencies] clap = { workspace = true } +crossterm = { workspace = true, features = ["events"] } uucore = { workspace = true } -crossterm = { workspace = true } fluent = { workspace = true } [target.'cfg(all(unix, not(target_os = "fuchsia")))'.dependencies] +[target.'cfg(windows)'.dependencies] +crossterm = { workspace = true, features = ["windows"] } + [target.'cfg(target_os = "macos")'.dependencies] crossterm = { workspace = true, features = ["use-dev-tty"] } From bf8503b4dcee8567011f93c1068f18b6e95df2db Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Mon, 26 Jan 2026 14:07:46 +0900 Subject: [PATCH 358/425] README.package.md: Fix MSRV --- README.package.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.package.md b/README.package.md index 355b153db..ebf7724f6 100644 --- a/README.package.md +++ b/README.package.md @@ -14,7 +14,7 @@ [![dependency status](https://deps.rs/repo/github/uutils/coreutils/status.svg)](https://deps.rs/repo/github/uutils/coreutils) [![CodeCov](https://codecov.io/gh/uutils/coreutils/branch/master/graph/badge.svg)](https://codecov.io/gh/uutils/coreutils) -![MSRV](https://img.shields.io/badge/MSRV-1.70.0-brightgreen) +![MSRV](https://img.shields.io/badge/MSRV-1.85.0-brightgreen) From 5500e1cc351de1679960bad109e4b24a04c2a28b Mon Sep 17 00:00:00 2001 From: Tuomas Tynkkynen Date: Mon, 26 Jan 2026 16:54:09 +0200 Subject: [PATCH 359/425] test: Trim whitespace in integer comparisons Matches upstream behaviour. Fixes #10410. --- src/uu/test/src/test.rs | 3 +++ tests/by-util/test_test.rs | 20 ++++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/src/uu/test/src/test.rs b/src/uu/test/src/test.rs index 0e4e809d7..48d691a39 100644 --- a/src/uu/test/src/test.rs +++ b/src/uu/test/src/test.rs @@ -183,11 +183,13 @@ fn integers(a: &OsStr, b: &OsStr, op: &OsStr) -> ParseResult { // Parse the two inputs let a: i128 = a .to_str() + .map(|s| s.trim()) .and_then(|s| s.parse().ok()) .ok_or_else(|| ParseError::InvalidInteger(a.quote().to_string()))?; let b: i128 = b .to_str() + .map(|s| s.trim()) .and_then(|s| s.parse().ok()) .ok_or_else(|| ParseError::InvalidInteger(b.quote().to_string()))?; @@ -229,6 +231,7 @@ fn files(a: &OsStr, b: &OsStr, op: &OsStr) -> ParseResult { fn isatty(fd: &OsStr) -> ParseResult { fd.to_str() + .map(|s| s.trim()) .and_then(|s| s.parse().ok()) .ok_or_else(|| ParseError::InvalidInteger(fd.quote().to_string())) .map(|i| unsafe { libc::isatty(i) == 1 }) diff --git a/tests/by-util/test_test.rs b/tests/by-util/test_test.rs index 21ea1893e..d7f8215bd 100644 --- a/tests/by-util/test_test.rs +++ b/tests/by-util/test_test.rs @@ -314,6 +314,26 @@ fn test_invalid_utf8_integer_compare() { .stderr_is("test: invalid integer $'fo\\x80o'\n"); } +#[test] +fn test_integer_whitespace_stripping() { + new_ucmd!().args(&["42", "-eq", " 42 "]).succeeds(); + new_ucmd!().args(&["42", "-eq", " 42"]).succeeds(); + new_ucmd!().args(&["42", "-eq", "42 "]).succeeds(); + new_ucmd!().args(&[" 42 ", "-eq", "42"]).succeeds(); + + new_ucmd!().args(&["42", "-eq", "\t42"]).succeeds(); + new_ucmd!().args(&["42", "-eq", "\n42"]).succeeds(); + new_ucmd!().args(&["42", "-eq", "\x0b42"]).succeeds(); // Vertical tab + new_ucmd!().args(&["42", "-eq", "\x0c42"]).succeeds(); // Form feed + new_ucmd!().args(&["42", "-eq", "\r42"]).succeeds(); +} + +#[test] +fn test_isatty_whitespace_stripping() { + new_ucmd!().args(&["-t", " 0 "]).fails_with_code(1); + new_ucmd!().args(&["-t", "\n0\t"]).fails_with_code(1); +} + #[test] #[cfg(unix)] fn test_file_is_itself() { From 2839f78b92365ec4d9a765020acc50732505d493 Mon Sep 17 00:00:00 2001 From: Chris Dryden Date: Mon, 26 Jan 2026 11:35:29 -0500 Subject: [PATCH 360/425] Silently handle broken pipe on windows for yes (#10429) BrokenPipe errors should be ignored only on Windows. For unix platforms supporting SIGPIPE, receiving a BrokenPipe error means that the default SIGPIPE handler was disabled, and requires returning an error code. --- src/uu/yes/src/yes.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/uu/yes/src/yes.rs b/src/uu/yes/src/yes.rs index 98ee5550c..92527221b 100644 --- a/src/uu/yes/src/yes.rs +++ b/src/uu/yes/src/yes.rs @@ -27,6 +27,8 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { match exec(&buffer) { Ok(()) => Ok(()), + // On Windows, silently handle broken pipe since there's no SIGPIPE + #[cfg(windows)] Err(err) if err.kind() == io::ErrorKind::BrokenPipe => Ok(()), Err(err) => Err(USimpleError::new( 1, From 6124866934900219a4fba24c2ff3e3dead0c654c Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Mon, 26 Jan 2026 18:42:26 +0100 Subject: [PATCH 361/425] date: add locale-aware calendar support for era years (#10473) Implements era year calculation for Buddhist, Persian Solar Hijri, and Ethiopian calendars based on locale detection. The %Y format specifier now outputs era-appropriate years while maintaining Gregorian calendar for ISO-8601 and RFC-3339 formats for interoperability. --- .../cspell.dictionaries/jargon.wordlist.txt | 5 + Cargo.lock | 1 + fuzz/Cargo.lock | 2 + src/uu/date/Cargo.toml | 4 +- src/uu/date/src/date.rs | 55 ++-- src/uucore/src/lib/features/i18n/datetime.rs | 133 ++++++++++ tests/by-util/test_date.rs | 239 ++++++++++++++++++ 7 files changed, 422 insertions(+), 17 deletions(-) diff --git a/.vscode/cspell.dictionaries/jargon.wordlist.txt b/.vscode/cspell.dictionaries/jargon.wordlist.txt index 7ba13ab80..e4ab23afd 100644 --- a/.vscode/cspell.dictionaries/jargon.wordlist.txt +++ b/.vscode/cspell.dictionaries/jargon.wordlist.txt @@ -227,3 +227,8 @@ ENOTSUP enotsup SETFL tmpfs + +Hijri +Nowruz +charmap +hijri diff --git a/Cargo.lock b/Cargo.lock index df301dbd7..f68cf0c18 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3345,6 +3345,7 @@ dependencies = [ "codspeed-divan-compat", "fluent", "icu_calendar", + "icu_locale", "jiff", "nix", "parse_datetime", diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index 424578d1d..4094c806b 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -1718,6 +1718,8 @@ version = "0.6.0" dependencies = [ "clap", "fluent", + "icu_calendar", + "icu_locale", "jiff", "nix", "parse_datetime", diff --git a/src/uu/date/Cargo.toml b/src/uu/date/Cargo.toml index 9b927b1e2..8820d96b9 100644 --- a/src/uu/date/Cargo.toml +++ b/src/uu/date/Cargo.toml @@ -19,12 +19,14 @@ workspace = true path = "src/date.rs" [features] -i18n-datetime = ["uucore/i18n-datetime", "icu_calendar"] +default = ["i18n-datetime"] +i18n-datetime = ["uucore/i18n-datetime", "dep:icu_calendar", "dep:icu_locale"] [dependencies] clap = { workspace = true } fluent = { workspace = true } icu_calendar = { workspace = true, optional = true } +icu_locale = { workspace = true, optional = true } jiff = { workspace = true, features = [ "tzdb-bundle-platform", "tzdb-zoneinfo", diff --git a/src/uu/date/src/date.rs b/src/uu/date/src/date.rs index cf6732aaa..f82fe1c38 100644 --- a/src/uu/date/src/date.rs +++ b/src/uu/date/src/date.rs @@ -20,8 +20,10 @@ use std::sync::OnceLock; use uucore::display::Quotable; use uucore::error::FromIo; use uucore::error::{UResult, USimpleError}; +#[cfg(feature = "i18n-datetime")] use uucore::i18n::datetime::{ - get_localized_day_name, get_localized_month_name, should_use_icu_locale, + get_era_year, get_localized_day_name, get_localized_month_name, get_time_locale, + should_use_icu_locale, }; use uucore::translate; use uucore::{format_usage, show}; @@ -618,14 +620,14 @@ fn format_date_with_locale_aware_months( format_string: &str, config: &Config, ) -> Result { - // Only use ICU for non-default locales and when format string contains month or day specifiers - let use_icu = should_use_icu_locale(); - + // Only use ICU for non-English locales and when format string contains month, day, or era year specifiers if (format_string.contains("%B") || format_string.contains("%b") || format_string.contains("%A") - || format_string.contains("%a")) - && use_icu + || format_string.contains("%a") + || format_string.contains("%Y") + || format_string.contains("%Ey")) + && should_use_icu_locale() { let broken_down = BrokenDownTime::from(date); // Get localized month names if needed @@ -665,37 +667,58 @@ fn format_date_with_locale_aware_months( (String::new(), String::new()) }; - // Replace format specifiers with placeholders for successful ICU translations only + // Get era year if needed + let era_year = if format_string.contains("%Y") || format_string.contains("%Ey") { + if let (Some(year), Some(month), Some(day)) = + (broken_down.year(), broken_down.month(), broken_down.day()) + { + let (locale, _encoding) = get_time_locale(); + get_era_year(year.into(), month as u8, day as u8, locale) + } else { + None + } + } else { + None + }; + + // Replace format specifiers with NULL-byte placeholders for successful ICU translations only + // Use NULL bytes to avoid collision with user format strings let mut temp_format = format_string.to_string(); if !full_month.is_empty() { - temp_format = temp_format.replace("%B", "<<>>"); + temp_format = temp_format.replace("%B", "\0FULL_MONTH\0"); } if !abbrev_month.is_empty() { - temp_format = temp_format.replace("%b", "<<>>"); + temp_format = temp_format.replace("%b", "\0ABBREV_MONTH\0"); } if !full_day.is_empty() { - temp_format = temp_format.replace("%A", "<<>>"); + temp_format = temp_format.replace("%A", "\0FULL_DAY\0"); } if !abbrev_day.is_empty() { - temp_format = temp_format.replace("%a", "<<>>"); + temp_format = temp_format.replace("%a", "\0ABBREV_DAY\0"); + } + if era_year.is_some() { + temp_format = temp_format.replace("%Y", "\0ERA_YEAR\0"); } // Format with the temporary string let temp_result = broken_down.to_string_with_config(config, &temp_format)?; - // Replace placeholders with localized names + // Replace NULL-byte placeholders with localized names let mut final_result = temp_result; if !full_month.is_empty() { - final_result = final_result.replace("<<>>", &full_month); + final_result = final_result.replace("\0FULL_MONTH\0", &full_month); } if !abbrev_month.is_empty() { - final_result = final_result.replace("<<>>", &abbrev_month); + final_result = final_result.replace("\0ABBREV_MONTH\0", &abbrev_month); } if !full_day.is_empty() { - final_result = final_result.replace("<<>>", &full_day); + final_result = final_result.replace("\0FULL_DAY\0", &full_day); } if !abbrev_day.is_empty() { - final_result = final_result.replace("<<>>", &abbrev_day); + final_result = final_result.replace("\0ABBREV_DAY\0", &abbrev_day); + } + if let Some(era_year_val) = era_year { + final_result = final_result.replace("\0ERA_YEAR\0", &era_year_val.to_string()); } return Ok(final_result); diff --git a/src/uucore/src/lib/features/i18n/datetime.rs b/src/uucore/src/lib/features/i18n/datetime.rs index 4cca5ed22..e5d6a6662 100644 --- a/src/uucore/src/lib/features/i18n/datetime.rs +++ b/src/uucore/src/lib/features/i18n/datetime.rs @@ -116,6 +116,96 @@ pub fn get_localized_day_name(year: i32, month: u8, day: u8, full: bool) -> Stri formatted.trim().to_string() } +/// Determine the appropriate calendar system for a given locale +pub fn get_locale_calendar_type(locale: &Locale) -> CalendarType { + let locale_str = locale.to_string(); + + match locale_str.as_str() { + // Thai locales use Buddhist calendar + s if s.starts_with("th") => CalendarType::Buddhist, + // Persian/Farsi locales use Persian calendar (Solar Hijri) + s if s.starts_with("fa") => CalendarType::Persian, + // Amharic (Ethiopian) locales use Ethiopian calendar + s if s.starts_with("am") => CalendarType::Ethiopian, + // Default to Gregorian for all other locales + _ => CalendarType::Gregorian, + } +} + +/// Calendar types supported for locale-aware formatting +#[derive(Debug, Clone, PartialEq)] +pub enum CalendarType { + /// Gregorian calendar (used by most locales) + Gregorian, + /// Buddhist calendar (Thai locales) - adds 543 years to Gregorian year + Buddhist, + /// Persian Solar Hijri calendar (Persian/Farsi locales) - subtracts 621/622 years + Persian, + /// Ethiopian calendar (Amharic locales) - subtracts 7/8 years + Ethiopian, +} + +/// Convert a Gregorian date to the appropriate calendar system for a locale +/// +/// # Arguments +/// * `year` - Gregorian year +/// * `month` - Month (1-12) +/// * `day` - Day (1-31) +/// * `calendar_type` - Target calendar system +/// +/// # Returns +/// * `Some((era_year, month, day))` - Date in target calendar system +/// * `None` - If conversion fails +pub fn convert_date_to_locale_calendar( + year: i32, + month: u8, + day: u8, + calendar_type: &CalendarType, +) -> Option<(i32, u8, u8)> { + match calendar_type { + CalendarType::Gregorian => Some((year, month, day)), + CalendarType::Buddhist => { + // Buddhist calendar: Gregorian year + 543 + Some((year + 543, month, day)) + } + CalendarType::Persian => { + // Persian calendar conversion (Solar Hijri) + // March 21 (Nowruz) is roughly the start of the Persian year + let persian_year = if month > 3 || (month == 3 && day >= 21) { + year - 621 // After March 21 + } else { + year - 622 // Before March 21 + }; + Some((persian_year, month, day)) + } + CalendarType::Ethiopian => { + // Ethiopian calendar conversion + // September 11/12 is roughly the start of the Ethiopian year + let ethiopian_year = if month > 9 || (month == 9 && day >= 11) { + year - 7 // After September 11 + } else { + year - 8 // Before September 11 + }; + Some((ethiopian_year, month, day)) + } + } +} + +/// Get the era year for a given date and locale +pub fn get_era_year(year: i32, month: u8, day: u8, locale: &Locale) -> Option { + // Validate input date + if !(1..=12).contains(&month) || !(1..=31).contains(&day) { + return None; + } + + let calendar_type = get_locale_calendar_type(locale); + match calendar_type { + CalendarType::Gregorian => None, + _ => convert_date_to_locale_calendar(year, month, day, &calendar_type) + .map(|(era_year, _, _)| era_year), + } +} + #[cfg(test)] mod tests { use super::*; @@ -128,4 +218,47 @@ mod tests { // The caller (date.rs) will handle this by falling back to jiff assert!(name.is_empty() || name.len() >= 3); } + + #[test] + fn test_calendar_type_detection() { + let thai_locale = icu_locale::locale!("th-TH"); + let persian_locale = icu_locale::locale!("fa-IR"); + let amharic_locale = icu_locale::locale!("am-ET"); + let english_locale = icu_locale::locale!("en-US"); + + assert_eq!( + get_locale_calendar_type(&thai_locale), + CalendarType::Buddhist + ); + assert_eq!( + get_locale_calendar_type(&persian_locale), + CalendarType::Persian + ); + assert_eq!( + get_locale_calendar_type(&amharic_locale), + CalendarType::Ethiopian + ); + assert_eq!( + get_locale_calendar_type(&english_locale), + CalendarType::Gregorian + ); + } + + #[test] + fn test_era_year_conversion() { + let thai_locale = icu_locale::locale!("th-TH"); + let persian_locale = icu_locale::locale!("fa-IR"); + let amharic_locale = icu_locale::locale!("am-ET"); + + // Test Thai Buddhist calendar (2026 + 543 = 2569) + assert_eq!(get_era_year(2026, 6, 15, &thai_locale), Some(2569)); + + // Test Persian calendar (rough approximation) + assert_eq!(get_era_year(2026, 3, 22, &persian_locale), Some(1405)); + assert_eq!(get_era_year(2026, 3, 19, &persian_locale), Some(1404)); + + // Test Ethiopian calendar (rough approximation) + assert_eq!(get_era_year(2026, 9, 12, &amharic_locale), Some(2019)); + assert_eq!(get_era_year(2026, 9, 10, &amharic_locale), Some(2018)); + } } diff --git a/tests/by-util/test_date.rs b/tests/by-util/test_date.rs index ee2e0addd..1034dfdfc 100644 --- a/tests/by-util/test_date.rs +++ b/tests/by-util/test_date.rs @@ -1627,3 +1627,242 @@ fn test_date_parenthesis_vs_other_special_chars() { .stderr_contains("invalid date"); } } + +#[test] +#[cfg(unix)] +fn test_date_iranian_locale_solar_hijri_calendar() { + // Test Iranian locale uses Solar Hijri calendar + // Verify the Solar Hijri calendar is used in the Iranian locale + use std::process::Command; + + // Check if Iranian locale is available + let locale_check = Command::new("locale") + .env("LC_ALL", "fa_IR.UTF-8") + .arg("charmap") + .output(); + + let locale_available = match locale_check { + Ok(output) => String::from_utf8_lossy(&output.stdout).trim() == "UTF-8", + Err(_) => false, + }; + + if !locale_available { + println!("Skipping Iranian locale test - fa_IR.UTF-8 locale not available"); + return; + } + + // Get current year in Gregorian calendar + let current_year: i32 = new_ucmd!() + .env("LC_ALL", "C") + .arg("+%Y") + .succeeds() + .stdout_str() + .trim() + .parse() + .unwrap(); + + // 03-19 and 03-22 of the same Gregorian year are in different years in the + // Solar Hijri calendar + let year_march_19: i32 = new_ucmd!() + .env("LC_ALL", "fa_IR.UTF-8") + .arg("-d") + .arg(format!("{current_year}-03-19")) + .arg("+%Y") + .succeeds() + .stdout_str() + .trim() + .parse() + .unwrap(); + + let year_march_22: i32 = new_ucmd!() + .env("LC_ALL", "fa_IR.UTF-8") + .arg("-d") + .arg(format!("{current_year}-03-22")) + .arg("+%Y") + .succeeds() + .stdout_str() + .trim() + .parse() + .unwrap(); + + // Years should differ by 1 + assert_eq!(year_march_19, year_march_22 - 1); + + // The difference between the Gregorian year is 621 or 622 years + assert_eq!(year_march_19, current_year - 622); + assert_eq!(year_march_22, current_year - 621); + + // Check that --iso-8601 and --rfc-3339 use the Gregorian calendar + let iso_result = new_ucmd!() + .env("LC_ALL", "fa_IR.UTF-8") + .arg("--iso-8601=hours") + .succeeds(); + let iso_output = iso_result.stdout_str(); + assert!(iso_output.starts_with(¤t_year.to_string())); + + let rfc_result = new_ucmd!() + .env("LC_ALL", "fa_IR.UTF-8") + .arg("--rfc-3339=date") + .succeeds(); + let rfc_output = rfc_result.stdout_str(); + assert!(rfc_output.starts_with(¤t_year.to_string())); +} + +#[test] +#[cfg(unix)] +fn test_date_ethiopian_locale_calendar() { + // Test Ethiopian locale uses Ethiopian calendar + // Verify the Ethiopian calendar is used in the Ethiopian locale + use std::process::Command; + + // Check if Ethiopian locale is available + let locale_check = Command::new("locale") + .env("LC_ALL", "am_ET.UTF-8") + .arg("charmap") + .output(); + + let locale_available = match locale_check { + Ok(output) => String::from_utf8_lossy(&output.stdout).trim() == "UTF-8", + Err(_) => false, + }; + + if !locale_available { + println!("Skipping Ethiopian locale test - am_ET.UTF-8 locale not available"); + return; + } + + // Get current year in Gregorian calendar + let current_year: i32 = new_ucmd!() + .env("LC_ALL", "C") + .arg("+%Y") + .succeeds() + .stdout_str() + .trim() + .parse() + .unwrap(); + + // 09-10 and 09-12 of the same Gregorian year are in different years in the + // Ethiopian calendar + let year_september_10: i32 = new_ucmd!() + .env("LC_ALL", "am_ET.UTF-8") + .arg("-d") + .arg(format!("{current_year}-09-10")) + .arg("+%Y") + .succeeds() + .stdout_str() + .trim() + .parse() + .unwrap(); + + let year_september_12: i32 = new_ucmd!() + .env("LC_ALL", "am_ET.UTF-8") + .arg("-d") + .arg(format!("{current_year}-09-12")) + .arg("+%Y") + .succeeds() + .stdout_str() + .trim() + .parse() + .unwrap(); + + // Years should differ by 1 + assert_eq!(year_september_10, year_september_12 - 1); + + // The difference between the Gregorian year is 7 or 8 years + assert_eq!(year_september_10, current_year - 8); + assert_eq!(year_september_12, current_year - 7); + + // Check that --iso-8601 and --rfc-3339 use the Gregorian calendar + let iso_result = new_ucmd!() + .env("LC_ALL", "am_ET.UTF-8") + .arg("--iso-8601=hours") + .succeeds(); + let iso_output = iso_result.stdout_str(); + assert!(iso_output.starts_with(¤t_year.to_string())); + + let rfc_result = new_ucmd!() + .env("LC_ALL", "am_ET.UTF-8") + .arg("--rfc-3339=date") + .succeeds(); + let rfc_output = rfc_result.stdout_str(); + assert!(rfc_output.starts_with(¤t_year.to_string())); +} + +#[test] +#[cfg(unix)] +fn test_date_thai_locale_solar_calendar() { + // Test Thai locale uses Thai solar calendar + // Verify the Thai solar calendar is used with the Thai locale + use std::process::Command; + + // Check if Thai locale is available + let locale_check = Command::new("locale") + .env("LC_ALL", "th_TH.UTF-8") + .arg("charmap") + .output(); + + let locale_available = match locale_check { + Ok(output) => String::from_utf8_lossy(&output.stdout).trim() == "UTF-8", + Err(_) => false, + }; + + if !locale_available { + println!("Skipping Thai locale test - th_TH.UTF-8 locale not available"); + return; + } + + // Get current year in Gregorian calendar + let current_year: i32 = new_ucmd!() + .env("LC_ALL", "C") + .arg("+%Y") + .succeeds() + .stdout_str() + .trim() + .parse() + .unwrap(); + + // Since 1941, the year in the Thai solar calendar is the Gregorian year plus 543 + let thai_year: i32 = new_ucmd!() + .env("LC_ALL", "th_TH.UTF-8") + .arg("+%Y") + .succeeds() + .stdout_str() + .trim() + .parse() + .unwrap(); + + assert_eq!(thai_year, current_year + 543); + + // All months that have 31 days have names that end with "คม" (Thai characters) + let days_31_suffix = "\u{0E04}\u{0E21}"; // "คม" in Unicode + + for month in ["01", "03", "05", "07", "08", "10", "12"] { + let month_result = new_ucmd!() + .env("LC_ALL", "th_TH.UTF-8") + .arg("--date") + .arg(format!("{current_year}-{month}-01")) + .arg("+%B") + .succeeds(); + let month_name = month_result.stdout_str(); + + assert!( + month_name.trim().ends_with(days_31_suffix), + "Month {month} should end with 'คม', got: {month_name}" + ); + } + + // Check that --iso-8601 and --rfc-3339 use the Gregorian calendar + let iso_result = new_ucmd!() + .env("LC_ALL", "th_TH.UTF-8") + .arg("--iso-8601=hours") + .succeeds(); + let iso_output = iso_result.stdout_str(); + assert!(iso_output.starts_with(¤t_year.to_string())); + + let rfc_result = new_ucmd!() + .env("LC_ALL", "th_TH.UTF-8") + .arg("--rfc-3339=date") + .succeeds(); + let rfc_output = rfc_result.stdout_str(); + assert!(rfc_output.starts_with(¤t_year.to_string())); +} From fec6cdf5cf933f62e713149e2c7203f374cefec5 Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Tue, 27 Jan 2026 04:30:32 +0900 Subject: [PATCH 362/425] fuzzing.yml: Reproducible toolchain setup (#10487) --- .github/workflows/fuzzing.yml | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/.github/workflows/fuzzing.yml b/.github/workflows/fuzzing.yml index e1b85a1e8..4b5ac5e35 100644 --- a/.github/workflows/fuzzing.yml +++ b/.github/workflows/fuzzing.yml @@ -58,15 +58,16 @@ jobs: - uses: actions/checkout@v6 with: persist-credentials: false - - uses: dtolnay/rust-toolchain@nightly - name: Install `cargo-fuzz` - run: cargo install cargo-fuzz + run: | + echo "RUSTC_BOOTSTRAP=1" >> "${GITHUB_ENV}" # Use -Z + cargo install cargo-fuzz --locked - uses: Swatinem/rust-cache@v2 with: shared-key: "cargo-fuzz-cache-key" cache-directories: "fuzz/target" - name: Run `cargo-fuzz build` - run: cargo +nightly fuzz build + run: cargo fuzz build fuzz-run: needs: fuzz-build @@ -102,9 +103,10 @@ jobs: - uses: actions/checkout@v6 with: persist-credentials: false - - uses: dtolnay/rust-toolchain@nightly - name: Install `cargo-fuzz` - run: cargo install cargo-fuzz + run: | + echo "RUSTC_BOOTSTRAP=1" >> "${GITHUB_ENV}" # Use nightly + cargo install cargo-fuzz --locked - uses: Swatinem/rust-cache@v2 with: shared-key: "cargo-fuzz-cache-key" @@ -122,7 +124,7 @@ jobs: run: | mkdir -p fuzz/stats STATS_FILE="fuzz/stats/${{ matrix.test-target.name }}.txt" - cargo +nightly fuzz run ${{ matrix.test-target.name }} -- -max_total_time=${{ env.RUN_FOR }} -timeout=${{ env.RUN_FOR }} -detect_leaks=0 -print_final_stats=1 2>&1 | tee "$STATS_FILE" + cargo fuzz run ${{ matrix.test-target.name }} -- -max_total_time=${{ env.RUN_FOR }} -timeout=${{ env.RUN_FOR }} -detect_leaks=0 -print_final_stats=1 2>&1 | tee "$STATS_FILE" # Extract key stats from the output if grep -q "stat::number_of_executed_units" "$STATS_FILE"; then From 67046f5df71ee7a8a29470229022051a52e2bc1e Mon Sep 17 00:00:00 2001 From: Chris Dryden Date: Mon, 26 Jan 2026 14:41:39 -0500 Subject: [PATCH 363/425] timeout: add all signal handlers, pass on signals to child, add ignored signal handling (#10254) There was quite a bunch of different features missing in timeout, to start off, now that we have a mechanism to read the SIGPIPE handlers before they are overwritten by the rust runtime, it means that we can not propagate this signal down to the child processes if the signal is set to ignore. This also includes all of the latest changes since 9.9 where the specific signal sent to timeout will be propagated instead of just defaulting to a TERM signal. --- .../cspell.dictionaries/jargon.wordlist.txt | 11 + src/uu/timeout/src/status.rs | 4 - src/uu/timeout/src/timeout.rs | 210 +++++++++++------- src/uucore/src/lib/features/process.rs | 26 ++- tests/by-util/test_timeout.rs | 53 ++++- 5 files changed, 215 insertions(+), 89 deletions(-) diff --git a/.vscode/cspell.dictionaries/jargon.wordlist.txt b/.vscode/cspell.dictionaries/jargon.wordlist.txt index e4ab23afd..0eb8b3606 100644 --- a/.vscode/cspell.dictionaries/jargon.wordlist.txt +++ b/.vscode/cspell.dictionaries/jargon.wordlist.txt @@ -117,8 +117,10 @@ noxfer ofile oflag oflags +pdeathsig peekable performant +prctl precompiled precompute preload @@ -143,8 +145,17 @@ SETFL setlocale shortcode shortcodes +setpgid sigaction +CHLD +chld +SIGCHLD +sigchld siginfo +SIGTTIN +sigttin +SIGTTOU +sigttou sigusr strcasecmp subcommand diff --git a/src/uu/timeout/src/status.rs b/src/uu/timeout/src/status.rs index 1134fb88d..70fa2c097 100644 --- a/src/uu/timeout/src/status.rs +++ b/src/uu/timeout/src/status.rs @@ -33,9 +33,6 @@ pub(crate) enum ExitStatus { /// When a signal is sent to the child process or `timeout` itself. SignalSent(usize), - - /// When `SIGTERM` signal received. - Terminated, } impl From for i32 { @@ -46,7 +43,6 @@ impl From for i32 { ExitStatus::CannotInvoke => 126, ExitStatus::CommandNotFound => 127, ExitStatus::SignalSent(s) => 128 + s as Self, - ExitStatus::Terminated => 143, } } } diff --git a/src/uu/timeout/src/timeout.rs b/src/uu/timeout/src/timeout.rs index 2a917ae79..cac487036 100644 --- a/src/uu/timeout/src/timeout.rs +++ b/src/uu/timeout/src/timeout.rs @@ -9,8 +9,8 @@ mod status; use crate::status::ExitStatus; use clap::{Arg, ArgAction, Command}; -use std::io::ErrorKind; -use std::os::unix::process::{CommandExt, ExitStatusExt}; +use std::io::{ErrorKind, Write}; +use std::os::unix::process::ExitStatusExt; use std::process::{self, Child, Stdio}; use std::sync::atomic::{self, AtomicBool}; use std::time::Duration; @@ -21,12 +21,14 @@ use uucore::process::ChildExt; use uucore::translate; use uucore::{ - format_usage, show_error, + format_usage, signals::{signal_by_name_or_value, signal_name_by_value}, }; -use nix::sys::signal::{Signal, kill}; +use nix::sys::signal::{SigHandler, Signal, kill}; use nix::unistd::{Pid, getpid, setpgid}; +#[cfg(unix)] +use std::os::unix::process::CommandExt; pub mod options { pub static FOREGROUND: &str = "foreground"; @@ -177,32 +179,46 @@ pub fn uu_app() -> Command { .after_help(translate!("timeout-after-help")) } -/// Remove pre-existing SIGCHLD handlers that would make waiting for the child's exit code fail. -fn unblock_sigchld() { - unsafe { - nix::sys::signal::signal( - nix::sys::signal::Signal::SIGCHLD, - nix::sys::signal::SigHandler::SigDfl, - ) - .unwrap(); - } +/// Install SIGCHLD handler to ensure waiting for child works even if parent ignored SIGCHLD. +fn install_sigchld() { + extern "C" fn chld(_: libc::c_int) {} + let _ = unsafe { nix::sys::signal::signal(Signal::SIGCHLD, SigHandler::Handler(chld)) }; } -/// We should terminate child process when receiving TERM signal. +/// We should terminate child process when receiving termination signals. static SIGNALED: AtomicBool = AtomicBool::new(false); +/// Track which signal was received (0 = none/timeout expired naturally). +static RECEIVED_SIGNAL: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0); -fn catch_sigterm() { - use nix::sys::signal; - - extern "C" fn handle_sigterm(signal: libc::c_int) { - let signal = signal::Signal::try_from(signal).unwrap(); - if signal == signal::Signal::SIGTERM { - SIGNALED.store(true, atomic::Ordering::Relaxed); - } +/// Install signal handlers for termination signals. +fn install_signal_handlers(term_signal: usize) { + extern "C" fn handle_signal(sig: libc::c_int) { + SIGNALED.store(true, atomic::Ordering::Relaxed); + RECEIVED_SIGNAL.store(sig, atomic::Ordering::Relaxed); } - let handler = signal::SigHandler::Handler(handle_sigterm); - unsafe { signal::signal(signal::Signal::SIGTERM, handler) }.unwrap(); + let handler = SigHandler::Handler(handle_signal); + let sigpipe_ignored = uucore::signals::sigpipe_was_ignored(); + + for sig in [ + Signal::SIGALRM, + Signal::SIGINT, + Signal::SIGQUIT, + Signal::SIGHUP, + Signal::SIGTERM, + Signal::SIGPIPE, + Signal::SIGUSR1, + Signal::SIGUSR2, + ] { + if sig == Signal::SIGPIPE && sigpipe_ignored { + continue; // Skip SIGPIPE if it was ignored by parent + } + let _ = unsafe { nix::sys::signal::signal(sig, handler) }; + } + + if let Ok(sig) = Signal::try_from(term_signal as i32) { + let _ = unsafe { nix::sys::signal::signal(sig, handler) }; + } } /// Report that a signal is being sent if the verbose flag is set. @@ -213,26 +229,29 @@ fn report_if_verbose(signal: usize, cmd: &str, verbose: bool) { } else { signal_name_by_value(signal).unwrap().to_string() }; - show_error!( - "{}", + let mut stderr = std::io::stderr(); + let _ = writeln!( + stderr, + "timeout: {}", translate!("timeout-verbose-sending-signal", "signal" => s, "command" => cmd.quote()) ); + let _ = stderr.flush(); } } fn send_signal(process: &mut Child, signal: usize, foreground: bool) { // NOTE: GNU timeout doesn't check for errors of signal. // The subprocess might have exited just after the timeout. - // Sending a signal now would return "No such process", but we should still try to kill the children. - if foreground { - let _ = process.send_signal(signal); - } else { - let _ = process.send_signal_group(signal); - let kill_signal = signal_by_name_or_value("KILL").unwrap(); - let continued_signal = signal_by_name_or_value("CONT").unwrap(); - if signal != kill_signal && signal != continued_signal { - _ = process.send_signal_group(continued_signal); - } + let _ = process.send_signal(signal); + if signal == 0 || foreground { + return; + } + let _ = process.send_signal_group(signal); + let kill_signal = signal_by_name_or_value("KILL").unwrap(); + let continued_signal = signal_by_name_or_value("CONT").unwrap(); + if signal != kill_signal && signal != continued_signal { + let _ = process.send_signal(continued_signal); + let _ = process.send_signal_group(continued_signal); } } @@ -330,24 +349,46 @@ fn timeout( let _ = setpgid(Pid::from_raw(0), Pid::from_raw(0)); } - let mut command = process::Command::new(&cmd[0]); - command + let mut cmd_builder = process::Command::new(&cmd[0]); + cmd_builder .args(&cmd[1..]) .stdin(Stdio::inherit()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()); - // If stdin was closed before Rust reopened it as /dev/null, close it in child - if uucore::signals::stdin_was_closed() { + #[cfg(unix)] + { + #[cfg(target_os = "linux")] + let death_sig = Signal::try_from(signal as i32).ok(); + let sigpipe_was_ignored = uucore::signals::sigpipe_was_ignored(); + let stdin_was_closed = uucore::signals::stdin_was_closed(); + unsafe { - command.pre_exec(|| { - libc::close(libc::STDIN_FILENO); + cmd_builder.pre_exec(move || { + // Reset terminal signals to default + let _ = nix::sys::signal::signal(Signal::SIGTTIN, SigHandler::SigDfl); + let _ = nix::sys::signal::signal(Signal::SIGTTOU, SigHandler::SigDfl); + // Preserve SIGPIPE ignore status if parent had it ignored + if sigpipe_was_ignored { + let _ = nix::sys::signal::signal(Signal::SIGPIPE, SigHandler::SigIgn); + } + // If stdin was closed before Rust reopened it as /dev/null, close it in child + if stdin_was_closed { + libc::close(libc::STDIN_FILENO); + } + #[cfg(target_os = "linux")] + if let Some(sig) = death_sig { + let _ = nix::sys::prctl::set_pdeathsig(sig); + } Ok(()) }); } } - let process = &mut command.spawn().map_err(|err| { + install_sigchld(); + install_signal_handlers(signal); + + let process = &mut cmd_builder.spawn().map_err(|err| { let status_code = match err.kind() { ErrorKind::NotFound => ExitStatus::CommandNotFound.into(), ErrorKind::PermissionDenied => ExitStatus::CannotInvoke.into(), @@ -358,8 +399,7 @@ fn timeout( translate!("timeout-error-failed-to-execute-process", "error" => err), ) })?; - unblock_sigchld(); - catch_sigterm(); + // Wait for the child process for the specified time period. // // If the process exits within the specified time period (the @@ -381,41 +421,51 @@ fn timeout( Err(exit_code.into()) } Ok(None) => { - report_if_verbose(signal, &cmd[0], verbose); - send_signal(process, signal, foreground); - match kill_after { - None => { - let status = process.wait()?; - if SIGNALED.load(atomic::Ordering::Relaxed) { - Err(ExitStatus::Terminated.into()) - } else if preserve_status { - if let Some(ec) = status.code() { - Err(ec.into()) - } else if let Some(sc) = status.signal() { - Err(ExitStatus::SignalSent(sc.try_into().unwrap()).into()) - } else { - Err(ExitStatus::CommandTimedOut.into()) - } - } else { - Err(ExitStatus::CommandTimedOut.into()) - } - } - Some(kill_after) => { - match wait_or_kill_process( - process, - &cmd[0], - kill_after, - preserve_status, - foreground, - verbose, - ) { - Ok(status) => Err(status.into()), - Err(e) => Err(USimpleError::new( - ExitStatus::TimeoutFailed.into(), - e.to_string(), - )), - } - } + let received_sig = RECEIVED_SIGNAL.load(atomic::Ordering::Relaxed); + let is_external_signal = received_sig > 0 && received_sig != libc::SIGALRM; + let signal_to_send = if is_external_signal { + received_sig as usize + } else { + signal + }; + + report_if_verbose(signal_to_send, &cmd[0], verbose); + send_signal(process, signal_to_send, foreground); + + if let Some(kill_after) = kill_after { + return match wait_or_kill_process( + process, + &cmd[0], + kill_after, + preserve_status, + foreground, + verbose, + ) { + Ok(status) => Err(status.into()), + Err(e) => Err(USimpleError::new( + ExitStatus::TimeoutFailed.into(), + e.to_string(), + )), + }; + } + + let status = process.wait()?; + if is_external_signal { + Err(ExitStatus::SignalSent(received_sig as usize).into()) + } else if SIGNALED.load(atomic::Ordering::Relaxed) { + Err(ExitStatus::CommandTimedOut.into()) + } else if preserve_status { + Err(status + .code() + .or_else(|| { + status + .signal() + .map(|s| ExitStatus::SignalSent(s as usize).into()) + }) + .unwrap_or(ExitStatus::CommandTimedOut.into()) + .into()) + } else { + Err(ExitStatus::CommandTimedOut.into()) } } Err(_) => { diff --git a/src/uucore/src/lib/features/process.rs b/src/uucore/src/lib/features/process.rs index 043d4850d..b19d4a752 100644 --- a/src/uucore/src/lib/features/process.rs +++ b/src/uucore/src/lib/features/process.rs @@ -105,11 +105,29 @@ impl ChildExt for Child { } fn send_signal_group(&mut self, signal: usize) -> io::Result<()> { - // Ignore the signal, so we don't go into a signal loop. - if unsafe { libc::signal(signal as i32, libc::SIG_IGN) } == usize::MAX { - return Err(io::Error::last_os_error()); + // Send signal to our process group (group 0 = caller's group). + // This matches GNU coreutils behavior: if the child has remained in our + // process group, it will receive this signal along with all other processes + // in the group. If the child has created its own process group (via setpgid), + // it won't receive this group signal, but will have received the direct signal. + + // Signal 0 is special - it just checks if process exists, doesn't send anything. + // No need to manipulate signal handlers for it. + if signal == 0 { + let result = unsafe { libc::kill(0, 0) }; + return if result == 0 { + Ok(()) + } else { + Err(io::Error::last_os_error()) + }; } - if unsafe { libc::kill(0, signal as i32) } == 0 { + + // Ignore the signal temporarily so we don't receive it ourselves. + let old_handler = unsafe { libc::signal(signal as i32, libc::SIG_IGN) }; + let result = unsafe { libc::kill(0, signal as i32) }; + // Restore the old handler + unsafe { libc::signal(signal as i32, old_handler) }; + if result == 0 { Ok(()) } else { Err(io::Error::last_os_error()) diff --git a/tests/by-util/test_timeout.rs b/tests/by-util/test_timeout.rs index adce254d5..a9b9b29db 100644 --- a/tests/by-util/test_timeout.rs +++ b/tests/by-util/test_timeout.rs @@ -8,7 +8,8 @@ use std::time::Duration; use rstest::rstest; use uucore::display::Quotable; -use uutests::new_ucmd; +use uutests::util::TestScenario; +use uutests::{new_ucmd, util_name}; #[test] fn test_invalid_arg() { @@ -235,3 +236,53 @@ fn test_command_cannot_invoke() { // Try to execute a directory (should give permission denied or similar) new_ucmd!().args(&["1", "/"]).fails_with_code(126); } + +#[test] +#[cfg(unix)] +fn test_sigchld_ignored_by_parent() { + let ts = TestScenario::new(util_name!()); + let bin_path = ts.bin_path.to_string_lossy(); + ts.ucmd() + .args(&[ + "10", + "sh", + "-c", + &format!("trap '' CHLD; exec {bin_path} timeout 1 true"), + ]) + .succeeds(); +} + +#[test] +#[cfg(unix)] +fn test_with_background_child() { + new_ucmd!() + .args(&[".5", "sh", "-c", "sleep .1 & sleep 2"]) + .fails_with_code(124) + .no_stdout(); +} + +#[test] +#[cfg(unix)] +fn test_forward_sigint_to_child() { + let mut cmd = new_ucmd!() + .args(&[ + "10", + "sh", + "-c", + "trap 'echo got_int; exit 42' INT; sleep 5", + ]) + .run_no_wait(); + cmd.delay(100); + cmd.kill_with_custom_signal(nix::sys::signal::Signal::SIGINT); + cmd.make_assertion() + .is_not_alive() + .with_current_output() + .stdout_contains("got_int"); +} + +#[test] +fn test_foreground_signal0_kill_after() { + new_ucmd!() + .args(&["--foreground", "-s0", "-k.1", ".1", "sleep", "10"]) + .fails_with_code(137); +} From 1c09c368db1e50d77c199a6583210e1038ba8557 Mon Sep 17 00:00:00 2001 From: xtqqczze <45661989+xtqqczze@users.noreply.github.com> Date: Tue, 27 Jan 2026 01:14:35 +0000 Subject: [PATCH 364/425] ci: show diff for toml_format (#10501) --- .github/workflows/code-quality.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index 7927676ae..c902af152 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -1,6 +1,6 @@ name: Code Quality -# spell-checker:ignore (people) reactivecircus Swatinem dtolnay juliangruber pell taplo +# spell-checker:ignore (people) dtolnay juliangruber pell reactivecircus Swatinem taiki-e taplo # spell-checker:ignore (misc) TERMUX noaudio pkill swiftshader esac sccache pcoreutils shopt subshell dequote libsystemd on: @@ -205,8 +205,13 @@ jobs: with: persist-credentials: false + - name: Install taplo-cli + uses: taiki-e/install-action@v2 + with: + tool: taplo-cli + - name: Check - run: npx --yes @taplo/cli fmt --check + run: taplo fmt --check --diff python: name: Style/Python From 4783c5069c866d437fb7c6e3ffe31a49f2f0a4ca Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Tue, 27 Jan 2026 16:26:19 +0900 Subject: [PATCH 365/425] GnuTests: Drop cache action outside of VM, use preinstalled rust --- .github/workflows/GnuTests.yml | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/.github/workflows/GnuTests.yml b/.github/workflows/GnuTests.yml index 0f8ed7fd1..86b9cfedd 100644 --- a/.github/workflows/GnuTests.yml +++ b/.github/workflows/GnuTests.yml @@ -44,9 +44,6 @@ jobs: with: path: 'uutils' persist-credentials: false - - uses: dtolnay/rust-toolchain@master - with: - toolchain: stable - uses: Swatinem/rust-cache@v2 with: workspaces: "./uutils -> target" @@ -207,12 +204,6 @@ jobs: with: path: 'uutils' persist-credentials: false - - uses: dtolnay/rust-toolchain@master - with: - toolchain: stable - - uses: Swatinem/rust-cache@v2 - with: - workspaces: "./uutils -> target" - name: Checkout code (GNU coreutils) run: (mkdir -p gnu && cd gnu && bash ../uutils/util/fetch-gnu.sh) @@ -325,9 +316,6 @@ jobs: with: path: 'uutils' persist-credentials: false - - uses: dtolnay/rust-toolchain@master - with: - toolchain: stable - uses: Swatinem/rust-cache@v2 with: workspaces: "./uutils -> target" From d6c793c09abb43078ef6ecff6b8f49eccb0cc015 Mon Sep 17 00:00:00 2001 From: Etienne Cordonnier Date: Tue, 27 Jan 2026 10:25:17 +0100 Subject: [PATCH 366/425] build-gnu.sh: remove workaround for timeout/yes Now that https://github.com/uutils/coreutils/issues/7252 is fixed this workaround is not needed any more: This is a partial revert of https://github.com/uutils/coreutils/commit/fac6c2951d5ae22eed1517bff28169ebdb37ef3d ("util/build-gnu.sh: Bypass timeout/yes SIGPIPE handling bug") This is a revert of https://github.com/uutils/coreutils/commit/5004d4b45870a5b2e7cc90c87b04e85f7f725f30 ("build-gnu: replace `timeout` for `tests/tail/follow-stdin.sh`") Signed-off-by: Etienne Cordonnier --- util/build-gnu.sh | 8 -------- 1 file changed, 8 deletions(-) diff --git a/util/build-gnu.sh b/util/build-gnu.sh index 7a9f45c38..0d1c6d9e1 100755 --- a/util/build-gnu.sh +++ b/util/build-gnu.sh @@ -15,7 +15,6 @@ 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}")")" @@ -187,13 +186,6 @@ sed -i "s|cannot create regular file 'no-such/': Not a directory|'no-such/' is n # Our message is better 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 - -# 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 - # 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' grep -rlE '/usr/local/bin/\s?/usr/local/bin' init.cfg tests/* | xargs -r "${SED}" -Ei 's|/usr/local/bin/\s?/usr/local/bin/|/usr/local/bin/|g' From 9cc9ca832d9ffd0c2de1df2f3643000afb9fe16e Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Tue, 27 Jan 2026 21:25:28 +0900 Subject: [PATCH 367/425] CICD: Remove unused rustfmt (#10509) --- .github/workflows/CICD.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/CICD.yml b/.github/workflows/CICD.yml index 677c2d68a..e48d3c19e 100644 --- a/.github/workflows/CICD.yml +++ b/.github/workflows/CICD.yml @@ -1030,7 +1030,6 @@ jobs: - uses: dtolnay/rust-toolchain@master with: toolchain: ${{ env.RUST_MIN_SRV }} - components: rustfmt - uses: Swatinem/rust-cache@v2 - name: Run sccache-cache uses: mozilla-actions/sccache-action@v0.0.9 From 2e7cc476316fe52dfc016489758a2d2079b0aac4 Mon Sep 17 00:00:00 2001 From: Tuomas Tynkkynen Date: Tue, 27 Jan 2026 20:15:48 +0200 Subject: [PATCH 368/425] base64: Improve read error message (#10512) Instead of just "Uncategorized error" output: "base64: read error: Input/output error" --- src/uu/base32/src/base_common.rs | 31 ++++++++----------------------- tests/by-util/test_base64.rs | 9 +++++++++ 2 files changed, 17 insertions(+), 23 deletions(-) diff --git a/src/uu/base32/src/base_common.rs b/src/uu/base32/src/base_common.rs index d14642bfc..b7fef0ac2 100644 --- a/src/uu/base32/src/base_common.rs +++ b/src/uu/base32/src/base_common.rs @@ -8,7 +8,7 @@ use clap::{Arg, ArgAction, Command}; use std::ffi::OsString; use std::fs::File; -use std::io::{self, BufRead, BufReader, ErrorKind, Write}; +use std::io::{self, BufRead, BufReader, Write}; use std::path::{Path, PathBuf}; use uucore::display::Quotable; use uucore::encoding::{ @@ -16,7 +16,7 @@ use uucore::encoding::{ SupportsFastDecodeAndEncode, Z85Wrapper, for_base_common::{BASE32, BASE32HEX, BASE64URL, HEXUPPER_PERMISSIVE}, }; -use uucore::error::{FromIo, UResult, USimpleError, UUsageError}; +use uucore::error::{FromIo, UResult, USimpleError, UUsageError, strip_errno}; use uucore::format_usage; use uucore::translate; @@ -179,7 +179,7 @@ pub fn handle_input(input: &mut R, format: Format, config: Config) - let mut buffered = Vec::new(); input .read_to_end(&mut buffered) - .map_err(|err| USimpleError::new(1, format_read_error(err.kind())))?; + .map_err(|err| USimpleError::new(1, format_read_error(&err)))?; if config.decode { fast_decode::fast_decode_buffer( buffered, @@ -556,7 +556,7 @@ pub mod fast_encode { loop { let read_buffer = input .fill_buf() - .map_err(|err| USimpleError::new(1, super::format_read_error(err.kind())))?; + .map_err(|err| USimpleError::new(1, super::format_read_error(&err)))?; if read_buffer.is_empty() { break; } @@ -823,7 +823,7 @@ pub mod fast_decode { loop { let read_buffer = input .fill_buf() - .map_err(|err| USimpleError::new(1, super::format_read_error(err.kind())))?; + .map_err(|err| USimpleError::new(1, super::format_read_error(&err)))?; let read_len = read_buffer.len(); if read_len == 0 { break; @@ -919,23 +919,8 @@ pub mod fast_decode { } } -fn format_read_error(kind: ErrorKind) -> String { - let kind_string = kind.to_string(); - - // e.g. "is a directory" -> "Is a directory" - let mut kind_string_capitalized = String::with_capacity(kind_string.len()); - - for (index, ch) in kind_string.char_indices() { - if index == 0 { - for cha in ch.to_uppercase() { - kind_string_capitalized.push(cha); - } - } else { - kind_string_capitalized.push(ch); - } - } - - translate!("base-common-read-error", "error" => kind_string_capitalized) +fn format_read_error(error: &io::Error) -> String { + translate!("base-common-read-error", "error" => strip_errno(error)) } /// Determines if the input buffer contains any padding ('=') ignoring trailing whitespace. @@ -944,7 +929,7 @@ fn read_and_has_padding(input: &mut R) -> UResult<(bool, Vec break, + Ok(_) => unexpand_line(&mut buf, output, options, lastcol, tab_config)?, + Err(e) => return Err(e.map_err_context(|| file.maybe_quote().to_string())), + } + } + Ok(()) +} + fn unexpand(options: &Options) -> UResult<()> { let mut output = BufWriter::new(stdout()); let tab_config = &options.tab_config; - let mut buf = Vec::new(); let lastcol = if tab_config.tabstops.len() > 1 && tab_config.increment_size.is_none() && tab_config.extend_size.is_none() @@ -580,19 +598,9 @@ fn unexpand(options: &Options) -> UResult<()> { }; for file in &options.files { - let mut fh = match open(file) { - Ok(reader) => reader, - Err(err) => { - show!(err); - continue; - } - }; - - while match fh.read_until(b'\n', &mut buf) { - Ok(s) => s > 0, - Err(_) => !buf.is_empty(), - } { - unexpand_line(&mut buf, &mut output, options, lastcol, tab_config)?; + if let Err(e) = unexpand_file(file, &mut output, options, lastcol, tab_config) { + show!(e); + set_exit_code(1); } } output.flush()?; diff --git a/tests/by-util/test_unexpand.rs b/tests/by-util/test_unexpand.rs index fdba510c3..d29fecfd3 100644 --- a/tests/by-util/test_unexpand.rs +++ b/tests/by-util/test_unexpand.rs @@ -283,6 +283,15 @@ fn test_one_nonexisting_file() { .stderr_contains("asdf.txt: No such file or directory"); } +#[test] +#[cfg(all(target_os = "linux", not(target_env = "musl")))] +fn test_read_error() { + new_ucmd!() + .arg("/proc/self/mem") + .fails() + .stderr_contains("unexpand: /proc/self/mem: Input/output error"); +} + #[test] #[cfg(target_os = "linux")] fn test_non_utf8_filename() { From d1f127524d1df175b718f52e60009242a7013e69 Mon Sep 17 00:00:00 2001 From: Tuomas Tynkkynen Date: Tue, 27 Jan 2026 14:20:10 +0200 Subject: [PATCH 370/425] expand: Properly handle I/O errors when reading input --- src/uu/expand/src/expand.rs | 56 ++++++++++++++++++++---------------- tests/by-util/test_expand.rs | 9 ++++++ 2 files changed, 40 insertions(+), 25 deletions(-) diff --git a/src/uu/expand/src/expand.rs b/src/uu/expand/src/expand.rs index 690cbd0ee..5acd4fac2 100644 --- a/src/uu/expand/src/expand.rs +++ b/src/uu/expand/src/expand.rs @@ -15,9 +15,9 @@ use std::str::from_utf8; use thiserror::Error; use unicode_width::UnicodeWidthChar; use uucore::display::Quotable; -use uucore::error::{FromIo, UError, UResult, set_exit_code}; +use uucore::error::{FromIo, UError, UResult, USimpleError, set_exit_code}; use uucore::translate; -use uucore::{format_usage, show_error}; +use uucore::{format_usage, show}; pub mod options { pub static TABS: &str = "tabs"; @@ -296,6 +296,12 @@ fn open(path: &OsString) -> UResult>> { Ok(BufReader::new(Box::new(stdin()) as Box)) } else { let path_ref = Path::new(path); + if path_ref.is_dir() { + return Err(USimpleError::new( + 1, + translate!("expand-error-is-directory", "file" => path.maybe_quote()), + )); + } file_buf = File::open(path_ref).map_err_context(|| path.maybe_quote().to_string())?; Ok(BufReader::new(Box::new(file_buf) as Box)) } @@ -474,34 +480,34 @@ fn expand_line( Ok(()) } +fn expand_file( + file: &OsString, + output: &mut BufWriter, + options: &Options, +) -> UResult<()> { + let mut buf = Vec::new(); + let mut input = open(file)?; + let ts = options.tabstops.as_ref(); + loop { + match input.read_until(b'\n', &mut buf) { + Ok(0) => break, + Ok(_) => { + expand_line(&mut buf, output, ts, options) + .map_err_context(|| translate!("expand-error-failed-to-write-output"))?; + } + Err(e) => return Err(e.map_err_context(|| file.maybe_quote().to_string())), + } + } + Ok(()) +} + fn expand(options: &Options) -> UResult<()> { let mut output = BufWriter::new(stdout()); - let ts = options.tabstops.as_ref(); - let mut buf = Vec::new(); for file in &options.files { - if Path::new(file).is_dir() { - show_error!( - "{}", - translate!("expand-error-is-directory", "file" => file.maybe_quote()) - ); + if let Err(e) = expand_file(file, &mut output, options) { + show!(e); set_exit_code(1); - continue; - } - match open(file) { - Ok(mut fh) => { - while match fh.read_until(b'\n', &mut buf) { - Ok(s) => s > 0, - Err(_) => buf.is_empty(), - } { - expand_line(&mut buf, &mut output, ts, options) - .map_err_context(|| translate!("expand-error-failed-to-write-output"))?; - } - } - Err(e) => { - show_error!("{e}"); - set_exit_code(1); - } } } // Flush once at the end diff --git a/tests/by-util/test_expand.rs b/tests/by-util/test_expand.rs index 741aad366..78a0f6ae5 100644 --- a/tests/by-util/test_expand.rs +++ b/tests/by-util/test_expand.rs @@ -427,6 +427,15 @@ fn test_nonexisting_file() { .stdout_contains_line("// !note: file contains significant whitespace"); } +#[test] +#[cfg(all(target_os = "linux", not(target_env = "musl")))] +fn test_read_error() { + new_ucmd!() + .arg("/proc/self/mem") + .fails() + .stderr_contains("expand: /proc/self/mem: Input/output error"); +} + #[test] #[cfg(target_os = "linux")] fn test_expand_non_utf8_paths() { From 38e9dccb370845707286b40feb37961c1e824ae1 Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Tue, 27 Jan 2026 02:13:43 +0900 Subject: [PATCH 371/425] GNUmakefile: Complete TEST_PROGS --- GNUmakefile | 71 +---------------------------------------------------- 1 file changed, 1 insertion(+), 70 deletions(-) diff --git a/GNUmakefile b/GNUmakefile index cae34803b..af2fde7ac 100644 --- a/GNUmakefile +++ b/GNUmakefile @@ -124,78 +124,9 @@ ifneq ($(findstring stdbuf,$(UTILS)),) endif # Programs with usable tests -TEST_PROGS := \ - base32 \ - base64 \ - basename \ - cat \ - chcon \ - chgrp \ - chmod \ - chown \ - cksum \ - comm \ - cp \ - csplit \ - cut \ - date \ - dircolors \ - dirname \ - echo \ - env \ - expr \ - factor \ - false \ - fold \ - hashsum \ - head \ - install \ - link \ - ln \ - ls \ - mkdir \ - mktemp \ - mv \ - nl \ - numfmt \ - od \ - paste \ - pathchk \ - pinky \ - pr \ - printf \ - ptx \ - pwd \ - readlink \ - realpath \ - rm \ - rmdir \ - runcon \ - seq \ - sleep \ - sort \ - split \ - stat \ - stdbuf \ - sum \ - tac \ - tail \ - test \ - touch \ - tr \ - true \ - truncate \ - tsort \ - uname \ - unexpand \ - uniq \ - unlink \ - uudoc \ - wc \ - who TESTS := \ - $(sort $(filter $(UTILS),$(TEST_PROGS))) + $(sort $(filter $(UTILS),$(PROGS) $(UNIX_PROGS) $(SELINUX_PROGS))) TEST_NO_FAIL_FAST := TEST_SPEC_FEATURE := From 49047669d1796185c8d94b9a6dfeb121c0d04bfa Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Tue, 27 Jan 2026 05:26:15 +0900 Subject: [PATCH 372/425] CICD.yml: (partial) reproducible toolchain setup --- .github/workflows/CICD.yml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/CICD.yml b/.github/workflows/CICD.yml index e48d3c19e..844e86a77 100644 --- a/.github/workflows/CICD.yml +++ b/.github/workflows/CICD.yml @@ -58,15 +58,13 @@ jobs: - uses: actions/checkout@v6 with: persist-credentials: false - - uses: dtolnay/rust-toolchain@nightly - ## note: requires 'nightly' toolchain b/c `cargo-udeps` uses the `rustc` '-Z save-analysis' option - ## * ... ref: - uses: taiki-e/install-action@cargo-udeps - uses: Swatinem/rust-cache@v2 - name: Initialize workflow variables id: vars shell: bash run: | + echo "RUSTC_BOOTSTRAP=1" >> "${GITHUB_ENV}" # Use -Z ## VARs setup outputs() { step_id="${{ github.action }}"; for var in "$@" ; do echo steps.${step_id}.outputs.${var}="${!var}"; echo "${var}=${!var}" >> $GITHUB_OUTPUT; done; } # failure mode @@ -88,7 +86,7 @@ jobs: fault_type="${{ steps.vars.outputs.FAULT_TYPE }}" fault_prefix=$(echo "$fault_type" | tr '[:lower:]' '[:upper:]') # - cargo +nightly udeps ${{ steps.vars.outputs.CARGO_FEATURES_OPTION }} --all-targets &> udeps.log || cat udeps.log + cargo udeps ${{ steps.vars.outputs.CARGO_FEATURES_OPTION }} --all-targets &> udeps.log || cat udeps.log grep --ignore-case "all deps seem to have been used" udeps.log || { printf "%s\n" "::${fault_type} ::${fault_prefix}: \`cargo udeps\`: style violation (unused dependency found)" ; fault=true ; } if [ -n "${{ steps.vars.outputs.FAIL_ON_FAULT }}" ] && [ -n "$fault" ]; then exit 1 ; fi From 836e0cba72916c6349812eb193d4ab26cb6a605e Mon Sep 17 00:00:00 2001 From: mattsu <35655889+mattsu2020@users.noreply.github.com> Date: Wed, 28 Jan 2026 05:08:51 +0900 Subject: [PATCH 373/425] sort: gnu coreutils compatibility (sort.pl) (#9862) * fix: allow duplicate -o options for same output file in sort Modify sort command to permit multiple -o/--output flags specifying the same file, matching GNU sort behavior. Previously, any multiple -o flags would error, but now only differing output files are rejected. Added test case for duplicate outputs. * fix(sort): fix case-insensitive sorting to order punctuation after letters Changed ascii_case_insensitive_cmp to fold characters to uppercase instead of lowercase, ensuring that in ASCII case-insensitive sorting, letters are ordered before punctuation. Added tests to verify the correct behavior. --- src/uu/sort/src/sort.rs | 21 +++++++++++---------- tests/by-util/test_sort.rs | 28 ++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 10 deletions(-) diff --git a/src/uu/sort/src/sort.rs b/src/uu/sort/src/sort.rs index 01ddc63fb..ac21c395a 100644 --- a/src/uu/sort/src/sort.rs +++ b/src/uu/sort/src/sort.rs @@ -1857,11 +1857,12 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { uucore::clap_localization::handle_clap_result_with_exit_code(uu_app(), processed_args, 2)?; // Prevent -o/--output to be specified multiple times - if matches - .get_occurrences::(options::OUTPUT) - .is_some_and(|out| out.len() > 1) - { - return Err(SortError::MultipleOutputFiles.into()); + if let Some(mut outputs) = matches.get_many::(options::OUTPUT) { + if let Some(first) = outputs.next() { + if outputs.any(|out| out != first) { + return Err(SortError::MultipleOutputFiles.into()); + } + } } settings.debug = matches.get_flag(options::DEBUG); @@ -2627,17 +2628,17 @@ fn compare_by<'a>( } /// Compare two byte slices in ASCII case-insensitive order without allocating. -/// We lower each byte on the fly so that binary input (including `NUL`) stays +/// We upper each byte on the fly so that binary input (including `NUL`) stays /// untouched and we avoid locale-sensitive routines such as `strcasecmp`. fn ascii_case_insensitive_cmp(a: &[u8], b: &[u8]) -> Ordering { #[inline] - fn lower(byte: u8) -> u8 { - byte.to_ascii_lowercase() + fn fold(byte: u8) -> u8 { + byte.to_ascii_uppercase() } for (lhs, rhs) in a.iter().copied().zip(b.iter().copied()) { - let l = lower(lhs); - let r = lower(rhs); + let l = fold(lhs); + let r = fold(rhs); if l != r { return l.cmp(&r); } diff --git a/tests/by-util/test_sort.rs b/tests/by-util/test_sort.rs index f6842969c..bc2092b8d 100644 --- a/tests/by-util/test_sort.rs +++ b/tests/by-util/test_sort.rs @@ -208,6 +208,24 @@ fn test_version_sort_stable() { .stdout_is("0.1\n0.02\n0.2\n0.002\n0.3\n"); } +#[test] +fn test_ignore_case_orders_punctuation_after_letters() { + new_ucmd!() + .arg("-f") + .pipe_in("A\na\n_\n") + .succeeds() + .stdout_is("A\na\n_\n"); +} + +#[test] +fn test_ignore_case_unique_orders_punctuation_after_letters() { + new_ucmd!() + .arg("-fu") + .pipe_in("a\n_\n") + .succeeds() + .stdout_is("a\n_\n"); +} + #[test] fn test_human_numeric_whitespace() { test_helper( @@ -1458,6 +1476,16 @@ fn test_multiple_output_files() { .stderr_is("sort: multiple output files specified\n"); } +#[test] +// Test for GNU tests/sort/sort.pl "o3" +fn test_duplicate_output_files_allowed() { + new_ucmd!() + .args(&["-o", "foo", "-o", "foo"]) + .pipe_in("") + .succeeds() + .no_stderr(); +} + #[test] fn test_output_file_with_leading_dash() { let test_cases = [ From e3a2d341620db51cecc41f5a2bee668fb41b0f0e Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Wed, 28 Jan 2026 06:07:38 +0900 Subject: [PATCH 374/425] arch: Avoid >/dev/full panic (#10516) --- src/uu/arch/src/arch.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/uu/arch/src/arch.rs b/src/uu/arch/src/arch.rs index 7d1867763..a01b60874 100644 --- a/src/uu/arch/src/arch.rs +++ b/src/uu/arch/src/arch.rs @@ -3,9 +3,9 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -use platform_info::*; - use clap::Command; +use platform_info::*; +use std::io::{Write, stdout}; use uucore::error::{UResult, USimpleError}; use uucore::translate; @@ -16,7 +16,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { let uts = PlatformInfo::new().map_err(|_e| USimpleError::new(1, translate!("cannot-get-system")))?; - println!("{}", uts.machine().to_string_lossy().trim()); + writeln!(stdout(), "{}", uts.machine().to_string_lossy().trim())?; Ok(()) } From 40502229f5e8e9b7dd6eae803cd8500b768a963e Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Wed, 28 Jan 2026 06:08:59 +0900 Subject: [PATCH 375/425] uname: remove unwrap() for > /dev/full (#10517) --- src/uu/uname/src/uname.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/uu/uname/src/uname.rs b/src/uu/uname/src/uname.rs index 383d5c581..c35e9d51a 100644 --- a/src/uu/uname/src/uname.rs +++ b/src/uu/uname/src/uname.rs @@ -135,7 +135,8 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { os: matches.get_flag(options::OS), }; let output = UNameOutput::new(&options)?; - println_verbatim(output.display().as_os_str()).unwrap(); + println_verbatim(output.display().as_os_str()) + .map_err(|e| USimpleError::new(1, e.to_string()))?; Ok(()) } From 22cd20ae7fb31d7c6a34e742c0c122c922ce3aef Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 27 Jan 2026 21:09:24 +0000 Subject: [PATCH 376/425] chore(deps): update rust crate clap to v4.5.55 --- Cargo.lock | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f68cf0c18..ef717e281 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -77,7 +77,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -88,7 +88,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -337,18 +337,18 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.54" +version = "4.5.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394" +checksum = "3e34525d5bbbd55da2bb745d34b36121baac88d07619a9a09cfcf4a6c0832785" dependencies = [ "clap_builder", ] [[package]] name = "clap_builder" -version = "4.5.54" +version = "4.5.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00" +checksum = "59a20016a20a3da95bef50ec7238dbd09baeef4311dcdd38ec15aba69812fb61" dependencies = [ "anstream", "anstyle", @@ -1001,7 +1001,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -1713,7 +1713,7 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde_core", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -1996,7 +1996,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -2586,7 +2586,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -2896,7 +2896,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -4546,7 +4546,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] From 7443e292be4068dd8770c186b67d480b1b06e754 Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Wed, 28 Jan 2026 16:08:47 +0900 Subject: [PATCH 377/425] nice > /dev/full panics --- src/uu/nice/src/nice.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/uu/nice/src/nice.rs b/src/uu/nice/src/nice.rs index 78036649f..5c9192c96 100644 --- a/src/uu/nice/src/nice.rs +++ b/src/uu/nice/src/nice.rs @@ -8,7 +8,7 @@ use clap::{Arg, ArgAction, Command}; use libc::PRIO_PROCESS; use std::ffi::OsString; -use std::io::{Error, ErrorKind, Write}; +use std::io::{Error, ErrorKind, Write, stdout}; use std::num::IntErrorKind; use std::os::unix::process::CommandExt; use std::process; @@ -143,7 +143,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { } None => { if !matches.contains_id(options::COMMAND) { - println!("{niceness}"); + writeln!(stdout(), "{niceness}")?; return Ok(()); } 10_i32 From 4693a8f2482bf269e9ab51bd029879d6f59c409a Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Wed, 28 Jan 2026 16:22:15 +0900 Subject: [PATCH 378/425] logname > /dev/full panics (#10522) --- src/uu/logname/src/logname.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/uu/logname/src/logname.rs b/src/uu/logname/src/logname.rs index 3dd995495..6684bd9f4 100644 --- a/src/uu/logname/src/logname.rs +++ b/src/uu/logname/src/logname.rs @@ -7,6 +7,7 @@ use clap::Command; use std::ffi::CStr; +use std::io::{Write, stdout}; use uucore::translate; use uucore::{error::UResult, show_error}; @@ -26,7 +27,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { let _ = uucore::clap_localization::handle_clap_result(uu_app(), args)?; match get_userlogin() { - Some(userlogin) => println!("{userlogin}"), + Some(userlogin) => writeln!(stdout(), "{userlogin}")?, None => show_error!("{}", translate!("logname-error-no-login-name")), } From 9d0ac441562d246c39684b7cf748b788b0cf6753 Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Wed, 28 Jan 2026 16:22:31 +0900 Subject: [PATCH 379/425] gropus > /dev/full panics (#10523) --- src/uu/groups/src/groups.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/uu/groups/src/groups.rs b/src/uu/groups/src/groups.rs index 772e23cf5..d6ecc9ec4 100644 --- a/src/uu/groups/src/groups.rs +++ b/src/uu/groups/src/groups.rs @@ -5,6 +5,7 @@ // spell-checker:ignore (ToDO) passwd +use std::io::{Write, stdout}; use thiserror::Error; use uucore::{ display::Quotable, @@ -59,7 +60,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { return Err(GroupsError::GetGroupsFailed.into()); }; let groups: Vec = gids.iter().map(infallible_gid2grp).collect(); - println!("{}", groups.join(" ")); + writeln!(stdout(), "{}", groups.join(" "))?; return Ok(()); } @@ -67,7 +68,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { match Passwd::locate(user.as_str()) { Ok(p) => { let groups: Vec = p.belongs_to().iter().map(infallible_gid2grp).collect(); - println!("{user} : {}", groups.join(" ")); + writeln!(stdout(), "{user} : {}", groups.join(" "))?; } Err(_) => { // The `show!()` macro sets the global exit code for the program. From 53700fccdd3ab2e3f9f72148c8cca512a5478c67 Mon Sep 17 00:00:00 2001 From: Dorian Peron Date: Tue, 6 Jan 2026 20:28:05 +0100 Subject: [PATCH 380/425] checksum: Rework the OutputFormat decision --- src/uu/cksum/src/cksum.rs | 18 ++-- src/uu/hashsum/src/hashsum.rs | 21 +--- .../src/lib/features/checksum/compute.rs | 100 ++++++++++++------ src/uucore/src/lib/features/checksum/mod.rs | 2 + 4 files changed, 82 insertions(+), 59 deletions(-) diff --git a/src/uu/cksum/src/cksum.rs b/src/uu/cksum/src/cksum.rs index 7d3228407..2032d0662 100644 --- a/src/uu/cksum/src/cksum.rs +++ b/src/uu/cksum/src/cksum.rs @@ -9,7 +9,7 @@ use clap::builder::ValueParser; use clap::{Arg, ArgAction, Command}; use std::ffi::{OsStr, OsString}; use uucore::checksum::compute::{ - ChecksumComputeOptions, figure_out_output_format, perform_checksum_computation, + ChecksumComputeOptions, OutputFormat, perform_checksum_computation, }; use uucore::checksum::validate::{ ChecksumValidateOptions, ChecksumVerbose, perform_checksum_validation, @@ -208,18 +208,20 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { let (tag, binary) = handle_tag_text_binary_flags(std::env::args_os())?; + let output_format = OutputFormat::from_cksum( + algo_kind, + tag, + binary, + matches.get_flag(options::RAW), + matches.get_flag(options::BASE64), + ); + let algo = SizedAlgoKind::from_unsized(algo_kind, length)?; let line_ending = LineEnding::from_zero_flag(matches.get_flag(options::ZERO)); let opts = ChecksumComputeOptions { algo_kind: algo, - output_format: figure_out_output_format( - algo, - tag, - binary, - matches.get_flag(options::RAW), - matches.get_flag(options::BASE64), - ), + output_format, line_ending, }; diff --git a/src/uu/hashsum/src/hashsum.rs b/src/uu/hashsum/src/hashsum.rs index 1bad36355..6c401041a 100644 --- a/src/uu/hashsum/src/hashsum.rs +++ b/src/uu/hashsum/src/hashsum.rs @@ -13,7 +13,7 @@ use clap::builder::ValueParser; use clap::{Arg, ArgAction, ArgMatches, Command}; use uucore::checksum::compute::{ - ChecksumComputeOptions, figure_out_output_format, perform_checksum_computation, + ChecksumComputeOptions, OutputFormat, perform_checksum_computation, }; use uucore::checksum::validate::{ ChecksumValidateOptions, ChecksumVerbose, perform_checksum_validation, @@ -121,9 +121,6 @@ pub fn uumain(mut args: impl uucore::Args) -> UResult<()> { let args = iter::once(program.clone()).chain(args); - // Default binary in Windows, text mode otherwise - let binary_flag_default = cfg!(windows); - let (command, is_hashsum_bin) = uu_app(&binary_name); // FIXME: this should use try_get_matches_from() and crash!(), but at the moment that just @@ -148,13 +145,6 @@ pub fn uumain(mut args: impl uucore::Args) -> UResult<()> { (AlgoKind::from_bin_name(&binary_name)?, length) }; - let binary = if matches.get_flag("binary") { - true - } else if matches.get_flag("text") { - false - } else { - binary_flag_default - }; let check = matches.get_flag("check"); let check_flag = |flag| match (check, matches.get_flag(flag)) { @@ -204,16 +194,11 @@ pub fn uumain(mut args: impl uucore::Args) -> UResult<()> { let algo = SizedAlgoKind::from_unsized(algo_kind, length)?; let line_ending = LineEnding::from_zero_flag(matches.get_flag("zero")); + let output_format = OutputFormat::from_standalone(std::env::args_os())?; let opts = ChecksumComputeOptions { algo_kind: algo, - output_format: figure_out_output_format( - algo, - matches.get_flag(options::TAG), - binary, - /* raw */ false, - /* base64: */ false, - ), + output_format, line_ending, }; diff --git a/src/uucore/src/lib/features/checksum/compute.rs b/src/uucore/src/lib/features/checksum/compute.rs index c08765af4..c5b0cf6e4 100644 --- a/src/uucore/src/lib/features/checksum/compute.rs +++ b/src/uucore/src/lib/features/checksum/compute.rs @@ -5,12 +5,12 @@ // spell-checker:ignore bitlen -use std::ffi::OsStr; +use std::ffi::{OsStr, OsString}; use std::fs::File; use std::io::{self, BufReader, Read, Write}; use std::path::Path; -use crate::checksum::{ChecksumError, SizedAlgoKind, digest_reader, escape_filename}; +use crate::checksum::{AlgoKind, ChecksumError, SizedAlgoKind, digest_reader, escape_filename}; use crate::error::{FromIo, UResult, USimpleError}; use crate::line_ending::LineEnding; use crate::sum::DigestOutput; @@ -103,42 +103,76 @@ impl OutputFormat { fn is_raw(&self) -> bool { *self == Self::Raw } -} -/// Use already-processed arguments to decide the output format. -pub fn figure_out_output_format( - algo: SizedAlgoKind, - tag: bool, - binary: bool, - raw: bool, - base64: bool, -) -> OutputFormat { - // Raw output format takes precedence over anything else. - if raw { - return OutputFormat::Raw; - } + /// Find the correct output format for cksum. + pub fn from_cksum(algo: AlgoKind, tag: bool, binary: bool, raw: bool, base64: bool) -> Self { + // Raw output format takes precedence over anything else. + if raw { + return Self::Raw; + } - // Then, if the algo is legacy, takes precedence over the rest - if algo.is_legacy() { - return OutputFormat::Legacy; - } + // Then, if the algo is legacy, takes precedence over the rest + if algo.is_legacy() { + return Self::Legacy; + } - let digest_format = if base64 { - DigestFormat::Base64 - } else { - DigestFormat::Hexadecimal - }; - - // After that, decide between tagged and untagged output - if tag { - OutputFormat::Tagged(digest_format) - } else { - let reading_mode = if binary { - ReadingMode::Binary + let digest_format = if base64 { + DigestFormat::Base64 } else { - ReadingMode::Text + DigestFormat::Hexadecimal }; - OutputFormat::Untagged(digest_format, reading_mode) + + // After that, decide between tagged and untagged output + if tag { + Self::Tagged(digest_format) + } else { + let reading_mode = if binary { + ReadingMode::Binary + } else { + ReadingMode::Text + }; + Self::Untagged(digest_format, reading_mode) + } + } + + /// Find the correct output format for a standalone checksum util (b2sum, + /// md5sum, etc) + /// + /// Since standalone utils can't use the Raw or Legacy output format, it is + /// decided only using the --tag, --binary and --text arguments. + pub fn from_standalone(args: impl Iterator) -> UResult { + let mut text = true; + let mut tag = false; + + for arg in args { + if arg == "--" { + break; + } else if arg == "--tag" { + tag = true; + text = false; + } else if arg == "--binary" || arg == "-b" { + text = false; + } else if arg == "--text" || arg == "-t" { + // Finding a `--text` after `--tag` is an error. + if tag { + return Err(ChecksumError::TextAfterTag.into()); + } + text = true; + } + } + + if tag { + Ok(Self::Tagged(DigestFormat::Hexadecimal)) + } else { + Ok(Self::Untagged( + DigestFormat::Hexadecimal, + if text { + ReadingMode::Text + } else { + ReadingMode::Binary + }, + )) + } } } diff --git a/src/uucore/src/lib/features/checksum/mod.rs b/src/uucore/src/lib/features/checksum/mod.rs index 2f3d28b41..7ae4c775b 100644 --- a/src/uucore/src/lib/features/checksum/mod.rs +++ b/src/uucore/src/lib/features/checksum/mod.rs @@ -397,6 +397,8 @@ pub enum ChecksumError { BinaryTextConflict, #[error("--text mode is only supported with --untagged")] TextWithoutUntagged, + #[error("--tag does not support --text mode")] + TextAfterTag, #[error("--check is not supported with --algorithm={{bsd,sysv,crc,crc32b}}")] AlgorithmNotSupportedWithCheck, #[error("You cannot combine multiple hash algorithms!")] From 320c80b196d2516ed1ed67c3d66e5690ff2e2941 Mon Sep 17 00:00:00 2001 From: Dorian Peron Date: Tue, 6 Jan 2026 20:07:40 +0100 Subject: [PATCH 381/425] introduce checksum_common --- Cargo.lock | 11 ++ Cargo.toml | 1 + fuzz/Cargo.lock | 10 ++ src/uu/checksum_common/Cargo.toml | 35 ++++ src/uu/checksum_common/LICENSE | 1 + src/uu/checksum_common/locales/en-US.ftl | 19 ++ src/uu/checksum_common/locales/fr-FR.ftl | 19 ++ src/uu/checksum_common/src/cli.rs | 215 +++++++++++++++++++++++ src/uu/checksum_common/src/lib.rs | 207 ++++++++++++++++++++++ 9 files changed, 518 insertions(+) create mode 100644 src/uu/checksum_common/Cargo.toml create mode 120000 src/uu/checksum_common/LICENSE create mode 100644 src/uu/checksum_common/locales/en-US.ftl create mode 100644 src/uu/checksum_common/locales/fr-FR.ftl create mode 100644 src/uu/checksum_common/src/cli.rs create mode 100644 src/uu/checksum_common/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index ef717e281..e763db08a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3239,6 +3239,17 @@ dependencies = [ "uucore", ] +[[package]] +name = "uu_checksum_common" +version = "0.6.0" +dependencies = [ + "clap", + "codspeed-divan-compat", + "fluent", + "tempfile", + "uucore", +] + [[package]] name = "uu_chgrp" version = "0.6.0" diff --git a/Cargo.toml b/Cargo.toml index 398c60017..b30b0672c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -407,6 +407,7 @@ uucore = { version = "0.6.0", package = "uucore", path = "src/uucore" } uucore_procs = { version = "0.6.0", package = "uucore_procs", path = "src/uucore_procs" } uu_ls = { version = "0.6.0", path = "src/uu/ls" } uu_base32 = { version = "0.6.0", path = "src/uu/base32" } +uu_checksum_common = { version = "0.6.0", path = "src/uu/checksum_common" } uutests = { version = "0.6.0", package = "uutests", path = "tests/uutests" } [dependencies] diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index 4094c806b..62bd4b157 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -1692,12 +1692,22 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uu_checksum_common" +version = "0.6.0" +dependencies = [ + "clap", + "fluent", + "uucore", +] + [[package]] name = "uu_cksum" version = "0.6.0" dependencies = [ "clap", "fluent", + "uu_checksum_common", "uucore", ] diff --git a/src/uu/checksum_common/Cargo.toml b/src/uu/checksum_common/Cargo.toml new file mode 100644 index 000000000..079a46b46 --- /dev/null +++ b/src/uu/checksum_common/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "uu_checksum_common" +description = "Base for checksum utils" +version.workspace = true +authors.workspace = true +license.workspace = true +homepage.workspace = true +keywords.workspace = true +categories.workspace = true +edition.workspace = true +readme.workspace = true + +[lints] +workspace = true + +[lib] +path = "src/lib.rs" + +[dependencies] +clap = { workspace = true } +uucore = { workspace = true, features = [ + "checksum", + "encoding", + "sum", + "hardware", +] } +fluent = { workspace = true } + +[dev-dependencies] +divan = { workspace = true } +tempfile = { workspace = true } + +# [[bench]] +# name = "b2sum_bench" +# harness = false diff --git a/src/uu/checksum_common/LICENSE b/src/uu/checksum_common/LICENSE new file mode 120000 index 000000000..5853aaea5 --- /dev/null +++ b/src/uu/checksum_common/LICENSE @@ -0,0 +1 @@ +../../../LICENSE \ No newline at end of file diff --git a/src/uu/checksum_common/locales/en-US.ftl b/src/uu/checksum_common/locales/en-US.ftl new file mode 100644 index 000000000..0dddfb19c --- /dev/null +++ b/src/uu/checksum_common/locales/en-US.ftl @@ -0,0 +1,19 @@ +ck-common-after-help = With no FILE or when FILE is -, read standard input + +# checksum argument help messages +ck-common-help-algorithm = select the digest type to use. See DIGEST below +ck-common-help-untagged = create a reversed style checksum, without digest type +ck-common-help-tag-default = create a BSD style checksum (default) +ck-common-help-tag = create a BSD style checksum +ck-common-help-text = read in text mode (default) +ck-common-help-length = digest length in bits; must not exceed the max size and must be a multiple of 8 for blake2b; must be 224, 256, 384, or 512 for sha2 or sha3 +ck-common-help-check = read checksums from the FILEs and check them +ck-common-help-base64 = emit base64-encoded digests, not hexadecimal +ck-common-help-raw = emit a raw binary digest, not hexadecimal +ck-common-help-zero = end each output line with NUL, not newline, and disable file name escaping +ck-common-help-strict = exit non-zero for improperly formatted checksum lines +ck-common-help-warn = warn about improperly formatted checksum lines +ck-common-help-status = don't output anything, status code shows success +ck-common-help-quiet = don't print OK for each successfully verified file +ck-common-help-ignore-missing = don't fail or report status for missing files +ck-common-help-debug = print CPU hardware capability detection info used by cksum diff --git a/src/uu/checksum_common/locales/fr-FR.ftl b/src/uu/checksum_common/locales/fr-FR.ftl new file mode 100644 index 000000000..0b22519ca --- /dev/null +++ b/src/uu/checksum_common/locales/fr-FR.ftl @@ -0,0 +1,19 @@ +ck-common-after-help = Sans FICHIER ou quand FICHER est -, lit l'entrée standard + +# Messages d'aide d'arguments checksum +ck-common-help-algorithm = sélectionner le type de condensé à utiliser. Voir DIGEST ci-dessous +ck-common-help-untagged = créer une somme de contrôle de style inversé, sans type de condensé +ck-common-help-tag-default = créer une somme de contrôle de style BSD (par défaut) +ck-common-help-tag = créer une somme de contrôle de style BSD +ck-common-help-text = lire en mode texte (par défaut) +ck-common-help-length = longueur du condensé en bits ; ne doit pas dépasser le maximum pour l'algorithme blake2 et doit être un multiple de 8 +ck-common-help-raw = émettre un condensé binaire brut, pas hexadécimal +ck-common-help-strict = sortir avec un code non-zéro pour les lignes de somme de contrôle mal formatées +ck-common-help-check = lire les sommes de hachage des FICHIERs et les vérifier +ck-common-help-base64 = émettre un condensé base64, pas hexadécimal +ck-common-help-warn = avertir des lignes de somme de contrôle mal formatées +ck-common-help-status = ne rien afficher, le code de statut indique le succès +ck-common-help-quiet = ne pas afficher OK pour chaque fichier vérifié avec succès +ck-common-help-ignore-missing = ne pas échouer ou signaler le statut pour les fichiers manquants +ck-common-help-zero = terminer chaque ligne de sortie avec NUL, pas un saut de ligne, et désactiver l'échappement des noms de fichiers +ck-common-help-debug = afficher les informations de débogage sur la détection de la prise en charge matérielle du processeur diff --git a/src/uu/checksum_common/src/cli.rs b/src/uu/checksum_common/src/cli.rs new file mode 100644 index 000000000..a5e979e30 --- /dev/null +++ b/src/uu/checksum_common/src/cli.rs @@ -0,0 +1,215 @@ +// This file is part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +use clap::{Arg, ArgAction, Command}; +use uucore::{checksum::SUPPORTED_ALGORITHMS, translate}; + +/// List of all options that can be encountered in checksum utils +pub mod options { + // cksum-specific + pub const ALGORITHM: &str = "algorithm"; + pub const DEBUG: &str = "debug"; + + // positional arg + pub const FILE: &str = "file"; + + pub const UNTAGGED: &str = "untagged"; + pub const TAG: &str = "tag"; + pub const LENGTH: &str = "length"; + pub const RAW: &str = "raw"; + pub const BASE64: &str = "base64"; + pub const CHECK: &str = "check"; + pub const TEXT: &str = "text"; + pub const BINARY: &str = "binary"; + pub const ZERO: &str = "zero"; + + // check-specific + pub const STRICT: &str = "strict"; + pub const STATUS: &str = "status"; + pub const WARN: &str = "warn"; + pub const IGNORE_MISSING: &str = "ignore-missing"; + pub const QUIET: &str = "quiet"; +} + +/// `ChecksumCommand` is a convenience trait to more easily declare checksum +/// CLI interfaces with +pub trait ChecksumCommand { + fn with_algo(self) -> Self; + + fn with_length(self) -> Self; + + fn with_check_and_opts(self) -> Self; + + fn with_binary(self) -> Self; + + fn with_text(self, is_default: bool) -> Self; + + fn with_tag(self, is_default: bool) -> Self; + + fn with_untagged(self) -> Self; + + fn with_raw(self) -> Self; + + fn with_base64(self) -> Self; + + fn with_zero(self) -> Self; + + fn with_debug(self) -> Self; +} + +impl ChecksumCommand for Command { + fn with_algo(self) -> Self { + self.arg( + Arg::new(options::ALGORITHM) + .long(options::ALGORITHM) + .short('a') + .help(translate!("ck-common-help-algorithm")) + .value_name("ALGORITHM") + .value_parser(SUPPORTED_ALGORITHMS), + ) + } + + fn with_length(self) -> Self { + self.arg( + Arg::new(options::LENGTH) + .long(options::LENGTH) + .short('l') + .help(translate!("ck-common-help-length")) + .action(ArgAction::Set), + ) + } + + fn with_check_and_opts(self) -> Self { + self.arg( + Arg::new(options::CHECK) + .short('c') + .long(options::CHECK) + .help(translate!("ck-common-help-check")) + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(options::WARN) + .short('w') + .long("warn") + .help(translate!("ck-common-help-warn")) + .action(ArgAction::SetTrue) + .overrides_with_all([options::STATUS, options::QUIET]), + ) + .arg( + Arg::new(options::STATUS) + .long("status") + .help(translate!("ck-common-help-status")) + .action(ArgAction::SetTrue) + .overrides_with_all([options::WARN, options::QUIET]), + ) + .arg( + Arg::new(options::QUIET) + .long(options::QUIET) + .help(translate!("ck-common-help-quiet")) + .action(ArgAction::SetTrue) + .overrides_with_all([options::STATUS, options::WARN]), + ) + .arg( + Arg::new(options::IGNORE_MISSING) + .long(options::IGNORE_MISSING) + .help(translate!("ck-common-help-ignore-missing")) + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(options::STRICT) + .long(options::STRICT) + .help(translate!("ck-common-help-strict")) + .action(ArgAction::SetTrue), + ) + } + + fn with_binary(self) -> Self { + self.arg( + Arg::new(options::BINARY) + .long(options::BINARY) + .short('b') + .hide(true) + .action(ArgAction::SetTrue), + ) + } + + fn with_text(self, is_default: bool) -> Self { + let mut arg = Arg::new(options::TEXT) + .long(options::TEXT) + .short('t') + .action(ArgAction::SetTrue); + + arg = if is_default { + arg.help(translate!("ck-common-help-text")) + } else { + arg.hide(true) + }; + + self.arg(arg) + } + + fn with_tag(self, default: bool) -> Self { + let mut arg = Arg::new(options::TAG) + .long(options::TAG) + .action(ArgAction::SetTrue); + + arg = if default { + arg.help(translate!("ck-common-help-tag-default")) + } else { + arg.help(translate!("ck-common-help-tag")) + }; + + self.arg(arg) + } + + fn with_untagged(self) -> Self { + self.arg( + Arg::new(options::UNTAGGED) + .long(options::UNTAGGED) + .help(translate!("ck-common-help-untagged")) + .action(ArgAction::SetTrue), + ) + } + + fn with_raw(self) -> Self { + self.arg( + Arg::new(options::RAW) + .long(options::RAW) + .help(translate!("ck-common-help-raw")) + .action(ArgAction::SetTrue), + ) + } + + fn with_base64(self) -> Self { + self.arg( + Arg::new(options::BASE64) + .long(options::BASE64) + .help(translate!("ck-common-help-base64")) + .action(ArgAction::SetTrue) + // Even though this could easily just override an earlier '--raw', + // GNU cksum does not permit these flags to be combined: + .conflicts_with(options::RAW), + ) + } + + fn with_zero(self) -> Self { + self.arg( + Arg::new(options::ZERO) + .long(options::ZERO) + .short('z') + .help(translate!("ck-common-help-zero")) + .action(ArgAction::SetTrue), + ) + } + + fn with_debug(self) -> Self { + self.arg( + Arg::new(options::DEBUG) + .long(options::DEBUG) + .help(translate!("ck-common-help-debug")) + .action(ArgAction::SetTrue), + ) + } +} diff --git a/src/uu/checksum_common/src/lib.rs b/src/uu/checksum_common/src/lib.rs new file mode 100644 index 000000000..1d5a27265 --- /dev/null +++ b/src/uu/checksum_common/src/lib.rs @@ -0,0 +1,207 @@ +// This file is part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +// spell-checker:ignore (ToDO) algo + +use std::ffi::OsString; + +use clap::builder::ValueParser; +use clap::{Arg, ArgAction, ArgMatches, Command, ValueHint}; + +use uucore::checksum::compute::{ + ChecksumComputeOptions, OutputFormat, perform_checksum_computation, +}; +use uucore::checksum::validate::{self, ChecksumValidateOptions, ChecksumVerbose}; +use uucore::checksum::{AlgoKind, ChecksumError, SizedAlgoKind}; +use uucore::error::UResult; +use uucore::line_ending::LineEnding; +use uucore::{crate_version, format_usage, localized_help_template, util_name}; + +mod cli; +pub use cli::ChecksumCommand; +pub use cli::options; + +/// Expands to generate the right `uumain` and `uu_app` functions +/// for standalone checksum binaries. +/// +/// Example: +/// ``` +/// use uu_checksum_common::declare_standalone; +/// use uucore::checksum::AlgoKind; +/// +/// declare_standalone!("sha512sum", AlgoKind::Sha512); +/// ``` +#[macro_export] +macro_rules! declare_standalone { + ($bin:literal, $kind:expr) => { + #[::uucore::main] + pub fn uumain(args: impl ::uucore::Args) -> ::uucore::error::UResult<()> { + ::uu_checksum_common::standalone_main($kind, uu_app(), args) + } + + #[inline] + pub fn uu_app() -> ::clap::Command { + ::uu_checksum_common::standalone_checksum_app( + ::uucore::translate!(concat!($bin, "-about")), + ::uucore::translate!(concat!($bin, "-usage")), + ) + } + }; +} + +/// Entrypoint for standalone checksums accepting the `--length` argument +/// +/// Note: Ideally, we wouldn't require a `cmd` to be passed to the function, +/// but for localization purposes, the standalone binaries must declare their +/// command (with about and usage) themselves, otherwise calling --help from +/// the multicall binary results in an unformatted output. +pub fn standalone_with_length_main( + algo: AlgoKind, + cmd: Command, + args: impl uucore::Args, + validate_len: fn(&str) -> UResult>, +) -> UResult<()> { + let matches = uucore::clap_localization::handle_clap_result(cmd, args)?; + let algo = Some(algo); + + let length = matches + .get_one::(options::LENGTH) + .map(String::as_str) + .map(validate_len) + .transpose()? + .flatten(); + + let format = OutputFormat::from_standalone(std::env::args_os()); + + checksum_main(algo, length, matches, format?) +} + +/// Entrypoint for standalone checksums *NOT* accepting the `--length` argument +pub fn standalone_main(algo: AlgoKind, cmd: Command, args: impl uucore::Args) -> UResult<()> { + let matches = uucore::clap_localization::handle_clap_result(cmd, args)?; + let algo = Some(algo); + + let format = OutputFormat::from_standalone(std::env::args_os()); + + checksum_main(algo, None, matches, format?) +} + +/// Base command processing for all the checksum executables. +pub fn default_checksum_app(about: String, usage: String) -> Command { + Command::new(util_name()) + .version(crate_version!()) + .help_template(localized_help_template(util_name())) + .about(about) + .override_usage(format_usage(&usage)) + .infer_long_args(true) + .args_override_self(true) + .arg( + Arg::new(options::FILE) + .hide(true) + .action(ArgAction::Append) + .value_parser(ValueParser::os_string()) + .default_value("-") + .hide_default_value(true) + .value_hint(ValueHint::FilePath), + ) +} + +/// Command processing for standalone checksums accepting the `--length` +/// argument +pub fn standalone_checksum_app_with_length(about: String, usage: String) -> Command { + default_checksum_app(about, usage) + .with_binary() + .with_check_and_opts() + .with_length() + .with_tag(false) + .with_text(true) + .with_zero() +} + +/// Command processing for standalone checksums *NOT* accepting the `--length` +/// argument +pub fn standalone_checksum_app(about: String, usage: String) -> Command { + default_checksum_app(about, usage) + .with_binary() + .with_check_and_opts() + .with_tag(false) + .with_text(true) + .with_zero() +} + +/// This is the common entrypoint to all checksum utils. Performs some +/// validation on arguments and proceeds in computing or checking mode. +pub fn checksum_main( + algo: Option, + length: Option, + matches: ArgMatches, + output_format: OutputFormat, +) -> UResult<()> { + let check = matches.get_flag("check"); + + let check_flag = |flag| match (check, matches.get_flag(flag)) { + (_, false) => Ok(false), + (true, true) => Ok(true), + (false, true) => Err(ChecksumError::CheckOnlyFlag(flag.into())), + }; + + // Each of the following flags are only expected in --check mode. + // If we encounter them otherwise, end with an error. + let ignore_missing = check_flag("ignore-missing")?; + let warn = check_flag("warn")?; + let quiet = check_flag("quiet")?; + let strict = check_flag("strict")?; + let status = check_flag("status")?; + + // clap provides the default value -. So we unwrap() safety. + let files = matches + .get_many::(options::FILE) + .unwrap() + .map(|s| s.as_os_str()); + + if check { + // cksum does not support '--check'ing legacy algorithms + if algo.is_some_and(AlgoKind::is_legacy) { + return Err(ChecksumError::AlgorithmNotSupportedWithCheck.into()); + } + + let text_flag = matches.get_flag(options::TEXT); + let binary_flag = matches.get_flag(options::BINARY); + let tag = matches.get_flag(options::TAG); + + if tag || binary_flag || text_flag { + return Err(ChecksumError::BinaryTextConflict.into()); + } + + // Execute the checksum validation based on the presence of files or the use of stdin + + let verbose = ChecksumVerbose::new(status, quiet, warn); + let opts = ChecksumValidateOptions { + ignore_missing, + strict, + verbose, + }; + + return validate::perform_checksum_validation(files, algo, length, opts); + } + + // Not --check + + // Set the default algorithm to CRC when not '--check'ing. + let algo_kind = algo.unwrap_or(AlgoKind::Crc); + + let algo = SizedAlgoKind::from_unsized(algo_kind, length)?; + let line_ending = LineEnding::from_zero_flag(matches.get_flag(options::ZERO)); + + let opts = ChecksumComputeOptions { + algo_kind: algo, + output_format, + line_ending, + }; + + perform_checksum_computation(opts, files)?; + + Ok(()) +} From 52ef36d7adf7ae1a855a43316be6f70a13278580 Mon Sep 17 00:00:00 2001 From: Dorian Peron Date: Tue, 6 Jan 2026 20:55:58 +0100 Subject: [PATCH 382/425] cksum: transition to checksum_common --- Cargo.lock | 1 + src/uu/cksum/Cargo.toml | 1 + src/uu/cksum/locales/en-US.ftl | 16 -- src/uu/cksum/locales/fr-FR.ftl | 16 -- src/uu/cksum/src/cksum.rs | 265 ++++-------------------------- src/uucore/src/lib/mods/locale.rs | 5 + 6 files changed, 36 insertions(+), 268 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e763db08a..15df13ee7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3295,6 +3295,7 @@ dependencies = [ "clap", "codspeed-divan-compat", "fluent", + "uu_checksum_common", "uucore", ] diff --git a/src/uu/cksum/Cargo.toml b/src/uu/cksum/Cargo.toml index 1c343181c..5f509e313 100644 --- a/src/uu/cksum/Cargo.toml +++ b/src/uu/cksum/Cargo.toml @@ -25,6 +25,7 @@ uucore = { workspace = true, features = [ "sum", "hardware", ] } +uu_checksum_common = { workspace = true } fluent = { workspace = true } [dev-dependencies] diff --git a/src/uu/cksum/locales/en-US.ftl b/src/uu/cksum/locales/en-US.ftl index 834cd77b0..aece6fc5b 100644 --- a/src/uu/cksum/locales/en-US.ftl +++ b/src/uu/cksum/locales/en-US.ftl @@ -12,19 +12,3 @@ cksum-after-help = DIGEST determines the digest algorithm and default output for - sha3: (only available through cksum) - blake2b: (equivalent to b2sum) - sm3: (only available through cksum) - -# Help messages -cksum-help-algorithm = select the digest type to use. See DIGEST below -cksum-help-untagged = create a reversed style checksum, without digest type -cksum-help-tag = create a BSD style checksum, undo --untagged (default) -cksum-help-length = digest length in bits; must not exceed the max for the blake2 algorithm and must be a multiple of 8 -cksum-help-raw = emit a raw binary digest, not hexadecimal -cksum-help-strict = exit non-zero for improperly formatted checksum lines -cksum-help-check = read hashsums from the FILEs and check them -cksum-help-base64 = emit a base64 digest, not hexadecimal -cksum-help-warn = warn about improperly formatted checksum lines -cksum-help-status = don't output anything, status code shows success -cksum-help-quiet = don't print OK for each successfully verified file -cksum-help-ignore-missing = don't fail or report status for missing files -cksum-help-zero = end each output line with NUL, not newline, and disable file name escaping -cksum-help-debug = print CPU hardware capability detection info used by cksum diff --git a/src/uu/cksum/locales/fr-FR.ftl b/src/uu/cksum/locales/fr-FR.ftl index 01136f606..bbc12e59c 100644 --- a/src/uu/cksum/locales/fr-FR.ftl +++ b/src/uu/cksum/locales/fr-FR.ftl @@ -12,19 +12,3 @@ cksum-after-help = DIGEST détermine l'algorithme de condensé et le format de s - sha3 : (disponible uniquement via cksum) - blake2b : (équivalent à b2sum) - sm3 : (disponible uniquement via cksum) - -# Messages d'aide -cksum-help-algorithm = sélectionner le type de condensé à utiliser. Voir DIGEST ci-dessous -cksum-help-untagged = créer une somme de contrôle de style inversé, sans type de condensé -cksum-help-tag = créer une somme de contrôle de style BSD, annuler --untagged (par défaut) -cksum-help-length = longueur du condensé en bits ; ne doit pas dépasser le maximum pour l'algorithme blake2 et doit être un multiple de 8 -cksum-help-raw = émettre un condensé binaire brut, pas hexadécimal -cksum-help-strict = sortir avec un code non-zéro pour les lignes de somme de contrôle mal formatées -cksum-help-check = lire les sommes de hachage des FICHIERs et les vérifier -cksum-help-base64 = émettre un condensé base64, pas hexadécimal -cksum-help-warn = avertir des lignes de somme de contrôle mal formatées -cksum-help-status = ne rien afficher, le code de statut indique le succès -cksum-help-quiet = ne pas afficher OK pour chaque fichier vérifié avec succès -cksum-help-ignore-missing = ne pas échouer ou signaler le statut pour les fichiers manquants -cksum-help-zero = terminer chaque ligne de sortie avec NUL, pas un saut de ligne, et désactiver l'échappement des noms de fichiers -cksum-help-debug = afficher les informations de débogage sur la détection de la prise en charge matérielle du processeur diff --git a/src/uu/cksum/src/cksum.rs b/src/uu/cksum/src/cksum.rs index 2032d0662..0f9fdee5f 100644 --- a/src/uu/cksum/src/cksum.rs +++ b/src/uu/cksum/src/cksum.rs @@ -5,23 +5,18 @@ // spell-checker:ignore (ToDO) fname, algo, bitlen -use clap::builder::ValueParser; -use clap::{Arg, ArgAction, Command}; -use std::ffi::{OsStr, OsString}; -use uucore::checksum::compute::{ - ChecksumComputeOptions, OutputFormat, perform_checksum_computation, -}; -use uucore::checksum::validate::{ - ChecksumValidateOptions, ChecksumVerbose, perform_checksum_validation, -}; +use std::ffi::OsStr; + +use clap::Command; +use uu_checksum_common::{ChecksumCommand, checksum_main, default_checksum_app, options}; + +use uucore::checksum::compute::OutputFormat; use uucore::checksum::{ - AlgoKind, ChecksumError, SUPPORTED_ALGORITHMS, SizedAlgoKind, calculate_blake2b_length_str, - sanitize_sha2_sha3_length_str, + AlgoKind, ChecksumError, calculate_blake2b_length_str, sanitize_sha2_sha3_length_str, }; use uucore::error::UResult; use uucore::hardware::{HasHardwareFeatures as _, SimdPolicy}; -use uucore::line_ending::LineEnding; -use uucore::{format_usage, show_error, translate}; +use uucore::{show_error, translate}; /// Print CPU hardware capability detection information to stderr /// This matches GNU cksum's --debug behavior @@ -47,26 +42,6 @@ fn print_cpu_debug_info() { } } -mod options { - pub const ALGORITHM: &str = "algorithm"; - pub const FILE: &str = "file"; - pub const UNTAGGED: &str = "untagged"; - pub const TAG: &str = "tag"; - pub const LENGTH: &str = "length"; - pub const RAW: &str = "raw"; - pub const BASE64: &str = "base64"; - pub const CHECK: &str = "check"; - pub const STRICT: &str = "strict"; - pub const TEXT: &str = "text"; - pub const BINARY: &str = "binary"; - pub const STATUS: &str = "status"; - pub const WARN: &str = "warn"; - pub const IGNORE_MISSING: &str = "ignore-missing"; - pub const QUIET: &str = "quiet"; - pub const ZERO: &str = "zero"; - pub const DEBUG: &str = "debug"; -} - /// cksum has a bunch of legacy behavior. We handle this in this function to /// make sure they are self contained and "easier" to understand. /// @@ -137,22 +112,6 @@ fn maybe_sanitize_length( pub fn uumain(args: impl uucore::Args) -> UResult<()> { let matches = uucore::clap_localization::handle_clap_result(uu_app(), args)?; - let check = matches.get_flag(options::CHECK); - - let check_flag = |flag| match (check, matches.get_flag(flag)) { - (_, false) => Ok(false), - (true, true) => Ok(true), - (false, true) => Err(ChecksumError::CheckOnlyFlag(flag.into())), - }; - - // Each of the following flags are only expected in --check mode. - // If we encounter them otherwise, end with an error. - let ignore_missing = check_flag(options::IGNORE_MISSING)?; - let warn = check_flag(options::WARN)?; - let quiet = check_flag(options::QUIET)?; - let strict = check_flag(options::STRICT)?; - let status = check_flag(options::STATUS)?; - let algo_cli = matches .get_one::(options::ALGORITHM) .map(AlgoKind::from_cksum) @@ -164,202 +123,36 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { let length = maybe_sanitize_length(algo_cli, input_length)?; - // clap provides the default value -. So we unwrap() safety. - let files = matches - .get_many::(options::FILE) - .unwrap() - .map(|s| s.as_os_str()); + let (tag, binary) = handle_tag_text_binary_flags(std::env::args_os())?; - if check { - // cksum does not support '--check'ing legacy algorithms - if algo_cli.is_some_and(AlgoKind::is_legacy) { - return Err(ChecksumError::AlgorithmNotSupportedWithCheck.into()); - } - - let text_flag = matches.get_flag(options::TEXT); - let binary_flag = matches.get_flag(options::BINARY); - let tag = matches.get_flag(options::TAG); - - if tag || binary_flag || text_flag { - return Err(ChecksumError::BinaryTextConflict.into()); - } - - // Execute the checksum validation based on the presence of files or the use of stdin - - let verbose = ChecksumVerbose::new(status, quiet, warn); - let opts = ChecksumValidateOptions { - ignore_missing, - strict, - verbose, - }; - - return perform_checksum_validation(files, algo_cli, length, opts); - } - - // Not --check + let output_format = OutputFormat::from_cksum( + algo_cli.unwrap_or(AlgoKind::Crc), + tag, + binary, + /* raw */ matches.get_flag(options::RAW), + /* base64 */ matches.get_flag(options::BASE64), + ); // Print hardware debug info if requested if matches.get_flag(options::DEBUG) { print_cpu_debug_info(); } - // Set the default algorithm to CRC when not '--check'ing. - let algo_kind = algo_cli.unwrap_or(AlgoKind::Crc); - - let (tag, binary) = handle_tag_text_binary_flags(std::env::args_os())?; - - let output_format = OutputFormat::from_cksum( - algo_kind, - tag, - binary, - matches.get_flag(options::RAW), - matches.get_flag(options::BASE64), - ); - - let algo = SizedAlgoKind::from_unsized(algo_kind, length)?; - let line_ending = LineEnding::from_zero_flag(matches.get_flag(options::ZERO)); - - let opts = ChecksumComputeOptions { - algo_kind: algo, - output_format, - line_ending, - }; - - perform_checksum_computation(opts, files)?; - - Ok(()) + checksum_main(algo_cli, length, matches, output_format) } pub fn uu_app() -> Command { - Command::new(uucore::util_name()) - .version(uucore::crate_version!()) - .help_template(uucore::localized_help_template(uucore::util_name())) - .about(translate!("cksum-about")) - .override_usage(format_usage(&translate!("cksum-usage"))) - .infer_long_args(true) - .args_override_self(true) - .arg( - Arg::new(options::FILE) - .hide(true) - .action(ArgAction::Append) - .value_parser(ValueParser::os_string()) - .default_value("-") - .hide_default_value(true) - .value_hint(clap::ValueHint::FilePath), - ) - .arg( - Arg::new(options::ALGORITHM) - .long(options::ALGORITHM) - .short('a') - .help(translate!("cksum-help-algorithm")) - .value_name("ALGORITHM") - .value_parser(SUPPORTED_ALGORITHMS), - ) - .arg( - Arg::new(options::UNTAGGED) - .long(options::UNTAGGED) - .help(translate!("cksum-help-untagged")) - .action(ArgAction::SetTrue) - .overrides_with(options::TAG), - ) - .arg( - Arg::new(options::TAG) - .long(options::TAG) - .help(translate!("cksum-help-tag")) - .action(ArgAction::SetTrue) - .overrides_with(options::UNTAGGED), - ) - .arg( - Arg::new(options::LENGTH) - .long(options::LENGTH) - .short('l') - .help(translate!("cksum-help-length")) - .action(ArgAction::Set), - ) - .arg( - Arg::new(options::RAW) - .long(options::RAW) - .help(translate!("cksum-help-raw")) - .action(ArgAction::SetTrue), - ) - .arg( - Arg::new(options::STRICT) - .long(options::STRICT) - .help(translate!("cksum-help-strict")) - .action(ArgAction::SetTrue), - ) - .arg( - Arg::new(options::CHECK) - .short('c') - .long(options::CHECK) - .help(translate!("cksum-help-check")) - .action(ArgAction::SetTrue), - ) - .arg( - Arg::new(options::BASE64) - .long(options::BASE64) - .help(translate!("cksum-help-base64")) - .action(ArgAction::SetTrue) - // Even though this could easily just override an earlier '--raw', - // GNU cksum does not permit these flags to be combined: - .conflicts_with(options::RAW), - ) - .arg( - Arg::new(options::TEXT) - .long(options::TEXT) - .short('t') - .hide(true) - .overrides_with(options::BINARY) - .action(ArgAction::SetTrue), - ) - .arg( - Arg::new(options::BINARY) - .long(options::BINARY) - .short('b') - .hide(true) - .overrides_with(options::TEXT) - .action(ArgAction::SetTrue), - ) - .arg( - Arg::new(options::WARN) - .short('w') - .long("warn") - .help(translate!("cksum-help-warn")) - .action(ArgAction::SetTrue) - .overrides_with_all([options::STATUS, options::QUIET]), - ) - .arg( - Arg::new(options::STATUS) - .long("status") - .help(translate!("cksum-help-status")) - .action(ArgAction::SetTrue) - .overrides_with_all([options::WARN, options::QUIET]), - ) - .arg( - Arg::new(options::QUIET) - .long(options::QUIET) - .help(translate!("cksum-help-quiet")) - .action(ArgAction::SetTrue) - .overrides_with_all([options::WARN, options::STATUS]), - ) - .arg( - Arg::new(options::IGNORE_MISSING) - .long(options::IGNORE_MISSING) - .help(translate!("cksum-help-ignore-missing")) - .action(ArgAction::SetTrue), - ) - .arg( - Arg::new(options::ZERO) - .long(options::ZERO) - .short('z') - .help(translate!("cksum-help-zero")) - .action(ArgAction::SetTrue), - ) - .arg( - Arg::new(options::DEBUG) - .long(options::DEBUG) - .help(translate!("cksum-help-debug")) - .action(ArgAction::SetTrue), - ) + default_checksum_app(translate!("cksum-about"), translate!("cksum-usage")) + .with_algo() + .with_untagged() + .with_tag(true) + .with_length() + .with_raw() + .with_check_and_opts() + .with_base64() + .with_text(false) + .with_binary() + .with_zero() + .with_debug() .after_help(translate!("cksum-after-help")) } diff --git a/src/uucore/src/lib/mods/locale.rs b/src/uucore/src/lib/mods/locale.rs index ec9a78b43..8425d1e1b 100644 --- a/src/uucore/src/lib/mods/locale.rs +++ b/src/uucore/src/lib/mods/locale.rs @@ -156,6 +156,11 @@ fn create_bundle( // Then, try to load utility-specific strings from the utility's locale directory try_add_resource_from(get_locales_dir(util_name).ok()); + // checksum binaries also require fluent files from the checksum_common crate + if ["cksum"].contains(&util_name) { + try_add_resource_from(get_locales_dir("checksum_common").ok()); + } + // If we have at least one resource, return the bundle if bundle.has_message("common-error") || bundle.has_message(&format!("{util_name}-about")) { Ok(bundle) From a968342d426383b304d7887251648648d1647134 Mon Sep 17 00:00:00 2001 From: Dorian Peron Date: Tue, 6 Jan 2026 23:39:27 +0100 Subject: [PATCH 383/425] md5sum: introduce standalone binary --- Cargo.lock | 13 + Cargo.toml | 2 + GNUmakefile | 1 - build.rs | 1 - src/common/validation.rs | 5 +- src/uu/md5sum/Cargo.toml | 42 + src/uu/md5sum/LICENSE | 1 + src/uu/md5sum/locales/en-US.ftl | 2 + src/uu/md5sum/locales/fr-FR.ftl | 2 + src/uu/md5sum/src/main.rs | 1 + src/uu/md5sum/src/md5sum.rs | 1 + src/uucore/src/lib/lib.rs | 4 +- src/uucore/src/lib/mods/locale.rs | 2 +- tests/by-util/test_hashsum.rs | 52 +- tests/by-util/test_md5sum.rs | 812 ++++++++++++++++++ tests/fixtures/md5sum/input.txt | 1 + .../{hashsum => md5sum}/md5.checkfile | 0 .../fixtures/{hashsum => md5sum}/md5.expected | 0 tests/tests.rs | 4 + 19 files changed, 923 insertions(+), 23 deletions(-) create mode 100644 src/uu/md5sum/Cargo.toml create mode 120000 src/uu/md5sum/LICENSE create mode 100644 src/uu/md5sum/locales/en-US.ftl create mode 100644 src/uu/md5sum/locales/fr-FR.ftl create mode 100644 src/uu/md5sum/src/main.rs create mode 100644 src/uu/md5sum/src/md5sum.rs create mode 100644 tests/by-util/test_md5sum.rs create mode 100644 tests/fixtures/md5sum/input.txt rename tests/fixtures/{hashsum => md5sum}/md5.checkfile (100%) rename tests/fixtures/{hashsum => md5sum}/md5.expected (100%) diff --git a/Cargo.lock b/Cargo.lock index 15df13ee7..49d209ab0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -599,6 +599,7 @@ dependencies = [ "uu_ln", "uu_logname", "uu_ls", + "uu_md5sum", "uu_mkdir", "uu_mkfifo", "uu_mknod", @@ -3675,6 +3676,18 @@ dependencies = [ "uutils_term_grid", ] +[[package]] +name = "uu_md5sum" +version = "0.6.0" +dependencies = [ + "clap", + "codspeed-divan-compat", + "fluent", + "tempfile", + "uu_checksum_common", + "uucore", +] + [[package]] name = "uu_mkdir" version = "0.6.0" diff --git a/Cargo.toml b/Cargo.toml index b30b0672c..f6354a644 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -86,6 +86,7 @@ feat_common_core = [ "basenc", "cat", "cksum", + "md5sum", "comm", "cp", "csplit", @@ -437,6 +438,7 @@ chmod = { optional = true, version = "0.6.0", package = "uu_chmod", path = "src/ chown = { optional = true, version = "0.6.0", package = "uu_chown", path = "src/uu/chown" } chroot = { optional = true, version = "0.6.0", package = "uu_chroot", path = "src/uu/chroot" } cksum = { optional = true, version = "0.6.0", package = "uu_cksum", path = "src/uu/cksum" } +md5sum = { optional = true, version = "0.6.0", package = "uu_md5sum", path = "src/uu/md5sum" } comm = { optional = true, version = "0.6.0", package = "uu_comm", path = "src/uu/comm" } cp = { optional = true, version = "0.6.0", package = "uu_cp", path = "src/uu/cp" } csplit = { optional = true, version = "0.6.0", package = "uu_csplit", path = "src/uu/csplit" } diff --git a/GNUmakefile b/GNUmakefile index af2fde7ac..243e038c4 100644 --- a/GNUmakefile +++ b/GNUmakefile @@ -97,7 +97,6 @@ SELINUX_PROGS := \ HASHSUM_PROGS := \ b2sum \ - md5sum \ sha1sum \ sha224sum \ sha256sum \ diff --git a/build.rs b/build.rs index aabd96832..989f7630d 100644 --- a/build.rs +++ b/build.rs @@ -91,7 +91,6 @@ pub fn main() { phf_map.entry(krate, format!("({krate}::uumain, {krate}::uu_app_custom)")); let map_value = format!("({krate}::uumain, {krate}::uu_app_common)"); - phf_map.entry("md5sum", map_value.clone()); phf_map.entry("sha1sum", map_value.clone()); phf_map.entry("sha224sum", map_value.clone()); phf_map.entry("sha256sum", map_value.clone()); diff --git a/src/common/validation.rs b/src/common/validation.rs index f3923adb8..d332d304f 100644 --- a/src/common/validation.rs +++ b/src/common/validation.rs @@ -51,9 +51,7 @@ fn get_canonical_util_name(util_name: &str) -> &str { "[" => "test", // hashsum aliases - all these hash commands are aliases for hashsum - "md5sum" | "sha1sum" | "sha224sum" | "sha256sum" | "sha384sum" | "sha512sum" | "b2sum" => { - "hashsum" - } + "sha1sum" | "sha224sum" | "sha256sum" | "sha384sum" | "sha512sum" | "b2sum" => "hashsum", "dir" => "ls", // dir is an alias for ls @@ -85,7 +83,6 @@ mod tests { fn test_get_canonical_util_name() { // Test a few key aliases assert_eq!(get_canonical_util_name("["), "test"); - assert_eq!(get_canonical_util_name("md5sum"), "hashsum"); assert_eq!(get_canonical_util_name("dir"), "ls"); // Test passthrough case diff --git a/src/uu/md5sum/Cargo.toml b/src/uu/md5sum/Cargo.toml new file mode 100644 index 000000000..70ecfe0cd --- /dev/null +++ b/src/uu/md5sum/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "uu_md5sum" +description = "md5sum ~ (uutils) Print or check the MD5 checksums" +repository = "https://github.com/uutils/coreutils/tree/main/src/uu/md5sum" +version.workspace = true +authors.workspace = true +license.workspace = true +homepage.workspace = true +keywords.workspace = true +categories.workspace = true +edition.workspace = true +readme.workspace = true + +[lints] +workspace = true + +[lib] +path = "src/md5sum.rs" + +[dependencies] +clap = { workspace = true } +uu_checksum_common = { workspace = true } +uucore = { workspace = true, features = [ + "checksum", + "encoding", + "sum", + "hardware", +] } +fluent = { workspace = true } + +[dev-dependencies] +divan = { workspace = true } +tempfile = { workspace = true } +uucore = { workspace = true, features = ["benchmark"] } + +[[bin]] +name = "md5sum" +path = "src/main.rs" + +# [[bench]] +# name = "b2sum_bench" +# harness = false diff --git a/src/uu/md5sum/LICENSE b/src/uu/md5sum/LICENSE new file mode 120000 index 000000000..5853aaea5 --- /dev/null +++ b/src/uu/md5sum/LICENSE @@ -0,0 +1 @@ +../../../LICENSE \ No newline at end of file diff --git a/src/uu/md5sum/locales/en-US.ftl b/src/uu/md5sum/locales/en-US.ftl new file mode 100644 index 000000000..9712ff7c6 --- /dev/null +++ b/src/uu/md5sum/locales/en-US.ftl @@ -0,0 +1,2 @@ +md5sum-about = Print or check the MD5 checksums +md5sum-usage = md5sum [OPTIONS] [FILE]... diff --git a/src/uu/md5sum/locales/fr-FR.ftl b/src/uu/md5sum/locales/fr-FR.ftl new file mode 100644 index 000000000..8da43df36 --- /dev/null +++ b/src/uu/md5sum/locales/fr-FR.ftl @@ -0,0 +1,2 @@ +md5sum-about = Afficher le MD5 et la taille de chaque fichier +md5sum-usage = md5sum [OPTION]... [FICHIER]... diff --git a/src/uu/md5sum/src/main.rs b/src/uu/md5sum/src/main.rs new file mode 100644 index 000000000..d5509656f --- /dev/null +++ b/src/uu/md5sum/src/main.rs @@ -0,0 +1 @@ +uucore::bin!(uu_md5sum); diff --git a/src/uu/md5sum/src/md5sum.rs b/src/uu/md5sum/src/md5sum.rs new file mode 100644 index 000000000..c9366eb4b --- /dev/null +++ b/src/uu/md5sum/src/md5sum.rs @@ -0,0 +1 @@ +uu_checksum_common::declare_standalone!("md5sum", uucore::checksum::AlgoKind::Md5); diff --git a/src/uucore/src/lib/lib.rs b/src/uucore/src/lib/lib.rs index 03ae3d955..effefbc06 100644 --- a/src/uucore/src/lib/lib.rs +++ b/src/uucore/src/lib/lib.rs @@ -173,9 +173,7 @@ pub fn get_canonical_util_name(util_name: &str) -> &str { "[" => "test", // hashsum aliases - all these hash commands are aliases for hashsum - "md5sum" | "sha1sum" | "sha224sum" | "sha256sum" | "sha384sum" | "sha512sum" | "b2sum" => { - "hashsum" - } + "sha1sum" | "sha224sum" | "sha256sum" | "sha384sum" | "sha512sum" | "b2sum" => "hashsum", "dir" => "ls", // dir is an alias for ls diff --git a/src/uucore/src/lib/mods/locale.rs b/src/uucore/src/lib/mods/locale.rs index 8425d1e1b..19b9be9f1 100644 --- a/src/uucore/src/lib/mods/locale.rs +++ b/src/uucore/src/lib/mods/locale.rs @@ -157,7 +157,7 @@ fn create_bundle( try_add_resource_from(get_locales_dir(util_name).ok()); // checksum binaries also require fluent files from the checksum_common crate - if ["cksum"].contains(&util_name) { + if ["cksum", "md5sum"].contains(&util_name) { try_add_resource_from(get_locales_dir("checksum_common").ok()); } diff --git a/tests/by-util/test_hashsum.rs b/tests/by-util/test_hashsum.rs index 2f1719b0e..2022f81b8 100644 --- a/tests/by-util/test_hashsum.rs +++ b/tests/by-util/test_hashsum.rs @@ -201,7 +201,6 @@ macro_rules! test_digest_with_len { }; } -test_digest! {md5, md5} test_digest! {sha1, sha1} test_digest! {b3sum, b3sum} test_digest! {shake128, shake128} @@ -237,6 +236,7 @@ fn test_check_sha1() { .stderr_is(""); } +#[ignore = "moved to standalone"] #[test] fn test_check_md5_ignore_missing() { let scene = TestScenario::new(util_name!()); @@ -428,6 +428,7 @@ fn test_check_file_not_found_warning() { // Asterisk `*` is a reserved paths character on win32, nor the path can end with a whitespace. // ref: https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file#naming-conventions +#[ignore = "moved to standalone"] #[test] fn test_check_md5sum() { let scene = TestScenario::new(util_name!()); @@ -478,6 +479,7 @@ fn test_check_md5sum() { } // GNU also supports one line sep +#[ignore = "moved to standalone"] #[test] fn test_check_md5sum_only_one_space() { let scene = TestScenario::new(util_name!()); @@ -501,6 +503,7 @@ fn test_check_md5sum_only_one_space() { .stdout_only("a: OK\n b: OK\nc: OK\n"); } +#[ignore = "moved to standalone"] #[test] fn test_check_md5sum_reverse_bsd() { let scene = TestScenario::new(util_name!()); @@ -550,6 +553,7 @@ fn test_check_md5sum_reverse_bsd() { } } +#[ignore = "moved to standalone"] #[test] fn test_check_md5sum_mixed_format() { let scene = TestScenario::new(util_name!()); @@ -622,6 +626,7 @@ fn test_tag() { ); } +#[ignore = "moved to standalone"] #[test] #[cfg(not(windows))] fn test_with_escape_filename() { @@ -637,6 +642,7 @@ fn test_with_escape_filename() { assert!(stdout.trim().ends_with("a\\nb")); } +#[ignore = "moved to standalone"] #[test] #[cfg(not(windows))] fn test_with_escape_filename_zero_text() { @@ -657,6 +663,7 @@ fn test_with_escape_filename_zero_text() { assert!(stdout.contains("a\nb")); } +#[ignore = "moved to standalone"] #[test] fn test_check_empty_line() { let scene = TestScenario::new(util_name!()); @@ -675,6 +682,7 @@ fn test_check_empty_line() { .stderr_contains("WARNING: 1 line is improperly formatted"); } +#[ignore = "moved to standalone"] #[test] #[cfg(not(windows))] fn test_check_with_escape_filename() { @@ -699,6 +707,7 @@ fn test_check_with_escape_filename() { result.stdout_is("\\a\\nb: OK\n"); } +#[ignore = "moved to standalone"] #[test] fn test_check_strict_error() { let scene = TestScenario::new(util_name!()); @@ -718,6 +727,7 @@ fn test_check_strict_error() { .stderr_contains("WARNING: 3 lines are improperly formatted"); } +#[ignore = "moved to standalone"] #[test] fn test_check_warn() { let scene = TestScenario::new(util_name!()); @@ -746,6 +756,7 @@ fn test_check_warn() { .fails(); } +#[ignore = "moved to standalone"] #[test] fn test_check_status() { let scene = TestScenario::new(util_name!()); @@ -762,6 +773,7 @@ fn test_check_status() { .no_output(); } +#[ignore = "moved to standalone"] #[test] fn test_check_status_code() { let scene = TestScenario::new(util_name!()); @@ -779,6 +791,7 @@ fn test_check_status_code() { .stdout_is(""); } +#[ignore = "moved to standalone"] #[test] fn test_sha1_with_md5sum_should_fail() { let scene = TestScenario::new(util_name!()); @@ -795,6 +808,7 @@ fn test_sha1_with_md5sum_should_fail() { .stderr_does_not_contain("WARNING: 1 line is improperly formatted"); } +#[ignore = "moved to standalone"] #[test] // Disabled on Windows because of the "*" #[cfg(not(windows))] @@ -834,6 +848,7 @@ fn test_check_one_two_space_star() { .stdout_is("*empty: OK\n"); } +#[ignore = "moved to standalone"] #[test] // Disabled on Windows because of the "*" #[cfg(not(windows))] @@ -876,6 +891,7 @@ fn test_check_space_star_or_not() { .stderr_contains("WARNING: 1 line is improperly formatted"); } +#[ignore = "moved to standalone"] #[test] fn test_check_no_backslash_no_space() { let scene = TestScenario::new(util_name!()); @@ -891,6 +907,7 @@ fn test_check_no_backslash_no_space() { .stdout_is("f: OK\n"); } +#[ignore = "moved to standalone"] #[test] fn test_incomplete_format() { let scene = TestScenario::new(util_name!()); @@ -906,6 +923,7 @@ fn test_incomplete_format() { .stderr_contains("no properly formatted checksum lines found"); } +#[ignore = "moved to standalone"] #[test] fn test_start_error() { let scene = TestScenario::new(util_name!()); @@ -923,6 +941,7 @@ fn test_start_error() { .stderr_contains("WARNING: 1 line is improperly formatted"); } +#[ignore = "moved to standalone"] #[test] fn test_check_check_ignore_no_file() { let scene = TestScenario::new(util_name!()); @@ -939,6 +958,7 @@ fn test_check_check_ignore_no_file() { .stderr_contains("in.md5: no file was verified"); } +#[ignore = "moved to standalone"] #[test] fn test_check_directory_error() { let scene = TestScenario::new(util_name!()); @@ -958,6 +978,7 @@ fn test_check_directory_error() { .stderr_contains(err_msg); } +#[ignore = "moved to standalone"] #[test] #[cfg(not(windows))] fn test_continue_after_directory_error() { @@ -990,6 +1011,7 @@ fn test_continue_after_directory_error() { .stderr_is(err_msg); } +#[ignore = "moved to standalone"] #[test] fn test_check_quiet() { let scene = TestScenario::new(util_name!()); @@ -1030,6 +1052,7 @@ fn test_check_quiet() { .stderr_contains("md5sum: the --strict option is meaningful only when verifying checksums"); } +#[ignore = "moved to standalone"] #[test] fn test_star_to_start() { let scene = TestScenario::new(util_name!()); @@ -1081,6 +1104,7 @@ fn test_check_b2sum_strict_check() { .stdout_only(&output); } +#[ignore = "moved to standalone"] #[test] fn test_check_md5_comment_line() { // A comment in a checksum file shall be discarded unnoticed. @@ -1106,6 +1130,7 @@ fn test_check_md5_comment_line() { .no_stderr(); } +#[ignore = "moved to standalone"] #[test] fn test_check_md5_comment_only() { // A file only filled with comments is equivalent to an empty file, @@ -1125,6 +1150,7 @@ fn test_check_md5_comment_only() { .stderr_contains("no properly formatted checksum lines found"); } +#[ignore = "moved to standalone"] #[test] fn test_check_md5_comment_leading_space() { // A file only filled with comments is equivalent to an empty file, @@ -1198,12 +1224,12 @@ fn test_help_shows_correct_utility_name() { let scene = TestScenario::new(util_name!()); // Test md5sum - scene - .ccmd("md5sum") - .arg("--help") - .succeeds() - .stdout_contains("Usage: md5sum") - .stdout_does_not_contain("Usage: hashsum"); + // scene + // .ccmd("md5sum") + // .arg("--help") + // .succeeds() + // .stdout_contains("Usage: md5sum") + // .stdout_does_not_contain("Usage: hashsum"); // Test sha256sum scene @@ -1214,12 +1240,12 @@ fn test_help_shows_correct_utility_name() { .stdout_does_not_contain("Usage: hashsum"); // Test b2sum - scene - .ccmd("b2sum") - .arg("--help") - .succeeds() - .stdout_contains("Usage: b2sum") - .stdout_does_not_contain("Usage: hashsum"); + // scene + // .ccmd("b2sum") + // .arg("--help") + // .succeeds() + // .stdout_contains("Usage: b2sum") + // .stdout_does_not_contain("Usage: hashsum"); // Test that generic hashsum still shows the correct usage scene diff --git a/tests/by-util/test_md5sum.rs b/tests/by-util/test_md5sum.rs new file mode 100644 index 000000000..6ccf173cf --- /dev/null +++ b/tests/by-util/test_md5sum.rs @@ -0,0 +1,812 @@ +// This file is part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +use uutests::new_ucmd; +use uutests::util::TestScenario; +use uutests::util_name; +// spell-checker:ignore checkfile, testf, ntestf +macro_rules! get_hash( + ($str:expr) => ( + $str.split(' ').collect::>()[0] + ); +); + +macro_rules! test_digest { + ($id:ident) => { + mod $id { + use uutests::util::*; + use uutests::util_name; + static EXPECTED_FILE: &'static str = concat!(stringify!($id), ".expected"); + static CHECK_FILE: &'static str = concat!(stringify!($id), ".checkfile"); + static INPUT_FILE: &'static str = "input.txt"; + + #[test] + fn test_single_file() { + let ts = TestScenario::new(util_name!()); + assert_eq!( + ts.fixtures.read(EXPECTED_FILE), + get_hash!( + ts.ucmd() + .arg(INPUT_FILE) + .succeeds() + .no_stderr() + .stdout_str() + ) + ); + } + + #[test] + fn test_stdin() { + let ts = TestScenario::new(util_name!()); + assert_eq!( + ts.fixtures.read(EXPECTED_FILE), + get_hash!( + ts.ucmd() + .pipe_in_fixture(INPUT_FILE) + .succeeds() + .no_stderr() + .stdout_str() + ) + ); + } + + #[test] + fn test_check() { + let ts = TestScenario::new(util_name!()); + println!("File content='{}'", ts.fixtures.read(INPUT_FILE)); + println!("Check file='{}'", ts.fixtures.read(CHECK_FILE)); + + ts.ucmd() + .args(&["--check", CHECK_FILE]) + .succeeds() + .no_stderr() + .stdout_is("input.txt: OK\n"); + } + + #[test] + fn test_zero() { + let ts = TestScenario::new(util_name!()); + assert_eq!( + ts.fixtures.read(EXPECTED_FILE), + get_hash!( + ts.ucmd() + .arg("--zero") + .arg(INPUT_FILE) + .succeeds() + .no_stderr() + .stdout_str() + ) + ); + } + + #[test] + fn test_missing_file() { + let ts = TestScenario::new(util_name!()); + let at = &ts.fixtures; + + at.write("a", "file1\n"); + at.write("c", "file3\n"); + + ts.ucmd() + .args(&["a", "b", "c"]) + .fails() + .stdout_contains("a\n") + .stdout_contains("c\n") + .stderr_contains("b: No such file or directory"); + } + } + }; +} + +test_digest! {md5} + +#[test] +fn test_check_md5_ignore_missing() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + at.write("testf", "foobar\n"); + at.write( + "testf.sha1", + "14758f1afd44c09b7992073ccf00b43d testf\n14758f1afd44c09b7992073ccf00b43d testf2\n", + ); + scene + .ccmd("md5sum") + .arg("-c") + .arg(at.subdir.join("testf.sha1")) + .fails() + .stdout_contains("testf2: FAILED open or read"); + + scene + .ccmd("md5sum") + .arg("-c") + .arg("--ignore-missing") + .arg(at.subdir.join("testf.sha1")) + .succeeds() + .stdout_is("testf: OK\n") + .stderr_is(""); + + scene + .ccmd("md5sum") + .arg("--ignore-missing") + .arg(at.subdir.join("testf.sha1")) + .fails() + .stderr_contains( + "md5sum: the --ignore-missing option is meaningful only when verifying checksums", + ); +} + +// Asterisk `*` is a reserved paths character on win32, nor the path can end with a whitespace. +// ref: https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file#naming-conventions +#[test] +fn test_check_md5sum() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + #[cfg(not(windows))] + { + for f in &["a", " b", "*c", "dd", " "] { + at.write(f, &format!("{f}\n")); + } + at.write( + "check.md5sum", + "60b725f10c9c85c70d97880dfe8191b3 a\n\ + bf35d7536c785cf06730d5a40301eba2 b\n\ + f5b61709718c1ecf8db1aea8547d4698 *c\n\ + b064a020db8018f18ff5ae367d01b212 dd\n\ + d784fa8b6d98d27699781bd9a7cf19f0 ", + ); + scene + .ccmd("md5sum") + .arg("--strict") + .arg("-c") + .arg("check.md5sum") + .succeeds() + .stdout_is("a: OK\n b: OK\n*c: OK\ndd: OK\n : OK\n") + .stderr_is(""); + } + #[cfg(windows)] + { + for f in &["a", " b", "dd"] { + at.write(f, &format!("{f}\n")); + } + at.write( + "check.md5sum", + "60b725f10c9c85c70d97880dfe8191b3 a\n\ + bf35d7536c785cf06730d5a40301eba2 b\n\ + b064a020db8018f18ff5ae367d01b212 dd", + ); + scene + .ccmd("md5sum") + .arg("--strict") + .arg("-c") + .arg("check.md5sum") + .succeeds() + .stdout_is("a: OK\n b: OK\ndd: OK\n") + .stderr_is(""); + } +} + +// GNU also supports one line sep +#[test] +fn test_check_md5sum_only_one_space() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + for f in ["a", " b", "c"] { + at.write(f, &format!("{f}\n")); + } + at.write( + "check.md5sum", + "60b725f10c9c85c70d97880dfe8191b3 a\n\ + bf35d7536c785cf06730d5a40301eba2 b\n\ + 2cd6ee2c70b0bde53fbe6cac3c8b8bb1 c\n", + ); + scene + .ccmd("md5sum") + .arg("--strict") + .arg("-c") + .arg("check.md5sum") + .succeeds() + .stdout_only("a: OK\n b: OK\nc: OK\n"); +} + +#[test] +fn test_check_md5sum_reverse_bsd() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + #[cfg(not(windows))] + { + for f in &["a", " b", "*c", "dd", " "] { + at.write(f, &format!("{f}\n")); + } + at.write( + "check.md5sum", + "60b725f10c9c85c70d97880dfe8191b3 a\n\ + bf35d7536c785cf06730d5a40301eba2 b\n\ + f5b61709718c1ecf8db1aea8547d4698 *c\n\ + b064a020db8018f18ff5ae367d01b212 dd\n\ + d784fa8b6d98d27699781bd9a7cf19f0 ", + ); + scene + .ccmd("md5sum") + .arg("--strict") + .arg("-c") + .arg("check.md5sum") + .succeeds() + .stdout_is("a: OK\n b: OK\n*c: OK\ndd: OK\n : OK\n") + .stderr_is(""); + } + #[cfg(windows)] + { + for f in &["a", " b", "dd"] { + at.write(f, &format!("{f}\n")); + } + at.write( + "check.md5sum", + "60b725f10c9c85c70d97880dfe8191b3 a\n\ + bf35d7536c785cf06730d5a40301eba2 b\n\ + b064a020db8018f18ff5ae367d01b212 dd", + ); + scene + .ccmd("md5sum") + .arg("--strict") + .arg("-c") + .arg("check.md5sum") + .succeeds() + .stdout_is("a: OK\n b: OK\ndd: OK\n") + .stderr_is(""); + } +} + +#[test] +fn test_check_md5sum_mixed_format() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + #[cfg(not(windows))] + { + for f in &[" b", "*c", "dd", " "] { + at.write(f, &format!("{f}\n")); + } + at.write( + "check.md5sum", + "bf35d7536c785cf06730d5a40301eba2 b\n\ + f5b61709718c1ecf8db1aea8547d4698 *c\n\ + b064a020db8018f18ff5ae367d01b212 dd\n\ + d784fa8b6d98d27699781bd9a7cf19f0 ", + ); + } + #[cfg(windows)] + { + for f in &[" b", "dd"] { + at.write(f, &format!("{f}\n")); + } + at.write( + "check.md5sum", + "bf35d7536c785cf06730d5a40301eba2 b\n\ + b064a020db8018f18ff5ae367d01b212 dd", + ); + } + scene + .ccmd("md5sum") + .arg("--strict") + .arg("-c") + .arg("check.md5sum") + .fails_with_code(1); +} + +#[test] +fn test_invalid_arg() { + new_ucmd!().arg("--definitely-invalid").fails_with_code(1); +} + +#[test] +fn test_conflicting_arg() { + new_ucmd!().arg("--tag").arg("--check").fails_with_code(1); + new_ucmd!().arg("--tag").arg("--text").fails_with_code(1); +} + +#[test] +#[cfg_attr(windows, ignore = "Disabled on windows")] +fn test_with_escape_filename() { + let scene = TestScenario::new(util_name!()); + + let at = &scene.fixtures; + let filename = "a\nb"; + at.touch(filename); + let result = scene.ccmd("md5sum").arg("--text").arg(filename).succeeds(); + let stdout = result.stdout_str(); + println!("stdout {stdout}"); + assert!(stdout.starts_with('\\')); + assert!(stdout.trim().ends_with("a\\nb")); +} + +#[test] +#[cfg_attr(windows, ignore = "Disabled on windows")] +fn test_with_escape_filename_zero_text() { + let scene = TestScenario::new(util_name!()); + + let at = &scene.fixtures; + let filename = "a\nb"; + at.touch(filename); + let result = scene + .ccmd("md5sum") + .arg("--text") + .arg("--zero") + .arg(filename) + .succeeds(); + let stdout = result.stdout_str(); + println!("stdout {stdout}"); + assert!(!stdout.starts_with('\\')); + assert!(stdout.contains("a\nb")); +} + +#[test] +fn test_check_empty_line() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + at.touch("f"); + at.write( + "in.md5", + "d41d8cd98f00b204e9800998ecf8427e f\n\nd41d8cd98f00b204e9800998ecf8427e f\ninvalid\n\n", + ); + scene + .ccmd("md5sum") + .arg("--check") + .arg(at.subdir.join("in.md5")) + .succeeds() + .stderr_contains("WARNING: 1 line is improperly formatted"); +} + +#[test] +#[cfg_attr(windows, ignore = "Disabled on windows")] +fn test_check_with_escape_filename() { + let scene = TestScenario::new(util_name!()); + + let at = &scene.fixtures; + + let filename = "a\nb"; + at.touch(filename); + let result = scene.ccmd("md5sum").arg("--tag").arg(filename).succeeds(); + let stdout = result.stdout_str(); + println!("stdout {stdout}"); + assert!(stdout.starts_with("\\MD5")); + assert!(stdout.contains("a\\nb")); + at.write("check.md5", stdout); + let result = scene + .ccmd("md5sum") + .arg("--strict") + .arg("-c") + .arg("check.md5") + .succeeds(); + result.stdout_is("\\a\\nb: OK\n"); +} + +#[test] +fn test_check_strict_error() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + at.touch("f"); + at.write( + "in.md5", + "ERR\nERR\nd41d8cd98f00b204e9800998ecf8427e f\nERR\n", + ); + scene + .ccmd("md5sum") + .arg("--check") + .arg("--strict") + .arg(at.subdir.join("in.md5")) + .fails() + .stderr_contains("WARNING: 3 lines are improperly formatted"); +} + +#[test] +fn test_check_warn() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + at.touch("f"); + at.write( + "in.md5", + "d41d8cd98f00b204e9800998ecf8427e f\nd41d8cd98f00b204e9800998ecf8427e f\ninvalid\n", + ); + scene + .ccmd("md5sum") + .arg("--check") + .arg("--warn") + .arg(at.subdir.join("in.md5")) + .succeeds() + .stderr_contains("in.md5: 3: improperly formatted MD5 checksum line") + .stderr_contains("WARNING: 1 line is improperly formatted"); + + // with strict, we should fail the execution + scene + .ccmd("md5sum") + .arg("--check") + .arg("--strict") + .arg(at.subdir.join("in.md5")) + .fails(); +} + +#[test] +fn test_check_status() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + at.touch("f"); + at.write("in.md5", "MD5(f)= d41d8cd98f00b204e9800998ecf8427f\n"); + scene + .ccmd("md5sum") + .arg("--check") + .arg("--status") + .arg(at.subdir.join("in.md5")) + .fails() + .no_output(); +} + +#[test] +fn test_check_status_code() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + at.touch("f"); + at.write("in.md5", "d41d8cd98f00b204e9800998ecf8427f f\n"); + scene + .ccmd("md5sum") + .arg("--check") + .arg("--status") + .arg(at.subdir.join("in.md5")) + .fails() + .stderr_is("") + .stdout_is(""); +} + +#[test] +fn test_sha1_with_md5sum_should_fail() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + at.touch("f"); + at.write("f.sha1", "SHA1 (f) = d41d8cd98f00b204e9800998ecf8427e\n"); + scene + .ccmd("md5sum") + .arg("--check") + .arg(at.subdir.join("f.sha1")) + .fails() + .stderr_contains("f.sha1: no properly formatted checksum lines found") + .stderr_does_not_contain("WARNING: 1 line is improperly formatted"); +} + +#[test] +// Disabled on Windows because of the "*" +#[cfg_attr(windows, ignore = "Disabled on windows")] +fn test_check_one_two_space_star() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + at.touch("empty"); + + // with one space, the "*" is removed + at.write("in.md5", "d41d8cd98f00b204e9800998ecf8427e *empty\n"); + + scene + .ccmd("md5sum") + .arg("--check") + .arg(at.subdir.join("in.md5")) + .succeeds() + .stdout_is("empty: OK\n"); + + // with two spaces, the "*" is not removed + at.write("in.md5", "d41d8cd98f00b204e9800998ecf8427e *empty\n"); + // First should fail as *empty doesn't exit + scene + .ccmd("md5sum") + .arg("--check") + .arg(at.subdir.join("in.md5")) + .fails() + .stdout_is("*empty: FAILED open or read\n"); + + at.touch("*empty"); + // Should pass as we have the file + scene + .ccmd("md5sum") + .arg("--check") + .arg(at.subdir.join("in.md5")) + .succeeds() + .stdout_is("*empty: OK\n"); +} + +#[test] +// Disabled on Windows because of the "*" +#[cfg_attr(windows, ignore = "Disabled on windows")] +fn test_check_space_star_or_not() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + at.touch("a"); + at.touch("*c"); + + // with one space, the "*" is removed + at.write( + "in.md5", + "d41d8cd98f00b204e9800998ecf8427e *c\n + d41d8cd98f00b204e9800998ecf8427e a\n", + ); + + scene + .ccmd("md5sum") + .arg("--check") + .arg(at.subdir.join("in.md5")) + .fails() + .stdout_contains("c: FAILED") + .stdout_does_not_contain("a: FAILED") + .stderr_contains("WARNING: 1 line is improperly formatted"); + + at.write( + "in.md5", + "d41d8cd98f00b204e9800998ecf8427e a\n + d41d8cd98f00b204e9800998ecf8427e *c\n", + ); + + // First should fail as *empty doesn't exit + scene + .ccmd("md5sum") + .arg("--check") + .arg(at.subdir.join("in.md5")) + .succeeds() + .stdout_contains("a: OK") + .stderr_contains("WARNING: 1 line is improperly formatted"); +} + +#[test] +fn test_check_no_backslash_no_space() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + at.touch("f"); + at.write("in.md5", "MD5(f)= d41d8cd98f00b204e9800998ecf8427e\n"); + scene + .ccmd("md5sum") + .arg("--check") + .arg(at.subdir.join("in.md5")) + .succeeds() + .stdout_is("f: OK\n"); +} + +#[test] +fn test_incomplete_format() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + at.touch("f"); + at.write("in.md5", "MD5 (\n"); + scene + .ccmd("md5sum") + .arg("--check") + .arg(at.subdir.join("in.md5")) + .fails() + .stderr_contains("no properly formatted checksum lines found"); +} + +#[test] +fn test_start_error() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + at.touch("f"); + at.write("in.md5", "ERR\nd41d8cd98f00b204e9800998ecf8427e f\n"); + scene + .ccmd("md5sum") + .arg("--check") + .arg("--strict") + .arg(at.subdir.join("in.md5")) + .fails() + .stdout_is("f: OK\n") + .stderr_contains("WARNING: 1 line is improperly formatted"); +} + +#[test] +fn test_check_check_ignore_no_file() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + at.touch("f"); + at.write("in.md5", "d41d8cd98f00b204e9800998ecf8427f missing\n"); + scene + .ccmd("md5sum") + .arg("--check") + .arg("--ignore-missing") + .arg(at.subdir.join("in.md5")) + .fails() + .stderr_contains("in.md5: no file was verified"); +} + +#[test] +fn test_check_directory_error() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + at.mkdir("d"); + at.write("in.md5", "d41d8cd98f00b204e9800998ecf8427f d\n"); + #[cfg(not(windows))] + let err_msg = "md5sum: d: Is a directory\n"; + #[cfg(windows)] + let err_msg = "md5sum: d: Permission denied\n"; + scene + .ccmd("md5sum") + .arg("--check") + .arg(at.subdir.join("in.md5")) + .fails() + .stderr_contains(err_msg); +} + +#[test] +#[cfg(not(windows))] +fn test_continue_after_directory_error() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + at.mkdir("d"); + at.touch("file"); + at.touch("no_read_perms"); + at.set_mode("no_read_perms", 200); + + let (out, err_msg) = ( + "d41d8cd98f00b204e9800998ecf8427e file\n", + [ + "md5sum: d: Is a directory", + "md5sum: dne: No such file or directory", + "md5sum: no_read_perms: Permission denied\n", + ] + .join("\n"), + ); + + scene + .ccmd("md5sum") + .arg("d") + .arg("dne") + .arg("no_read_perms") + .arg("file") + .fails() + .stdout_is(out) + .stderr_is(err_msg); +} + +#[test] +fn test_check_quiet() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + at.touch("f"); + at.write("in.md5", "d41d8cd98f00b204e9800998ecf8427e f\n"); + scene + .ccmd("md5sum") + .arg("--quiet") + .arg("--check") + .arg(at.subdir.join("in.md5")) + .succeeds() + .no_output(); + + // incorrect md5 + at.write("in.md5", "d41d8cd98f00b204e9800998ecf8427f f\n"); + scene + .ccmd("md5sum") + .arg("--quiet") + .arg("--check") + .arg(at.subdir.join("in.md5")) + .fails() + .stdout_contains("f: FAILED") + .stderr_contains("WARNING: 1 computed checksum did NOT match"); + + scene + .ccmd("md5sum") + .arg("--quiet") + .arg(at.subdir.join("in.md5")) + .fails() + .stderr_contains("md5sum: the --quiet option is meaningful only when verifying checksums"); + scene + .ccmd("md5sum") + .arg("--strict") + .arg(at.subdir.join("in.md5")) + .fails() + .stderr_contains("md5sum: the --strict option is meaningful only when verifying checksums"); +} + +#[test] +fn test_star_to_start() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + at.touch("f"); + at.write("in.md5", "d41d8cd98f00b204e9800998ecf8427e *f\n"); + scene + .ccmd("md5sum") + .arg("--check") + .arg(at.subdir.join("in.md5")) + .succeeds() + .stdout_only("f: OK\n"); +} + +#[test] +fn test_check_md5_comment_line() { + // A comment in a checksum file shall be discarded unnoticed. + + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + at.write("foo", "foo-content\n"); + at.write( + "MD5SUM", + "\ + # This is a comment\n\ + 8411029f3f5b781026a93db636aca721 foo\n\ + # next comment is empty\n#", + ); + + scene + .ccmd("md5sum") + .arg("--check") + .arg("MD5SUM") + .succeeds() + .stdout_contains("foo: OK") + .no_stderr(); +} + +#[test] +fn test_check_md5_comment_only() { + // A file only filled with comments is equivalent to an empty file, + // and therefore produces an error. + + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + at.write("foo", "foo-content\n"); + at.write("MD5SUM", "# This is a comment\n"); + + scene + .ccmd("md5sum") + .arg("--check") + .arg("MD5SUM") + .fails() + .stderr_contains("no properly formatted checksum lines found"); +} + +#[test] +fn test_check_md5_comment_leading_space() { + // A file only filled with comments is equivalent to an empty file, + // and therefore produces an error. + + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + at.write("foo", "foo-content\n"); + at.write( + "MD5SUM", + " # This is a comment\n\ + 8411029f3f5b781026a93db636aca721 foo\n", + ); + + scene + .ccmd("md5sum") + .arg("--check") + .arg("MD5SUM") + .succeeds() + .stdout_contains("foo: OK") + .stderr_contains("WARNING: 1 line is improperly formatted"); +} + +#[test] +fn test_help_shows_correct_utility_name() { + // Test md5sum + new_ucmd!() + .arg("--help") + .succeeds() + .stdout_contains("Usage: md5sum") + .stdout_does_not_contain("Usage: hashsum"); +} diff --git a/tests/fixtures/md5sum/input.txt b/tests/fixtures/md5sum/input.txt new file mode 100644 index 000000000..8c01d89ae --- /dev/null +++ b/tests/fixtures/md5sum/input.txt @@ -0,0 +1 @@ +hello, world \ No newline at end of file diff --git a/tests/fixtures/hashsum/md5.checkfile b/tests/fixtures/md5sum/md5.checkfile similarity index 100% rename from tests/fixtures/hashsum/md5.checkfile rename to tests/fixtures/md5sum/md5.checkfile diff --git a/tests/fixtures/hashsum/md5.expected b/tests/fixtures/md5sum/md5.expected similarity index 100% rename from tests/fixtures/hashsum/md5.expected rename to tests/fixtures/md5sum/md5.expected diff --git a/tests/tests.rs b/tests/tests.rs index 9ffdfd4a3..1b2a2131b 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -68,6 +68,10 @@ mod test_cksum; #[path = "by-util/test_comm.rs"] mod test_comm; +#[cfg(feature = "md5sum")] +#[path = "by-util/test_md5sum.rs"] +mod test_md5sum; + #[cfg(feature = "cp")] #[path = "by-util/test_cp.rs"] mod test_cp; From d22c7e14ece8791dec463e32d39d333e8d882a3d Mon Sep 17 00:00:00 2001 From: Dorian Peron Date: Fri, 9 Jan 2026 15:43:12 +0100 Subject: [PATCH 384/425] b2sum: introduce standalone binary --- Cargo.lock | 13 + Cargo.toml | 2 + GNUmakefile | 1 - build.rs | 1 - src/common/validation.rs | 2 +- src/uu/b2sum/Cargo.toml | 38 +++ src/uu/b2sum/LICENSE | 1 + src/uu/b2sum/locales/en-US.ftl | 2 + src/uu/b2sum/locales/fr-FR.ftl | 2 + src/uu/b2sum/src/b2sum.rs | 29 ++ src/uu/b2sum/src/main.rs | 1 + src/uucore/src/lib/lib.rs | 2 +- src/uucore/src/lib/mods/locale.rs | 2 +- tests/by-util/test_b2sum.rs | 299 ++++++++++++++++++ tests/by-util/test_hashsum.rs | 11 +- .../{hashsum => b2sum}/b2sum.checkfile | 0 .../{hashsum => b2sum}/b2sum.expected | 0 tests/fixtures/b2sum/input.txt | 1 + tests/tests.rs | 4 + 19 files changed, 405 insertions(+), 6 deletions(-) create mode 100644 src/uu/b2sum/Cargo.toml create mode 120000 src/uu/b2sum/LICENSE create mode 100644 src/uu/b2sum/locales/en-US.ftl create mode 100644 src/uu/b2sum/locales/fr-FR.ftl create mode 100644 src/uu/b2sum/src/b2sum.rs create mode 100644 src/uu/b2sum/src/main.rs create mode 100644 tests/by-util/test_b2sum.rs rename tests/fixtures/{hashsum => b2sum}/b2sum.checkfile (100%) rename tests/fixtures/{hashsum => b2sum}/b2sum.expected (100%) create mode 100644 tests/fixtures/b2sum/input.txt diff --git a/Cargo.lock b/Cargo.lock index 49d209ab0..a1b35d585 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -556,6 +556,7 @@ dependencies = [ "unicode-width 0.2.2", "unindent", "uu_arch", + "uu_b2sum", "uu_base32", "uu_base64", "uu_basename", @@ -3172,6 +3173,18 @@ dependencies = [ "uucore", ] +[[package]] +name = "uu_b2sum" +version = "0.6.0" +dependencies = [ + "clap", + "codspeed-divan-compat", + "fluent", + "tempfile", + "uu_checksum_common", + "uucore", +] + [[package]] name = "uu_base32" version = "0.6.0" diff --git a/Cargo.toml b/Cargo.toml index f6354a644..429959b28 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -86,6 +86,7 @@ feat_common_core = [ "basenc", "cat", "cksum", + "b2sum", "md5sum", "comm", "cp", @@ -438,6 +439,7 @@ chmod = { optional = true, version = "0.6.0", package = "uu_chmod", path = "src/ chown = { optional = true, version = "0.6.0", package = "uu_chown", path = "src/uu/chown" } chroot = { optional = true, version = "0.6.0", package = "uu_chroot", path = "src/uu/chroot" } cksum = { optional = true, version = "0.6.0", package = "uu_cksum", path = "src/uu/cksum" } +b2sum = { optional = true, version = "0.6.0", package = "uu_b2sum", path = "src/uu/b2sum" } md5sum = { optional = true, version = "0.6.0", package = "uu_md5sum", path = "src/uu/md5sum" } comm = { optional = true, version = "0.6.0", package = "uu_comm", path = "src/uu/comm" } cp = { optional = true, version = "0.6.0", package = "uu_cp", path = "src/uu/cp" } diff --git a/GNUmakefile b/GNUmakefile index 243e038c4..b28ef8903 100644 --- a/GNUmakefile +++ b/GNUmakefile @@ -96,7 +96,6 @@ SELINUX_PROGS := \ runcon HASHSUM_PROGS := \ - b2sum \ sha1sum \ sha224sum \ sha256sum \ diff --git a/build.rs b/build.rs index 989f7630d..39a26a807 100644 --- a/build.rs +++ b/build.rs @@ -96,7 +96,6 @@ pub fn main() { phf_map.entry("sha256sum", map_value.clone()); phf_map.entry("sha384sum", map_value.clone()); phf_map.entry("sha512sum", map_value.clone()); - phf_map.entry("b2sum", map_value.clone()); } _ => { phf_map.entry(krate, map_value.clone()); diff --git a/src/common/validation.rs b/src/common/validation.rs index d332d304f..773505bed 100644 --- a/src/common/validation.rs +++ b/src/common/validation.rs @@ -51,7 +51,7 @@ fn get_canonical_util_name(util_name: &str) -> &str { "[" => "test", // hashsum aliases - all these hash commands are aliases for hashsum - "sha1sum" | "sha224sum" | "sha256sum" | "sha384sum" | "sha512sum" | "b2sum" => "hashsum", + "sha1sum" | "sha224sum" | "sha256sum" | "sha384sum" | "sha512sum" => "hashsum", "dir" => "ls", // dir is an alias for ls diff --git a/src/uu/b2sum/Cargo.toml b/src/uu/b2sum/Cargo.toml new file mode 100644 index 000000000..61b0b702a --- /dev/null +++ b/src/uu/b2sum/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "uu_b2sum" +description = "b2sum ~ (uutils) Print or check the BLAKE2b checksums" +repository = "https://github.com/uutils/coreutils/tree/main/src/uu/b2sum" +version.workspace = true +authors.workspace = true +license.workspace = true +homepage.workspace = true +keywords.workspace = true +categories.workspace = true +edition.workspace = true +readme.workspace = true + +[lints] +workspace = true + +[lib] +path = "src/b2sum.rs" + +[dependencies] +clap = { workspace = true } +uu_checksum_common = { workspace = true } +uucore = { workspace = true, features = [ + "checksum", + "encoding", + "sum", + "hardware", +] } +fluent = { workspace = true } + +[dev-dependencies] +divan = { workspace = true } +tempfile = { workspace = true } +uucore = { workspace = true, features = ["benchmark"] } + +[[bin]] +name = "b2sum" +path = "src/main.rs" diff --git a/src/uu/b2sum/LICENSE b/src/uu/b2sum/LICENSE new file mode 120000 index 000000000..5853aaea5 --- /dev/null +++ b/src/uu/b2sum/LICENSE @@ -0,0 +1 @@ +../../../LICENSE \ No newline at end of file diff --git a/src/uu/b2sum/locales/en-US.ftl b/src/uu/b2sum/locales/en-US.ftl new file mode 100644 index 000000000..a5ab9ea7e --- /dev/null +++ b/src/uu/b2sum/locales/en-US.ftl @@ -0,0 +1,2 @@ +b2sum-about = Print or check the BLAKE2b checksums +b2sum-usage = b2sum [OPTIONS] [FILE]... diff --git a/src/uu/b2sum/locales/fr-FR.ftl b/src/uu/b2sum/locales/fr-FR.ftl new file mode 100644 index 000000000..7cb93e5d8 --- /dev/null +++ b/src/uu/b2sum/locales/fr-FR.ftl @@ -0,0 +1,2 @@ +b2sum-about = Afficher le BLAKE2b et la taille de chaque fichier +b2sum-usage = b2sum [OPTION]... [FICHIER]... diff --git a/src/uu/b2sum/src/b2sum.rs b/src/uu/b2sum/src/b2sum.rs new file mode 100644 index 000000000..502bd8b53 --- /dev/null +++ b/src/uu/b2sum/src/b2sum.rs @@ -0,0 +1,29 @@ +// This file is part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +// spell-checker:ignore (ToDO) algo + +use clap::Command; + +use uu_checksum_common::{standalone_checksum_app_with_length, standalone_with_length_main}; + +use uucore::checksum::{AlgoKind, calculate_blake2b_length_str}; +use uucore::error::UResult; +use uucore::translate; + +#[uucore::main] +pub fn uumain(args: impl uucore::Args) -> UResult<()> { + standalone_with_length_main( + AlgoKind::Blake2b, + uu_app(), + args, + calculate_blake2b_length_str, + ) +} + +#[inline] +pub fn uu_app() -> Command { + standalone_checksum_app_with_length(translate!("b2sum-about"), translate!("b2sum-usage")) +} diff --git a/src/uu/b2sum/src/main.rs b/src/uu/b2sum/src/main.rs new file mode 100644 index 000000000..422fa2fe7 --- /dev/null +++ b/src/uu/b2sum/src/main.rs @@ -0,0 +1 @@ +uucore::bin!(uu_b2sum); diff --git a/src/uucore/src/lib/lib.rs b/src/uucore/src/lib/lib.rs index effefbc06..d793867c5 100644 --- a/src/uucore/src/lib/lib.rs +++ b/src/uucore/src/lib/lib.rs @@ -173,7 +173,7 @@ pub fn get_canonical_util_name(util_name: &str) -> &str { "[" => "test", // hashsum aliases - all these hash commands are aliases for hashsum - "sha1sum" | "sha224sum" | "sha256sum" | "sha384sum" | "sha512sum" | "b2sum" => "hashsum", + "sha1sum" | "sha224sum" | "sha256sum" | "sha384sum" | "sha512sum" => "hashsum", "dir" => "ls", // dir is an alias for ls diff --git a/src/uucore/src/lib/mods/locale.rs b/src/uucore/src/lib/mods/locale.rs index 19b9be9f1..6036501f4 100644 --- a/src/uucore/src/lib/mods/locale.rs +++ b/src/uucore/src/lib/mods/locale.rs @@ -157,7 +157,7 @@ fn create_bundle( try_add_resource_from(get_locales_dir(util_name).ok()); // checksum binaries also require fluent files from the checksum_common crate - if ["cksum", "md5sum"].contains(&util_name) { + if ["cksum", "b2sum", "md5sum"].contains(&util_name) { try_add_resource_from(get_locales_dir("checksum_common").ok()); } diff --git a/tests/by-util/test_b2sum.rs b/tests/by-util/test_b2sum.rs new file mode 100644 index 000000000..30e2c46ca --- /dev/null +++ b/tests/by-util/test_b2sum.rs @@ -0,0 +1,299 @@ +// This file is part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +use rstest::rstest; + +use uutests::new_ucmd; +use uutests::util::TestScenario; +use uutests::util_name; +// spell-checker:ignore checkfile, testf, ntestf +macro_rules! get_hash( + ($str:expr) => ( + $str.split(' ').collect::>()[0] + ); +); + +macro_rules! test_digest_with_len { + ($id:ident, $size:expr) => { + mod $id { + use uutests::util::*; + use uutests::util_name; + static LENGTH_ARG: &'static str = concat!("--length=", stringify!($size)); + static EXPECTED_FILE: &'static str = concat!(stringify!($id), ".expected"); + static CHECK_FILE: &'static str = concat!(stringify!($id), ".checkfile"); + static INPUT_FILE: &'static str = "input.txt"; + + #[test] + fn test_single_file() { + let ts = TestScenario::new(util_name!()); + assert_eq!( + ts.fixtures.read(EXPECTED_FILE), + get_hash!( + ts.ucmd() + .arg(LENGTH_ARG) + .arg(INPUT_FILE) + .succeeds() + .no_stderr() + .stdout_str() + ) + ); + } + + #[test] + fn test_stdin() { + let ts = TestScenario::new(util_name!()); + assert_eq!( + ts.fixtures.read(EXPECTED_FILE), + get_hash!( + ts.ucmd() + .arg(LENGTH_ARG) + .pipe_in_fixture(INPUT_FILE) + .succeeds() + .no_stderr() + .stdout_str() + ) + ); + } + + #[test] + fn test_check() { + let ts = TestScenario::new(util_name!()); + println!("File content='{}'", ts.fixtures.read(INPUT_FILE)); + println!("Check file='{}'", ts.fixtures.read(CHECK_FILE)); + + ts.ucmd() + .args(&[LENGTH_ARG, "--check", CHECK_FILE]) + .succeeds() + .no_stderr() + .stdout_is("input.txt: OK\n"); + } + + #[test] + fn test_zero() { + let ts = TestScenario::new(util_name!()); + assert_eq!( + ts.fixtures.read(EXPECTED_FILE), + get_hash!( + ts.ucmd() + .arg(LENGTH_ARG) + .arg("--zero") + .arg(INPUT_FILE) + .succeeds() + .no_stderr() + .stdout_str() + ) + ); + } + + #[test] + fn test_missing_file() { + let ts = TestScenario::new(util_name!()); + let at = &ts.fixtures; + + at.write("a", "file1\n"); + at.write("c", "file3\n"); + + ts.ucmd() + .args(&[LENGTH_ARG, "a", "b", "c"]) + .fails() + .stdout_contains("a\n") + .stdout_contains("c\n") + .stderr_contains("b: No such file or directory"); + } + } + }; +} + +test_digest_with_len! {b2sum, 512} + +#[test] +fn test_check_b2sum_length_option_0() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + at.write("testf", "foobar\n"); + at.write("testf.b2sum", "9e2bf63e933e610efee4a8d6cd4a9387e80860edee97e27db3b37a828d226ab1eb92a9cdd8ca9ca67a753edaf8bd89a0558496f67a30af6f766943839acf0110 testf\n"); + + scene + .ccmd("b2sum") + .arg("--length=0") + .arg("-c") + .arg(at.subdir.join("testf.b2sum")) + .succeeds() + .stdout_only("testf: OK\n"); +} + +#[test] +fn test_check_b2sum_length_duplicate() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + at.write("testf", "foobar\n"); + + scene + .ccmd("b2sum") + .arg("--length=123") + .arg("--length=128") + .arg("testf") + .succeeds() + .stdout_contains("d6d45901dec53e65d2b55fb6e2ab67b0"); +} + +#[test] +fn test_check_b2sum_length_option_8() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + at.write("testf", "foobar\n"); + at.write("testf.b2sum", "6a testf\n"); + + scene + .ccmd("b2sum") + .arg("--length=8") + .arg("-c") + .arg(at.subdir.join("testf.b2sum")) + .succeeds() + .stdout_only("testf: OK\n"); +} + +#[test] +fn test_invalid_b2sum_length_option_not_multiple_of_8() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + at.write("testf", "foobar\n"); + + scene + .ccmd("b2sum") + .arg("--length=9") + .arg(at.subdir.join("testf")) + .fails_with_code(1) + .stderr_contains("b2sum: invalid length: '9'") + .stderr_contains("b2sum: length is not a multiple of 8"); +} + +#[rstest] +#[case("513")] +#[case("1024")] +#[case("18446744073709552000")] +fn test_invalid_b2sum_length_option_too_large(#[case] len: &str) { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + at.write("testf", "foobar\n"); + + scene + .ccmd("b2sum") + .arg("--length") + .arg(len) + .arg(at.subdir.join("testf")) + .fails_with_code(1) + .no_stdout() + .stderr_contains(format!("b2sum: invalid length: '{len}'")) + .stderr_contains("b2sum: maximum digest length for 'BLAKE2b' is 512 bits"); +} + +#[test] +fn test_check_b2sum_tag_output() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + at.touch("f"); + + scene + .ccmd("b2sum") + .arg("--length=0") + .arg("--tag") + .arg("f") + .succeeds() + .stdout_only("BLAKE2b (f) = 786a02f742015903c6c6fd852552d272912f4740e15847618a86e217f71f5419d25e1031afee585313896444934eb04b903a685b1448b755d56f701afe9be2ce\n"); + + scene + .ccmd("b2sum") + .arg("--length=128") + .arg("--tag") + .arg("f") + .succeeds() + .stdout_only("BLAKE2b-128 (f) = cae66941d9efbd404e4d88758ea67670\n"); +} + +#[test] +fn test_check_b2sum_verify() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + at.write("a", "a\n"); + + scene + .ccmd("b2sum") + .arg("--tag") + .arg("a") + .succeeds() + .stdout_only("BLAKE2b (a) = bedfbb90d858c2d67b7ee8f7523be3d3b54004ef9e4f02f2ad79a1d05bfdfe49b81e3c92ebf99b504102b6bf003fa342587f5b3124c205f55204e8c4b4ce7d7c\n"); + + scene + .ccmd("b2sum") + .arg("--tag") + .arg("-l") + .arg("128") + .arg("a") + .succeeds() + .stdout_only("BLAKE2b-128 (a) = b93e0fc7bb21633c08bba07c5e71dc00\n"); +} + +#[test] +fn test_invalid_arg() { + new_ucmd!().arg("--definitely-invalid").fails_with_code(1); +} + +#[test] +fn test_check_b2sum_strict_check() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + at.touch("f"); + + let checksums = [ + "2e f\n", + "e4a6a0577479b2b4 f\n", + "cae66941d9efbd404e4d88758ea67670 f\n", + "246c0442cd564aced8145b8b60f1370aa7 f\n", + "0e5751c026e543b2e8ab2eb06099daa1d1e5df47778f7787faab45cdf12fe3a8 f\n", + "4ded8c5fc8b12f3273f877ca585a44ad6503249a2b345d6d9c0e67d85bcb700db4178c0303e93b8f4ad758b8e2c9fd8b3d0c28e585f1928334bb77d36782e8 f\n", + "786a02f742015903c6c6fd852552d272912f4740e15847618a86e217f71f5419d25e1031afee585313896444934eb04b903a685b1448b755d56f701afe9be2ce f\n", + ]; + + at.write("ck", &checksums.join("")); + + let output = "f: OK\n".to_string().repeat(checksums.len()); + + scene + .ccmd("b2sum") + .arg("-c") + .arg(at.subdir.join("ck")) + .succeeds() + .stdout_only(&output); + + scene + .ccmd("b2sum") + .arg("--strict") + .arg("-c") + .arg(at.subdir.join("ck")) + .succeeds() + .stdout_only(&output); +} + +#[test] +fn test_help_shows_correct_utility_name() { + // Test that help output shows the actual utility name instead of "hashsum" + let scene = TestScenario::new(util_name!()); + + // Test b2sum + scene + .ccmd("b2sum") + .arg("--help") + .succeeds() + .stdout_contains("Usage: b2sum") + .stdout_does_not_contain("Usage: hashsum"); +} diff --git a/tests/by-util/test_hashsum.rs b/tests/by-util/test_hashsum.rs index 2022f81b8..26125dc5e 100644 --- a/tests/by-util/test_hashsum.rs +++ b/tests/by-util/test_hashsum.rs @@ -214,7 +214,6 @@ test_digest_with_len! {sha3_224, sha3, 224} test_digest_with_len! {sha3_256, sha3, 256} test_digest_with_len! {sha3_384, sha3, 384} test_digest_with_len! {sha3_512, sha3, 512} -test_digest_with_len! {b2sum, b2sum, 512} #[test] fn test_check_sha1() { @@ -271,6 +270,7 @@ fn test_check_md5_ignore_missing() { .stderr_contains("the --ignore-missing option is meaningful only when verifying checksums"); } +#[ignore = "moved to standalone"] #[test] fn test_check_b2sum_length_option_0() { let scene = TestScenario::new(util_name!()); @@ -288,6 +288,7 @@ fn test_check_b2sum_length_option_0() { .stdout_only("testf: OK\n"); } +#[ignore = "moved to standalone"] #[test] fn test_check_b2sum_length_duplicate() { let scene = TestScenario::new(util_name!()); @@ -304,6 +305,7 @@ fn test_check_b2sum_length_duplicate() { .stdout_contains("d6d45901dec53e65d2b55fb6e2ab67b0"); } +#[ignore = "moved to standalone"] #[test] fn test_check_b2sum_length_option_8() { let scene = TestScenario::new(util_name!()); @@ -321,6 +323,7 @@ fn test_check_b2sum_length_option_8() { .stdout_only("testf: OK\n"); } +#[ignore = "moved to standalone"] #[test] fn test_invalid_b2sum_length_option_not_multiple_of_8() { let scene = TestScenario::new(util_name!()); @@ -338,8 +341,11 @@ fn test_invalid_b2sum_length_option_not_multiple_of_8() { } #[rstest] +#[ignore = "moved to standalone"] #[case("513")] +#[ignore = "moved to standalone"] #[case("1024")] +#[ignore = "moved to standalone"] #[case("18446744073709552000")] fn test_invalid_b2sum_length_option_too_large(#[case] len: &str) { let scene = TestScenario::new(util_name!()); @@ -358,6 +364,7 @@ fn test_invalid_b2sum_length_option_too_large(#[case] len: &str) { .stderr_contains("b2sum: maximum digest length for 'BLAKE2b' is 512 bits"); } +#[ignore = "moved to standalone"] #[test] fn test_check_b2sum_tag_output() { let scene = TestScenario::new(util_name!()); @@ -382,6 +389,7 @@ fn test_check_b2sum_tag_output() { .stdout_only("BLAKE2b-128 (f) = cae66941d9efbd404e4d88758ea67670\n"); } +#[ignore = "moved to standalone"] #[test] fn test_check_b2sum_verify() { let scene = TestScenario::new(util_name!()); @@ -1068,6 +1076,7 @@ fn test_star_to_start() { .stdout_only("f: OK\n"); } +#[ignore = "moved to standalone"] #[test] fn test_check_b2sum_strict_check() { let scene = TestScenario::new(util_name!()); diff --git a/tests/fixtures/hashsum/b2sum.checkfile b/tests/fixtures/b2sum/b2sum.checkfile similarity index 100% rename from tests/fixtures/hashsum/b2sum.checkfile rename to tests/fixtures/b2sum/b2sum.checkfile diff --git a/tests/fixtures/hashsum/b2sum.expected b/tests/fixtures/b2sum/b2sum.expected similarity index 100% rename from tests/fixtures/hashsum/b2sum.expected rename to tests/fixtures/b2sum/b2sum.expected diff --git a/tests/fixtures/b2sum/input.txt b/tests/fixtures/b2sum/input.txt new file mode 100644 index 000000000..8c01d89ae --- /dev/null +++ b/tests/fixtures/b2sum/input.txt @@ -0,0 +1 @@ +hello, world \ No newline at end of file diff --git a/tests/tests.rs b/tests/tests.rs index 1b2a2131b..78225ac97 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -68,6 +68,10 @@ mod test_cksum; #[path = "by-util/test_comm.rs"] mod test_comm; +#[cfg(feature = "b2sum")] +#[path = "by-util/test_b2sum.rs"] +mod test_b2sum; + #[cfg(feature = "md5sum")] #[path = "by-util/test_md5sum.rs"] mod test_md5sum; From 6aa5b1fe66841c337060fccb48e9c87299ca4450 Mon Sep 17 00:00:00 2001 From: Dorian Peron Date: Fri, 9 Jan 2026 16:40:17 +0100 Subject: [PATCH 385/425] sha1sum: introduce standalone binary --- Cargo.lock | 13 ++ Cargo.toml | 2 + GNUmakefile | 1 - build.rs | 1 - src/common/validation.rs | 2 +- src/uu/sha1sum/Cargo.toml | 38 ++++ src/uu/sha1sum/LICENSE | 1 + src/uu/sha1sum/locales/en-US.ftl | 2 + src/uu/sha1sum/locales/fr-FR.ftl | 2 + src/uu/sha1sum/src/main.rs | 1 + src/uu/sha1sum/src/sha1sum.rs | 1 + src/uucore/src/lib/lib.rs | 2 +- src/uucore/src/lib/mods/locale.rs | 2 +- tests/by-util/test_hashsum.rs | 3 +- tests/by-util/test_sha1sum.rs | 165 ++++++++++++++++++ tests/fixtures/sha1sum/input.txt | 1 + .../{hashsum => sha1sum}/sha1.checkfile | 0 .../{hashsum => sha1sum}/sha1.expected | 0 tests/tests.rs | 4 + 19 files changed, 235 insertions(+), 6 deletions(-) create mode 100644 src/uu/sha1sum/Cargo.toml create mode 120000 src/uu/sha1sum/LICENSE create mode 100644 src/uu/sha1sum/locales/en-US.ftl create mode 100644 src/uu/sha1sum/locales/fr-FR.ftl create mode 100644 src/uu/sha1sum/src/main.rs create mode 100644 src/uu/sha1sum/src/sha1sum.rs create mode 100644 tests/by-util/test_sha1sum.rs create mode 100644 tests/fixtures/sha1sum/input.txt rename tests/fixtures/{hashsum => sha1sum}/sha1.checkfile (100%) rename tests/fixtures/{hashsum => sha1sum}/sha1.expected (100%) diff --git a/Cargo.lock b/Cargo.lock index a1b35d585..8269a9be2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -627,6 +627,7 @@ dependencies = [ "uu_rmdir", "uu_runcon", "uu_seq", + "uu_sha1sum", "uu_shred", "uu_shuf", "uu_sleep", @@ -3984,6 +3985,18 @@ dependencies = [ "uucore", ] +[[package]] +name = "uu_sha1sum" +version = "0.6.0" +dependencies = [ + "clap", + "codspeed-divan-compat", + "fluent", + "tempfile", + "uu_checksum_common", + "uucore", +] + [[package]] name = "uu_shred" version = "0.6.0" diff --git a/Cargo.toml b/Cargo.toml index 429959b28..d2e72e7ac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -88,6 +88,7 @@ feat_common_core = [ "cksum", "b2sum", "md5sum", + "sha1sum", "comm", "cp", "csplit", @@ -441,6 +442,7 @@ chroot = { optional = true, version = "0.6.0", package = "uu_chroot", path = "sr cksum = { optional = true, version = "0.6.0", package = "uu_cksum", path = "src/uu/cksum" } b2sum = { optional = true, version = "0.6.0", package = "uu_b2sum", path = "src/uu/b2sum" } md5sum = { optional = true, version = "0.6.0", package = "uu_md5sum", path = "src/uu/md5sum" } +sha1sum = { optional = true, version = "0.6.0", package = "uu_sha1sum", path = "src/uu/sha1sum" } comm = { optional = true, version = "0.6.0", package = "uu_comm", path = "src/uu/comm" } cp = { optional = true, version = "0.6.0", package = "uu_cp", path = "src/uu/cp" } csplit = { optional = true, version = "0.6.0", package = "uu_csplit", path = "src/uu/csplit" } diff --git a/GNUmakefile b/GNUmakefile index b28ef8903..053441c74 100644 --- a/GNUmakefile +++ b/GNUmakefile @@ -96,7 +96,6 @@ SELINUX_PROGS := \ runcon HASHSUM_PROGS := \ - sha1sum \ sha224sum \ sha256sum \ sha384sum \ diff --git a/build.rs b/build.rs index 39a26a807..6b0e177fd 100644 --- a/build.rs +++ b/build.rs @@ -91,7 +91,6 @@ pub fn main() { phf_map.entry(krate, format!("({krate}::uumain, {krate}::uu_app_custom)")); let map_value = format!("({krate}::uumain, {krate}::uu_app_common)"); - phf_map.entry("sha1sum", map_value.clone()); phf_map.entry("sha224sum", map_value.clone()); phf_map.entry("sha256sum", map_value.clone()); phf_map.entry("sha384sum", map_value.clone()); diff --git a/src/common/validation.rs b/src/common/validation.rs index 773505bed..ffadfd917 100644 --- a/src/common/validation.rs +++ b/src/common/validation.rs @@ -51,7 +51,7 @@ fn get_canonical_util_name(util_name: &str) -> &str { "[" => "test", // hashsum aliases - all these hash commands are aliases for hashsum - "sha1sum" | "sha224sum" | "sha256sum" | "sha384sum" | "sha512sum" => "hashsum", + "sha224sum" | "sha256sum" | "sha384sum" | "sha512sum" => "hashsum", "dir" => "ls", // dir is an alias for ls diff --git a/src/uu/sha1sum/Cargo.toml b/src/uu/sha1sum/Cargo.toml new file mode 100644 index 000000000..001bddd69 --- /dev/null +++ b/src/uu/sha1sum/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "uu_sha1sum" +description = "sha1sum ~ (uutils) Print or check the SHA1 checksums" +repository = "https://github.com/uutils/coreutils/tree/main/src/uu/sha1sum" +version.workspace = true +authors.workspace = true +license.workspace = true +homepage.workspace = true +keywords.workspace = true +categories.workspace = true +edition.workspace = true +readme.workspace = true + +[lints] +workspace = true + +[lib] +path = "src/sha1sum.rs" + +[dependencies] +clap = { workspace = true } +uu_checksum_common = { workspace = true } +uucore = { workspace = true, features = [ + "checksum", + "encoding", + "sum", + "hardware", +] } +fluent = { workspace = true } + +[dev-dependencies] +divan = { workspace = true } +tempfile = { workspace = true } +uucore = { workspace = true, features = ["benchmark"] } + +[[bin]] +name = "sha1sum" +path = "src/main.rs" diff --git a/src/uu/sha1sum/LICENSE b/src/uu/sha1sum/LICENSE new file mode 120000 index 000000000..5853aaea5 --- /dev/null +++ b/src/uu/sha1sum/LICENSE @@ -0,0 +1 @@ +../../../LICENSE \ No newline at end of file diff --git a/src/uu/sha1sum/locales/en-US.ftl b/src/uu/sha1sum/locales/en-US.ftl new file mode 100644 index 000000000..378b8f8d4 --- /dev/null +++ b/src/uu/sha1sum/locales/en-US.ftl @@ -0,0 +1,2 @@ +sha1sum-about = Print or check the SHA1 checksums +sha1sum-usage = sha1sum [OPTIONS] [FILE]... diff --git a/src/uu/sha1sum/locales/fr-FR.ftl b/src/uu/sha1sum/locales/fr-FR.ftl new file mode 100644 index 000000000..865bd8071 --- /dev/null +++ b/src/uu/sha1sum/locales/fr-FR.ftl @@ -0,0 +1,2 @@ +sha1sum-about = Afficher le SHA1 et la taille de chaque fichier +sha1sum-usage = sha1sum [OPTION]... [FICHIER]... diff --git a/src/uu/sha1sum/src/main.rs b/src/uu/sha1sum/src/main.rs new file mode 100644 index 000000000..18d80cfde --- /dev/null +++ b/src/uu/sha1sum/src/main.rs @@ -0,0 +1 @@ +uucore::bin!(uu_sha1sum); diff --git a/src/uu/sha1sum/src/sha1sum.rs b/src/uu/sha1sum/src/sha1sum.rs new file mode 100644 index 000000000..e715c7966 --- /dev/null +++ b/src/uu/sha1sum/src/sha1sum.rs @@ -0,0 +1 @@ +uu_checksum_common::declare_standalone!("sha1sum", uucore::checksum::AlgoKind::Sha1); diff --git a/src/uucore/src/lib/lib.rs b/src/uucore/src/lib/lib.rs index d793867c5..203a7ebb2 100644 --- a/src/uucore/src/lib/lib.rs +++ b/src/uucore/src/lib/lib.rs @@ -173,7 +173,7 @@ pub fn get_canonical_util_name(util_name: &str) -> &str { "[" => "test", // hashsum aliases - all these hash commands are aliases for hashsum - "sha1sum" | "sha224sum" | "sha256sum" | "sha384sum" | "sha512sum" => "hashsum", + "sha224sum" | "sha256sum" | "sha384sum" | "sha512sum" => "hashsum", "dir" => "ls", // dir is an alias for ls diff --git a/src/uucore/src/lib/mods/locale.rs b/src/uucore/src/lib/mods/locale.rs index 6036501f4..2b740eff8 100644 --- a/src/uucore/src/lib/mods/locale.rs +++ b/src/uucore/src/lib/mods/locale.rs @@ -157,7 +157,7 @@ fn create_bundle( try_add_resource_from(get_locales_dir(util_name).ok()); // checksum binaries also require fluent files from the checksum_common crate - if ["cksum", "b2sum", "md5sum"].contains(&util_name) { + if ["cksum", "b2sum", "md5sum", "sha1sum"].contains(&util_name) { try_add_resource_from(get_locales_dir("checksum_common").ok()); } diff --git a/tests/by-util/test_hashsum.rs b/tests/by-util/test_hashsum.rs index 26125dc5e..646724d66 100644 --- a/tests/by-util/test_hashsum.rs +++ b/tests/by-util/test_hashsum.rs @@ -201,7 +201,6 @@ macro_rules! test_digest_with_len { }; } -test_digest! {sha1, sha1} test_digest! {b3sum, b3sum} test_digest! {shake128, shake128} test_digest! {shake256, shake256} @@ -215,6 +214,7 @@ test_digest_with_len! {sha3_256, sha3, 256} test_digest_with_len! {sha3_384, sha3, 384} test_digest_with_len! {sha3_512, sha3, 512} +#[ignore = "moved to standalone"] #[test] fn test_check_sha1() { // To make sure that #3815 doesn't happen again @@ -414,6 +414,7 @@ fn test_check_b2sum_verify() { .stdout_only("BLAKE2b-128 (a) = b93e0fc7bb21633c08bba07c5e71dc00\n"); } +#[ignore = "moved to standalone"] #[test] fn test_check_file_not_found_warning() { let scene = TestScenario::new(util_name!()); diff --git a/tests/by-util/test_sha1sum.rs b/tests/by-util/test_sha1sum.rs new file mode 100644 index 000000000..d0e7f6f3d --- /dev/null +++ b/tests/by-util/test_sha1sum.rs @@ -0,0 +1,165 @@ +// This file is part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +use uutests::new_ucmd; +use uutests::util::TestScenario; +use uutests::util_name; +// spell-checker:ignore checkfile, testf, ntestf +macro_rules! get_hash( + ($str:expr) => ( + $str.split(' ').collect::>()[0] + ); +); + +macro_rules! test_digest { + ($id:ident) => { + mod $id { + use uutests::util::*; + use uutests::util_name; + static EXPECTED_FILE: &'static str = concat!(stringify!($id), ".expected"); + static CHECK_FILE: &'static str = concat!(stringify!($id), ".checkfile"); + static INPUT_FILE: &'static str = "input.txt"; + + #[test] + fn test_single_file() { + let ts = TestScenario::new(util_name!()); + assert_eq!( + ts.fixtures.read(EXPECTED_FILE), + get_hash!( + ts.ucmd() + .arg(INPUT_FILE) + .succeeds() + .no_stderr() + .stdout_str() + ) + ); + } + + #[test] + fn test_stdin() { + let ts = TestScenario::new(util_name!()); + assert_eq!( + ts.fixtures.read(EXPECTED_FILE), + get_hash!( + ts.ucmd() + .pipe_in_fixture(INPUT_FILE) + .succeeds() + .no_stderr() + .stdout_str() + ) + ); + } + + #[test] + fn test_check() { + let ts = TestScenario::new(util_name!()); + println!("File content='{}'", ts.fixtures.read(INPUT_FILE)); + println!("Check file='{}'", ts.fixtures.read(CHECK_FILE)); + + ts.ucmd() + .args(&["--check", CHECK_FILE]) + .succeeds() + .no_stderr() + .stdout_is("input.txt: OK\n"); + } + + #[test] + fn test_zero() { + let ts = TestScenario::new(util_name!()); + assert_eq!( + ts.fixtures.read(EXPECTED_FILE), + get_hash!( + ts.ucmd() + .arg("--zero") + .arg(INPUT_FILE) + .succeeds() + .no_stderr() + .stdout_str() + ) + ); + } + + #[test] + fn test_missing_file() { + let ts = TestScenario::new(util_name!()); + let at = &ts.fixtures; + + at.write("a", "file1\n"); + at.write("c", "file3\n"); + + ts.ucmd() + .args(&["a", "b", "c"]) + .fails() + .stdout_contains("a\n") + .stdout_contains("c\n") + .stderr_contains("b: No such file or directory"); + } + } + }; +} + +test_digest! {sha1} + +#[test] +fn test_check_sha1() { + // To make sure that #3815 doesn't happen again + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + at.write("testf", "foobar\n"); + at.write( + "testf.sha1", + "988881adc9fc3655077dc2d4d757d480b5ea0e11 testf\n", + ); + scene + .ccmd("sha1sum") + .arg("-c") + .arg(at.subdir.join("testf.sha1")) + .succeeds() + .stdout_is("testf: OK\n") + .stderr_is(""); +} + +#[test] +fn test_check_file_not_found_warning() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + at.write("testf", "foobar\n"); + at.write( + "testf.sha1", + "988881adc9fc3655077dc2d4d757d480b5ea0e11 testf\n", + ); + at.remove("testf"); + scene + .ccmd("sha1sum") + .arg("-c") + .arg(at.subdir.join("testf.sha1")) + .fails() + .stdout_is("testf: FAILED open or read\n") + .stderr_is("sha1sum: testf: No such file or directory\nsha1sum: WARNING: 1 listed file could not be read\n"); +} + +#[test] +fn test_invalid_arg() { + new_ucmd!().arg("--definitely-invalid").fails_with_code(1); +} + +#[test] +fn test_conflicting_arg() { + new_ucmd!().arg("--tag").arg("--check").fails_with_code(1); + new_ucmd!().arg("--tag").arg("--text").fails_with_code(1); +} + +#[test] +fn test_help_shows_correct_utility_name() { + // Test that help output shows the actual utility name instead of "hashsum" + + new_ucmd!() + .arg("--help") + .succeeds() + .stdout_contains("Usage: sha1sum") + .stdout_does_not_contain("Usage: hashsum"); +} diff --git a/tests/fixtures/sha1sum/input.txt b/tests/fixtures/sha1sum/input.txt new file mode 100644 index 000000000..8c01d89ae --- /dev/null +++ b/tests/fixtures/sha1sum/input.txt @@ -0,0 +1 @@ +hello, world \ No newline at end of file diff --git a/tests/fixtures/hashsum/sha1.checkfile b/tests/fixtures/sha1sum/sha1.checkfile similarity index 100% rename from tests/fixtures/hashsum/sha1.checkfile rename to tests/fixtures/sha1sum/sha1.checkfile diff --git a/tests/fixtures/hashsum/sha1.expected b/tests/fixtures/sha1sum/sha1.expected similarity index 100% rename from tests/fixtures/hashsum/sha1.expected rename to tests/fixtures/sha1sum/sha1.expected diff --git a/tests/tests.rs b/tests/tests.rs index 78225ac97..76deb8c7d 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -76,6 +76,10 @@ mod test_b2sum; #[path = "by-util/test_md5sum.rs"] mod test_md5sum; +#[cfg(feature = "sha1sum")] +#[path = "by-util/test_sha1sum.rs"] +mod test_sha1sum; + #[cfg(feature = "cp")] #[path = "by-util/test_cp.rs"] mod test_cp; From e5f72019d24b737d4745029cded664647c0825da Mon Sep 17 00:00:00 2001 From: Dorian Peron Date: Fri, 9 Jan 2026 17:03:13 +0100 Subject: [PATCH 386/425] sha224sum: introduce standalone binary --- Cargo.lock | 13 ++ Cargo.toml | 2 + GNUmakefile | 1 - build.rs | 1 - src/common/validation.rs | 2 +- src/uu/sha224sum/Cargo.toml | 38 ++++++ src/uu/sha224sum/LICENSE | 1 + src/uu/sha224sum/locales/en-US.ftl | 2 + src/uu/sha224sum/locales/fr-FR.ftl | 2 + src/uu/sha224sum/src/main.rs | 1 + src/uu/sha224sum/src/sha224sum.rs | 1 + src/uucore/src/lib/lib.rs | 2 +- src/uucore/src/lib/mods/locale.rs | 2 +- tests/by-util/test_hashsum.rs | 1 - tests/by-util/test_sha224sum.rs | 120 ++++++++++++++++++ tests/fixtures/sha224sum/input.txt | 1 + .../{hashsum => sha224sum}/sha224.checkfile | 0 .../{hashsum => sha224sum}/sha224.expected | 0 tests/tests.rs | 4 + 19 files changed, 188 insertions(+), 6 deletions(-) create mode 100644 src/uu/sha224sum/Cargo.toml create mode 120000 src/uu/sha224sum/LICENSE create mode 100644 src/uu/sha224sum/locales/en-US.ftl create mode 100644 src/uu/sha224sum/locales/fr-FR.ftl create mode 100644 src/uu/sha224sum/src/main.rs create mode 100644 src/uu/sha224sum/src/sha224sum.rs create mode 100644 tests/by-util/test_sha224sum.rs create mode 100644 tests/fixtures/sha224sum/input.txt rename tests/fixtures/{hashsum => sha224sum}/sha224.checkfile (100%) rename tests/fixtures/{hashsum => sha224sum}/sha224.expected (100%) diff --git a/Cargo.lock b/Cargo.lock index 8269a9be2..8232e2e27 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -628,6 +628,7 @@ dependencies = [ "uu_runcon", "uu_seq", "uu_sha1sum", + "uu_sha224sum", "uu_shred", "uu_shuf", "uu_sleep", @@ -3997,6 +3998,18 @@ dependencies = [ "uucore", ] +[[package]] +name = "uu_sha224sum" +version = "0.6.0" +dependencies = [ + "clap", + "codspeed-divan-compat", + "fluent", + "tempfile", + "uu_checksum_common", + "uucore", +] + [[package]] name = "uu_shred" version = "0.6.0" diff --git a/Cargo.toml b/Cargo.toml index d2e72e7ac..789586c3d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -89,6 +89,7 @@ feat_common_core = [ "b2sum", "md5sum", "sha1sum", + "sha224sum", "comm", "cp", "csplit", @@ -443,6 +444,7 @@ cksum = { optional = true, version = "0.6.0", package = "uu_cksum", path = "src/ b2sum = { optional = true, version = "0.6.0", package = "uu_b2sum", path = "src/uu/b2sum" } md5sum = { optional = true, version = "0.6.0", package = "uu_md5sum", path = "src/uu/md5sum" } sha1sum = { optional = true, version = "0.6.0", package = "uu_sha1sum", path = "src/uu/sha1sum" } +sha224sum = { optional = true, version = "0.6.0", package = "uu_sha224sum", path = "src/uu/sha224sum" } comm = { optional = true, version = "0.6.0", package = "uu_comm", path = "src/uu/comm" } cp = { optional = true, version = "0.6.0", package = "uu_cp", path = "src/uu/cp" } csplit = { optional = true, version = "0.6.0", package = "uu_csplit", path = "src/uu/csplit" } diff --git a/GNUmakefile b/GNUmakefile index 053441c74..f713e6390 100644 --- a/GNUmakefile +++ b/GNUmakefile @@ -96,7 +96,6 @@ SELINUX_PROGS := \ runcon HASHSUM_PROGS := \ - sha224sum \ sha256sum \ sha384sum \ sha512sum diff --git a/build.rs b/build.rs index 6b0e177fd..49d8c675c 100644 --- a/build.rs +++ b/build.rs @@ -91,7 +91,6 @@ pub fn main() { phf_map.entry(krate, format!("({krate}::uumain, {krate}::uu_app_custom)")); let map_value = format!("({krate}::uumain, {krate}::uu_app_common)"); - phf_map.entry("sha224sum", map_value.clone()); phf_map.entry("sha256sum", map_value.clone()); phf_map.entry("sha384sum", map_value.clone()); phf_map.entry("sha512sum", map_value.clone()); diff --git a/src/common/validation.rs b/src/common/validation.rs index ffadfd917..c5aaac3d0 100644 --- a/src/common/validation.rs +++ b/src/common/validation.rs @@ -51,7 +51,7 @@ fn get_canonical_util_name(util_name: &str) -> &str { "[" => "test", // hashsum aliases - all these hash commands are aliases for hashsum - "sha224sum" | "sha256sum" | "sha384sum" | "sha512sum" => "hashsum", + "sha256sum" | "sha384sum" | "sha512sum" => "hashsum", "dir" => "ls", // dir is an alias for ls diff --git a/src/uu/sha224sum/Cargo.toml b/src/uu/sha224sum/Cargo.toml new file mode 100644 index 000000000..25086ee42 --- /dev/null +++ b/src/uu/sha224sum/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "uu_sha224sum" +description = "sha224sum ~ (uutils) Print or check the SHA224 checksums" +repository = "https://github.com/uutils/coreutils/tree/main/src/uu/sha224sum" +version.workspace = true +authors.workspace = true +license.workspace = true +homepage.workspace = true +keywords.workspace = true +categories.workspace = true +edition.workspace = true +readme.workspace = true + +[lints] +workspace = true + +[lib] +path = "src/sha224sum.rs" + +[dependencies] +clap = { workspace = true } +uu_checksum_common = { workspace = true } +uucore = { workspace = true, features = [ + "checksum", + "encoding", + "sum", + "hardware", +] } +fluent = { workspace = true } + +[dev-dependencies] +divan = { workspace = true } +tempfile = { workspace = true } +uucore = { workspace = true, features = ["benchmark"] } + +[[bin]] +name = "sha224sum" +path = "src/main.rs" diff --git a/src/uu/sha224sum/LICENSE b/src/uu/sha224sum/LICENSE new file mode 120000 index 000000000..5853aaea5 --- /dev/null +++ b/src/uu/sha224sum/LICENSE @@ -0,0 +1 @@ +../../../LICENSE \ No newline at end of file diff --git a/src/uu/sha224sum/locales/en-US.ftl b/src/uu/sha224sum/locales/en-US.ftl new file mode 100644 index 000000000..00f852b71 --- /dev/null +++ b/src/uu/sha224sum/locales/en-US.ftl @@ -0,0 +1,2 @@ +sha224sum-about = Print or check the SHA224 checksums +sha224sum-usage = sha224sum [OPTIONS] [FILE]... diff --git a/src/uu/sha224sum/locales/fr-FR.ftl b/src/uu/sha224sum/locales/fr-FR.ftl new file mode 100644 index 000000000..dbd90e9f3 --- /dev/null +++ b/src/uu/sha224sum/locales/fr-FR.ftl @@ -0,0 +1,2 @@ +sha224sum-about = Afficher le SHA224 et la taille de chaque fichier +sha224sum-usage = sha224sum [OPTION]... [FICHIER]... diff --git a/src/uu/sha224sum/src/main.rs b/src/uu/sha224sum/src/main.rs new file mode 100644 index 000000000..974671331 --- /dev/null +++ b/src/uu/sha224sum/src/main.rs @@ -0,0 +1 @@ +uucore::bin!(uu_sha224sum); diff --git a/src/uu/sha224sum/src/sha224sum.rs b/src/uu/sha224sum/src/sha224sum.rs new file mode 100644 index 000000000..349104675 --- /dev/null +++ b/src/uu/sha224sum/src/sha224sum.rs @@ -0,0 +1 @@ +uu_checksum_common::declare_standalone!("sha224sum", uucore::checksum::AlgoKind::Sha224); diff --git a/src/uucore/src/lib/lib.rs b/src/uucore/src/lib/lib.rs index 203a7ebb2..b013e9128 100644 --- a/src/uucore/src/lib/lib.rs +++ b/src/uucore/src/lib/lib.rs @@ -173,7 +173,7 @@ pub fn get_canonical_util_name(util_name: &str) -> &str { "[" => "test", // hashsum aliases - all these hash commands are aliases for hashsum - "sha224sum" | "sha256sum" | "sha384sum" | "sha512sum" => "hashsum", + "sha256sum" | "sha384sum" | "sha512sum" => "hashsum", "dir" => "ls", // dir is an alias for ls diff --git a/src/uucore/src/lib/mods/locale.rs b/src/uucore/src/lib/mods/locale.rs index 2b740eff8..c69a5452d 100644 --- a/src/uucore/src/lib/mods/locale.rs +++ b/src/uucore/src/lib/mods/locale.rs @@ -157,7 +157,7 @@ fn create_bundle( try_add_resource_from(get_locales_dir(util_name).ok()); // checksum binaries also require fluent files from the checksum_common crate - if ["cksum", "b2sum", "md5sum", "sha1sum"].contains(&util_name) { + if ["cksum", "b2sum", "md5sum", "sha1sum", "sha224sum"].contains(&util_name) { try_add_resource_from(get_locales_dir("checksum_common").ok()); } diff --git a/tests/by-util/test_hashsum.rs b/tests/by-util/test_hashsum.rs index 646724d66..ea044f6ab 100644 --- a/tests/by-util/test_hashsum.rs +++ b/tests/by-util/test_hashsum.rs @@ -205,7 +205,6 @@ test_digest! {b3sum, b3sum} test_digest! {shake128, shake128} test_digest! {shake256, shake256} -test_digest_with_len! {sha224, sha224, 224} test_digest_with_len! {sha256, sha256, 256} test_digest_with_len! {sha384, sha384, 384} test_digest_with_len! {sha512, sha512, 512} diff --git a/tests/by-util/test_sha224sum.rs b/tests/by-util/test_sha224sum.rs new file mode 100644 index 000000000..e2b7129b8 --- /dev/null +++ b/tests/by-util/test_sha224sum.rs @@ -0,0 +1,120 @@ +// This file is part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +use uutests::new_ucmd; +// spell-checker:ignore checkfile, testf, ntestf +macro_rules! get_hash( + ($str:expr) => ( + $str.split(' ').collect::>()[0] + ); +); + +macro_rules! test_digest { + ($id:ident) => { + mod $id { + use uutests::util::*; + use uutests::util_name; + static EXPECTED_FILE: &'static str = concat!(stringify!($id), ".expected"); + static CHECK_FILE: &'static str = concat!(stringify!($id), ".checkfile"); + static INPUT_FILE: &'static str = "input.txt"; + + #[test] + fn test_single_file() { + let ts = TestScenario::new(util_name!()); + assert_eq!( + ts.fixtures.read(EXPECTED_FILE), + get_hash!( + ts.ucmd() + .arg(INPUT_FILE) + .succeeds() + .no_stderr() + .stdout_str() + ) + ); + } + + #[test] + fn test_stdin() { + let ts = TestScenario::new(util_name!()); + assert_eq!( + ts.fixtures.read(EXPECTED_FILE), + get_hash!( + ts.ucmd() + .pipe_in_fixture(INPUT_FILE) + .succeeds() + .no_stderr() + .stdout_str() + ) + ); + } + + #[test] + fn test_check() { + let ts = TestScenario::new(util_name!()); + println!("File content='{}'", ts.fixtures.read(INPUT_FILE)); + println!("Check file='{}'", ts.fixtures.read(CHECK_FILE)); + + ts.ucmd() + .args(&["--check", CHECK_FILE]) + .succeeds() + .no_stderr() + .stdout_is("input.txt: OK\n"); + } + + #[test] + fn test_zero() { + let ts = TestScenario::new(util_name!()); + assert_eq!( + ts.fixtures.read(EXPECTED_FILE), + get_hash!( + ts.ucmd() + .arg("--zero") + .arg(INPUT_FILE) + .succeeds() + .no_stderr() + .stdout_str() + ) + ); + } + + #[test] + fn test_missing_file() { + let ts = TestScenario::new(util_name!()); + let at = &ts.fixtures; + + at.write("a", "file1\n"); + at.write("c", "file3\n"); + + ts.ucmd() + .args(&["a", "b", "c"]) + .fails() + .stdout_contains("a\n") + .stdout_contains("c\n") + .stderr_contains("b: No such file or directory"); + } + } + }; +} +test_digest! {sha224} + +#[test] +fn test_invalid_arg() { + new_ucmd!().arg("--definitely-invalid").fails_with_code(1); +} + +#[test] +fn test_conflicting_arg() { + new_ucmd!().arg("--tag").arg("--check").fails_with_code(1); +} + +#[test] +fn test_help_shows_correct_utility_name() { + // Test that help output shows the actual utility name instead of "hashsum" + new_ucmd!() + .arg("--help") + .succeeds() + .stdout_contains("Usage: sha224sum") + .stdout_does_not_contain("Usage: hashsum"); +} diff --git a/tests/fixtures/sha224sum/input.txt b/tests/fixtures/sha224sum/input.txt new file mode 100644 index 000000000..8c01d89ae --- /dev/null +++ b/tests/fixtures/sha224sum/input.txt @@ -0,0 +1 @@ +hello, world \ No newline at end of file diff --git a/tests/fixtures/hashsum/sha224.checkfile b/tests/fixtures/sha224sum/sha224.checkfile similarity index 100% rename from tests/fixtures/hashsum/sha224.checkfile rename to tests/fixtures/sha224sum/sha224.checkfile diff --git a/tests/fixtures/hashsum/sha224.expected b/tests/fixtures/sha224sum/sha224.expected similarity index 100% rename from tests/fixtures/hashsum/sha224.expected rename to tests/fixtures/sha224sum/sha224.expected diff --git a/tests/tests.rs b/tests/tests.rs index 76deb8c7d..b51ed1c5b 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -80,6 +80,10 @@ mod test_md5sum; #[path = "by-util/test_sha1sum.rs"] mod test_sha1sum; +#[cfg(feature = "sha224sum")] +#[path = "by-util/test_sha224sum.rs"] +mod test_sha224sum; + #[cfg(feature = "cp")] #[path = "by-util/test_cp.rs"] mod test_cp; From 928ca15251557d4fd6fc16db209f65f44eb0f018 Mon Sep 17 00:00:00 2001 From: Dorian Peron Date: Fri, 9 Jan 2026 17:18:39 +0100 Subject: [PATCH 387/425] sha256sum: introduce standalone binary --- Cargo.lock | 13 ++ Cargo.toml | 2 + GNUmakefile | 1 - build.rs | 1 - src/common/validation.rs | 2 +- src/uu/sha256sum/Cargo.toml | 38 ++++ src/uu/sha256sum/LICENSE | 1 + src/uu/sha256sum/locales/en-US.ftl | 2 + src/uu/sha256sum/locales/fr-FR.ftl | 2 + src/uu/sha256sum/src/main.rs | 1 + src/uu/sha256sum/src/sha256sum.rs | 1 + src/uucore/src/lib/lib.rs | 2 +- src/uucore/src/lib/mods/locale.rs | 11 +- tests/by-util/test_hashsum.rs | 18 +- tests/by-util/test_sha256sum.rs | 181 ++++++++++++++++++ tests/fixtures/sha256sum/binary.png | Bin 0 -> 8055 bytes .../binary.sha256.checkfile | 0 .../binary.sha256.expected | 0 tests/fixtures/sha256sum/input.txt | 1 + .../{hashsum => sha256sum}/sha256.checkfile | 0 .../{hashsum => sha256sum}/sha256.expected | 0 tests/tests.rs | 4 + 22 files changed, 268 insertions(+), 13 deletions(-) create mode 100644 src/uu/sha256sum/Cargo.toml create mode 120000 src/uu/sha256sum/LICENSE create mode 100644 src/uu/sha256sum/locales/en-US.ftl create mode 100644 src/uu/sha256sum/locales/fr-FR.ftl create mode 100644 src/uu/sha256sum/src/main.rs create mode 100644 src/uu/sha256sum/src/sha256sum.rs create mode 100644 tests/by-util/test_sha256sum.rs create mode 100644 tests/fixtures/sha256sum/binary.png rename tests/fixtures/{hashsum => sha256sum}/binary.sha256.checkfile (100%) rename tests/fixtures/{hashsum => sha256sum}/binary.sha256.expected (100%) create mode 100644 tests/fixtures/sha256sum/input.txt rename tests/fixtures/{hashsum => sha256sum}/sha256.checkfile (100%) rename tests/fixtures/{hashsum => sha256sum}/sha256.expected (100%) diff --git a/Cargo.lock b/Cargo.lock index 8232e2e27..aa8dcdf27 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -629,6 +629,7 @@ dependencies = [ "uu_seq", "uu_sha1sum", "uu_sha224sum", + "uu_sha256sum", "uu_shred", "uu_shuf", "uu_sleep", @@ -4010,6 +4011,18 @@ dependencies = [ "uucore", ] +[[package]] +name = "uu_sha256sum" +version = "0.6.0" +dependencies = [ + "clap", + "codspeed-divan-compat", + "fluent", + "tempfile", + "uu_checksum_common", + "uucore", +] + [[package]] name = "uu_shred" version = "0.6.0" diff --git a/Cargo.toml b/Cargo.toml index 789586c3d..801f67979 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -90,6 +90,7 @@ feat_common_core = [ "md5sum", "sha1sum", "sha224sum", + "sha256sum", "comm", "cp", "csplit", @@ -445,6 +446,7 @@ b2sum = { optional = true, version = "0.6.0", package = "uu_b2sum", path = "src/ md5sum = { optional = true, version = "0.6.0", package = "uu_md5sum", path = "src/uu/md5sum" } sha1sum = { optional = true, version = "0.6.0", package = "uu_sha1sum", path = "src/uu/sha1sum" } sha224sum = { optional = true, version = "0.6.0", package = "uu_sha224sum", path = "src/uu/sha224sum" } +sha256sum = { optional = true, version = "0.6.0", package = "uu_sha256sum", path = "src/uu/sha256sum" } comm = { optional = true, version = "0.6.0", package = "uu_comm", path = "src/uu/comm" } cp = { optional = true, version = "0.6.0", package = "uu_cp", path = "src/uu/cp" } csplit = { optional = true, version = "0.6.0", package = "uu_csplit", path = "src/uu/csplit" } diff --git a/GNUmakefile b/GNUmakefile index f713e6390..998968d09 100644 --- a/GNUmakefile +++ b/GNUmakefile @@ -96,7 +96,6 @@ SELINUX_PROGS := \ runcon HASHSUM_PROGS := \ - sha256sum \ sha384sum \ sha512sum diff --git a/build.rs b/build.rs index 49d8c675c..c206e892f 100644 --- a/build.rs +++ b/build.rs @@ -91,7 +91,6 @@ pub fn main() { phf_map.entry(krate, format!("({krate}::uumain, {krate}::uu_app_custom)")); let map_value = format!("({krate}::uumain, {krate}::uu_app_common)"); - phf_map.entry("sha256sum", map_value.clone()); phf_map.entry("sha384sum", map_value.clone()); phf_map.entry("sha512sum", map_value.clone()); } diff --git a/src/common/validation.rs b/src/common/validation.rs index c5aaac3d0..aa01b4679 100644 --- a/src/common/validation.rs +++ b/src/common/validation.rs @@ -51,7 +51,7 @@ fn get_canonical_util_name(util_name: &str) -> &str { "[" => "test", // hashsum aliases - all these hash commands are aliases for hashsum - "sha256sum" | "sha384sum" | "sha512sum" => "hashsum", + "sha384sum" | "sha512sum" => "hashsum", "dir" => "ls", // dir is an alias for ls diff --git a/src/uu/sha256sum/Cargo.toml b/src/uu/sha256sum/Cargo.toml new file mode 100644 index 000000000..2ca6204c0 --- /dev/null +++ b/src/uu/sha256sum/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "uu_sha256sum" +description = "sha256sum ~ (uutils) Print or check the SHA256 checksums" +repository = "https://github.com/uutils/coreutils/tree/main/src/uu/sha256sum" +version.workspace = true +authors.workspace = true +license.workspace = true +homepage.workspace = true +keywords.workspace = true +categories.workspace = true +edition.workspace = true +readme.workspace = true + +[lints] +workspace = true + +[lib] +path = "src/sha256sum.rs" + +[dependencies] +clap = { workspace = true } +uu_checksum_common = { workspace = true } +uucore = { workspace = true, features = [ + "checksum", + "encoding", + "sum", + "hardware", +] } +fluent = { workspace = true } + +[dev-dependencies] +divan = { workspace = true } +tempfile = { workspace = true } +uucore = { workspace = true, features = ["benchmark"] } + +[[bin]] +name = "sha256sum" +path = "src/main.rs" diff --git a/src/uu/sha256sum/LICENSE b/src/uu/sha256sum/LICENSE new file mode 120000 index 000000000..5853aaea5 --- /dev/null +++ b/src/uu/sha256sum/LICENSE @@ -0,0 +1 @@ +../../../LICENSE \ No newline at end of file diff --git a/src/uu/sha256sum/locales/en-US.ftl b/src/uu/sha256sum/locales/en-US.ftl new file mode 100644 index 000000000..60a0b4a3f --- /dev/null +++ b/src/uu/sha256sum/locales/en-US.ftl @@ -0,0 +1,2 @@ +sha256sum-about = Print or check the SHA256 checksums +sha256sum-usage = sha256sum [OPTIONS] [FILE]... diff --git a/src/uu/sha256sum/locales/fr-FR.ftl b/src/uu/sha256sum/locales/fr-FR.ftl new file mode 100644 index 000000000..baaa2f83b --- /dev/null +++ b/src/uu/sha256sum/locales/fr-FR.ftl @@ -0,0 +1,2 @@ +sha256sum-about = Afficher le SHA256 et la taille de chaque fichier +sha256sum-usage = sha256sum [OPTION]... [FICHIER]... diff --git a/src/uu/sha256sum/src/main.rs b/src/uu/sha256sum/src/main.rs new file mode 100644 index 000000000..323cd315d --- /dev/null +++ b/src/uu/sha256sum/src/main.rs @@ -0,0 +1 @@ +uucore::bin!(uu_sha256sum); diff --git a/src/uu/sha256sum/src/sha256sum.rs b/src/uu/sha256sum/src/sha256sum.rs new file mode 100644 index 000000000..ab47a23df --- /dev/null +++ b/src/uu/sha256sum/src/sha256sum.rs @@ -0,0 +1 @@ +uu_checksum_common::declare_standalone!("sha256sum", uucore::checksum::AlgoKind::Sha256); diff --git a/src/uucore/src/lib/lib.rs b/src/uucore/src/lib/lib.rs index b013e9128..3e6003f31 100644 --- a/src/uucore/src/lib/lib.rs +++ b/src/uucore/src/lib/lib.rs @@ -173,7 +173,7 @@ pub fn get_canonical_util_name(util_name: &str) -> &str { "[" => "test", // hashsum aliases - all these hash commands are aliases for hashsum - "sha256sum" | "sha384sum" | "sha512sum" => "hashsum", + "sha384sum" | "sha512sum" => "hashsum", "dir" => "ls", // dir is an alias for ls diff --git a/src/uucore/src/lib/mods/locale.rs b/src/uucore/src/lib/mods/locale.rs index c69a5452d..da5a996d5 100644 --- a/src/uucore/src/lib/mods/locale.rs +++ b/src/uucore/src/lib/mods/locale.rs @@ -157,7 +157,16 @@ fn create_bundle( try_add_resource_from(get_locales_dir(util_name).ok()); // checksum binaries also require fluent files from the checksum_common crate - if ["cksum", "b2sum", "md5sum", "sha1sum", "sha224sum"].contains(&util_name) { + if [ + "cksum", + "b2sum", + "md5sum", + "sha1sum", + "sha224sum", + "sha256sum", + ] + .contains(&util_name) + { try_add_resource_from(get_locales_dir("checksum_common").ok()); } diff --git a/tests/by-util/test_hashsum.rs b/tests/by-util/test_hashsum.rs index ea044f6ab..ff6d92f2d 100644 --- a/tests/by-util/test_hashsum.rs +++ b/tests/by-util/test_hashsum.rs @@ -205,7 +205,6 @@ test_digest! {b3sum, b3sum} test_digest! {shake128, shake128} test_digest! {shake256, shake256} -test_digest_with_len! {sha256, sha256, 256} test_digest_with_len! {sha384, sha384, 384} test_digest_with_len! {sha512, sha512, 512} test_digest_with_len! {sha3_224, sha3, 224} @@ -618,6 +617,7 @@ fn test_conflicting_arg() { .fails_with_code(1); } +#[ignore = "moved to standalone"] #[test] fn test_tag() { let scene = TestScenario::new(util_name!()); @@ -1184,6 +1184,7 @@ fn test_check_md5_comment_leading_space() { .stderr_contains("WARNING: 1 line is improperly formatted"); } +#[ignore = "moved to standalone"] #[test] fn test_sha256_binary() { let ts = TestScenario::new(util_name!()); @@ -1200,6 +1201,7 @@ fn test_sha256_binary() { ); } +#[ignore = "moved to standalone"] #[test] fn test_sha256_stdin_binary() { let ts = TestScenario::new(util_name!()); @@ -1217,8 +1219,8 @@ fn test_sha256_stdin_binary() { } // This test is currently disabled on windows +#[ignore = "moved to standalone"] #[test] -#[cfg_attr(windows, ignore = "Discussion is in #9168")] fn test_check_sha256_binary() { new_ucmd!() .args(&["--sha256", "--check", "binary.sha256.checkfile"]) @@ -1241,12 +1243,12 @@ fn test_help_shows_correct_utility_name() { // .stdout_does_not_contain("Usage: hashsum"); // Test sha256sum - scene - .ccmd("sha256sum") - .arg("--help") - .succeeds() - .stdout_contains("Usage: sha256sum") - .stdout_does_not_contain("Usage: hashsum"); + // scene + // .ccmd("sha256sum") + // .arg("--help") + // .succeeds() + // .stdout_contains("Usage: sha256sum") + // .stdout_does_not_contain("Usage: hashsum"); // Test b2sum // scene diff --git a/tests/by-util/test_sha256sum.rs b/tests/by-util/test_sha256sum.rs new file mode 100644 index 000000000..b3b538384 --- /dev/null +++ b/tests/by-util/test_sha256sum.rs @@ -0,0 +1,181 @@ +// This file is part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +use uutests::new_ucmd; +use uutests::util::TestScenario; +use uutests::util_name; +// spell-checker:ignore checkfile, testf, ntestf +macro_rules! get_hash( + ($str:expr) => ( + $str.split(' ').collect::>()[0] + ); +); + +macro_rules! test_digest { + ($id:ident) => { + mod $id { + use uutests::util::*; + use uutests::util_name; + static EXPECTED_FILE: &'static str = concat!(stringify!($id), ".expected"); + static CHECK_FILE: &'static str = concat!(stringify!($id), ".checkfile"); + static INPUT_FILE: &'static str = "input.txt"; + + #[test] + fn test_single_file() { + let ts = TestScenario::new(util_name!()); + assert_eq!( + ts.fixtures.read(EXPECTED_FILE), + get_hash!( + ts.ucmd() + .arg(INPUT_FILE) + .succeeds() + .no_stderr() + .stdout_str() + ) + ); + } + + #[test] + fn test_stdin() { + let ts = TestScenario::new(util_name!()); + assert_eq!( + ts.fixtures.read(EXPECTED_FILE), + get_hash!( + ts.ucmd() + .pipe_in_fixture(INPUT_FILE) + .succeeds() + .no_stderr() + .stdout_str() + ) + ); + } + + #[test] + fn test_check() { + let ts = TestScenario::new(util_name!()); + println!("File content='{}'", ts.fixtures.read(INPUT_FILE)); + println!("Check file='{}'", ts.fixtures.read(CHECK_FILE)); + + ts.ucmd() + .args(&["--check", CHECK_FILE]) + .succeeds() + .no_stderr() + .stdout_is("input.txt: OK\n"); + } + + #[test] + fn test_zero() { + let ts = TestScenario::new(util_name!()); + assert_eq!( + ts.fixtures.read(EXPECTED_FILE), + get_hash!( + ts.ucmd() + .arg("--zero") + .arg(INPUT_FILE) + .succeeds() + .no_stderr() + .stdout_str() + ) + ); + } + + #[test] + fn test_missing_file() { + let ts = TestScenario::new(util_name!()); + let at = &ts.fixtures; + + at.write("a", "file1\n"); + at.write("c", "file3\n"); + + ts.ucmd() + .args(&["a", "b", "c"]) + .fails() + .stdout_contains("a\n") + .stdout_contains("c\n") + .stderr_contains("b: No such file or directory"); + } + } + }; +} + +test_digest! {sha256} + +#[test] +fn test_invalid_arg() { + new_ucmd!().arg("--definitely-invalid").fails_with_code(1); +} + +#[test] +fn test_conflicting_arg() { + new_ucmd!().arg("--tag").arg("--check").fails_with_code(1); + new_ucmd!().arg("--tag").arg("--text").fails_with_code(1); +} + +#[test] +fn test_tag() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + at.write("foobar", "foo bar\n"); + scene + .ccmd("sha256sum") + .arg("--tag") + .arg("foobar") + .succeeds() + .stdout_is( + "SHA256 (foobar) = 1f2ec52b774368781bed1d1fb140a92e0eb6348090619c9291f9a5a3c8e8d151\n", + ); +} + +#[test] +fn test_sha256_binary() { + let ts = TestScenario::new(util_name!()); + assert_eq!( + ts.fixtures.read("binary.sha256.expected"), + get_hash!( + ts.ucmd() + .arg("binary.png") + .succeeds() + .no_stderr() + .stdout_str() + ) + ); +} + +#[test] +fn test_sha256_stdin_binary() { + let ts = TestScenario::new(util_name!()); + assert_eq!( + ts.fixtures.read("binary.sha256.expected"), + get_hash!( + ts.ucmd() + .pipe_in_fixture("binary.png") + .succeeds() + .no_stderr() + .stdout_str() + ) + ); +} + +// This test is currently disabled on windows +#[test] +#[cfg_attr(windows, ignore = "Discussion is in #9168")] +fn test_check_sha256_binary() { + new_ucmd!() + .args(&["--check", "binary.sha256.checkfile"]) + .succeeds() + .no_stderr() + .stdout_is("binary.png: OK\n"); +} + +#[test] +fn test_help_shows_correct_utility_name() { + // Test that help output shows the actual utility name instead of "hashsum" + new_ucmd!() + .arg("--help") + .succeeds() + .stdout_contains("Usage: sha256sum") + .stdout_does_not_contain("Usage: hashsum"); +} diff --git a/tests/fixtures/sha256sum/binary.png b/tests/fixtures/sha256sum/binary.png new file mode 100644 index 0000000000000000000000000000000000000000..6c4161338f200299744af6dff35884ac5c94516b GIT binary patch literal 8055 zcmeAS@N?(olHy`uVBq!ia0y~yU^oH79Bd2>3~M9S&0}DYxASyy45^s&W-j{-(W`&I z?=LQYsjy+U!h!`Mj;anhD;7woLK?U4#- zHsxY*^kQM%$iNjnVbkeNZ{F=Wf8hJC{V~ta%`q-McjnIS=bvq!Z+tWN{v6BVy5d{w zMGj67@nVoURllxtw-KB3qzv{1ONQC0Y7Nf{!%s2H5LA(oWsqS{sAqW1n8Ts4*tVgZ zf#<}J`n`--7%KD|_J}kzGsti*;BE+Ic*T6eP{QeAGuOm_udFXGy2MbVcVfcC$ZL!l zVh;C?Mi#x}R5+Z#aaLAu?k{bIX4Mpn;MJ*?B1bg!y?wUoFg#|cnXa15-O$K5|6&5i znM*QrtL&^-bNE+ixbOPo6L9T+#fb{dBgIlYmt@!$99-;dmEC@~Hl;@>fN%Mp^QEog z>FspCiwhAu^$G*&{Op5skHsF7U;a!5QVpMX7^|+9@w>_MAdBHSg9P`3`HU$(A1b#!V_3%? zF#YkbVv&99&Q6g}E_eQWQn+@p&1|N!4=PjgZOU%!`jKO4{nXR<{gDX@*M65UrN8|U z)o1xXmGi}u#M6qA=bvc&J;@-(9rN4O<^2f(;kznUm#SvvEO~jrdHW9QiW^1lVYBb3 zRo3qAxIa_TsZQ;tGHrP=P^oQ88V-#=Ym zxc`X8N9%>YT4xzcrW(BTDBiH_mx<~h9hOa*%nPdbMfE*j+|W(p(b#Llvu9RW@VO2DCJU(FusZF{Q1-Rro+HD**$q`` z(|ot^yf|zn$MqnN;XYSfzKDymI$OoU`zi~9cTP=hI$smT^y*dEoQuCcGhF|A{f_&T zN%`GhPBC0BxfsANYNPsN_jD$^?RilQFC-uA(v^C(sCU@{1<%xd58j(qpZWcH;;bFo z3eNj9Jwoq@26Xtiue=b^zrs+t!;LGGMRVehT>IXy9a4)Gq*gL-Fn#Bx@yOud{kO*? z!dnlh&3pfZ_vbQ&6WjGCurKHAQxfLVIF)qoEW;z4U;4{p&#RqmyX|!Ov&yEvI??$B z)1T+`-`}>_e&=Eh`CWhR26lb?GV}YRVupT(7pw{L4AqPo(w{6#6hF`Fwy3%$azo0e zBg8GFpRH`lXUVq9{1=&5t~h1RkatdSj_;9!tET0xlQmxVT3PU`%5BC~stw7R+l0z* zr%Y#MdS9-vcHRDGrsuZ&+QrbeveJ9w@zePW@_!^V)VW`Nb#70d{lNn3U8l9Ky*IZJ z{WkkwjG{(4cXD)R9YYv{#Ixe}Wj$`6>LrgZHDi2hK67!lpt=09OJ{$IGOV^`iu&>M zvN(^es`Qn>S^bP5$NwZfXSPs$u;q&o@7IsPeCBg5KApv);bpmYW9qt-eXoKxOi{ZL zWV+d;z{{ZXn_Hsuy^cPWI@gpZ9~P@FzGN@4VDYigWi2(&_Bff`h>Tgbbqy^ymk zx;Xpxbag zEzO?A>?zNtY@TP>kh&@K<&j0)A3jOkyUzA*m#Jl)RH1YJLvKH=-vztMyB4@Q%0%>> zmwsAZZU3&lult;!?v=Tjr*wF4FZP`(wngwWcgfmScVxdaA3u3q(BwmJp?yl@CEZ1u z*LN1$XT-d5FAs|4-OY4B-*#1EG=s*YhraDVwn`UD64HO5r>wJL zM(nmtkJnw?v;AMqftG)51wCI*Zjs)$CM{}5@13Wgf4z;F_qfIFD95EHWj7%|tNF|Y zTGAJ$M=t(eb!pR@`K#9)*z!~~+&4O|aI)9TMSDs^{`ao>T<-ICP0gFb+FJ8P|LnZX z{*O5!erhSl36&_lcBj_UxtUH=9=oM^2?g;ri)}MH|5&;(T;*`ThKQEP6KTG+g+;P+ zn|MBcmTU;*44AZ3t7S!iK>E4zU8j|7wAOia-0Vn9KXy4wk#Adpu9Q@0pzpn>>q4%? zc*e6ie~R$E|HGneN?w@h>OjeF&kI{Oxy(4yW}q@Vo_U7s!@v6yF0iYv2$`V%vP6XS zJi`vNMb(mrHcm>jo9|Mxe|hM!|Dh`tW@m~eXis^0j9XHZk>{tQ@?`m!d@|QpRH-et z+VtqK&yPjjT5QSP+0F+9=J3D#vDDb;nemqQ!EJpP7WZdO;B*tPS#dyVkGbizT{C)C zESj|LjMv453jP@rIG2S)uk+ldXtm71V@}qx&pv*dRw5}=A9a~sig=sZYQ+07@$w_b zuNxbt`7+3I66b+f%Pv^%KRHqAK!C)ZlOlK3H710fIdCKM z+Q)`Z%+o4;wkqmg>A!X-N|rUj|Jj!gCHv#rXQ~r5a`GO1@%YNV&G<3D{PF*J3ofl$ z7&*h-Qi}18=;7Mh+83@ODZ6Iv;wY{^^4(aa z`K4g~i8YJ`^Y8b&h&=IATh_ys>~+cH<|)^bkUL663v?GLe7)0kIkRQP$sZl>l0C$n z8D-=z?w$8kZ<_uh_SUC&GCP_sns}L*=q~zYpqDmDMRC61%*kI~-l^(6^yKk-PQC{@ z0siG>QFpHxKW#BE;=1IZzfbbh*GbFrTMn;PtXp=;#>HIRn02RtW6s8POdnz!Ue7uD zUB+#%w)l^~k&N#WPuTONhq&3>rE9gQGA7Tr6;uhT5n%l(aUf7Ac$Kzf zc8njY`}dbGFYaAvPhp)C$C&Qln*lle5v%ZOKZDa6F*3b!kOy;%B2Wl82H`nYr zeP*XeqS2{z>;H+4mP%Ykb{X@WBUv>cZTQC9u+472sHs`b7ejr^8HZOc++cD0`TIqG z)7Ldz4mGc9aX)Pp)H!4Kd)5aYS${kjk9us5TO?Q4nfETWB6#9medkX%qYt=-l`P*f zuhO~f`_H>|4F68vHApY}Z1w+TrTluqCk9z}gQBA5DJ+zEVB#}no?ZR>GZyRH4n2r# z+^6ztO1O?B^Fr~4VhQKfTnB99IHzwCUBcdQd!@)`r)3`24j;-j!*291dBS7fvRHY0 z_Fn;0^IP+S|0`HrUVFcYrE2!+_PIJ1M$02)XI!Y8-e7(@&E)&~ze;yyj~VmZFDm$K z`eJ$WgInLj8RUe1%J~eCc!n(E>H#r`t zHr`(j*UPV;ezry3sf9{XKg%;}k4Xx|`s)2umf zHz)TFy$_ofUg})TQ~xxhEH*&d*g4Lioikzg(Hr*T%%W(Vhir(5=ii+=oISta5o$$oM|e9pq)d)#wkZCsU)ZwLvV zci~ppu{*VqAFOKqP0wEUH#?O$A;GYGPu>S}AN=Z#$GvoB2J^aYcfD?HBa`;xM^Td_5!FnGeYl9u@=SEx9Cme{q? zYoU(w<&|+yEjSTH%+ct2p&gDB0`(@Ex&Bakw zB^mC~GaP5-&2zhcSaKHME*|Obj5QWb2QQyz3z+_BRb3s+DaOSLY^;9{EC^s;ez`vL zw@7x!btPBBd6KTYY>bPHdhg4LTZwMu4Y}|_n9X^X!Na1|A9}T$D|r@)vhIs? zZ0WhhwueLZPAI!|YTo&)%oeRuU-q?m{g8Vr&11JESZ94(-N$x@AI{s%7vFAL-X~Qe zVf$yB)|#Ha49&J>t*4%>n_Xd0Vb$<|$KM0l?h7Wq>u{dAdSR4f>mQ{8X`Z?j9seTF z75Ck(DZ0#YGg3E!Y0^_4X4$qC=c0L9yM3-Pm~RU*Y&jR4@@-0J1%q2*RK7;epP>Ig z0~L#JXe?GR;w&uMxZP66q-B9}H~{eme94dS9&>@1hoO7iTq2wJ%2 z^Gk^rJC}Wwa^!oKxG*FvfbopqG(`^0`6)h|L_YpmygA%qQjGG6k6T;=*UIqKnw;S2 zdeu>T;Y!+~7aK~hUUYMO@p4j&C+lL#u2ZHObEBL47TaE%C#CgxL%-V2PZ86+v>Q#d zYJa2#=IQ1}hV9Lo$$0T*K3`JDW%(H!mu&j?S83VSOP>Fi%-9>fA@sQPv76Op*HV3+ zpS1jYx~sHDq0FeLydlt|0Pv;9eYgL}JAp%e59&Mg%GDDZ$vG$wR=~Gj;Md+7lt?8YlJ#DE@K(xfJ z-6^}S9=&zu|A~+Z+ZpbxR^;!H7F%BOF=^=(_D!1)&30nhy?*Q82i}%uZ&Wl+vN&pUkNx@;c&`PV{ZyN&ll8-AXF&AJg{zL_e$`lZHy~uC^E#HJJ|~w? zwqlrEq4p`}nf#qHTQNq9M)s<7!zVSZZC6oR|T|9yO!#k*4=d86s(oantxH-0=j zJ@s|S560Gq3BH#nBt`vM6t~Ke9x1?v4#%JN+c(zL&lKYW;6x*vbIz z-TUiO^zT|X+9y1+n`=5JI+FWdn0|=ZRqJ{A3>FQyk4(dA#9}`Sm2He;4!}bMiZ%h>aHN{QrVCuCKe}5g^6e8UiX8$8|>bsCLbv}V9yNcZ}a39v_ zSS1mDTx*hWNB^pAI@cApok+{MuCZa()tJSK3vQiq4`0)J`AiD`y86%yx-VKCMV8E{ zyc}BEuvpY}6-P^MemPt4xgGaX)n87SeO7Yssb37=G*7YK-Zrgl;sch}O?hkuJ`=c` zHBNZ;OkaA+U|qJ=l^U5hI~N_AnN+(tYF%n%@;#ef{bl#37uH(+b8_EuFjHI0E=V_c zQn2VLFTC7G{_Nkq*lXtu=d34hDkOe8@-FJ`dhu>Q9cg>#iz@ z3s+6wI`_iz%fDwG+Ida%_R5`7Pr3rm1T3)nlht>AYPW&75m(XFwC?+bf^FOGcRHv2 z+0M0XZR+{^n+l&rFaEx-uE0|}wQ=|HuWzrEIo-d+Uh2APOU|8N54t|>m?S$_CM{6+ z!26U%i#xPV+!I_Q{B5antY}VGCm-6jcGp4GwWa&!9DMwH z%3PZjOH6ld%lP)Q>8oi_{ENa`Z}vO;{=a+vf5(!u@d=T)u4S7Ue7+Da?t5AFetR-Q zjG$w|!L&1HE$_Z>S7Ky)*L`4?(d63^&O3wm#>E*AZ&wJ3y{kz{{kf-o zTIepdWphlE=5Jdm_VqZ^HPvlf@1BaPcIe1Fefs>IZF`DVz5Bi6!o-i4qTDLZ|7ECn zcdb*WWf9+|2Wwp4E!&u~B*Z+vJZ$zZzhWiBMO(JzC(o;LJMXtO{mYvv+qBj_+_L>` zAK&E*XZy@Nyj*{mnd-Y&9_CSVTO*U8C*#j57}@OdmjA&S!TJ5lHRm>7))r^}5q@NQ zuIKe8N0Ya99rCJkxkJNsPVcKZ^+(q#M*2^I-4*d81@}|y>;(cR>^Aw?T=6lPL0DneCvTq?8J z<7kt9TwkI{=iHU^L+|`NQ=iN!WNxO^eAUWt`8&n}nOufiU!jp z^K2hX8$xwn*2h17X85ALPyY+=m(Kx5Z!(n3XsY;hG%{6qUqtM;fGgj7Wu)WR-A>JX zA3X1(T(g4o^7m(%3U(+}Zk_W|!&d2n;OVb|N8|r!KaSWi?}O7>;cdOYg+*qEvG;zy z9v&~$Tx2n4d4T1Ch)bu=pD#K4(LPvaNfoRAE#WG315l1b2vEt23QAF^;S2+f>u-$Ck+0 zzU*BUWVzr*{gdhvjT8DNCs=$RDSx+{V^n|oQ|a4jd>a=Y->4<`X8+zBzgBA}?fR+0 zP-}WZ_T*#6r7Pwom9l*3JZAe=m%)$4zQ(ta z^3Gkq{<`4R$AyyS`RlICP)ZWykD3~(v^s18`;OO3CY$;kkx*F>89py!`;%{rzA(Os zc7OX-AmM0HhvcNJ(}z6U_ir=tSTgC~mKSlyYovu$h&(4iv+#ZL}#WRS=0T7ssDdZOr82ZBJ8nr@bvJjEmL-8A2~Ut z#L)BTl=#-BlIMqX?pNw&6d3BqS?(+OZdN+?I8(1X^ED3EM$Kys-)3)1-5YVQ?4`HV z85h1FkubA7xsZ*ES2;O$Wyq~FI$15Fb8)Wc*DZFcu4mtbm`A+GVxGHJmNU5g;jN>O zzHPqzaQnubO^v)_8xC&UsFoUeU+-Q(tl{=OZp&+LL@@YgPmveh&-P*a4(*A@W;&f& zm>9oq6X${FJzMr3c)mT+%eLY8j@?_-YkA|6j(=V~Kk@jvsqxbJi(j4>$UF1L>$CON znVS*>&mY`Y^=IA0LXnb)&Xz@@k9m2Y&Gg{kb$;8~z?&zfqzbpWzdQUeCt>~T(isj< zq|1M8xyW3z{-(v^`;vD!Lw@>cGyFO7wC?PVcW)1s-m~L5biugEtgGdj&CJlN6TDgX zNuAvhcS==R-v31C=SA+n_o!buwm)EvLg2i$e;X{?7qSUXcjr_quK1cHUF`S7$#v7T zn&s|&|NrZr7yEPTN2OY1Q0o@)goPLUKTdZunQ_a3U-H%Fd(Y&jTHbml$th_c_#;mK z@{cw9j#xHv9CsI85U6y0!S!h+`Zr(PXLSpYO4+Trd8&T$=cnzb>Xdxva2a_lTr=g! zw5``Q%TFbDT@;accCpXs8l%?l(Ek?v3%OHS7|YMOeCQ6iKSeZU$MO3qO21d1S@-nI z#mge6PD_Vr$2E7j&EJ2~{hZ~j`PL_Eo?O)1I;ZL9#h&QRTK98fc6ucxD_rN*k}jO; z5VlB}Va?ZxpO<&sQpnu$#j;^@=8+fMB|otreSh^?-}2ixy$=@6ZgcaQ{B`l{uNX*@(Whr|5*xG*w?@;$op^oA+AX_-gQHg0*I=>E&zMKIorJzHXudj1J_)|LH* zjhP(ob5Gwgx~Ota_hS1tH_K16o>dx&{+U-3JmJy~?F5eAeGCvL$YQHe@K>UKgIIl+-gCU`#;tm z@XTd)eo%gLS6LF1-$d?(YYGcb%bsZtn)v#A|Azf0mTpxhf4rXkI`Q<>@5L-7>zp*s zKh3xwCgVHr!w2n5oh3U$JyY|-7KbzM2upaO=jrfVtEFi90qde$zeJ~JzYo5`uB0sg z{K;}AiQ^hiynnh1ckb9YZ~2X%3Ckv>>=XOpE9yR(d1l2{?b?!MZUOer9Lv-naL;+~ z8ZP$OWm5j_J&8XR-f@XAC@Fg!yl_Rt#q7PSt@8ByC+^O6N|bB3JX6xgY=1||EGy@K z7y9PqCXL^3+C!%!7{XXyOqu=i@V>AlmSXL#a~|o4btFCwi~C-& zZ5xALs>t`4>-U#Ftd$TtmTVcfFXPLn->TM6*`5{p6gxURjhHU_;?XMxqi@0$`xI@W zi+0!bq(n=9nxMSHe}3J|)r>J3i7w@vT-w(#f7ra{^=P@^h`p zs`D>DJkC>TqUg^*mDjMa-=Tfa75BDyy#pmT-%S3Q70&QqU_$YlM`i{RMM1BOx9$ve zI40Mme9`2f)!yfd$*T>G&0WskwzPVDSI9{1$D6FHUQYrg84@D-EbDoG^sqT6-1*e` zKU&G5Y}QOUuA-Ss)?Cwztl4keUfpJqah~;!(7awBPK(~3nS4(x>>sSVsMxb--=t-x zH+N^SIp6!((cLshqI@Zj+Od$JidOD#N%JQ9Fn!PwJ(^%3G3n%geg=mB|Ct{?OPlQ{ Sw4Q;1fx*+&&t;ucLK6V^nqaB` literal 0 HcmV?d00001 diff --git a/tests/fixtures/hashsum/binary.sha256.checkfile b/tests/fixtures/sha256sum/binary.sha256.checkfile similarity index 100% rename from tests/fixtures/hashsum/binary.sha256.checkfile rename to tests/fixtures/sha256sum/binary.sha256.checkfile diff --git a/tests/fixtures/hashsum/binary.sha256.expected b/tests/fixtures/sha256sum/binary.sha256.expected similarity index 100% rename from tests/fixtures/hashsum/binary.sha256.expected rename to tests/fixtures/sha256sum/binary.sha256.expected diff --git a/tests/fixtures/sha256sum/input.txt b/tests/fixtures/sha256sum/input.txt new file mode 100644 index 000000000..8c01d89ae --- /dev/null +++ b/tests/fixtures/sha256sum/input.txt @@ -0,0 +1 @@ +hello, world \ No newline at end of file diff --git a/tests/fixtures/hashsum/sha256.checkfile b/tests/fixtures/sha256sum/sha256.checkfile similarity index 100% rename from tests/fixtures/hashsum/sha256.checkfile rename to tests/fixtures/sha256sum/sha256.checkfile diff --git a/tests/fixtures/hashsum/sha256.expected b/tests/fixtures/sha256sum/sha256.expected similarity index 100% rename from tests/fixtures/hashsum/sha256.expected rename to tests/fixtures/sha256sum/sha256.expected diff --git a/tests/tests.rs b/tests/tests.rs index b51ed1c5b..26cf7b7aa 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -84,6 +84,10 @@ mod test_sha1sum; #[path = "by-util/test_sha224sum.rs"] mod test_sha224sum; +#[cfg(feature = "sha256sum")] +#[path = "by-util/test_sha256sum.rs"] +mod test_sha256sum; + #[cfg(feature = "cp")] #[path = "by-util/test_cp.rs"] mod test_cp; From 417c4242130c3bf60e8d55b07ee40f9e44992e90 Mon Sep 17 00:00:00 2001 From: Dorian Peron Date: Fri, 9 Jan 2026 17:44:43 +0100 Subject: [PATCH 388/425] sha384sum: introduce standalone binary --- Cargo.lock | 13 ++ Cargo.toml | 2 + GNUmakefile | 1 - build.rs | 1 - src/common/validation.rs | 2 +- src/uu/sha384sum/Cargo.toml | 38 ++++++ src/uu/sha384sum/LICENSE | 1 + src/uu/sha384sum/locales/en-US.ftl | 2 + src/uu/sha384sum/locales/fr-FR.ftl | 2 + src/uu/sha384sum/src/main.rs | 1 + src/uu/sha384sum/src/sha384sum.rs | 1 + src/uucore/src/lib/lib.rs | 2 +- src/uucore/src/lib/mods/locale.rs | 1 + tests/by-util/test_hashsum.rs | 1 - tests/by-util/test_sha384sum.rs | 122 ++++++++++++++++++ tests/fixtures/sha384sum/input.txt | 1 + .../{hashsum => sha384sum}/sha384.checkfile | 0 .../{hashsum => sha384sum}/sha384.expected | 0 tests/tests.rs | 4 + 19 files changed, 190 insertions(+), 5 deletions(-) create mode 100644 src/uu/sha384sum/Cargo.toml create mode 120000 src/uu/sha384sum/LICENSE create mode 100644 src/uu/sha384sum/locales/en-US.ftl create mode 100644 src/uu/sha384sum/locales/fr-FR.ftl create mode 100644 src/uu/sha384sum/src/main.rs create mode 100644 src/uu/sha384sum/src/sha384sum.rs create mode 100644 tests/by-util/test_sha384sum.rs create mode 100644 tests/fixtures/sha384sum/input.txt rename tests/fixtures/{hashsum => sha384sum}/sha384.checkfile (100%) rename tests/fixtures/{hashsum => sha384sum}/sha384.expected (100%) diff --git a/Cargo.lock b/Cargo.lock index aa8dcdf27..9db65e310 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -630,6 +630,7 @@ dependencies = [ "uu_sha1sum", "uu_sha224sum", "uu_sha256sum", + "uu_sha384sum", "uu_shred", "uu_shuf", "uu_sleep", @@ -4023,6 +4024,18 @@ dependencies = [ "uucore", ] +[[package]] +name = "uu_sha384sum" +version = "0.6.0" +dependencies = [ + "clap", + "codspeed-divan-compat", + "fluent", + "tempfile", + "uu_checksum_common", + "uucore", +] + [[package]] name = "uu_shred" version = "0.6.0" diff --git a/Cargo.toml b/Cargo.toml index 801f67979..a24e87060 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -91,6 +91,7 @@ feat_common_core = [ "sha1sum", "sha224sum", "sha256sum", + "sha384sum", "comm", "cp", "csplit", @@ -447,6 +448,7 @@ md5sum = { optional = true, version = "0.6.0", package = "uu_md5sum", path = "sr sha1sum = { optional = true, version = "0.6.0", package = "uu_sha1sum", path = "src/uu/sha1sum" } sha224sum = { optional = true, version = "0.6.0", package = "uu_sha224sum", path = "src/uu/sha224sum" } sha256sum = { optional = true, version = "0.6.0", package = "uu_sha256sum", path = "src/uu/sha256sum" } +sha384sum = { optional = true, version = "0.6.0", package = "uu_sha384sum", path = "src/uu/sha384sum" } comm = { optional = true, version = "0.6.0", package = "uu_comm", path = "src/uu/comm" } cp = { optional = true, version = "0.6.0", package = "uu_cp", path = "src/uu/cp" } csplit = { optional = true, version = "0.6.0", package = "uu_csplit", path = "src/uu/csplit" } diff --git a/GNUmakefile b/GNUmakefile index 998968d09..7805ff9d6 100644 --- a/GNUmakefile +++ b/GNUmakefile @@ -96,7 +96,6 @@ SELINUX_PROGS := \ runcon HASHSUM_PROGS := \ - sha384sum \ sha512sum $(info Detected OS = $(OS)) diff --git a/build.rs b/build.rs index c206e892f..8a5e0ec5c 100644 --- a/build.rs +++ b/build.rs @@ -91,7 +91,6 @@ pub fn main() { phf_map.entry(krate, format!("({krate}::uumain, {krate}::uu_app_custom)")); let map_value = format!("({krate}::uumain, {krate}::uu_app_common)"); - phf_map.entry("sha384sum", map_value.clone()); phf_map.entry("sha512sum", map_value.clone()); } _ => { diff --git a/src/common/validation.rs b/src/common/validation.rs index aa01b4679..5c90de222 100644 --- a/src/common/validation.rs +++ b/src/common/validation.rs @@ -51,7 +51,7 @@ fn get_canonical_util_name(util_name: &str) -> &str { "[" => "test", // hashsum aliases - all these hash commands are aliases for hashsum - "sha384sum" | "sha512sum" => "hashsum", + "sha512sum" => "hashsum", "dir" => "ls", // dir is an alias for ls diff --git a/src/uu/sha384sum/Cargo.toml b/src/uu/sha384sum/Cargo.toml new file mode 100644 index 000000000..2fb9ca037 --- /dev/null +++ b/src/uu/sha384sum/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "uu_sha384sum" +description = "sha384sum ~ (uutils) Print or check the SHA384 checksums" +repository = "https://github.com/uutils/coreutils/tree/main/src/uu/sha384sum" +version.workspace = true +authors.workspace = true +license.workspace = true +homepage.workspace = true +keywords.workspace = true +categories.workspace = true +edition.workspace = true +readme.workspace = true + +[lints] +workspace = true + +[lib] +path = "src/sha384sum.rs" + +[dependencies] +clap = { workspace = true } +uu_checksum_common = { workspace = true } +uucore = { workspace = true, features = [ + "checksum", + "encoding", + "sum", + "hardware", +] } +fluent = { workspace = true } + +[dev-dependencies] +divan = { workspace = true } +tempfile = { workspace = true } +uucore = { workspace = true, features = ["benchmark"] } + +[[bin]] +name = "sha384sum" +path = "src/main.rs" diff --git a/src/uu/sha384sum/LICENSE b/src/uu/sha384sum/LICENSE new file mode 120000 index 000000000..5853aaea5 --- /dev/null +++ b/src/uu/sha384sum/LICENSE @@ -0,0 +1 @@ +../../../LICENSE \ No newline at end of file diff --git a/src/uu/sha384sum/locales/en-US.ftl b/src/uu/sha384sum/locales/en-US.ftl new file mode 100644 index 000000000..e10a99c1e --- /dev/null +++ b/src/uu/sha384sum/locales/en-US.ftl @@ -0,0 +1,2 @@ +sha384sum-about = Print or check the SHA384 checksums +sha384sum-usage = sha384sum [OPTIONS] [FILE]... diff --git a/src/uu/sha384sum/locales/fr-FR.ftl b/src/uu/sha384sum/locales/fr-FR.ftl new file mode 100644 index 000000000..f751315ec --- /dev/null +++ b/src/uu/sha384sum/locales/fr-FR.ftl @@ -0,0 +1,2 @@ +sha1sum-about = Afficher le SHA384 et la taille de chaque fichier +sha1sum-usage = sha384sum [OPTION]... [FICHIER]... diff --git a/src/uu/sha384sum/src/main.rs b/src/uu/sha384sum/src/main.rs new file mode 100644 index 000000000..c87f32e28 --- /dev/null +++ b/src/uu/sha384sum/src/main.rs @@ -0,0 +1 @@ +uucore::bin!(uu_sha384sum); diff --git a/src/uu/sha384sum/src/sha384sum.rs b/src/uu/sha384sum/src/sha384sum.rs new file mode 100644 index 000000000..818478e29 --- /dev/null +++ b/src/uu/sha384sum/src/sha384sum.rs @@ -0,0 +1 @@ +uu_checksum_common::declare_standalone!("sha384sum", uucore::checksum::AlgoKind::Sha384); diff --git a/src/uucore/src/lib/lib.rs b/src/uucore/src/lib/lib.rs index 3e6003f31..75b00426b 100644 --- a/src/uucore/src/lib/lib.rs +++ b/src/uucore/src/lib/lib.rs @@ -173,7 +173,7 @@ pub fn get_canonical_util_name(util_name: &str) -> &str { "[" => "test", // hashsum aliases - all these hash commands are aliases for hashsum - "sha384sum" | "sha512sum" => "hashsum", + "sha512sum" => "hashsum", "dir" => "ls", // dir is an alias for ls diff --git a/src/uucore/src/lib/mods/locale.rs b/src/uucore/src/lib/mods/locale.rs index da5a996d5..a2994b6e3 100644 --- a/src/uucore/src/lib/mods/locale.rs +++ b/src/uucore/src/lib/mods/locale.rs @@ -164,6 +164,7 @@ fn create_bundle( "sha1sum", "sha224sum", "sha256sum", + "sha384sum", ] .contains(&util_name) { diff --git a/tests/by-util/test_hashsum.rs b/tests/by-util/test_hashsum.rs index ff6d92f2d..0e23ecd59 100644 --- a/tests/by-util/test_hashsum.rs +++ b/tests/by-util/test_hashsum.rs @@ -205,7 +205,6 @@ test_digest! {b3sum, b3sum} test_digest! {shake128, shake128} test_digest! {shake256, shake256} -test_digest_with_len! {sha384, sha384, 384} test_digest_with_len! {sha512, sha512, 512} test_digest_with_len! {sha3_224, sha3, 224} test_digest_with_len! {sha3_256, sha3, 256} diff --git a/tests/by-util/test_sha384sum.rs b/tests/by-util/test_sha384sum.rs new file mode 100644 index 000000000..9dbc73080 --- /dev/null +++ b/tests/by-util/test_sha384sum.rs @@ -0,0 +1,122 @@ +// This file is part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +use uutests::new_ucmd; +// spell-checker:ignore checkfile, testf, ntestf +macro_rules! get_hash( + ($str:expr) => ( + $str.split(' ').collect::>()[0] + ); +); + +macro_rules! test_digest { + ($id:ident) => { + mod $id { + use uutests::util::*; + use uutests::util_name; + static EXPECTED_FILE: &'static str = concat!(stringify!($id), ".expected"); + static CHECK_FILE: &'static str = concat!(stringify!($id), ".checkfile"); + static INPUT_FILE: &'static str = "input.txt"; + + #[test] + fn test_single_file() { + let ts = TestScenario::new(util_name!()); + assert_eq!( + ts.fixtures.read(EXPECTED_FILE), + get_hash!( + ts.ucmd() + .arg(INPUT_FILE) + .succeeds() + .no_stderr() + .stdout_str() + ) + ); + } + + #[test] + fn test_stdin() { + let ts = TestScenario::new(util_name!()); + assert_eq!( + ts.fixtures.read(EXPECTED_FILE), + get_hash!( + ts.ucmd() + .pipe_in_fixture(INPUT_FILE) + .succeeds() + .no_stderr() + .stdout_str() + ) + ); + } + + #[test] + fn test_check() { + let ts = TestScenario::new(util_name!()); + println!("File content='{}'", ts.fixtures.read(INPUT_FILE)); + println!("Check file='{}'", ts.fixtures.read(CHECK_FILE)); + + ts.ucmd() + .args(&["--check", CHECK_FILE]) + .succeeds() + .no_stderr() + .stdout_is("input.txt: OK\n"); + } + + #[test] + fn test_zero() { + let ts = TestScenario::new(util_name!()); + assert_eq!( + ts.fixtures.read(EXPECTED_FILE), + get_hash!( + ts.ucmd() + .arg("--zero") + .arg(INPUT_FILE) + .succeeds() + .no_stderr() + .stdout_str() + ) + ); + } + + #[test] + fn test_missing_file() { + let ts = TestScenario::new(util_name!()); + let at = &ts.fixtures; + + at.write("a", "file1\n"); + at.write("c", "file3\n"); + + ts.ucmd() + .args(&["a", "b", "c"]) + .fails() + .stdout_contains("a\n") + .stdout_contains("c\n") + .stderr_contains("b: No such file or directory"); + } + } + }; +} + +test_digest! {sha384} + +#[test] +fn test_invalid_arg() { + new_ucmd!().arg("--definitely-invalid").fails_with_code(1); +} + +#[test] +fn test_conflicting_arg() { + new_ucmd!().arg("--tag").arg("--check").fails_with_code(1); + new_ucmd!().arg("--tag").arg("--text").fails_with_code(1); +} + +#[test] +fn test_help_shows_correct_utility_name() { + // Test that help output shows the actual utility name instead of "hashsum" + new_ucmd!() + .arg("--help") + .succeeds() + .stdout_contains("Usage: sha384sum") + .stdout_does_not_contain("Usage: hashsum"); +} diff --git a/tests/fixtures/sha384sum/input.txt b/tests/fixtures/sha384sum/input.txt new file mode 100644 index 000000000..8c01d89ae --- /dev/null +++ b/tests/fixtures/sha384sum/input.txt @@ -0,0 +1 @@ +hello, world \ No newline at end of file diff --git a/tests/fixtures/hashsum/sha384.checkfile b/tests/fixtures/sha384sum/sha384.checkfile similarity index 100% rename from tests/fixtures/hashsum/sha384.checkfile rename to tests/fixtures/sha384sum/sha384.checkfile diff --git a/tests/fixtures/hashsum/sha384.expected b/tests/fixtures/sha384sum/sha384.expected similarity index 100% rename from tests/fixtures/hashsum/sha384.expected rename to tests/fixtures/sha384sum/sha384.expected diff --git a/tests/tests.rs b/tests/tests.rs index 26cf7b7aa..0cc604994 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -88,6 +88,10 @@ mod test_sha224sum; #[path = "by-util/test_sha256sum.rs"] mod test_sha256sum; +#[cfg(feature = "sha384sum")] +#[path = "by-util/test_sha384sum.rs"] +mod test_sha384sum; + #[cfg(feature = "cp")] #[path = "by-util/test_cp.rs"] mod test_cp; From 70c37a72adddc25880e30ab70ed0583071b4d5e9 Mon Sep 17 00:00:00 2001 From: Dorian Peron Date: Fri, 9 Jan 2026 17:58:36 +0100 Subject: [PATCH 389/425] sha512sum: introduce standalone binary --- Cargo.lock | 13 +++ Cargo.toml | 2 + GNUmakefile | 3 - build.rs | 3 - src/common/validation.rs | 3 - src/uu/sha512sum/Cargo.toml | 38 +++++++ src/uu/sha512sum/LICENSE | 1 + src/uu/sha512sum/locales/en-US.ftl | 2 + src/uu/sha512sum/locales/fr-FR.ftl | 2 + src/uu/sha512sum/src/main.rs | 1 + src/uu/sha512sum/src/sha512sum.rs | 1 + src/uucore/src/lib/lib.rs | 3 - src/uucore/src/lib/mods/locale.rs | 1 + tests/by-util/test_hashsum.rs | 1 - tests/by-util/test_sha512sum.rs | 122 ++++++++++++++++++++++ tests/fixtures/sha512sum/input.txt | 1 + tests/fixtures/sha512sum/sha512.checkfile | 1 + tests/fixtures/sha512sum/sha512.expected | 1 + tests/tests.rs | 4 + 19 files changed, 190 insertions(+), 13 deletions(-) create mode 100644 src/uu/sha512sum/Cargo.toml create mode 120000 src/uu/sha512sum/LICENSE create mode 100644 src/uu/sha512sum/locales/en-US.ftl create mode 100644 src/uu/sha512sum/locales/fr-FR.ftl create mode 100644 src/uu/sha512sum/src/main.rs create mode 100644 src/uu/sha512sum/src/sha512sum.rs create mode 100644 tests/by-util/test_sha512sum.rs create mode 100644 tests/fixtures/sha512sum/input.txt create mode 100644 tests/fixtures/sha512sum/sha512.checkfile create mode 100644 tests/fixtures/sha512sum/sha512.expected diff --git a/Cargo.lock b/Cargo.lock index 9db65e310..17c05da50 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -631,6 +631,7 @@ dependencies = [ "uu_sha224sum", "uu_sha256sum", "uu_sha384sum", + "uu_sha512sum", "uu_shred", "uu_shuf", "uu_sleep", @@ -4036,6 +4037,18 @@ dependencies = [ "uucore", ] +[[package]] +name = "uu_sha512sum" +version = "0.6.0" +dependencies = [ + "clap", + "codspeed-divan-compat", + "fluent", + "tempfile", + "uu_checksum_common", + "uucore", +] + [[package]] name = "uu_shred" version = "0.6.0" diff --git a/Cargo.toml b/Cargo.toml index a24e87060..12e062f8f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -92,6 +92,7 @@ feat_common_core = [ "sha224sum", "sha256sum", "sha384sum", + "sha512sum", "comm", "cp", "csplit", @@ -449,6 +450,7 @@ sha1sum = { optional = true, version = "0.6.0", package = "uu_sha1sum", path = " sha224sum = { optional = true, version = "0.6.0", package = "uu_sha224sum", path = "src/uu/sha224sum" } sha256sum = { optional = true, version = "0.6.0", package = "uu_sha256sum", path = "src/uu/sha256sum" } sha384sum = { optional = true, version = "0.6.0", package = "uu_sha384sum", path = "src/uu/sha384sum" } +sha512sum = { optional = true, version = "0.6.0", package = "uu_sha512sum", path = "src/uu/sha512sum" } comm = { optional = true, version = "0.6.0", package = "uu_comm", path = "src/uu/comm" } cp = { optional = true, version = "0.6.0", package = "uu_cp", path = "src/uu/cp" } csplit = { optional = true, version = "0.6.0", package = "uu_csplit", path = "src/uu/csplit" } diff --git a/GNUmakefile b/GNUmakefile index 7805ff9d6..b1c8ff2dc 100644 --- a/GNUmakefile +++ b/GNUmakefile @@ -95,9 +95,6 @@ SELINUX_PROGS := \ chcon \ runcon -HASHSUM_PROGS := \ - sha512sum - $(info Detected OS = $(OS)) ifeq (,$(findstring MINGW,$(OS))) diff --git a/build.rs b/build.rs index 8a5e0ec5c..a7eb90312 100644 --- a/build.rs +++ b/build.rs @@ -89,9 +89,6 @@ pub fn main() { } "hashsum" => { phf_map.entry(krate, format!("({krate}::uumain, {krate}::uu_app_custom)")); - - let map_value = format!("({krate}::uumain, {krate}::uu_app_common)"); - phf_map.entry("sha512sum", map_value.clone()); } _ => { phf_map.entry(krate, map_value.clone()); diff --git a/src/common/validation.rs b/src/common/validation.rs index 5c90de222..d723ca926 100644 --- a/src/common/validation.rs +++ b/src/common/validation.rs @@ -50,9 +50,6 @@ fn get_canonical_util_name(util_name: &str) -> &str { // uu_test aliases - '[' is an alias for test "[" => "test", - // hashsum aliases - all these hash commands are aliases for hashsum - "sha512sum" => "hashsum", - "dir" => "ls", // dir is an alias for ls // Default case - return the util name as is diff --git a/src/uu/sha512sum/Cargo.toml b/src/uu/sha512sum/Cargo.toml new file mode 100644 index 000000000..0cea1453b --- /dev/null +++ b/src/uu/sha512sum/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "uu_sha512sum" +description = "sha512sum ~ (uutils) Print or check the SHA512 checksums" +repository = "https://github.com/uutils/coreutils/tree/main/src/uu/sha512sum" +version.workspace = true +authors.workspace = true +license.workspace = true +homepage.workspace = true +keywords.workspace = true +categories.workspace = true +edition.workspace = true +readme.workspace = true + +[lints] +workspace = true + +[lib] +path = "src/sha512sum.rs" + +[dependencies] +clap = { workspace = true } +uu_checksum_common = { workspace = true } +uucore = { workspace = true, features = [ + "checksum", + "encoding", + "sum", + "hardware", +] } +fluent = { workspace = true } + +[dev-dependencies] +divan = { workspace = true } +tempfile = { workspace = true } +uucore = { workspace = true, features = ["benchmark"] } + +[[bin]] +name = "sha512sum" +path = "src/main.rs" diff --git a/src/uu/sha512sum/LICENSE b/src/uu/sha512sum/LICENSE new file mode 120000 index 000000000..5853aaea5 --- /dev/null +++ b/src/uu/sha512sum/LICENSE @@ -0,0 +1 @@ +../../../LICENSE \ No newline at end of file diff --git a/src/uu/sha512sum/locales/en-US.ftl b/src/uu/sha512sum/locales/en-US.ftl new file mode 100644 index 000000000..395a90077 --- /dev/null +++ b/src/uu/sha512sum/locales/en-US.ftl @@ -0,0 +1,2 @@ +sha512sum-about = Print or check the SHA512 checksums +sha512sum-usage = sha512sum [OPTIONS] [FILE]... diff --git a/src/uu/sha512sum/locales/fr-FR.ftl b/src/uu/sha512sum/locales/fr-FR.ftl new file mode 100644 index 000000000..59abcc2f9 --- /dev/null +++ b/src/uu/sha512sum/locales/fr-FR.ftl @@ -0,0 +1,2 @@ +sha512sum-about = Afficher le SHA512 et la taille de chaque fichier +sha512sum-usage = sha512sum [OPTION]... [FICHIER]... diff --git a/src/uu/sha512sum/src/main.rs b/src/uu/sha512sum/src/main.rs new file mode 100644 index 000000000..64a6ecea6 --- /dev/null +++ b/src/uu/sha512sum/src/main.rs @@ -0,0 +1 @@ +uucore::bin!(uu_sha512sum); diff --git a/src/uu/sha512sum/src/sha512sum.rs b/src/uu/sha512sum/src/sha512sum.rs new file mode 100644 index 000000000..125d263f0 --- /dev/null +++ b/src/uu/sha512sum/src/sha512sum.rs @@ -0,0 +1 @@ +uu_checksum_common::declare_standalone!("sha512sum", uucore::checksum::AlgoKind::Sha512); diff --git a/src/uucore/src/lib/lib.rs b/src/uucore/src/lib/lib.rs index 75b00426b..228ca3ede 100644 --- a/src/uucore/src/lib/lib.rs +++ b/src/uucore/src/lib/lib.rs @@ -172,9 +172,6 @@ pub fn get_canonical_util_name(util_name: &str) -> &str { // uu_test aliases - '[' is an alias for test "[" => "test", - // hashsum aliases - all these hash commands are aliases for hashsum - "sha512sum" => "hashsum", - "dir" => "ls", // dir is an alias for ls // Default case - return the util name as is diff --git a/src/uucore/src/lib/mods/locale.rs b/src/uucore/src/lib/mods/locale.rs index a2994b6e3..a6dad4c62 100644 --- a/src/uucore/src/lib/mods/locale.rs +++ b/src/uucore/src/lib/mods/locale.rs @@ -165,6 +165,7 @@ fn create_bundle( "sha224sum", "sha256sum", "sha384sum", + "sha512sum", ] .contains(&util_name) { diff --git a/tests/by-util/test_hashsum.rs b/tests/by-util/test_hashsum.rs index 0e23ecd59..c139469d6 100644 --- a/tests/by-util/test_hashsum.rs +++ b/tests/by-util/test_hashsum.rs @@ -205,7 +205,6 @@ test_digest! {b3sum, b3sum} test_digest! {shake128, shake128} test_digest! {shake256, shake256} -test_digest_with_len! {sha512, sha512, 512} test_digest_with_len! {sha3_224, sha3, 224} test_digest_with_len! {sha3_256, sha3, 256} test_digest_with_len! {sha3_384, sha3, 384} diff --git a/tests/by-util/test_sha512sum.rs b/tests/by-util/test_sha512sum.rs new file mode 100644 index 000000000..5e01ad32a --- /dev/null +++ b/tests/by-util/test_sha512sum.rs @@ -0,0 +1,122 @@ +// This file is part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +use uutests::new_ucmd; +// spell-checker:ignore checkfile, testf, ntestf +macro_rules! get_hash( + ($str:expr) => ( + $str.split(' ').collect::>()[0] + ); +); + +macro_rules! test_digest { + ($id:ident) => { + mod $id { + use uutests::util::*; + use uutests::util_name; + static EXPECTED_FILE: &'static str = concat!(stringify!($id), ".expected"); + static CHECK_FILE: &'static str = concat!(stringify!($id), ".checkfile"); + static INPUT_FILE: &'static str = "input.txt"; + + #[test] + fn test_single_file() { + let ts = TestScenario::new(util_name!()); + assert_eq!( + ts.fixtures.read(EXPECTED_FILE), + get_hash!( + ts.ucmd() + .arg(INPUT_FILE) + .succeeds() + .no_stderr() + .stdout_str() + ) + ); + } + + #[test] + fn test_stdin() { + let ts = TestScenario::new(util_name!()); + assert_eq!( + ts.fixtures.read(EXPECTED_FILE), + get_hash!( + ts.ucmd() + .pipe_in_fixture(INPUT_FILE) + .succeeds() + .no_stderr() + .stdout_str() + ) + ); + } + + #[test] + fn test_check() { + let ts = TestScenario::new(util_name!()); + println!("File content='{}'", ts.fixtures.read(INPUT_FILE)); + println!("Check file='{}'", ts.fixtures.read(CHECK_FILE)); + + ts.ucmd() + .args(&["--check", CHECK_FILE]) + .succeeds() + .no_stderr() + .stdout_is("input.txt: OK\n"); + } + + #[test] + fn test_zero() { + let ts = TestScenario::new(util_name!()); + assert_eq!( + ts.fixtures.read(EXPECTED_FILE), + get_hash!( + ts.ucmd() + .arg("--zero") + .arg(INPUT_FILE) + .succeeds() + .no_stderr() + .stdout_str() + ) + ); + } + + #[test] + fn test_missing_file() { + let ts = TestScenario::new(util_name!()); + let at = &ts.fixtures; + + at.write("a", "file1\n"); + at.write("c", "file3\n"); + + ts.ucmd() + .args(&["a", "b", "c"]) + .fails() + .stdout_contains("a\n") + .stdout_contains("c\n") + .stderr_contains("b: No such file or directory"); + } + } + }; +} + +test_digest! {sha512} + +#[test] +fn test_invalid_arg() { + new_ucmd!().arg("--definitely-invalid").fails_with_code(1); +} + +#[test] +fn test_conflicting_arg() { + new_ucmd!().arg("--tag").arg("--check").fails_with_code(1); + new_ucmd!().arg("--tag").arg("--text").fails_with_code(1); +} + +#[test] +fn test_help_shows_correct_utility_name() { + // Test that help output shows the actual utility name instead of "hashsum" + new_ucmd!() + .arg("--help") + .succeeds() + .stdout_contains("Usage: sha512sum") + .stdout_does_not_contain("Usage: hashsum"); +} diff --git a/tests/fixtures/sha512sum/input.txt b/tests/fixtures/sha512sum/input.txt new file mode 100644 index 000000000..8c01d89ae --- /dev/null +++ b/tests/fixtures/sha512sum/input.txt @@ -0,0 +1 @@ +hello, world \ No newline at end of file diff --git a/tests/fixtures/sha512sum/sha512.checkfile b/tests/fixtures/sha512sum/sha512.checkfile new file mode 100644 index 000000000..41a55cabb --- /dev/null +++ b/tests/fixtures/sha512sum/sha512.checkfile @@ -0,0 +1 @@ +8710339dcb6814d0d9d2290ef422285c9322b7163951f9a0ca8f883d3305286f44139aa374848e4174f5aada663027e4548637b6d19894aec4fb6c46a139fbf9 input.txt diff --git a/tests/fixtures/sha512sum/sha512.expected b/tests/fixtures/sha512sum/sha512.expected new file mode 100644 index 000000000..fd8173686 --- /dev/null +++ b/tests/fixtures/sha512sum/sha512.expected @@ -0,0 +1 @@ +8710339dcb6814d0d9d2290ef422285c9322b7163951f9a0ca8f883d3305286f44139aa374848e4174f5aada663027e4548637b6d19894aec4fb6c46a139fbf9 \ No newline at end of file diff --git a/tests/tests.rs b/tests/tests.rs index 0cc604994..d2ecbca10 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -92,6 +92,10 @@ mod test_sha256sum; #[path = "by-util/test_sha384sum.rs"] mod test_sha384sum; +#[cfg(feature = "sha512sum")] +#[path = "by-util/test_sha512sum.rs"] +mod test_sha512sum; + #[cfg(feature = "cp")] #[path = "by-util/test_cp.rs"] mod test_cp; From eeca1e1d90197996428a255f671bd9919dce1509 Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Wed, 28 Jan 2026 20:03:30 +0900 Subject: [PATCH 390/425] readlink /etc/mtab > /dev/full panics (#10525) --- src/uu/readlink/src/readlink.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/uu/readlink/src/readlink.rs b/src/uu/readlink/src/readlink.rs index cdc1d97b0..f220d6a05 100644 --- a/src/uu/readlink/src/readlink.rs +++ b/src/uu/readlink/src/readlink.rs @@ -185,7 +185,7 @@ pub fn uu_app() -> Command { fn show(path: &Path, line_ending: Option) -> std::io::Result<()> { uucore::display::print_verbatim(path)?; if let Some(line_ending) = line_ending { - print!("{line_ending}"); + write!(stdout(), "{line_ending}")?; } stdout().flush() } From aee49f1e10968d2efd3f92bffe810d5e0abee991 Mon Sep 17 00:00:00 2001 From: mattsu <35655889+mattsu2020@users.noreply.github.com> Date: Wed, 28 Jan 2026 23:17:08 +0900 Subject: [PATCH 391/425] Add support for -c short option and fix character mode tab handling (#10530) This commit adds the `-c` short option for the `--characters` flag and fixes tab handling in character mode. Previously, tabs were only handled in column mode, but now they are properly processed in both character and column modes. The character mode now correctly advances to the next tab stop, handles carriage returns, and backspace characters. Tests have been added to verify the new functionality. --- src/uu/fold/src/fold.rs | 33 ++++++++++++++++++++++++++++----- tests/by-util/test_fold.rs | 27 +++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 5 deletions(-) diff --git a/src/uu/fold/src/fold.rs b/src/uu/fold/src/fold.rs index d79d6d422..71d4756a7 100644 --- a/src/uu/fold/src/fold.rs +++ b/src/uu/fold/src/fold.rs @@ -98,6 +98,7 @@ pub fn uu_app() -> Command { .arg( Arg::new(options::CHARACTERS) .long(options::CHARACTERS) + .short('c') .help(translate!("fold-characters-help")) .conflicts_with(options::BYTES) .action(ArgAction::SetTrue), @@ -260,9 +261,31 @@ fn next_tab_stop(col_count: usize) -> usize { fn compute_col_count(buffer: &[u8], mode: WidthMode) -> usize { match mode { - WidthMode::Characters => std::str::from_utf8(buffer) - .map(|s| s.chars().count()) - .unwrap_or(buffer.len()), + WidthMode::Characters => { + if let Ok(s) = std::str::from_utf8(buffer) { + let mut width = 0; + for ch in s.chars() { + match ch { + '\r' => width = 0, + '\t' => width = next_tab_stop(width), + '\x08' => width = width.saturating_sub(1), + _ => width += 1, + } + } + width + } else { + let mut width = 0; + for &byte in buffer { + match byte { + CR => width = 0, + TAB => width = next_tab_stop(width), + 0x08 => width = width.saturating_sub(1), + _ => width += 1, + } + } + width + } + } WidthMode::Columns => { if let Ok(s) = std::str::from_utf8(buffer) { let mut width = 0; @@ -382,7 +405,7 @@ fn process_ascii_line(line: &[u8], ctx: &mut FoldContext<'_, W>) -> UR *ctx.col_count = ctx.col_count.saturating_sub(1); idx += 1; } - TAB if ctx.mode == WidthMode::Columns => { + TAB => { loop { let next_stop = next_tab_stop(*ctx.col_count); if next_stop > ctx.width && !ctx.output.is_empty() { @@ -523,7 +546,7 @@ fn process_utf8_chars(line: &str, ctx: &mut FoldContext<'_, W>) -> URe continue; } - if ctx.mode == WidthMode::Columns && ch == '\t' { + if ch == '\t' { loop { let next_stop = next_tab_stop(*ctx.col_count); if next_stop > ctx.width && !ctx.output.is_empty() { diff --git a/tests/by-util/test_fold.rs b/tests/by-util/test_fold.rs index 1fe466ba5..c6ae6b56d 100644 --- a/tests/by-util/test_fold.rs +++ b/tests/by-util/test_fold.rs @@ -65,6 +65,15 @@ fn test_wide_characters_with_characters_option() { .stdout_is("\u{B250}\u{B250}\u{B250}\n"); } +#[test] +fn test_wide_characters_with_characters_short_option() { + new_ucmd!() + .args(&["-c", "-w", "5"]) + .pipe_in("\u{B250}\u{B250}\u{B250}\n") + .succeeds() + .stdout_is("\u{B250}\u{B250}\u{B250}\n"); +} + #[test] fn test_multiple_wide_characters_in_column_mode() { let wide = '\u{FF1A}'; @@ -540,6 +549,24 @@ fn test_fold_after_tab() { .stdout_is("a\tbb\nb\n"); } +#[test] +fn test_fold_characters_tab_advances_to_next_tab_stop() { + new_ucmd!() + .args(&["-c", "-w", "4"]) + .pipe_in("ab\tcd\n") + .succeeds() + .stdout_is("ab\n\t\ncd\n"); +} + +#[test] +fn test_fold_characters_tab_with_non_ascii() { + new_ucmd!() + .args(&["-c", "-w", "2"]) + .pipe_in("\u{00E9}\tb\n") + .succeeds() + .stdout_is("\u{00E9}\n\t\nb\n"); +} + #[test] fn test_fold_at_tab_as_word_boundary() { new_ucmd!() From 7b240b7b1539dc0612491df01e5570af7d98a9f9 Mon Sep 17 00:00:00 2001 From: xtqqczze <45661989+xtqqczze@users.noreply.github.com> Date: Wed, 28 Jan 2026 16:50:16 +0000 Subject: [PATCH 392/425] refactor: rename print functions to write for consistency All these functions have a parameter like: `writer: &mut impl Write` --- src/uu/factor/src/factor.rs | 6 +-- src/uu/join/src/join.rs | 54 +++++++++---------- src/uu/sort/src/sort.rs | 4 +- src/uu/stat/src/stat.rs | 12 ++--- src/uu/tail/src/chunks.rs | 14 ++--- src/uu/tail/src/tail.rs | 6 +-- src/uu/uniq/src/uniq.rs | 12 ++--- .../src/lib/features/checksum/validate.rs | 12 ++--- 8 files changed, 60 insertions(+), 60 deletions(-) diff --git a/src/uu/factor/src/factor.rs b/src/uu/factor/src/factor.rs index 15af962d6..898679893 100644 --- a/src/uu/factor/src/factor.rs +++ b/src/uu/factor/src/factor.rs @@ -23,7 +23,7 @@ mod options { pub static NUMBER: &str = "NUMBER"; } -fn print_factors_str( +fn write_factors_str( num_str: &str, w: &mut io::BufWriter, print_exponents: bool, @@ -159,7 +159,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { if let Some(values) = matches.get_many::(options::NUMBER) { for number in values { - print_factors_str(number, &mut w, print_exponents)?; + write_factors_str(number, &mut w, print_exponents)?; } } else { let stdin = stdin(); @@ -168,7 +168,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { match line { Ok(line) => { for number in line.split_whitespace() { - print_factors_str(number, &mut w, print_exponents)?; + write_factors_str(number, &mut w, print_exponents)?; } } Err(e) => { diff --git a/src/uu/join/src/join.rs b/src/uu/join/src/join.rs index 5d2dd2cc9..45aa79cef 100644 --- a/src/uu/join/src/join.rs +++ b/src/uu/join/src/join.rs @@ -220,8 +220,8 @@ impl<'a, Sep: Separator> Repr<'a, Sep> { !self.format.is_empty() } - /// Print the field or empty filler if the field is not set. - fn print_field( + /// Write the field or empty filler if the field is not set. + fn write_field( &self, writer: &mut impl Write, field: Option<&[u8]>, @@ -234,8 +234,8 @@ impl<'a, Sep: Separator> Repr<'a, Sep> { writer.write_all(value) } - /// Print each field except the one at the index. - fn print_fields( + /// Write each field except the one at the index. + fn write_fields( &self, writer: &mut impl Write, line: &Line, @@ -250,8 +250,8 @@ impl<'a, Sep: Separator> Repr<'a, Sep> { Ok(()) } - /// Print each field or the empty filler if the field is not set. - fn print_format(&self, writer: &mut impl Write, f: F) -> Result<(), std::io::Error> + /// Write each field or the empty filler if the field is not set. + fn write_format(&self, writer: &mut impl Write, f: F) -> Result<(), std::io::Error> where F: Fn(&Spec) -> Option<&'a [u8]>, { @@ -270,7 +270,7 @@ impl<'a, Sep: Separator> Repr<'a, Sep> { Ok(()) } - fn print_line_ending(&self, writer: &mut impl Write) -> Result<(), std::io::Error> { + fn write_line_ending(&self, writer: &mut impl Write) -> Result<(), std::io::Error> { writer.write_all(&[self.line_ending as u8]) } } @@ -468,7 +468,7 @@ impl<'a> State<'a> { repr: &Repr<'a, Sep>, ) -> UResult<()> { if self.print_unpaired { - self.print_first_line(writer, repr)?; + self.write_first_line(writer, repr)?; } self.reset_next_line(input)?; @@ -491,8 +491,8 @@ impl<'a> State<'a> { Ok(None) } - /// Print lines in the buffers as headers. - fn print_headers( + /// Write lines in the buffers as headers. + fn write_headers( &self, writer: &mut impl Write, other: &State, @@ -502,10 +502,10 @@ impl<'a> State<'a> { if other.has_line() { self.combine(writer, other, repr)?; } else { - self.print_first_line(writer, repr)?; + self.write_first_line(writer, repr)?; } } else if other.has_line() { - other.print_first_line(writer, repr)?; + other.write_first_line(writer, repr)?; } Ok(()) @@ -523,7 +523,7 @@ impl<'a> State<'a> { for line1 in &self.seq { for line2 in &other.seq { if repr.uses_format() { - repr.print_format(writer, |spec| match *spec { + repr.write_format(writer, |spec| match *spec { Spec::Key => key, Spec::Field(file_num, field_num) => { if file_num == self.file_num { @@ -538,12 +538,12 @@ impl<'a> State<'a> { } })?; } else { - repr.print_field(writer, key)?; - repr.print_fields(writer, line1, self.key)?; - repr.print_fields(writer, line2, other.key)?; + repr.write_field(writer, key)?; + repr.write_fields(writer, line1, self.key)?; + repr.write_fields(writer, line2, other.key)?; } - repr.print_line_ending(writer)?; + repr.write_line_ending(writer)?; } } @@ -601,13 +601,13 @@ impl<'a> State<'a> { ) -> UResult<()> { if self.has_line() { if self.print_unpaired { - self.print_first_line(writer, repr)?; + self.write_first_line(writer, repr)?; } let mut next_line = self.next_line(input)?; while let Some(line) = &next_line { if self.print_unpaired { - self.print_line(writer, line, repr)?; + self.write_line(writer, line, repr)?; } self.reset(next_line); next_line = self.next_line(input)?; @@ -665,14 +665,14 @@ impl<'a> State<'a> { self.seq[0].get_field(self.key) } - fn print_line( + fn write_line( &self, writer: &mut impl Write, line: &Line, repr: &Repr<'a, Sep>, ) -> Result<(), std::io::Error> { if repr.uses_format() { - repr.print_format(writer, |spec| match *spec { + repr.write_format(writer, |spec| match *spec { Spec::Key => line.get_field(self.key), Spec::Field(file_num, field_num) => { if file_num == self.file_num { @@ -683,19 +683,19 @@ impl<'a> State<'a> { } })?; } else { - repr.print_field(writer, line.get_field(self.key))?; - repr.print_fields(writer, line, self.key)?; + repr.write_field(writer, line.get_field(self.key))?; + repr.write_fields(writer, line, self.key)?; } - repr.print_line_ending(writer) + repr.write_line_ending(writer) } - fn print_first_line( + fn write_first_line( &self, writer: &mut impl Write, repr: &Repr<'a, Sep>, ) -> Result<(), std::io::Error> { - self.print_line(writer, &self.seq[0], repr) + self.write_line(writer, &self.seq[0], repr) } } @@ -1033,7 +1033,7 @@ fn exec( let mut writer = BufWriter::new(stdout.lock()); if settings.headers { - state1.print_headers(&mut writer, &state2, &repr)?; + state1.write_headers(&mut writer, &state2, &repr)?; state1.reset_read_line(&input)?; state2.reset_read_line(&input)?; } diff --git a/src/uu/sort/src/sort.rs b/src/uu/sort/src/sort.rs index ac21c395a..6c6091e92 100644 --- a/src/uu/sort/src/sort.rs +++ b/src/uu/sort/src/sort.rs @@ -630,7 +630,7 @@ impl<'a> Line<'a> { fn print(&self, writer: &mut impl Write, settings: &GlobalSettings) -> std::io::Result<()> { if settings.debug { - self.print_debug(settings, writer)?; + self.write_debug(settings, writer)?; } else { writer.write_all(self.line)?; writer.write_all(&[settings.line_ending.into()])?; @@ -640,7 +640,7 @@ impl<'a> Line<'a> { /// Writes indicators for the selections this line matched. The original line content is NOT expected /// to be already printed. - fn print_debug( + fn write_debug( &self, settings: &GlobalSettings, writer: &mut impl Write, diff --git a/src/uu/stat/src/stat.rs b/src/uu/stat/src/stat.rs index 24982e6a4..1b7f91584 100644 --- a/src/uu/stat/src/stat.rs +++ b/src/uu/stat/src/stat.rs @@ -139,20 +139,20 @@ fn pad_and_print_bytes( }; if left_pad > 0 { - print_padding(&mut writer, left_pad)?; + write_padding(&mut writer, left_pad)?; } writer.write_all(display_bytes)?; if right_pad > 0 { - print_padding(&mut writer, right_pad)?; + write_padding(&mut writer, right_pad)?; } Ok(()) } -/// print padding based on a writer W and n size +/// write padding based on a writer W and n size /// writer is genric to be any buffer like: `std::io::stdout` /// n is the calculated padding size -fn print_padding(writer: &mut W, n: usize) -> Result<(), std::io::Error> { +fn write_padding(writer: &mut W, n: usize) -> Result<(), std::io::Error> { for _ in 0..n { writer.write_all(b" ")?; } @@ -1406,7 +1406,7 @@ fn pretty_time(meta: &Metadata, md_time_field: MetadataTimeField) -> String { #[cfg(test)] mod tests { - use crate::{pad_and_print_bytes, print_padding, quote_file_name}; + use crate::{pad_and_print_bytes, quote_file_name, write_padding}; use super::{Flags, Precision, ScanUtil, Stater, Token, group_num, precision_trunc}; @@ -1554,7 +1554,7 @@ mod tests { #[test] fn test_print_padding() { let mut buffer = Vec::new(); - print_padding(&mut buffer, 5).unwrap(); + write_padding(&mut buffer, 5).unwrap(); assert_eq!(&buffer, b" "); } diff --git a/src/uu/tail/src/chunks.rs b/src/uu/tail/src/chunks.rs index 14f1fbe5a..3ab7cf4e3 100644 --- a/src/uu/tail/src/chunks.rs +++ b/src/uu/tail/src/chunks.rs @@ -507,24 +507,24 @@ impl LinesChunk { bytes_offset } - /// Print the bytes contained in this buffer calculated with the given offset in number of + /// Write the bytes contained in this buffer calculated with the given offset in number of /// lines. /// /// # Arguments /// /// * `writer`: must implement [`Write`] /// * `offset`: An offset in number of lines. - pub fn print_lines(&self, writer: &mut impl Write, offset: usize) -> UResult<()> { - self.print_bytes(writer, self.calculate_bytes_offset_from(offset)) + pub fn write_lines(&self, writer: &mut impl Write, offset: usize) -> UResult<()> { + self.write_bytes(writer, self.calculate_bytes_offset_from(offset)) } - /// Print the bytes contained in this buffer beginning from the given offset in number of bytes. + /// Write the bytes contained in this buffer beginning from the given offset in number of bytes. /// /// # Arguments /// /// * `writer`: must implement [`Write`] /// * `offset`: An offset in number of bytes. - pub fn print_bytes(&self, writer: &mut impl Write, offset: usize) -> UResult<()> { + pub fn write_bytes(&self, writer: &mut impl Write, offset: usize) -> UResult<()> { writer.write_all(self.get_buffer_with(offset))?; Ok(()) } @@ -617,9 +617,9 @@ impl LinesChunkBuffer { Ok(()) } - pub fn print(&self, mut writer: impl Write) -> UResult<()> { + pub fn write(&self, mut writer: impl Write) -> UResult<()> { for chunk in &self.chunks { - chunk.print_bytes(&mut writer, 0)?; + chunk.write_bytes(&mut writer, 0)?; } Ok(()) } diff --git a/src/uu/tail/src/tail.rs b/src/uu/tail/src/tail.rs index 7b82e9566..0ae60a08d 100644 --- a/src/uu/tail/src/tail.rs +++ b/src/uu/tail/src/tail.rs @@ -502,7 +502,7 @@ fn unbounded_tail(reader: &mut BufReader, settings: &Settings) -> UR FilterMode::Lines(Signum::Negative(count), sep) => { let mut chunks = chunks::LinesChunkBuffer::new(*sep, *count); chunks.fill(reader)?; - chunks.print(&mut writer)?; + chunks.write(&mut writer)?; } FilterMode::Lines(Signum::PlusZero | Signum::Positive(1), _) => { io::copy(reader, &mut writer)?; @@ -519,7 +519,7 @@ fn unbounded_tail(reader: &mut BufReader, settings: &Settings) -> UR } } if chunk.has_data() { - chunk.print_lines(&mut writer, num_skip as usize)?; + chunk.write_lines(&mut writer, num_skip as usize)?; io::copy(reader, &mut writer)?; } } @@ -531,7 +531,7 @@ fn unbounded_tail(reader: &mut BufReader, settings: &Settings) -> UR FilterMode::Lines(Signum::MinusZero, sep) => { let mut chunks = chunks::LinesChunkBuffer::new(*sep, 0); chunks.fill(reader)?; - chunks.print(&mut writer)?; + chunks.write(&mut writer)?; } FilterMode::Bytes(Signum::PlusZero | Signum::Positive(1)) => { io::copy(reader, &mut writer)?; diff --git a/src/uu/uniq/src/uniq.rs b/src/uu/uniq/src/uniq.rs index ae9b88f2d..a968e6bf0 100644 --- a/src/uu/uniq/src/uniq.rs +++ b/src/uu/uniq/src/uniq.rs @@ -72,7 +72,7 @@ macro_rules! write_line_terminator { } impl Uniq { - pub fn print_uniq(&self, mut reader: impl BufRead, mut writer: impl Write) -> UResult<()> { + pub fn write_uniq(&self, mut reader: impl BufRead, mut writer: impl Write) -> UResult<()> { let mut first_line_printed = false; let mut group_count = 1; let line_terminator = self.get_line_terminator(); @@ -97,7 +97,7 @@ impl Uniq { if self.keys_are_equal(¤t_buf, ¤t_meta, &next_buf, &next_meta) { if self.all_repeated { - self.print_line(writer, ¤t_buf, group_count, first_line_printed)?; + self.write_line(writer, ¤t_buf, group_count, first_line_printed)?; first_line_printed = true; std::mem::swap(&mut current_buf, &mut next_buf); std::mem::swap(&mut current_meta, &mut next_meta); @@ -107,7 +107,7 @@ impl Uniq { if (group_count == 1 && !self.repeats_only) || (group_count > 1 && !self.uniques_only) { - self.print_line(writer, ¤t_buf, group_count, first_line_printed)?; + self.write_line(writer, ¤t_buf, group_count, first_line_printed)?; first_line_printed = true; } std::mem::swap(&mut current_buf, &mut next_buf); @@ -118,7 +118,7 @@ impl Uniq { } if (group_count == 1 && !self.repeats_only) || (group_count > 1 && !self.uniques_only) { - self.print_line(writer, ¤t_buf, group_count, first_line_printed)?; + self.write_line(writer, ¤t_buf, group_count, first_line_printed)?; first_line_printed = true; } if (self.delimiters == Delimiters::Append || self.delimiters == Delimiters::Both) @@ -250,7 +250,7 @@ impl Uniq { || self.delimiters == Delimiters::Both) } - fn print_line( + fn write_line( &self, writer: &mut impl Write, line: &[u8], @@ -678,7 +678,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { )); } - uniq.print_uniq( + uniq.write_uniq( open_input_file(in_file_name)?, open_output_file(out_file_name)?, ) diff --git a/src/uucore/src/lib/features/checksum/validate.rs b/src/uucore/src/lib/features/checksum/validate.rs index aa950abac..990ae8dab 100644 --- a/src/uucore/src/lib/features/checksum/validate.rs +++ b/src/uucore/src/lib/features/checksum/validate.rs @@ -229,9 +229,9 @@ impl Display for FileChecksumResult { } } -/// Print to the given buffer the checksum validation status of a file which +/// Write to the given buffer the checksum validation status of a file which /// name might contain non-utf-8 characters. -fn print_file_report( +fn write_file_report( mut w: W, filename: &[u8], result: FileChecksumResult, @@ -533,7 +533,7 @@ fn get_file_to_check( Ok(Box::new(io::stdin())) // Use stdin if "-" is specified in the checksum file } else { let failed_open = || { - print_file_report( + write_file_report( io::stdout(), filename_bytes, FileChecksumResult::CantOpen, @@ -685,7 +685,7 @@ fn compute_and_check_digest_from_file( DigestOutput::Crc(n) => n.to_be_bytes() == expected_checksum, DigestOutput::U16(n) => n.to_be_bytes() == expected_checksum, }; - print_file_report( + write_file_report( std::io::stdout(), filename, FileChecksumResult::from_bool(checksum_correct), @@ -1212,7 +1212,7 @@ mod tests { } #[test] - fn test_print_file_report() { + fn test_write_file_report() { let opts = ChecksumValidateOptions::default(); let cases: &[(&[u8], FileChecksumResult, &str, &[u8])] = &[ @@ -1245,7 +1245,7 @@ mod tests { for (filename, result, prefix, expected) in cases { let mut buffer: Vec = vec![]; - print_file_report(&mut buffer, filename, *result, prefix, opts.verbose); + write_file_report(&mut buffer, filename, *result, prefix, opts.verbose); assert_eq!(&buffer, expected); } } From 76897d7caceb32f37c8a99a8369c41756f40fad7 Mon Sep 17 00:00:00 2001 From: Christopher Dryden Date: Mon, 26 Jan 2026 20:27:46 +0000 Subject: [PATCH 393/425] date: use jiff-icu for locale calendar conversions --- Cargo.lock | 34 ++- Cargo.toml | 1 + src/uu/date/Cargo.toml | 3 +- src/uu/date/src/date.rs | 113 +-------- src/uucore/Cargo.toml | 3 +- src/uucore/src/lib/features/i18n/datetime.rs | 242 +++++-------------- tests/by-util/test_date.rs | 102 +++++++- 7 files changed, 203 insertions(+), 295 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 17c05da50..acd8dc124 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -337,18 +337,18 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.55" +version = "4.5.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e34525d5bbbd55da2bb745d34b36121baac88d07619a9a09cfcf4a6c0832785" +checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394" dependencies = [ "clap_builder", ] [[package]] name = "clap_builder" -version = "4.5.55" +version = "4.5.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59a20016a20a3da95bef50ec7238dbd09baeef4311dcdd38ec15aba69812fb61" +checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00" dependencies = [ "anstream", "anstyle", @@ -1008,7 +1008,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1355,6 +1355,7 @@ dependencies = [ "icu_locale_core", "icu_provider", "ixdtf", + "serde", "tinystr", "zerovec", ] @@ -1720,7 +1721,18 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde_core", - "windows-sys 0.59.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "jiff-icu" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e67c2beaae8b10a82d849b9aabb698a43a682f32b17bcdc035d5ecadb44d646" +dependencies = [ + "icu_calendar", + "icu_time", + "jiff", ] [[package]] @@ -2003,7 +2015,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2593,7 +2605,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2903,7 +2915,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3378,6 +3390,7 @@ dependencies = [ "icu_calendar", "icu_locale", "jiff", + "jiff-icu", "nix", "parse_datetime", "tempfile", @@ -4456,6 +4469,7 @@ dependencies = [ "icu_provider", "itertools 0.14.0", "jiff", + "jiff-icu", "libc", "md-5", "memchr", @@ -4649,7 +4663,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 12e062f8f..228272604 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -346,6 +346,7 @@ indicatif = "0.18.0" itertools = "0.14.0" itoa = "1.0.15" jiff = "0.2.18" +jiff-icu = "0.2.2" libc = "0.2.172" lscolors = { version = "0.21.0", default-features = false, features = [ "gnu_legacy", diff --git a/src/uu/date/Cargo.toml b/src/uu/date/Cargo.toml index 8820d96b9..c563e6c62 100644 --- a/src/uu/date/Cargo.toml +++ b/src/uu/date/Cargo.toml @@ -20,13 +20,14 @@ path = "src/date.rs" [features] default = ["i18n-datetime"] -i18n-datetime = ["uucore/i18n-datetime", "dep:icu_calendar", "dep:icu_locale"] +i18n-datetime = ["uucore/i18n-datetime", "dep:icu_calendar", "dep:icu_locale", "dep:jiff-icu"] [dependencies] clap = { workspace = true } fluent = { workspace = true } icu_calendar = { workspace = true, optional = true } icu_locale = { workspace = true, optional = true } +jiff-icu = { workspace = true, optional = true } jiff = { workspace = true, features = [ "tzdb-bundle-platform", "tzdb-zoneinfo", diff --git a/src/uu/date/src/date.rs b/src/uu/date/src/date.rs index f82fe1c38..cc507c781 100644 --- a/src/uu/date/src/date.rs +++ b/src/uu/date/src/date.rs @@ -21,10 +21,7 @@ use uucore::display::Quotable; use uucore::error::FromIo; use uucore::error::{UResult, USimpleError}; #[cfg(feature = "i18n-datetime")] -use uucore::i18n::datetime::{ - get_era_year, get_localized_day_name, get_localized_month_name, get_time_locale, - should_use_icu_locale, -}; +use uucore::i18n::datetime::{localize_format_string, should_use_icu_locale}; use uucore::translate; use uucore::{format_usage, show}; #[cfg(windows)] @@ -620,112 +617,14 @@ fn format_date_with_locale_aware_months( format_string: &str, config: &Config, ) -> Result { - // Only use ICU for non-English locales and when format string contains month, day, or era year specifiers - if (format_string.contains("%B") - || format_string.contains("%b") - || format_string.contains("%A") - || format_string.contains("%a") - || format_string.contains("%Y") - || format_string.contains("%Ey")) - && should_use_icu_locale() - { - let broken_down = BrokenDownTime::from(date); - // Get localized month names if needed - let (full_month, abbrev_month) = - if format_string.contains("%B") || format_string.contains("%b") { - if let Some(month_val) = broken_down.month() { - let month_u8 = if (1..=12).contains(&month_val) { - month_val as u8 - } else { - 1 // fallback to January for invalid values - }; - ( - get_localized_month_name(month_u8, true), - get_localized_month_name(month_u8, false), - ) - } else { - (String::new(), String::new()) - } - } else { - (String::new(), String::new()) - }; + let broken_down = BrokenDownTime::from(date); - // Get localized day names if needed - let (full_day, abbrev_day) = if format_string.contains("%A") || format_string.contains("%a") - { - if let (Some(year), Some(month), Some(day)) = - (broken_down.year(), broken_down.month(), broken_down.day()) - { - ( - get_localized_day_name(year.into(), month as u8, day as u8, true), - get_localized_day_name(year.into(), month as u8, day as u8, false), - ) - } else { - (String::new(), String::new()) - } - } else { - (String::new(), String::new()) - }; - - // Get era year if needed - let era_year = if format_string.contains("%Y") || format_string.contains("%Ey") { - if let (Some(year), Some(month), Some(day)) = - (broken_down.year(), broken_down.month(), broken_down.day()) - { - let (locale, _encoding) = get_time_locale(); - get_era_year(year.into(), month as u8, day as u8, locale) - } else { - None - } - } else { - None - }; - - // Replace format specifiers with NULL-byte placeholders for successful ICU translations only - // Use NULL bytes to avoid collision with user format strings - let mut temp_format = format_string.to_string(); - if !full_month.is_empty() { - temp_format = temp_format.replace("%B", "\0FULL_MONTH\0"); - } - if !abbrev_month.is_empty() { - temp_format = temp_format.replace("%b", "\0ABBREV_MONTH\0"); - } - if !full_day.is_empty() { - temp_format = temp_format.replace("%A", "\0FULL_DAY\0"); - } - if !abbrev_day.is_empty() { - temp_format = temp_format.replace("%a", "\0ABBREV_DAY\0"); - } - if era_year.is_some() { - temp_format = temp_format.replace("%Y", "\0ERA_YEAR\0"); - } - - // Format with the temporary string - let temp_result = broken_down.to_string_with_config(config, &temp_format)?; - - // Replace NULL-byte placeholders with localized names - let mut final_result = temp_result; - if !full_month.is_empty() { - final_result = final_result.replace("\0FULL_MONTH\0", &full_month); - } - if !abbrev_month.is_empty() { - final_result = final_result.replace("\0ABBREV_MONTH\0", &abbrev_month); - } - if !full_day.is_empty() { - final_result = final_result.replace("\0FULL_DAY\0", &full_day); - } - if !abbrev_day.is_empty() { - final_result = final_result.replace("\0ABBREV_DAY\0", &abbrev_day); - } - if let Some(era_year_val) = era_year { - final_result = final_result.replace("\0ERA_YEAR\0", &era_year_val.to_string()); - } - - return Ok(final_result); + if !should_use_icu_locale() { + return broken_down.to_string_with_config(config, format_string); } - // Fallback to regular formatting - BrokenDownTime::from(date).to_string_with_config(config, format_string) + let fmt = localize_format_string(format_string, &date.date()); + broken_down.to_string_with_config(config, &fmt) } /// Return the appropriate format string for the given settings. diff --git a/src/uucore/Cargo.toml b/src/uucore/Cargo.toml index 507f7740c..d4383d33f 100644 --- a/src/uucore/Cargo.toml +++ b/src/uucore/Cargo.toml @@ -82,6 +82,7 @@ icu_decimal = { workspace = true, optional = true, features = [ ] } icu_locale = { workspace = true, optional = true, features = ["compiled_data"] } icu_provider = { workspace = true, optional = true } +jiff-icu = { workspace = true, optional = true } # Fluent dependencies (always available for localization) fluent = { workspace = true } @@ -153,7 +154,7 @@ i18n-all = ["i18n-collator", "i18n-decimal", "i18n-datetime"] i18n-common = ["icu_locale"] i18n-collator = ["i18n-common", "icu_collator"] i18n-decimal = ["i18n-common", "icu_decimal", "icu_provider"] -i18n-datetime = ["i18n-common", "icu_calendar", "icu_datetime"] +i18n-datetime = ["i18n-common", "icu_calendar", "icu_datetime", "jiff-icu", "jiff"] mode = ["libc"] perms = ["entries", "libc", "walkdir"] buf-copy = [] diff --git a/src/uucore/src/lib/features/i18n/datetime.rs b/src/uucore/src/lib/features/i18n/datetime.rs index e5d6a6662..68721e8fb 100644 --- a/src/uucore/src/lib/features/i18n/datetime.rs +++ b/src/uucore/src/lib/features/i18n/datetime.rs @@ -3,13 +3,17 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -//! Locale-aware datetime formatting utilities using ICU -// spell-checker:ignore fieldsets janvier +// spell-checker:ignore fieldsets prefs + +//! Locale-aware datetime formatting utilities using ICU and jiff-icu use icu_calendar::Date; +use icu_calendar::cal::{Buddhist, Ethiopian, Iso, Persian}; use icu_datetime::DateTimeFormatter; use icu_datetime::fieldsets; use icu_locale::Locale; +use jiff::civil::Date as JiffDate; +use jiff_icu::ConvertFrom; use std::sync::OnceLock; use crate::i18n::get_locale_from_env; @@ -34,88 +38,6 @@ pub fn should_use_icu_locale() -> bool { *locale != locale!("und") } -/// Get a localized month name for the given month number (1-12) -/// -/// # Arguments -/// * `month` - Month number (1 = January, 2 = February, etc.) -/// * `full` - If true, return full month name (e.g., "January"), otherwise abbreviated (e.g., "Jan") -/// -/// # Returns -/// Localized month name, or falls back to English if locale is not supported -pub fn get_localized_month_name(month: u8, full: bool) -> String { - // Get locale from environment - let (locale, _encoding) = get_time_locale(); - - // Create a date with the specified month (use year 2000, day 1 as arbitrary values) - let Ok(date) = Date::try_new_gregorian(2000, month, 1) else { - // Invalid month, return empty string to signal failure - return String::new(); - }; - - // Configure field set for month formatting - // Use Year-Month-Day format to ensure we get textual month names - let field_set = if full { - fieldsets::YMD::long() - } else { - fieldsets::YMD::medium() - }; - - // Create formatter with locale - let Ok(formatter) = DateTimeFormatter::try_new(locale.clone().into(), field_set) else { - // Failed to create formatter, return empty string to signal failure - return String::new(); - }; - - // Format the date to get full date, then extract month - let formatted = formatter.format(&date).to_string(); - // Extract month name from formatted date like "15 janvier 2000" or "2000-01-15" - // Look for a word that contains letters (the month name) - let words: Vec<&str> = formatted.split_whitespace().collect(); - - // Return the month name as extracted from ICU (no further processing needed) - // ICU already handles the full vs abbreviated formatting correctly - words - .iter() - .find(|word| word.chars().any(|c| c.is_alphabetic())) - .map_or_else(String::new, |s| (*s).to_string()) -} - -/// Get a localized day name for the given date components -/// -/// # Arguments -/// * `year` - The year -/// * `month` - The month (1-12) -/// * `day` - The day of the month -/// * `full` - If true, return full day name (e.g., "Monday"), otherwise abbreviated (e.g., "Mon") -/// -/// # Returns -/// Localized day name, or falls back to empty string if locale is not supported -pub fn get_localized_day_name(year: i32, month: u8, day: u8, full: bool) -> String { - // Create ICU Date from components - let Ok(date) = Date::try_new_gregorian(year, month, day) else { - return String::new(); - }; - - // Get locale from environment - let (locale, _encoding) = get_time_locale(); - - // Configure field set for day formatting - let field_set = if full { - fieldsets::E::long() // Full day name - } else { - fieldsets::E::short() // Abbreviated day name - }; - - // Create formatter with locale - let Ok(formatter) = DateTimeFormatter::try_new(locale.clone().into(), field_set) else { - return String::new(); - }; - - // Format the date to get day name - let formatted = formatter.format(&date).to_string(); - formatted.trim().to_string() -} - /// Determine the appropriate calendar system for a given locale pub fn get_locale_calendar_type(locale: &Locale) -> CalendarType { let locale_str = locale.to_string(); @@ -145,120 +67,90 @@ pub enum CalendarType { Ethiopian, } -/// Convert a Gregorian date to the appropriate calendar system for a locale -/// -/// # Arguments -/// * `year` - Gregorian year -/// * `month` - Month (1-12) -/// * `day` - Day (1-31) -/// * `calendar_type` - Target calendar system -/// -/// # Returns -/// * `Some((era_year, month, day))` - Date in target calendar system -/// * `None` - If conversion fails -pub fn convert_date_to_locale_calendar( - year: i32, - month: u8, - day: u8, - calendar_type: &CalendarType, -) -> Option<(i32, u8, u8)> { - match calendar_type { - CalendarType::Gregorian => Some((year, month, day)), - CalendarType::Buddhist => { - // Buddhist calendar: Gregorian year + 543 - Some((year + 543, month, day)) - } - CalendarType::Persian => { - // Persian calendar conversion (Solar Hijri) - // March 21 (Nowruz) is roughly the start of the Persian year - let persian_year = if month > 3 || (month == 3 && day >= 21) { - year - 621 // After March 21 - } else { - year - 622 // Before March 21 - }; - Some((persian_year, month, day)) - } - CalendarType::Ethiopian => { - // Ethiopian calendar conversion - // September 11/12 is roughly the start of the Ethiopian year - let ethiopian_year = if month > 9 || (month == 9 && day >= 11) { - year - 7 // After September 11 - } else { - year - 8 // Before September 11 - }; - Some((ethiopian_year, month, day)) - } - } -} +/// Transform a strftime format string to use locale-specific calendar values +pub fn localize_format_string(format: &str, date: &JiffDate) -> String { + let (locale, _) = get_time_locale(); + let iso_date = Date::::convert_from(*date); -/// Get the era year for a given date and locale -pub fn get_era_year(year: i32, month: u8, day: u8, locale: &Locale) -> Option { - // Validate input date - if !(1..=12).contains(&month) || !(1..=31).contains(&day) { - return None; - } + let mut fmt = format.to_string(); + // For non-Gregorian calendars, replace date components with converted values let calendar_type = get_locale_calendar_type(locale); - match calendar_type { - CalendarType::Gregorian => None, - _ => convert_date_to_locale_calendar(year, month, day, &calendar_type) - .map(|(era_year, _, _)| era_year), + if calendar_type != CalendarType::Gregorian { + let (cal_year, cal_month, cal_day) = match calendar_type { + CalendarType::Buddhist => { + let d = iso_date.to_calendar(Buddhist); + (d.extended_year(), d.month().ordinal, d.day_of_month().0) + } + CalendarType::Persian => { + let d = iso_date.to_calendar(Persian); + (d.extended_year(), d.month().ordinal, d.day_of_month().0) + } + CalendarType::Ethiopian => { + let d = iso_date.to_calendar(Ethiopian::new()); + (d.extended_year(), d.month().ordinal, d.day_of_month().0) + } + CalendarType::Gregorian => unreachable!(), + }; + fmt = fmt + .replace("%Y", &cal_year.to_string()) + .replace("%m", &format!("{cal_month:02}")) + .replace("%d", &format!("{cal_day:02}")) + .replace("%e", &format!("{cal_day:2}")); } + + // Format localized names using ICU DateTimeFormatter + let locale_prefs = locale.clone().into(); + + if fmt.contains("%B") { + if let Ok(f) = DateTimeFormatter::try_new(locale_prefs, fieldsets::M::long()) { + fmt = fmt.replace("%B", &f.format(&iso_date).to_string()); + } + } + if fmt.contains("%b") || fmt.contains("%h") { + if let Ok(f) = DateTimeFormatter::try_new(locale_prefs, fieldsets::M::medium()) { + let month_abbrev = f.format(&iso_date).to_string(); + fmt = fmt + .replace("%b", &month_abbrev) + .replace("%h", &month_abbrev); + } + } + if fmt.contains("%A") { + if let Ok(f) = DateTimeFormatter::try_new(locale_prefs, fieldsets::E::long()) { + fmt = fmt.replace("%A", &f.format(&iso_date).to_string()); + } + } + if fmt.contains("%a") { + if let Ok(f) = DateTimeFormatter::try_new(locale_prefs, fieldsets::E::short()) { + fmt = fmt.replace("%a", &f.format(&iso_date).to_string()); + } + } + + fmt } #[cfg(test)] mod tests { use super::*; - #[test] - fn test_localized_month_name_fallback() { - // This should work even if locale is not available - let name = get_localized_month_name(1, true); - // The function may return empty string if ICU fails, which is fine - // The caller (date.rs) will handle this by falling back to jiff - assert!(name.is_empty() || name.len() >= 3); - } - #[test] fn test_calendar_type_detection() { - let thai_locale = icu_locale::locale!("th-TH"); - let persian_locale = icu_locale::locale!("fa-IR"); - let amharic_locale = icu_locale::locale!("am-ET"); - let english_locale = icu_locale::locale!("en-US"); - + use icu_locale::locale; assert_eq!( - get_locale_calendar_type(&thai_locale), + get_locale_calendar_type(&locale!("th-TH")), CalendarType::Buddhist ); assert_eq!( - get_locale_calendar_type(&persian_locale), + get_locale_calendar_type(&locale!("fa-IR")), CalendarType::Persian ); assert_eq!( - get_locale_calendar_type(&amharic_locale), + get_locale_calendar_type(&locale!("am-ET")), CalendarType::Ethiopian ); assert_eq!( - get_locale_calendar_type(&english_locale), + get_locale_calendar_type(&locale!("en-US")), CalendarType::Gregorian ); } - - #[test] - fn test_era_year_conversion() { - let thai_locale = icu_locale::locale!("th-TH"); - let persian_locale = icu_locale::locale!("fa-IR"); - let amharic_locale = icu_locale::locale!("am-ET"); - - // Test Thai Buddhist calendar (2026 + 543 = 2569) - assert_eq!(get_era_year(2026, 6, 15, &thai_locale), Some(2569)); - - // Test Persian calendar (rough approximation) - assert_eq!(get_era_year(2026, 3, 22, &persian_locale), Some(1405)); - assert_eq!(get_era_year(2026, 3, 19, &persian_locale), Some(1404)); - - // Test Ethiopian calendar (rough approximation) - assert_eq!(get_era_year(2026, 9, 12, &amharic_locale), Some(2019)); - assert_eq!(get_era_year(2026, 9, 10, &amharic_locale), Some(2018)); - } } diff --git a/tests/by-util/test_date.rs b/tests/by-util/test_date.rs index 1034dfdfc..9e5d931c8 100644 --- a/tests/by-util/test_date.rs +++ b/tests/by-util/test_date.rs @@ -3,7 +3,7 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. // -// spell-checker: ignore: AEDT AEST EEST NZDT NZST Kolkata Iseconds févr février janv janvier mercredi samedi sommes +// spell-checker: ignore: AEDT AEST EEST NZDT NZST Kolkata Iseconds févr février janv janvier mercredi samedi sommes juin décembre Januar Juni Dezember enero junio diciembre gennaio giugno dicembre junho dezembro lundi dimanche Montag Sonntag Samstag sábado use std::cmp::Ordering; @@ -1866,3 +1866,103 @@ fn test_date_thai_locale_solar_calendar() { let rfc_output = rfc_result.stdout_str(); assert!(rfc_output.starts_with(¤t_year.to_string())); } + +fn check_date(locale: &str, date: &str, fmt: &str, expected: &str) { + let actual = new_ucmd!() + .env("LC_ALL", locale) + .arg("-d") + .arg(date) + .arg(fmt) + .succeeds() + .stdout_str() + .trim() + .to_string(); + assert_eq!(actual, expected, "LC_ALL={locale} date -d '{date}' '{fmt}'"); +} + +#[test] +#[cfg(unix)] +fn test_locale_calendar_conversions() { + // Persian (Solar Hijri) - Nowruz is March 20/21 + for (d, e) in [ + ("2026-01-01", "1404-10-11"), + ("2026-01-26", "1404-11-06"), + ("2026-03-20", "1404-12-29"), + ("2026-03-21", "1405-01-01"), + ("2026-03-22", "1405-01-02"), + ("2026-06-15", "1405-03-25"), + ("2026-12-31", "1405-10-10"), + ("2025-03-20", "1403-12-30"), + ("2025-03-21", "1404-01-01"), + ("2024-03-19", "1402-12-29"), + ("2024-03-20", "1403-01-01"), + ("2000-03-20", "1379-01-01"), + ] { + check_date("fa_IR.UTF-8", d, "+%Y-%m-%d", e); + } + + // Thai Buddhist (year + 543, same month/day) + for (d, e) in [ + ("2026-01-01", "2569-01-01"), + ("2026-01-26", "2569-01-26"), + ("2026-06-15", "2569-06-15"), + ("2026-12-31", "2569-12-31"), + ("2025-01-01", "2568-01-01"), + ("2024-02-29", "2567-02-29"), + ("2000-01-01", "2543-01-01"), + ("1970-01-01", "2513-01-01"), + ] { + check_date("th_TH.UTF-8", d, "+%Y-%m-%d", e); + } + + // Ethiopian (13 months, New Year on Sept 11) + for (d, e) in [ + ("2026-01-01", "2018-04-23"), + ("2026-01-26", "2018-05-18"), + ("2026-09-10", "2018-13-05"), + ("2026-09-11", "2019-01-01"), + ("2026-09-12", "2019-01-02"), + ("2026-12-31", "2019-04-22"), + ("2025-09-11", "2018-01-01"), + ("2025-09-10", "2017-13-05"), + ("2000-09-11", "1993-01-01"), + ] { + check_date("am_ET.UTF-8", d, "+%Y-%m-%d", e); + } +} + +#[test] +#[cfg(unix)] +fn test_locale_month_names() { + // %B full month names: Jan, Jun, Dec for each locale + for (loc, jan, jun, dec) in [ + ("fr_FR.UTF-8", "janvier", "juin", "décembre"), + ("de_DE.UTF-8", "Januar", "Juni", "Dezember"), + ("es_ES.UTF-8", "enero", "junio", "diciembre"), + ("it_IT.UTF-8", "gennaio", "giugno", "dicembre"), + ("pt_BR.UTF-8", "janeiro", "junho", "dezembro"), + ("ja_JP.UTF-8", "1月", "6月", "12月"), + ("zh_CN.UTF-8", "一月", "六月", "十二月"), + ] { + check_date(loc, "2026-01-15", "+%B", jan); + check_date(loc, "2026-06-15", "+%B", jun); + check_date(loc, "2026-12-15", "+%B", dec); + } +} + +#[test] +#[cfg(unix)] +fn test_locale_day_names() { + // %A full day names: Mon (26th), Sun (25th), Sat (24th) Jan 2026 + for (loc, mon, sun, sat) in [ + ("fr_FR.UTF-8", "lundi", "dimanche", "samedi"), + ("de_DE.UTF-8", "Montag", "Sonntag", "Samstag"), + ("es_ES.UTF-8", "lunes", "domingo", "sábado"), + ("ja_JP.UTF-8", "月曜日", "日曜日", "土曜日"), + ("zh_CN.UTF-8", "星期一", "星期日", "星期六"), + ] { + check_date(loc, "2026-01-26", "+%A", mon); + check_date(loc, "2026-01-25", "+%A", sun); + check_date(loc, "2026-01-24", "+%A", sat); + } +} From 0e162a3ff6e0b8eee894affb5a5fa801da55dfb3 Mon Sep 17 00:00:00 2001 From: Christopher Dryden Date: Mon, 26 Jan 2026 20:33:07 +0000 Subject: [PATCH 394/425] fuzz: update Cargo.lock for jiff-icu --- fuzz/Cargo.lock | 134 +++++++++++++++++++++++++++--------------------- 1 file changed, 75 insertions(+), 59 deletions(-) diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index 62bd4b157..c4d05638d 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -131,9 +131,9 @@ checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" [[package]] name = "blake2b_simd" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06e903a20b159e944f91ec8499fe1e55651480c541ea0a584f5d967c49ad9d99" +checksum = "b79834656f71332577234b50bfc009996f7449e0c056884e6a02492ded0ca2f3" dependencies = [ "arrayref", "arrayvec", @@ -142,15 +142,16 @@ dependencies = [ [[package]] name = "blake3" -version = "1.8.2" +version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3888aaa89e4b2a40fca9848e400f6a658a5a3978de7be858e209cafa8be9a4a0" +checksum = "2468ef7d57b3fb7e16b576e8377cdbde2320c60e1491e961d11da40fc4f02a2d" dependencies = [ "arrayref", "arrayvec", "cc", "cfg-if", "constant_time_eq", + "cpufeatures", ] [[package]] @@ -206,9 +207,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.51" +version = "1.2.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a0aeaff4ff1a90589618835a598e545176939b97874f7abc7851caa0618f203" +checksum = "6354c81bbfd62d9cfa9cb3c773c2b7b2a3a482d569de977fd0e961f6e7c00583" dependencies = [ "find-msvc-tools", "jobserver", @@ -230,9 +231,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chrono" -version = "0.4.42" +version = "0.4.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" dependencies = [ "iana-time-zone", "num-traits", @@ -263,9 +264,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.6" +version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" +checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" [[package]] name = "colorchoice" @@ -307,16 +308,16 @@ version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" dependencies = [ - "getrandom 0.2.16", + "getrandom 0.2.17", "once_cell", "tiny-keccak", ] [[package]] name = "constant_time_eq" -version = "0.3.1" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" [[package]] name = "core-foundation-sys" @@ -415,15 +416,15 @@ dependencies = [ [[package]] name = "data-encoding" -version = "2.9.0" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" [[package]] name = "data-encoding-macro" -version = "0.1.18" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47ce6c96ea0102f01122a185683611bd5ac8d99e62bc59dd12e6bda344ee673d" +checksum = "8142a83c17aa9461d637e649271eae18bf2edd00e91f2e105df36c3c16355bdb" dependencies = [ "data-encoding", "data-encoding-macro-internal", @@ -431,9 +432,9 @@ dependencies = [ [[package]] name = "data-encoding-macro-internal" -version = "0.1.16" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d162beedaa69905488a8da94f5ac3edb4dd4788b732fadb7bd120b2625c1976" +checksum = "7ab67060fc6b8ef687992d439ca0fa36e7ed17e9a0b16b25b601e8757df720de" dependencies = [ "data-encoding", "syn", @@ -517,9 +518,9 @@ checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] name = "find-msvc-tools" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645cbb3a84e60b7531617d5ae4e57f7e27308f6445f5abf653209ea76dec8dff" +checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db" [[package]] name = "fixed_decimal" @@ -534,9 +535,9 @@ dependencies = [ [[package]] name = "flate2" -version = "1.1.5" +version = "1.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" +checksum = "b375d6465b98090a5f25b1c7703f3859783755aa9a80433b36e0379a3ec2f369" dependencies = [ "crc32fast", "miniz_oxide", @@ -605,9 +606,9 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", "libc", @@ -681,6 +682,7 @@ dependencies = [ "icu_locale_core", "icu_provider", "ixdtf", + "serde", "tinystr", "zerovec", ] @@ -810,9 +812,9 @@ dependencies = [ [[package]] name = "icu_locale_data" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f03e2fcaefecdf05619f3d6f91740e79ab969b4dd54f77cbf546b1d0d28e3147" +checksum = "1c5f1d16b4c3a2642d3a719f18f6b06070ab0aef246a6418130c955ae08aa831" [[package]] name = "icu_normalizer" @@ -985,6 +987,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "jiff-icu" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e67c2beaae8b10a82d849b9aabb698a43a682f32b17bcdc035d5ecadb44d646" +dependencies = [ + "icu_calendar", + "icu_time", + "jiff", +] + [[package]] name = "jiff-static" version = "0.2.18" @@ -1023,9 +1036,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.83" +version = "0.3.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" dependencies = [ "once_cell", "wasm-bindgen", @@ -1058,9 +1071,9 @@ dependencies = [ [[package]] name = "libm" -version = "0.2.15" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "linux-raw-sys" @@ -1283,9 +1296,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.105" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] @@ -1316,9 +1329,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.43" +version = "1.0.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" dependencies = [ "proc-macro2", ] @@ -1351,9 +1364,9 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.9.3" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ "getrandom 0.3.4", ] @@ -1540,9 +1553,9 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "syn" -version = "2.0.113" +version = "2.0.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "678faa00651c9eb72dd2020cbdf275d92eccb2400d568e419efdd64838145cb4" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" dependencies = [ "proc-macro2", "quote", @@ -1585,18 +1598,18 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", @@ -1731,6 +1744,7 @@ dependencies = [ "icu_calendar", "icu_locale", "jiff", + "jiff-icu", "nix", "parse_datetime", "uucore", @@ -1897,6 +1911,8 @@ dependencies = [ "icu_locale", "icu_provider", "itertools", + "jiff", + "jiff-icu", "libc", "md-5", "memchr", @@ -1982,18 +1998,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.1+wasi-0.2.4" +version = "1.0.2+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" dependencies = [ "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.106" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" dependencies = [ "cfg-if", "once_cell", @@ -2004,9 +2020,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.106" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2014,9 +2030,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.106" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" dependencies = [ "bumpalo", "proc-macro2", @@ -2027,9 +2043,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.106" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" dependencies = [ "unicode-ident", ] @@ -2205,9 +2221,9 @@ dependencies = [ [[package]] name = "wit-bindgen" -version = "0.46.0" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" [[package]] name = "write16" @@ -2255,18 +2271,18 @@ checksum = "9b3a41ce106832b4da1c065baa4c31cf640cf965fa1483816402b7f6b96f0a64" [[package]] name = "zerocopy" -version = "0.8.31" +version = "0.8.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd74ec98b9250adb3ca554bdde269adf631549f51d8a8f8f0a10b50f1cb298c3" +checksum = "71ddd76bcebeed25db614f82bf31a9f4222d3fbba300e6fb6c00afa26cbd4d9d" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.31" +version = "0.8.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a" +checksum = "d8187381b52e32220d50b255276aa16a084ec0a9017a0ca2152a1f55c539758d" dependencies = [ "proc-macro2", "quote", From fdf3ea9523a77f773c77094be181b4159e43dcce Mon Sep 17 00:00:00 2001 From: Christopher Dryden Date: Mon, 26 Jan 2026 20:48:15 +0000 Subject: [PATCH 395/425] fix: add cfg(unix) to check_date, format Cargo.toml files --- src/uu/date/Cargo.toml | 7 ++++++- src/uucore/Cargo.toml | 8 +++++++- tests/by-util/test_date.rs | 1 + 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/uu/date/Cargo.toml b/src/uu/date/Cargo.toml index c563e6c62..3cf27595e 100644 --- a/src/uu/date/Cargo.toml +++ b/src/uu/date/Cargo.toml @@ -20,7 +20,12 @@ path = "src/date.rs" [features] default = ["i18n-datetime"] -i18n-datetime = ["uucore/i18n-datetime", "dep:icu_calendar", "dep:icu_locale", "dep:jiff-icu"] +i18n-datetime = [ + "uucore/i18n-datetime", + "dep:icu_calendar", + "dep:icu_locale", + "dep:jiff-icu", +] [dependencies] clap = { workspace = true } diff --git a/src/uucore/Cargo.toml b/src/uucore/Cargo.toml index d4383d33f..d18d0630e 100644 --- a/src/uucore/Cargo.toml +++ b/src/uucore/Cargo.toml @@ -154,7 +154,13 @@ i18n-all = ["i18n-collator", "i18n-decimal", "i18n-datetime"] i18n-common = ["icu_locale"] i18n-collator = ["i18n-common", "icu_collator"] i18n-decimal = ["i18n-common", "icu_decimal", "icu_provider"] -i18n-datetime = ["i18n-common", "icu_calendar", "icu_datetime", "jiff-icu", "jiff"] +i18n-datetime = [ + "i18n-common", + "icu_calendar", + "icu_datetime", + "jiff-icu", + "jiff", +] mode = ["libc"] perms = ["entries", "libc", "walkdir"] buf-copy = [] diff --git a/tests/by-util/test_date.rs b/tests/by-util/test_date.rs index 9e5d931c8..3445d4c0a 100644 --- a/tests/by-util/test_date.rs +++ b/tests/by-util/test_date.rs @@ -1867,6 +1867,7 @@ fn test_date_thai_locale_solar_calendar() { assert!(rfc_output.starts_with(¤t_year.to_string())); } +#[cfg(unix)] fn check_date(locale: &str, date: &str, fmt: &str, expected: &str) { let actual = new_ucmd!() .env("LC_ALL", locale) From 4d50bea59b7ce279e016c2ec7ceee05861800b94 Mon Sep 17 00:00:00 2001 From: FidelSch Date: Wed, 28 Jan 2026 16:54:01 -0300 Subject: [PATCH 396/425] uucore(fs): expand path normalization (#10532) * uucore(fs): expand path normalization and add tests for edge cases * fs: improved path normalization --- src/uucore/src/lib/features/fs.rs | 46 +++++++++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/src/uucore/src/lib/features/fs.rs b/src/uucore/src/lib/features/fs.rs index a783d04ea..94ed5d1dc 100644 --- a/src/uucore/src/lib/features/fs.rs +++ b/src/uucore/src/lib/features/fs.rs @@ -249,13 +249,24 @@ pub fn normalize_path(path: &Path) -> PathBuf { } Component::CurDir => {} Component::ParentDir => { - ret.pop(); + if ret.as_os_str().is_empty() + || matches!(ret.components().next_back(), Some(Component::ParentDir)) + { + ret.push(".."); + } else { + ret.pop(); + } } Component::Normal(c) => { ret.push(c); } } } + + if ret.as_os_str().is_empty() { + ret.push("."); + } + ret } @@ -874,7 +885,38 @@ mod tests { test: &'a str, } - const NORMALIZE_PATH_TESTS: [NormalizePathTestCase; 8] = [ + const NORMALIZE_PATH_TESTS: [NormalizePathTestCase; 15] = [ + NormalizePathTestCase { + path: "foo/bar/../..", + test: ".", + }, + NormalizePathTestCase { + path: ".", + test: ".", + }, + // Should not try to eliminate leading .. components, + // as it may point to a sibling of the current dir + NormalizePathTestCase { + path: "../foo", + test: "../foo", + }, + // Try to go down, then escape above current dir and back down again + NormalizePathTestCase { + path: "foo/../../../bar/baz", + test: "../../bar/baz", + }, + NormalizePathTestCase { + path: "../../foo/..", + test: "../..", + }, + NormalizePathTestCase { + path: "foo/../../..", + test: "../..", + }, + NormalizePathTestCase { + path: "foo/bar/../../..", + test: "..", + }, NormalizePathTestCase { path: "./foo/bar.txt", test: "foo/bar.txt", From fb078774dc307ef8ddc8cf90554f11ab8c0f7923 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 28 Jan 2026 20:17:16 +0000 Subject: [PATCH 397/425] chore(deps): update rust crate clap to v4.5.55 --- Cargo.lock | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index acd8dc124..15333ecbc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -337,18 +337,18 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.54" +version = "4.5.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394" +checksum = "3e34525d5bbbd55da2bb745d34b36121baac88d07619a9a09cfcf4a6c0832785" dependencies = [ "clap_builder", ] [[package]] name = "clap_builder" -version = "4.5.54" +version = "4.5.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00" +checksum = "59a20016a20a3da95bef50ec7238dbd09baeef4311dcdd38ec15aba69812fb61" dependencies = [ "anstream", "anstyle", @@ -1008,7 +1008,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -1721,7 +1721,7 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde_core", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2015,7 +2015,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2605,7 +2605,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2915,7 +2915,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4663,7 +4663,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] From 7207e92f3bc5826d8c2554b7709672d4cc823e44 Mon Sep 17 00:00:00 2001 From: Christopher Dryden Date: Wed, 28 Jan 2026 21:32:32 +0000 Subject: [PATCH 398/425] fuzz_date: skip combined short options like -Rf- that read from stdin --- fuzz/fuzz_targets/fuzz_date.rs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/fuzz/fuzz_targets/fuzz_date.rs b/fuzz/fuzz_targets/fuzz_date.rs index 16a792105..32441b155 100644 --- a/fuzz/fuzz_targets/fuzz_date.rs +++ b/fuzz/fuzz_targets/fuzz_date.rs @@ -18,12 +18,13 @@ fuzz_target!(|data: &[u8]| { for i in 0..fuzz_args.len() { if let Some(arg) = fuzz_args.get(i) { let arg_str = arg.to_string_lossy(); - // Skip if -f- or --file=- (reads dates from stdin) - if (arg_str == "-f" - && fuzz_args - .get(i + 1) - .map(|a| a.to_string_lossy() == "-") - .unwrap_or(false)) + // Skip if -f- or --file=- or combined options like -Rf- (reads dates from stdin) + if (arg_str.starts_with('-') && !arg_str.starts_with("--") && arg_str.ends_with("f-")) + || (arg_str == "-f" + && fuzz_args + .get(i + 1) + .map(|a| a.to_string_lossy() == "-") + .unwrap_or(false)) || arg_str == "-f-" || arg_str == "--file=-" { From 316475878c9fc134caef9d74450aec2ec295c7ab Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Thu, 29 Jan 2026 03:16:01 +0900 Subject: [PATCH 399/425] ln -svf /dev/null /tmp/a > /dev/full panics --- src/uu/ln/src/ln.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/uu/ln/src/ln.rs b/src/uu/ln/src/ln.rs index 0abd1721a..c20a2fc88 100644 --- a/src/uu/ln/src/ln.rs +++ b/src/uu/ln/src/ln.rs @@ -6,6 +6,7 @@ // spell-checker:ignore (ToDO) srcpath targetpath EEXIST use clap::{Arg, ArgAction, Command}; +use std::io::{Write, stdout}; use uucore::display::Quotable; use uucore::error::{FromIo, UError, UResult}; use uucore::fs::{make_path_relative_to, paths_refer_to_same_file}; @@ -455,10 +456,15 @@ fn link(src: &Path, dst: &Path, settings: &Settings) -> UResult<()> { } if settings.verbose { - print!("{} -> {}", dst.quote(), source.quote()); + let mut out = stdout(); + write!(out, "{} -> {}", dst.quote(), source.quote())?; match backup_path { - Some(path) => println!(" ({})", translate!("ln-backup", "backup" => path.quote())), - None => println!(), + Some(path) => writeln!( + out, + " ({})", + translate!("ln-backup", "backup" => path.quote()) + )?, + None => writeln!(out)?, } } Ok(()) From cd50a4d1308179800a0e23cb72d1bd6ef2f4c4db Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Thu, 29 Jan 2026 03:28:01 +0900 Subject: [PATCH 400/425] mkdir -pv a >/dev/full panics --- src/uu/mkdir/src/mkdir.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/uu/mkdir/src/mkdir.rs b/src/uu/mkdir/src/mkdir.rs index d08640ff0..7ea655352 100644 --- a/src/uu/mkdir/src/mkdir.rs +++ b/src/uu/mkdir/src/mkdir.rs @@ -9,6 +9,7 @@ use clap::builder::ValueParser; use clap::parser::ValuesRef; use clap::{Arg, ArgAction, ArgMatches, Command}; use std::ffi::OsString; +use std::io::{Write, stdout}; use std::path::{Path, PathBuf}; #[cfg(all(unix, target_os = "linux"))] use uucore::error::FromIo; @@ -308,10 +309,11 @@ fn create_single_dir(path: &Path, is_parent: bool, config: &Config) -> UResult<( match create_dir_with_mode(path, create_mode) { Ok(()) => { if config.verbose { - println!( + writeln!( + stdout(), "{}", translate!("mkdir-verbose-created-directory", "util_name" => uucore::util_name(), "path" => path.quote()) - ); + )?; } // On Linux, we may need to add ACL permission bits via chmod. @@ -357,10 +359,11 @@ fn create_single_dir(path: &Path, is_parent: bool, config: &Config) -> UResult<( // Print verbose message for logical directories, even if they exist // This matches GNU behavior for paths like "test_dir/../test_dir_a" if config.verbose && is_parent && config.recursive && !ends_with_parent_dir { - println!( + writeln!( + stdout(), "{}", translate!("mkdir-verbose-created-directory", "util_name" => uucore::util_name(), "path" => path.quote()) - ); + )?; } Ok(()) } From 6f955dafb8781f7147475b63655c8956100e4adf Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Thu, 29 Jan 2026 03:35:04 +0900 Subject: [PATCH 401/425] install -dv a >/dev/full panics --- src/uu/install/src/install.rs | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/src/uu/install/src/install.rs b/src/uu/install/src/install.rs index 7dde478b4..a3cd77931 100644 --- a/src/uu/install/src/install.rs +++ b/src/uu/install/src/install.rs @@ -16,6 +16,7 @@ use std::ffi::OsString; use std::fmt::Debug; use std::fs::{self, metadata}; use std::fs::{File, OpenOptions}; +use std::io::{Write, stdout}; use std::path::{MAIN_SEPARATOR, Path, PathBuf}; use std::process; use thiserror::Error; @@ -496,10 +497,11 @@ fn directory(paths: &[OsString], b: &Behavior) -> UResult<()> { } if b.verbose { - println!( + writeln!( + stdout(), "{}", translate!("install-verbose-creating-directory", "path" => path_to_create.quote()) - ); + )?; } } @@ -627,10 +629,11 @@ fn standard(mut paths: Vec, b: &Behavior) -> UResult<()> { result.push(part.as_os_str()); if !result.is_dir() { // Don't display when the directory already exists - println!( + writeln!( + stdout(), "{}", translate!("install-verbose-creating-directory-step", "path" => result.quote()) - ); + )?; } } } @@ -757,7 +760,7 @@ fn chown_optional_user_group(path: &Path, b: &Behavior) -> UResult<()> { Err(e) => return Err(InstallError::MetadataFailed(e).into()), }; match wrap_chown(path, &meta, owner_id, group_id, false, verbosity) { - Ok(msg) if b.verbose && !msg.is_empty() => println!("chown: {msg}"), + Ok(msg) if b.verbose && !msg.is_empty() => writeln!(stdout(), "chown: {msg}")?, Ok(_) => {} Err(e) => return Err(InstallError::ChownFailed(path.to_path_buf(), e).into()), } @@ -779,10 +782,11 @@ fn chown_optional_user_group(path: &Path, b: &Behavior) -> UResult<()> { fn perform_backup(to: &Path, b: &Behavior) -> UResult> { if to.exists() { if b.verbose { - println!( + writeln!( + stdout(), "{}", translate!("install-verbose-removed", "path" => to.quote()) - ); + )?; } let backup_path = backup_control::get_backup_path(b.backup_mode, to, &b.suffix); if let Some(ref backup_path) = backup_path { @@ -988,16 +992,18 @@ fn copy(from: &Path, to: &Path, b: &Behavior) -> UResult<()> { } if b.verbose { - print!( + write!( + stdout(), "{}", translate!("install-verbose-copy", "from" => from.quote(), "to" => to.quote()) - ); + )?; match backup_path { - Some(path) => println!( + Some(path) => writeln!( + stdout(), " {}", translate!("install-verbose-backup", "backup" => path.quote()) - ), - None => println!(), + )?, + None => writeln!(stdout())?, } } From 1d46cfa6eabfd6a7efa3b1203e533a18b348258e Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Thu, 29 Jan 2026 16:49:05 +0900 Subject: [PATCH 402/425] basename . >/dev/full panics --- src/uu/basename/src/basename.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/uu/basename/src/basename.rs b/src/uu/basename/src/basename.rs index cf2f21689..cf89346a2 100644 --- a/src/uu/basename/src/basename.rs +++ b/src/uu/basename/src/basename.rs @@ -71,8 +71,9 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { // for path in name_args { - stdout().write_all(&basename(path, &suffix)?)?; - print!("{line_ending}"); + let mut out = stdout(); + out.write_all(&basename(path, &suffix)?)?; + write!(out, "{line_ending}")?; } Ok(()) From 3e38c2ceb698c22f4fff335f62ee5a3e9190b833 Mon Sep 17 00:00:00 2001 From: Reuben Wong Date: Thu, 29 Jan 2026 23:02:48 +0800 Subject: [PATCH 403/425] mktemp: treat empty TMPDIR as unset and fallback to /tmp --- src/uu/mktemp/src/mktemp.rs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/uu/mktemp/src/mktemp.rs b/src/uu/mktemp/src/mktemp.rs index 5e3b8aa58..f84f00a00 100644 --- a/src/uu/mktemp/src/mktemp.rs +++ b/src/uu/mktemp/src/mktemp.rs @@ -44,6 +44,8 @@ const TMPDIR_ENV_VAR: &str = "TMPDIR"; #[cfg(windows)] const TMPDIR_ENV_VAR: &str = "TMP"; +const FALLBACK_TMPDIR: &str = "/tmp"; + #[derive(Error, Debug)] enum MkTempError { #[error("{}", translate!("mktemp-error-persist-file", "path" => .0.quote()))] @@ -119,14 +121,12 @@ impl Options { Some(d) => d.clone(), // Otherwise use $TMPDIR if set, else use the system's default // temporary directory. - None => env::var(TMPDIR_ENV_VAR) - .ok() - .map_or_else(env::temp_dir, PathBuf::from), + None => get_tmpdir_env_or_default(), }); let (tmpdir, template) = match matches.get_one::(ARG_TEMPLATE) { // If no template argument is given, `--tmpdir` is implied. None => { - let tmpdir = Some(tmpdir.unwrap_or_else(env::temp_dir)); + let tmpdir = Some(tmpdir.unwrap_or_else(get_tmpdir_env_or_default)); let template = DEFAULT_TEMPLATE; (tmpdir, OsString::from(template)) } @@ -595,6 +595,14 @@ fn exec(dir: &Path, prefix: &str, rand: usize, suffix: &str, make_dir: bool) -> Ok(path) } +/// Reads from `TMPDIR_ENV_VAR` but defaults to /tmp if value is set to empty string. +fn get_tmpdir_env_or_default() -> PathBuf { + match env::var_os(TMPDIR_ENV_VAR) { + Some(val) if val.is_empty() => PathBuf::from(FALLBACK_TMPDIR), + _ => env::temp_dir(), + } +} + /// Create a temporary file or directory /// /// Behavior is determined by the `options` parameter, see [`Options`] for details. From fca717a517c2ba574d34bc707b4d76d7b3f63150 Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Fri, 30 Jan 2026 00:45:18 +0900 Subject: [PATCH 404/425] ci: use --nodocs when installing deps in VM (#10557) --- .github/workflows/CICD.yml | 4 ++-- .github/workflows/GnuTests.yml | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/CICD.yml b/.github/workflows/CICD.yml index 844e86a77..c76126461 100644 --- a/.github/workflows/CICD.yml +++ b/.github/workflows/CICD.yml @@ -1,7 +1,7 @@ name: CICD # spell-checker:ignore (abbrev/names) CACHEDIR CICD CodeCOV MacOS MinGW MSVC musl taiki -# spell-checker:ignore (env/flags) Awarnings Ccodegen Coverflow Cpanic Dwarnings RUSTDOCFLAGS RUSTFLAGS Zpanic CARGOFLAGS CLEVEL +# spell-checker:ignore (env/flags) Awarnings Ccodegen Coverflow Cpanic Dwarnings RUSTDOCFLAGS RUSTFLAGS Zpanic CARGOFLAGS CLEVEL nodocs # 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 zstd @@ -1273,7 +1273,7 @@ jobs: - run: rsync -v -a -e ssh . lima-default:~/work/ - name: Setup Rust and other build deps in VM run: | - lima sudo dnf install gcc g++ git rustup libselinux-devel clang-devel attr -y + lima sudo dnf install --nodocs gcc g++ git rustup libselinux-devel clang-devel attr -y lima rustup-init -y --default-toolchain stable --profile minimal -c clippy - name: Verify SELinux Status run: | diff --git a/.github/workflows/GnuTests.yml b/.github/workflows/GnuTests.yml index 86b9cfedd..7eb40b1d4 100644 --- a/.github/workflows/GnuTests.yml +++ b/.github/workflows/GnuTests.yml @@ -6,7 +6,7 @@ name: GnuTests # spell-checker:ignore (options) Ccodegen Coverflow Cpanic Zpanic # spell-checker:ignore (people) Dawid Dziurla * dawidd dtolnay # spell-checker:ignore (vars) FILESET SUBDIRS XPASS -# spell-checker:ignore userns +# spell-checker:ignore userns nodocs # * note: to run a single test => `REPO/util/run-gnu-test.sh PATH/TO/TEST/SCRIPT` @@ -226,8 +226,7 @@ jobs: - name: Install dependencies in VM run: | - lima sudo dnf -y update - lima sudo dnf -y install autoconf bison gperf gcc gdb jq libacl-devel libattr-devel libcap-devel libselinux-devel attr rustup clang-devel automake patch quilt + lima sudo dnf -y install --nodocs autoconf bison gperf gcc gdb jq libacl-devel libattr-devel libcap-devel libselinux-devel attr rustup clang-devel automake patch quilt lima rustup-init -y --profile=minimal --default-toolchain stable - name: Copy the sources to VM run: | From a2715e1b0050c19382c152161ebc5e4b4d4a2190 Mon Sep 17 00:00:00 2001 From: Tuomas Tynkkynen Date: Mon, 26 Jan 2026 23:14:53 +0200 Subject: [PATCH 405/425] comm: Properly handle I/O errors when reading input --- src/uu/comm/src/comm.rs | 63 +++++++++++++++++++++----------------- tests/by-util/test_comm.rs | 16 ++++++++++ 2 files changed, 51 insertions(+), 28 deletions(-) diff --git a/src/uu/comm/src/comm.rs b/src/uu/comm/src/comm.rs index 4e05678ef..be77debc0 100644 --- a/src/uu/comm/src/comm.rs +++ b/src/uu/comm/src/comm.rs @@ -194,7 +194,14 @@ fn write_line_with_delimiter(writer: &mut W, delim: &[u8], line: &[u8] Ok(()) } -fn comm(a: &mut LineReader, b: &mut LineReader, delim: &str, opts: &ArgMatches) -> UResult<()> { +fn comm( + a: &mut LineReader, + b: &mut LineReader, + filename1: &OsString, + filename2: &OsString, + delim: &str, + opts: &ArgMatches, +) -> UResult<()> { let width_col_1 = usize::from(!opts.get_flag(options::COLUMN_1)); let width_col_2 = usize::from(!opts.get_flag(options::COLUMN_2)); @@ -204,9 +211,13 @@ fn comm(a: &mut LineReader, b: &mut LineReader, delim: &str, opts: &ArgMatches) let mut writer = BufWriter::new(io::stdout().lock()); let ra = &mut Vec::new(); - let mut na = a.read_line(ra); + let mut na = a + .read_line(ra) + .map_err_context(|| filename1.maybe_quote().to_string())?; let rb = &mut Vec::new(); - let mut nb = b.read_line(rb); + let mut nb = b + .read_line(rb) + .map_err_context(|| filename2.maybe_quote().to_string())?; let mut total_col_1 = 0; let mut total_col_2 = 0; @@ -218,31 +229,19 @@ fn comm(a: &mut LineReader, b: &mut LineReader, delim: &str, opts: &ArgMatches) // Determine if we should perform order checking let should_check_order = !no_check_order && (check_order - || if let (Some(file1), Some(file2)) = ( - opts.get_one::(options::FILE_1), - opts.get_one::(options::FILE_2), - ) { - !(paths_refer_to_same_file(file1.as_os_str(), file2.as_os_str(), true) - || are_files_identical(Path::new(file1), Path::new(file2)).unwrap_or(false)) - } else { - true - }); + || !(paths_refer_to_same_file(filename1.as_os_str(), filename2.as_os_str(), true) + || are_files_identical(Path::new(filename1), Path::new(filename2)) + .unwrap_or(false))); let mut checker1 = OrderChecker::new(FileNumber::One, check_order); let mut checker2 = OrderChecker::new(FileNumber::Two, check_order); let mut input_error = false; - while na.is_ok() || nb.is_ok() { - let ord = match (na.is_ok(), nb.is_ok()) { - (false, true) => Ordering::Greater, - (true, false) => Ordering::Less, - (true, true) => match (&na, &nb) { - (&Ok(0), &Ok(0)) => break, - (&Ok(0), _) => Ordering::Greater, - (_, &Ok(0)) => Ordering::Less, - _ => ra.cmp(&rb), - }, - _ => unreachable!(), + while na != 0 || nb != 0 { + let ord = match (na, nb) { + (0, _) => Ordering::Greater, + (_, 0) => Ordering::Less, + (_, _) => ra.as_slice().cmp(rb.as_slice()), }; match ord { @@ -256,7 +255,9 @@ fn comm(a: &mut LineReader, b: &mut LineReader, delim: &str, opts: &ArgMatches) .map_err_context(|| translate!("comm-error-write"))?; } ra.clear(); - na = a.read_line(ra); + na = a + .read_line(ra) + .map_err_context(|| filename1.maybe_quote().to_string())?; total_col_1 += 1; } Ordering::Greater => { @@ -267,7 +268,9 @@ fn comm(a: &mut LineReader, b: &mut LineReader, delim: &str, opts: &ArgMatches) write_line_with_delimiter(&mut writer, delim_col_2.as_bytes(), rb)?; } rb.clear(); - nb = b.read_line(rb); + nb = b + .read_line(rb) + .map_err_context(|| filename2.maybe_quote().to_string())?; total_col_2 += 1; } Ordering::Equal => { @@ -280,8 +283,12 @@ fn comm(a: &mut LineReader, b: &mut LineReader, delim: &str, opts: &ArgMatches) } ra.clear(); rb.clear(); - na = a.read_line(ra); - nb = b.read_line(rb); + na = a + .read_line(ra) + .map_err_context(|| filename1.maybe_quote().to_string())?; + nb = b + .read_line(rb) + .map_err_context(|| filename2.maybe_quote().to_string())?; total_col_3 += 1; } } @@ -360,7 +367,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { delim => delim, }; - comm(&mut f1, &mut f2, delim, &matches) + comm(&mut f1, &mut f2, filename1, filename2, delim, &matches) } pub fn uu_app() -> Command { diff --git a/tests/by-util/test_comm.rs b/tests/by-util/test_comm.rs index dbcee0598..e314cfaf1 100644 --- a/tests/by-util/test_comm.rs +++ b/tests/by-util/test_comm.rs @@ -711,3 +711,19 @@ fn test_comm_anonymous_pipes() { .succeeds() .stdout_is("99999\n"); } + +#[test] +#[cfg(all(target_os = "linux", not(target_env = "musl")))] +fn test_read_error() { + new_ucmd!() + .arg("/proc/self/mem") + .arg("/dev/null") + .fails() + .stderr_contains("comm: /proc/self/mem: Input/output error"); + + new_ucmd!() + .arg("/dev/null") + .arg("/proc/self/mem") + .fails() + .stderr_contains("comm: /proc/self/mem: Input/output error"); +} From c05ed657f52d6b62b6d66fb20d6d0b6533ed7bed Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 29 Jan 2026 17:59:36 +0000 Subject: [PATCH 406/425] chore(deps): update rust crate clap to v4.5.56 --- Cargo.lock | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 15333ecbc..c82b84cc5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -337,18 +337,18 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.55" +version = "4.5.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e34525d5bbbd55da2bb745d34b36121baac88d07619a9a09cfcf4a6c0832785" +checksum = "a75ca66430e33a14957acc24c5077b503e7d374151b2b4b3a10c83b4ceb4be0e" dependencies = [ "clap_builder", ] [[package]] name = "clap_builder" -version = "4.5.55" +version = "4.5.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59a20016a20a3da95bef50ec7238dbd09baeef4311dcdd38ec15aba69812fb61" +checksum = "793207c7fa6300a0608d1080b858e5fdbe713cdc1c8db9fb17777d8a13e63df0" dependencies = [ "anstream", "anstyle", @@ -1008,7 +1008,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1721,7 +1721,7 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde_core", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2015,7 +2015,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2605,7 +2605,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2915,7 +2915,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4663,7 +4663,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] From 5c721ad0a3c8234810352e57dd7243d17c23e7b3 Mon Sep 17 00:00:00 2001 From: Reuben Wong Date: Fri, 30 Jan 2026 10:00:46 +0800 Subject: [PATCH 407/425] retrigger checks From f6151c59de669f87603cd78ce47eedcbdb629821 Mon Sep 17 00:00:00 2001 From: Reuben Wong Date: Fri, 30 Jan 2026 18:07:46 +0800 Subject: [PATCH 408/425] add tests for correct creation --- tests/by-util/test_mktemp.rs | 45 ++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/tests/by-util/test_mktemp.rs b/tests/by-util/test_mktemp.rs index cedcb4927..f99c7d128 100644 --- a/tests/by-util/test_mktemp.rs +++ b/tests/by-util/test_mktemp.rs @@ -862,6 +862,51 @@ fn test_nonexistent_tmpdir_env_var() { } } +#[test] +fn test_empty_tmpdir_env_var() { + #[cfg(not(windows))] + { + let result = new_ucmd!().env(TMPDIR, "").succeeds(); + assert!(result.stdout_str().starts_with("/tmp")); + } + + #[cfg(windows)] + { + let result = new_ucmd!().env(TMPDIR, "").fails(); + result.no_stdout(); + let stderr = result.stderr_str(); + assert!( + stderr.starts_with("mktemp: failed to create file via template"), + "{stderr}" + ); + assert!( + stderr.ends_with("no\\such\\dir\\tmp.XXXXXXXXXX': No such file or directory\n"), + "{stderr}", + ); + } + + #[cfg(not(windows))] + { + let result = new_ucmd!().env(TMPDIR, "").arg("-d").succeeds(); + assert!(result.stdout_str().starts_with("/tmp")); + } + + #[cfg(windows)] + { + let result = new_ucmd!().env(TMPDIR, "").arg("-d").fails(); + result.no_stdout(); + let stderr = result.stderr_str(); + assert!( + stderr.starts_with("mktemp: failed to create directory via template"), + "{stderr}" + ); + assert!( + stderr.ends_with("no\\such\\dir\\tmp.XXXXXXXXXX': No such file or directory\n"), + "{stderr}", + ); + } +} + #[test] fn test_nonexistent_dir_prefix() { #[cfg(not(windows))] From 12193c52c629b6ed08ee2917d2a761e918cb405b Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Fri, 30 Jan 2026 19:15:19 +0900 Subject: [PATCH 409/425] cksum: Don't panic when checked file returned EIO (#10534) --- .../src/lib/features/checksum/validate.rs | 22 +++++++++++++++++-- tests/by-util/test_cksum.rs | 14 ++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/uucore/src/lib/features/checksum/validate.rs b/src/uucore/src/lib/features/checksum/validate.rs index 990ae8dab..5c7aef432 100644 --- a/src/uucore/src/lib/features/checksum/validate.rs +++ b/src/uucore/src/lib/features/checksum/validate.rs @@ -676,8 +676,26 @@ fn compute_and_check_digest_from_file( // TODO: improve function signature to use ReadingMode instead of binary bool // Set binary to false because --binary is not supported with --check - let (calculated_checksum, _) = - digest_reader(&mut digest, &mut file_reader, /* binary */ false).unwrap(); + + let (calculated_checksum, _) = match digest_reader(&mut digest, &mut file_reader, false) { + Ok(result) => result, + Err(err) => { + show!(err.map_err_context(|| { + locale_aware_escape_name(&real_filename_to_check, QuotingStyle::SHELL_ESCAPE) + .to_string_lossy() + .to_string() + })); + + write_file_report( + std::io::stdout(), + filename, + FileChecksumResult::CantOpen, + prefix, + opts.verbose, + ); + return Err(LineCheckError::CantOpenFile); + } + }; // Do the checksum validation let checksum_correct = match calculated_checksum { diff --git a/tests/by-util/test_cksum.rs b/tests/by-util/test_cksum.rs index d4685d619..a6b77a2d2 100644 --- a/tests/by-util/test_cksum.rs +++ b/tests/by-util/test_cksum.rs @@ -3046,3 +3046,17 @@ mod debug_flag { .stderr_contains("pclmul"); } } + +#[test] +#[cfg(all(target_os = "linux", not(target_env = "musl")))] +fn test_check_file_with_io_error() { + // /proc/self/mem causes EIO when read without proper seeking + new_ucmd!() + .arg("-a") + .arg("md5") + .arg("--check") + .pipe_in("d8e8fca2dc0f896fd7cb4cb0031ba249 /proc/self/mem\n") + .fails() + .stderr_contains("Input/output error") + .stdout_contains("FAILED open or read"); +} From 27d930c1ce5b74988a9f7b4163cc070e31e1cae4 Mon Sep 17 00:00:00 2001 From: Reuben Wong Date: Fri, 30 Jan 2026 18:28:59 +0800 Subject: [PATCH 410/425] fix test based on windows build --- tests/by-util/test_mktemp.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/by-util/test_mktemp.rs b/tests/by-util/test_mktemp.rs index f99c7d128..e40d5c06c 100644 --- a/tests/by-util/test_mktemp.rs +++ b/tests/by-util/test_mktemp.rs @@ -880,7 +880,7 @@ fn test_empty_tmpdir_env_var() { "{stderr}" ); assert!( - stderr.ends_with("no\\such\\dir\\tmp.XXXXXXXXXX': No such file or directory\n"), + stderr.ends_with("/tmp\tmp.XXXXXXXXXX': No such file or directory\n"), "{stderr}", ); } @@ -901,7 +901,7 @@ fn test_empty_tmpdir_env_var() { "{stderr}" ); assert!( - stderr.ends_with("no\\such\\dir\\tmp.XXXXXXXXXX': No such file or directory\n"), + stderr.ends_with("/tmp\tmp.XXXXXXXXXX': No such file or directory\n"), "{stderr}", ); } From 2fd74a89dcd4bf4f5dd3046850794d0bb9eda1dc Mon Sep 17 00:00:00 2001 From: Reuben Wong Date: Fri, 30 Jan 2026 22:28:41 +0800 Subject: [PATCH 411/425] fix unescaped t char --- tests/by-util/test_mktemp.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/by-util/test_mktemp.rs b/tests/by-util/test_mktemp.rs index e40d5c06c..b4d9c6c27 100644 --- a/tests/by-util/test_mktemp.rs +++ b/tests/by-util/test_mktemp.rs @@ -880,7 +880,7 @@ fn test_empty_tmpdir_env_var() { "{stderr}" ); assert!( - stderr.ends_with("/tmp\tmp.XXXXXXXXXX': No such file or directory\n"), + stderr.ends_with("/tmp\\tmp.XXXXXXXXXX': No such file or directory\n"), "{stderr}", ); } @@ -901,7 +901,7 @@ fn test_empty_tmpdir_env_var() { "{stderr}" ); assert!( - stderr.ends_with("/tmp\tmp.XXXXXXXXXX': No such file or directory\n"), + stderr.ends_with("/tmp\\tmp.XXXXXXXXXX': No such file or directory\n"), "{stderr}", ); } From e5bf2487621a55ff9e1c47d8c8fe141fd120ea9a Mon Sep 17 00:00:00 2001 From: Chris Dryden Date: Fri, 30 Jan 2026 10:39:09 -0500 Subject: [PATCH 412/425] expr: fix regex matching on inputs containing newlines (#10543) --- src/uu/expr/src/syntax_tree.rs | 6 +++--- tests/by-util/test_expr.rs | 8 ++++++++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/uu/expr/src/syntax_tree.rs b/src/uu/expr/src/syntax_tree.rs index 172b71d33..3cfd2c83f 100644 --- a/src/uu/expr/src/syntax_tree.rs +++ b/src/uu/expr/src/syntax_tree.rs @@ -354,7 +354,7 @@ fn build_regex(pattern_bytes: Vec) -> ExprResult<(Regex, String)> { // For UTF-8 locale, use UTF-8 encoding Regex::with_options_and_encoding( &re_string, - RegexOptions::REGEX_OPTION_SINGLELINE, + RegexOptions::REGEX_OPTION_SINGLELINE | RegexOptions::REGEX_OPTION_MULTILINE, Syntax::grep(), ) } @@ -362,7 +362,7 @@ fn build_regex(pattern_bytes: Vec) -> ExprResult<(Regex, String)> { // For non-UTF-8 locale, use ASCII encoding Regex::with_options_and_encoding( EncodedBytes::ascii(re_string.as_bytes()), - RegexOptions::REGEX_OPTION_SINGLELINE, + RegexOptions::REGEX_OPTION_SINGLELINE | RegexOptions::REGEX_OPTION_MULTILINE, Syntax::grep(), ) } @@ -427,7 +427,7 @@ fn find_match(regex: Regex, re_string: String, left_bytes: Vec) -> ExprResul // Need to create ASCII version of regex too let re_ascii = Regex::with_options_and_encoding( EncodedBytes::ascii(re_string.as_bytes()), - RegexOptions::REGEX_OPTION_SINGLELINE, + RegexOptions::REGEX_OPTION_SINGLELINE | RegexOptions::REGEX_OPTION_MULTILINE, Syntax::grep(), ) .ok(); diff --git a/tests/by-util/test_expr.rs b/tests/by-util/test_expr.rs index adf1cd4fd..ec1cf9159 100644 --- a/tests/by-util/test_expr.rs +++ b/tests/by-util/test_expr.rs @@ -457,6 +457,14 @@ fn test_regex_range_quantifier() { .stderr_only("expr: Invalid content of \\{\\}\n"); } +#[test] +fn test_regex_newline() { + new_ucmd!() + .args(&["line1\nline2\nline3 ", ":", ".*line2.*"]) + .succeeds() + .stdout_only("18\n"); +} + #[test] fn test_substr() { new_ucmd!() From 8001be03353fb2f113f1dc2054214e6baadd5ce6 Mon Sep 17 00:00:00 2001 From: Reuben Wong Date: Sat, 31 Jan 2026 00:02:49 +0800 Subject: [PATCH 413/425] failure expected on android temp directory is not located at /tmp --- tests/by-util/test_mktemp.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/by-util/test_mktemp.rs b/tests/by-util/test_mktemp.rs index b4d9c6c27..652f32a40 100644 --- a/tests/by-util/test_mktemp.rs +++ b/tests/by-util/test_mktemp.rs @@ -864,13 +864,13 @@ fn test_nonexistent_tmpdir_env_var() { #[test] fn test_empty_tmpdir_env_var() { - #[cfg(not(windows))] + #[cfg(not(any(windows, target_os = "android")))] { let result = new_ucmd!().env(TMPDIR, "").succeeds(); assert!(result.stdout_str().starts_with("/tmp")); } - #[cfg(windows)] + #[cfg(any(windows, target_os = "android"))] { let result = new_ucmd!().env(TMPDIR, "").fails(); result.no_stdout(); @@ -885,13 +885,13 @@ fn test_empty_tmpdir_env_var() { ); } - #[cfg(not(windows))] + #[cfg(not(any(windows, target_os = "android")))] { let result = new_ucmd!().env(TMPDIR, "").arg("-d").succeeds(); assert!(result.stdout_str().starts_with("/tmp")); } - #[cfg(windows)] + #[cfg(any(windows, target_os = "android"))] { let result = new_ucmd!().env(TMPDIR, "").arg("-d").fails(); result.no_stdout(); From fe454ba5ae8bd73d0af84e503de678c9c557a7b1 Mon Sep 17 00:00:00 2001 From: Reuben Wong Date: Sat, 31 Jan 2026 00:45:25 +0800 Subject: [PATCH 414/425] asserts specific to target build --- tests/by-util/test_mktemp.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/by-util/test_mktemp.rs b/tests/by-util/test_mktemp.rs index 652f32a40..306f147d8 100644 --- a/tests/by-util/test_mktemp.rs +++ b/tests/by-util/test_mktemp.rs @@ -879,10 +879,16 @@ fn test_empty_tmpdir_env_var() { stderr.starts_with("mktemp: failed to create file via template"), "{stderr}" ); + #[cfg(windows)] assert!( stderr.ends_with("/tmp\\tmp.XXXXXXXXXX': No such file or directory\n"), "{stderr}", ); + #[cfg(target_os = "android")] + assert!( + stderr.ends_with("/tmp/tmp.XXXXXXXXXX': No such file or directory\n"), + "{stderr}", + ); } #[cfg(not(any(windows, target_os = "android")))] @@ -900,10 +906,16 @@ fn test_empty_tmpdir_env_var() { stderr.starts_with("mktemp: failed to create directory via template"), "{stderr}" ); + #[cfg(windows)] assert!( stderr.ends_with("/tmp\\tmp.XXXXXXXXXX': No such file or directory\n"), "{stderr}", ); + #[cfg(target_os = "android")] + assert!( + stderr.ends_with("/tmp/tmp.XXXXXXXXXX': No such file or directory\n"), + "{stderr}", + ); } } From 3523193c0b3cf67cb831c777f69f6a80bf3b6bc5 Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Sat, 31 Jan 2026 03:57:26 +0900 Subject: [PATCH 415/425] wc --debug 2>/dev/full does not fail --- src/uu/wc/src/wc.rs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/uu/wc/src/wc.rs b/src/uu/wc/src/wc.rs index 62bf5c77f..866f213ff 100644 --- a/src/uu/wc/src/wc.rs +++ b/src/uu/wc/src/wc.rs @@ -16,7 +16,7 @@ use std::{ env, ffi::{OsStr, OsString}, fs::{self, File}, - io::{self, Write}, + io::{self, Write, stderr}, iter, path::{Path, PathBuf}, }; @@ -33,7 +33,7 @@ use uucore::{ hardware::{HardwareFeature, HasHardwareFeatures as _, SimdPolicy}, parser::shortcut_value_parser::ShortcutValueParser, quoting_style::{self, QuotingStyle}, - show, show_error, + show, }; use crate::{ @@ -934,19 +934,22 @@ fn wc(inputs: &Inputs, settings: &Settings) -> UResult<()> { let runtime_disabled = !features.disabled_runtime.is_empty(); if enabled_empty && !runtime_disabled { - show_error!("{}", translate!("wc-debug-hw-unavailable")); + let _ = writeln!(stderr(), "{}", translate!("wc-debug-hw-unavailable")); } else if runtime_disabled { - show_error!( + let _ = writeln!( + stderr(), "{}", translate!("wc-debug-hw-disabled-glibc", "features" => disabled.join(", ")) ); } else if !enabled_empty && disabled_empty { - show_error!( + let _ = writeln!( + stderr(), "{}", translate!("wc-debug-hw-using", "features" => enabled.join(", ")) ); } else { - show_error!( + let _ = writeln!( + stderr(), "{}", translate!( "wc-debug-hw-limited-glibc", From aae856b573f8e205f093c5ef8cec4bec16c41b13 Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Sat, 31 Jan 2026 04:35:03 +0900 Subject: [PATCH 416/425] env --debug 2>/dev/full does not abort --- src/uu/env/src/env.rs | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/uu/env/src/env.rs b/src/uu/env/src/env.rs index a5dd8a8d7..e8d01fe2a 100644 --- a/src/uu/env/src/env.rs +++ b/src/uu/env/src/env.rs @@ -30,6 +30,8 @@ use std::collections::{BTreeMap, BTreeSet}; use std::env; use std::ffi::{OsStr, OsString}; use std::io; +use std::io::Write as _; +use std::io::stderr; #[cfg(unix)] use std::os::unix::ffi::OsStrExt; #[cfg(unix)] @@ -495,9 +497,10 @@ pub fn parse_args_from_str(text: &NativeIntStr) -> UResult> } fn debug_print_args(args: &[OsString]) { - eprintln!("input args:"); + let mut error = stderr().lock(); + let _ = writeln!(error, "input args:"); for (i, arg) in args.iter().enumerate() { - eprintln!("arg[{i}]: {}", arg.quote()); + let _ = writeln!(error, "arg[{i}]: {}", arg.quote()); } } @@ -755,7 +758,7 @@ impl EnvAppData { Some(argv0) if cfg!(unix) => { let arg0 = Cow::Borrowed(argv0); if do_debug_printing { - eprintln!("argv0: {}", arg0.quote()); + let _ = writeln!(stderr(), "argv0: {}", arg0.quote()); } arg0 } @@ -770,11 +773,12 @@ impl EnvAppData { let args = &opts.program[1..]; if do_debug_printing { - eprintln!("executing: {}", prog.maybe_quote()); + let mut error = stderr().lock(); + let _ = writeln!(error, "executing: {}", prog.maybe_quote()); let arg_prefix = " arg"; - eprintln!("{arg_prefix}[{}]= {}", 0, arg0.quote()); + let _ = writeln!(error, "{arg_prefix}[{}]= {}", 0, arg0.quote()); for (i, arg) in args.iter().enumerate() { - eprintln!("{arg_prefix}[{}]= {}", i + 1, arg.quote()); + let _ = writeln!(error, "{arg_prefix}[{}]= {}", i + 1, arg.quote()); } } From f091362bd53cdccbc67beb95b26e60f912242659 Mon Sep 17 00:00:00 2001 From: xtqqczze <45661989+xtqqczze@users.noreply.github.com> Date: Fri, 30 Jan 2026 23:13:53 +0000 Subject: [PATCH 417/425] ci: use toolchain override shorthand --- .github/workflows/CICD.yml | 6 +++--- .github/workflows/FixPR.yml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/CICD.yml b/.github/workflows/CICD.yml index c76126461..9e336c7c3 100644 --- a/.github/workflows/CICD.yml +++ b/.github/workflows/CICD.yml @@ -221,8 +221,8 @@ jobs: # dependencies echo "## dependency list" ## * using the 'stable' toolchain is necessary to avoid "unexpected '--filter-platform'" errors - RUSTUP_TOOLCHAIN=stable cargo fetch --locked --quiet --target $(rustc --print host-tuple) - RUSTUP_TOOLCHAIN=stable cargo tree --no-dedupe --locked -e=no-dev --prefix=none ${{ steps.vars.outputs.CARGO_FEATURES_OPTION }} | grep -vE "$PWD" | sort --unique + cargo +stable fetch --locked --quiet --target $(rustc --print host-tuple) + cargo +stable tree --no-dedupe --locked -e=no-dev --prefix=none ${{ steps.vars.outputs.CARGO_FEATURES_OPTION }} | grep -vE "$PWD" | sort --unique - name: Test run: cargo nextest run --hide-progress-bar --profile ci ${{ steps.vars.outputs.CARGO_FEATURES_OPTION }} -p uucore -p coreutils env: @@ -1225,7 +1225,7 @@ jobs: fail_ci_if_error: false test_separately: - name: Separate Builds (individual and coreutils)# duplicated with other CI, but has better appearance + name: Separate Builds (individual and coreutils)# duplicated with other CI, but has better appearance runs-on: ${{ matrix.job.os }} strategy: fail-fast: false diff --git a/.github/workflows/FixPR.yml b/.github/workflows/FixPR.yml index 70f42278c..d086687e8 100644 --- a/.github/workflows/FixPR.yml +++ b/.github/workflows/FixPR.yml @@ -67,7 +67,7 @@ jobs: echo "## dependency list" cargo fetch --locked --quiet --target $(rustc --print host-tuple) ## * using the 'stable' toolchain is necessary to avoid "unexpected '--filter-platform'" errors - RUSTUP_TOOLCHAIN=stable cargo tree --locked --no-dedupe -e=no-dev --prefix=none --features ${{ matrix.job.features }} | grep -vE "$PWD" | sort --unique + cargo +stable tree --locked --no-dedupe -e=no-dev --prefix=none --features ${{ matrix.job.features }} | grep -vE "$PWD" | sort --unique - name: Commit any changes (to '${{ env.BRANCH_TARGET }}') uses: EndBug/add-and-commit@v9 with: From 6840da09106e041df72950355e81c13fd8fb9fe8 Mon Sep 17 00:00:00 2001 From: xtqqczze <45661989+xtqqczze@users.noreply.github.com> Date: Fri, 30 Jan 2026 23:15:46 +0000 Subject: [PATCH 418/425] editorconfig: specify toml settings --- .editorconfig | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.editorconfig b/.editorconfig index 9df8cbbbf..05007f4e7 100644 --- a/.editorconfig +++ b/.editorconfig @@ -57,6 +57,10 @@ switch_case_indent = true end_of_line = crlf insert_final_newline = false +[*.toml] +indent_size = 2 +indent_style = space + [*.{yaml,yml,[Yy][Mm][Ll],[Yy][Aa][Mm][Ll]}] # YAML indent_size = 2 From c8aa3fc48421d88e035917cf5ffe271e18cdc334 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 30 Jan 2026 16:30:36 -0800 Subject: [PATCH 419/425] chore(deps): update dawidd6/action-download-artifact action to v13 (#10586) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/CICD.yml | 4 ++-- .github/workflows/GnuTests.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/CICD.yml b/.github/workflows/CICD.yml index 9e336c7c3..706221247 100644 --- a/.github/workflows/CICD.yml +++ b/.github/workflows/CICD.yml @@ -548,14 +548,14 @@ jobs: --arg multisize "$SIZE_MULTI" \ '{($date): { sha: $sha, size: $size, multisize: $multisize, }}' > size-result.json - name: Download the previous individual size result - uses: dawidd6/action-download-artifact@v12 + uses: dawidd6/action-download-artifact@v13 with: workflow: CICD.yml name: individual-size-result repo: uutils/coreutils path: dl - name: Download the previous size result - uses: dawidd6/action-download-artifact@v12 + uses: dawidd6/action-download-artifact@v13 with: workflow: CICD.yml name: size-result diff --git a/.github/workflows/GnuTests.yml b/.github/workflows/GnuTests.yml index 7eb40b1d4..64c4edb33 100644 --- a/.github/workflows/GnuTests.yml +++ b/.github/workflows/GnuTests.yml @@ -372,7 +372,7 @@ jobs: path: 'uutils' persist-credentials: false - name: Retrieve reference artifacts - uses: dawidd6/action-download-artifact@v12 + uses: dawidd6/action-download-artifact@v13 # ref: continue-on-error: true ## don't break the build for missing reference artifacts (may be expired or just not generated yet) with: From e697d123511428cbee1014f319a1964c1872c5d8 Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Sat, 31 Jan 2026 09:48:06 +0900 Subject: [PATCH 420/425] stty: Don't panic when GNU provided stty 51 us (#10526) --- src/uu/stty/src/stty.rs | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/src/uu/stty/src/stty.rs b/src/uu/stty/src/stty.rs index 0c3fea02b..1d40a1a1a 100644 --- a/src/uu/stty/src/stty.rs +++ b/src/uu/stty/src/stty.rs @@ -12,6 +12,7 @@ // spell-checker:ignore sigquit sigtstp // spell-checker:ignore cbreak decctlq evenp litout oddp tcsadrain exta extb NCCS cfsetispeed // spell-checker:ignore notaflag notacombo notabaud +// spell-checker:ignore baudrate TCGETS mod flags; @@ -19,9 +20,13 @@ use crate::flags::AllFlags; use crate::flags::COMBINATION_SETTINGS; use clap::{Arg, ArgAction, ArgMatches, Command}; use nix::libc::{O_NONBLOCK, TIOCGWINSZ, TIOCSWINSZ, c_ushort}; + +#[cfg(target_os = "linux")] +use nix::libc::{TCGETS2, termios2}; + use nix::sys::termios::{ ControlFlags, InputFlags, LocalFlags, OutputFlags, SetArg, SpecialCharacterIndices as S, - Termios, cfgetospeed, cfsetispeed, cfsetospeed, tcgetattr, tcsetattr, + Termios, cfsetispeed, cfsetospeed, tcgetattr, tcsetattr, }; use nix::{ioctl_read_bad, ioctl_write_ptr_bad}; use std::cmp::Ordering; @@ -613,16 +618,27 @@ fn print_terminal_size( window_size: Option<&TermSize>, term_size: Option<&TermSize>, ) -> nix::Result<()> { - let speed = cfgetospeed(termios); + // GNU linked against glibc 2.42 provides us baudrate 51 which panics cfgetospeed + #[cfg(not(target_os = "linux"))] + let speed = nix::sys::termios::cfgetospeed(termios); + #[cfg(target_os = "linux")] + ioctl_read_bad!(tcgets2, TCGETS2, termios2); + #[cfg(target_os = "linux")] + let speed = { + let mut t2 = unsafe { std::mem::zeroed::() }; + unsafe { tcgets2(opts.file.as_raw_fd(), &raw mut t2)? }; + t2.c_ospeed + }; + let mut printer = WrappedPrinter::new(window_size); - // BSDs use a u32 for the baud rate, so we can simply print it. - #[cfg(bsd)] + // BSDs and Linux use a u32 for the baud rate, so we can simply print it. + #[cfg(any(target_os = "linux", bsd))] printer.print(&translate!("stty-output-speed", "speed" => speed)); // Other platforms need to use the baud rate enum, so printing the right value // becomes slightly more complicated. - #[cfg(not(bsd))] + #[cfg(not(any(target_os = "linux", bsd)))] for (text, baud_rate) in BAUD_RATES { if *baud_rate == speed { printer.print(&translate!("stty-output-speed", "speed" => (*text))); From f274c92ddfad0f1188c3f49945f0ac529361e1f2 Mon Sep 17 00:00:00 2001 From: xtqqczze <45661989+xtqqczze@users.noreply.github.com> Date: Sat, 31 Jan 2026 01:15:14 +0000 Subject: [PATCH 421/425] deny.toml: remove unmatched items from skip list --- deny.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/deny.toml b/deny.toml index 51bf577cf..242227509 100644 --- a/deny.toml +++ b/deny.toml @@ -23,7 +23,6 @@ allow = [ "ISC", "BSD-2-Clause", "BSD-3-Clause", - "BSL-1.0", "CC0-1.0", "Unicode-3.0", "Zlib", From 241091dd2c45ce63884501b91cb983faaf949386 Mon Sep 17 00:00:00 2001 From: xtqqczze <45661989+xtqqczze@users.noreply.github.com> Date: Sat, 31 Jan 2026 12:16:31 +0000 Subject: [PATCH 422/425] clippy: fix map_clone lint --- Cargo.lock | 1 + Cargo.toml | 3 ++- src/bin/coreutils.rs | 6 ++---- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c82b84cc5..c5c50b217 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -537,6 +537,7 @@ dependencies = [ "fluent-syntax", "glob", "hex-literal", + "itertools 0.14.0", "jiff", "libc", "nix", diff --git a/Cargo.toml b/Cargo.toml index 228272604..bfd06ded9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -419,10 +419,11 @@ uu_checksum_common = { version = "0.6.0", path = "src/uu/checksum_common" } uutests = { version = "0.6.0", package = "uutests", path = "tests/uutests" } [dependencies] -clap.workspace = true clap_complete = { workspace = true, optional = true } clap_mangen = { workspace = true, optional = true } +clap.workspace = true fluent-syntax = { workspace = true, optional = true } +itertools.workspace = true phf.workspace = true selinux = { workspace = true, optional = true } textwrap.workspace = true diff --git a/src/bin/coreutils.rs b/src/bin/coreutils.rs index 55c885237..8cd1f73cf 100644 --- a/src/bin/coreutils.rs +++ b/src/bin/coreutils.rs @@ -5,6 +5,7 @@ use clap::Command; use coreutils::validation; +use itertools::Itertools as _; use std::cmp; use std::ffi::OsString; use std::io::{self, Write}; @@ -28,10 +29,7 @@ fn usage(utils: &UtilityMap, name: &str) { println!("Options:"); println!(" --list lists all defined functions, one per row\n"); println!("Currently defined functions:\n"); - #[allow(clippy::map_clone)] - let mut utils: Vec<&str> = utils.keys().map(|&s| s).collect(); - utils.sort_unstable(); - let display_list = utils.join(", "); + let display_list = utils.keys().copied().sorted_unstable().join(", "); let width = cmp::min(textwrap::termwidth(), 100) - 4 * 2; // (opinion/heuristic) max 100 chars wide with 4 character side indentions println!( "{}", From ade6e8c6329979e90e98a625fbd732e27c2f8b5f Mon Sep 17 00:00:00 2001 From: Dorian Peron Date: Fri, 30 Jan 2026 16:17:31 +0100 Subject: [PATCH 423/425] Remove mentions to hashsum --- Cargo.lock | 10 - Cargo.toml | 2 - GNUmakefile | 6 - build.rs | 3 - docs/src/extensions.md | 7 - fuzz/fuzz_targets/fuzz_non_utf8_paths.rs | 7 - src/uu/hashsum/Cargo.toml | 30 - src/uu/hashsum/LICENSE | 1 - src/uu/hashsum/locales/en-US.ftl | 39 - src/uu/hashsum/locales/fr-FR.ftl | 37 - src/uu/hashsum/src/hashsum.rs | 408 ------- src/uu/hashsum/src/main.rs | 1 - tests/by-util/test_b2sum.rs | 7 +- tests/by-util/test_hashsum.rs | 1265 ---------------------- tests/by-util/test_sha1sum.rs | 2 - tests/by-util/test_sha256sum.rs | 1 - tests/by-util/test_sha384sum.rs | 1 - tests/by-util/test_sha512sum.rs | 1 - tests/test_localization_and_colors.rs | 27 - tests/tests.rs | 4 - util/show-utils.BAT | 4 +- util/show-utils.sh | 2 +- 22 files changed, 4 insertions(+), 1861 deletions(-) delete mode 100644 src/uu/hashsum/Cargo.toml delete mode 120000 src/uu/hashsum/LICENSE delete mode 100644 src/uu/hashsum/locales/en-US.ftl delete mode 100644 src/uu/hashsum/locales/fr-FR.ftl delete mode 100644 src/uu/hashsum/src/hashsum.rs delete mode 100644 src/uu/hashsum/src/main.rs delete mode 100644 tests/by-util/test_hashsum.rs diff --git a/Cargo.lock b/Cargo.lock index c82b84cc5..8141ded26 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -588,7 +588,6 @@ dependencies = [ "uu_fmt", "uu_fold", "uu_groups", - "uu_hashsum", "uu_head", "uu_hostid", "uu_hostname", @@ -3570,15 +3569,6 @@ dependencies = [ "uucore", ] -[[package]] -name = "uu_hashsum" -version = "0.6.0" -dependencies = [ - "clap", - "fluent", - "uucore", -] - [[package]] name = "uu_head" version = "0.6.0" diff --git a/Cargo.toml b/Cargo.toml index 228272604..80cce0fe1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -112,7 +112,6 @@ feat_common_core = [ "false", "fmt", "fold", - "hashsum", "head", "join", "link", @@ -472,7 +471,6 @@ false = { optional = true, version = "0.6.0", package = "uu_false", path = "src/ fmt = { optional = true, version = "0.6.0", package = "uu_fmt", path = "src/uu/fmt" } fold = { optional = true, version = "0.6.0", package = "uu_fold", path = "src/uu/fold" } groups = { optional = true, version = "0.6.0", package = "uu_groups", path = "src/uu/groups" } -hashsum = { optional = true, version = "0.6.0", package = "uu_hashsum", path = "src/uu/hashsum" } head = { optional = true, version = "0.6.0", package = "uu_head", path = "src/uu/head" } hostid = { optional = true, version = "0.6.0", package = "uu_hostid", path = "src/uu/hostid" } hostname = { optional = true, version = "0.6.0", package = "uu_hostname", path = "src/uu/hostname" } diff --git a/GNUmakefile b/GNUmakefile index b1c8ff2dc..f6b8f0432 100644 --- a/GNUmakefile +++ b/GNUmakefile @@ -105,9 +105,6 @@ ifeq ($(SELINUX_ENABLED),1) endif UTILS ?= $(filter-out $(SKIP_UTILS),$(PROGS)) -ifneq ($(filter hashsum,$(UTILS)),hashsum) - HASHSUM_PROGS := -endif ifneq ($(findstring stdbuf,$(UTILS)),) # Use external libstdbuf per default. It is more robust than embedding libstdbuf. @@ -306,9 +303,6 @@ else $(foreach prog, $(INSTALLEES), \ $(INSTALL) -m 755 $(BUILDDIR)/$(prog) $(INSTALLDIR_BIN)/$(PROG_PREFIX)$(prog) $(newline) \ ) - $(foreach prog, $(HASHSUM_PROGS), \ - cd $(INSTALLDIR_BIN) && $(LN) $(PROG_PREFIX)hashsum $(PROG_PREFIX)$(prog) $(newline) \ - ) $(if $(findstring test,$(INSTALLEES)), $(INSTALL) -m 755 $(BUILDDIR)/test $(INSTALLDIR_BIN)/$(PROG_PREFIX)[) endif diff --git a/build.rs b/build.rs index a7eb90312..4b77018ab 100644 --- a/build.rs +++ b/build.rs @@ -87,9 +87,6 @@ pub fn main() { "false" | "true" => { phf_map.entry(krate, format!("(r#{krate}::uumain, r#{krate}::uu_app)")); } - "hashsum" => { - phf_map.entry(krate, format!("({krate}::uumain, {krate}::uu_app_custom)")); - } _ => { phf_map.entry(krate, map_value.clone()); } diff --git a/docs/src/extensions.md b/docs/src/extensions.md index fa92c54a0..bb0dfff06 100644 --- a/docs/src/extensions.md +++ b/docs/src/extensions.md @@ -53,13 +53,6 @@ packages. `rm` can display a progress bar when the `-g`/`--progress` flag is set. -## `hashsum` (deprecated) - -This utility does not exist in GNU coreutils. `hashsum` is a utility that -supports computing the checksums with several algorithms. The flags and options -are identical to the `*sum` family of utils (`sha1sum`, `sha256sum`, `b2sum`, -etc.). This utility will be removed in the future and it is advised to use `cksum --untagged` instead. - ## `more` We provide a simple implementation of `more`, which is not part of GNU diff --git a/fuzz/fuzz_targets/fuzz_non_utf8_paths.rs b/fuzz/fuzz_targets/fuzz_non_utf8_paths.rs index 82e537484..56451502b 100644 --- a/fuzz/fuzz_targets/fuzz_non_utf8_paths.rs +++ b/fuzz/fuzz_targets/fuzz_non_utf8_paths.rs @@ -83,7 +83,6 @@ static PATH_PROGRAMS: &[&str] = &[ "vdir", "mkfifo", "mknod", - "hashsum", // File I/O utilities "dd", "sync", @@ -252,12 +251,6 @@ fn test_program_with_non_utf8_path(program: &str, path: &Path) -> CommandResult OsString::from("bs=1"), OsString::from("count=1"), ], - // Hashsum needs algorithm - "hashsum" => vec![ - OsString::from(program), - OsString::from("--md5"), - path_os.to_owned(), - ], // Encoding/decoding programs "base32" | "base64" | "basenc" => vec![OsString::from(program), path_os.to_owned()], "df" => vec![OsString::from(program), path_os.to_owned()], diff --git a/src/uu/hashsum/Cargo.toml b/src/uu/hashsum/Cargo.toml deleted file mode 100644 index 4c28a1588..000000000 --- a/src/uu/hashsum/Cargo.toml +++ /dev/null @@ -1,30 +0,0 @@ -[package] -name = "uu_hashsum" -description = "hashsum ~ (uutils) display or check input digests" -repository = "https://github.com/uutils/coreutils/tree/main/src/uu/hashsum" -version.workspace = true -authors.workspace = true -license.workspace = true -homepage.workspace = true -keywords.workspace = true -categories.workspace = true -edition.workspace = true -readme.workspace = true - -[lints] -workspace = true - -[lib] -path = "src/hashsum.rs" - -[dependencies] -clap = { workspace = true } -uucore = { workspace = true, features = ["checksum", "encoding", "sum"] } -fluent = { workspace = true } - -[[bin]] -name = "hashsum" -path = "src/main.rs" - -[dev-dependencies] -uucore = { workspace = true, features = ["benchmark"] } diff --git a/src/uu/hashsum/LICENSE b/src/uu/hashsum/LICENSE deleted file mode 120000 index 5853aaea5..000000000 --- a/src/uu/hashsum/LICENSE +++ /dev/null @@ -1 +0,0 @@ -../../../LICENSE \ No newline at end of file diff --git a/src/uu/hashsum/locales/en-US.ftl b/src/uu/hashsum/locales/en-US.ftl deleted file mode 100644 index c0a6a5567..000000000 --- a/src/uu/hashsum/locales/en-US.ftl +++ /dev/null @@ -1,39 +0,0 @@ -hashsum-about = Compute and check message digests. -hashsum-usage = hashsum -- [OPTIONS]... [FILE]... - -# Utility-specific usage template -hashsum-usage-specific = {$utility_name} [OPTION]... [FILE]... - -# Help messages -hashsum-help-binary-windows = read or check in binary mode (default) -hashsum-help-binary-other = read in binary mode -hashsum-help-text-windows = read or check in text mode -hashsum-help-text-other = read in text mode (default) -hashsum-help-check = read hashsums from the FILEs and check them -hashsum-help-tag = create a BSD-style checksum -hashsum-help-quiet = don't print OK for each successfully verified file -hashsum-help-status = don't output anything, status code shows success -hashsum-help-strict = exit non-zero for improperly formatted checksum lines -hashsum-help-ignore-missing = don't fail or report status for missing files -hashsum-help-warn = warn about improperly formatted checksum lines -hashsum-help-zero = end each output line with NUL, not newline -hashsum-help-length = digest length in bits; must not exceed the max for the blake2 algorithm and must be a multiple of 8 -# Algorithm help messages -hashsum-help-md5 = work with MD5 -hashsum-help-sha1 = work with SHA1 -hashsum-help-sha224 = work with SHA224 -hashsum-help-sha256 = work with SHA256 -hashsum-help-sha384 = work with SHA384 -hashsum-help-sha512 = work with SHA512 -hashsum-help-sha3 = work with SHA3 -hashsum-help-sha3-224 = work with SHA3-224 -hashsum-help-sha3-256 = work with SHA3-256 -hashsum-help-sha3-384 = work with SHA3-384 -hashsum-help-sha3-512 = work with SHA3-512 -hashsum-help-shake128 = work with SHAKE128 using BITS for the output size -hashsum-help-shake256 = work with SHAKE256 using BITS for the output size -hashsum-help-b2sum = work with BLAKE2 -hashsum-help-b3sum = work with BLAKE3 - -# Error messages -hashsum-error-failed-to-read-input = failed to read input diff --git a/src/uu/hashsum/locales/fr-FR.ftl b/src/uu/hashsum/locales/fr-FR.ftl deleted file mode 100644 index 26c61fec9..000000000 --- a/src/uu/hashsum/locales/fr-FR.ftl +++ /dev/null @@ -1,37 +0,0 @@ -hashsum-about = Calculer et vérifier les empreintes de messages. -hashsum-usage = hashsum -- [OPTION]... [FICHIER]... - -# Messages d'aide -hashsum-help-binary-windows = lire ou vérifier en mode binaire (par défaut) -hashsum-help-binary-other = lire en mode binaire -hashsum-help-text-windows = lire ou vérifier en mode texte -hashsum-help-text-other = lire en mode texte (par défaut) -hashsum-help-check = lire les empreintes depuis les FICHIERs et les vérifier -hashsum-help-tag = créer une somme de contrôle de style BSD -hashsum-help-quiet = ne pas afficher OK pour chaque fichier vérifié avec succès -hashsum-help-status = ne rien afficher, le code de statut indique le succès -hashsum-help-strict = sortir avec un code non-zéro pour les lignes de somme de contrôle mal formatées -hashsum-help-ignore-missing = ne pas échouer ou rapporter le statut pour les fichiers manquants -hashsum-help-warn = avertir des lignes de somme de contrôle mal formatées -hashsum-help-zero = terminer chaque ligne de sortie avec NUL, pas de retour à la ligne -hashsum-help-length = longueur de l'empreinte en bits ; ne doit pas dépasser le maximum pour l'algorithme blake2 et doit être un multiple de 8 - -# Messages d'aide des algorithmes -hashsum-help-md5 = travailler avec MD5 -hashsum-help-sha1 = travailler avec SHA1 -hashsum-help-sha224 = travailler avec SHA224 -hashsum-help-sha256 = travailler avec SHA256 -hashsum-help-sha384 = travailler avec SHA384 -hashsum-help-sha512 = travailler avec SHA512 -hashsum-help-sha3 = travailler avec SHA3 -hashsum-help-sha3-224 = travailler avec SHA3-224 -hashsum-help-sha3-256 = travailler avec SHA3-256 -hashsum-help-sha3-384 = travailler avec SHA3-384 -hashsum-help-sha3-512 = travailler avec SHA3-512 -hashsum-help-shake128 = travailler avec SHAKE128 en utilisant BITS pour la taille de sortie -hashsum-help-shake256 = travailler avec SHAKE256 en utilisant BITS pour la taille de sortie -hashsum-help-b2sum = travailler avec BLAKE2 -hashsum-help-b3sum = travailler avec BLAKE3 - -# Messages d'erreur -hashsum-error-failed-to-read-input = échec de la lecture de l'entrée diff --git a/src/uu/hashsum/src/hashsum.rs b/src/uu/hashsum/src/hashsum.rs deleted file mode 100644 index 6c401041a..000000000 --- a/src/uu/hashsum/src/hashsum.rs +++ /dev/null @@ -1,408 +0,0 @@ -// This file is part of the uutils coreutils package. -// -// For the full copyright and license information, please view the LICENSE -// file that was distributed with this source code. - -// spell-checker:ignore (ToDO) algo, algoname, bitlen, regexes, nread - -use std::ffi::{OsStr, OsString}; -use std::iter; -use std::path::Path; - -use clap::builder::ValueParser; -use clap::{Arg, ArgAction, ArgMatches, Command}; - -use uucore::checksum::compute::{ - ChecksumComputeOptions, OutputFormat, perform_checksum_computation, -}; -use uucore::checksum::validate::{ - ChecksumValidateOptions, ChecksumVerbose, perform_checksum_validation, -}; -use uucore::checksum::{ - AlgoKind, ChecksumError, SizedAlgoKind, calculate_blake2b_length_str, - sanitize_sha2_sha3_length_str, -}; -use uucore::error::UResult; -use uucore::line_ending::LineEnding; -use uucore::{format_usage, translate}; - -const NAME: &str = "hashsum"; - -/// Creates a hasher instance based on the command-line flags. -/// -/// # Arguments -/// -/// * `matches` - A reference to the `ArgMatches` object containing the command-line arguments. -/// -/// # Returns -/// -/// Returns a [`UResult`] of a tuple containing the algorithm name, the hasher instance, and -/// the output length in bits or an Err if multiple hash algorithms are specified or if a -/// required flag is missing. -#[allow(clippy::cognitive_complexity)] -fn create_algorithm_from_flags(matches: &ArgMatches) -> UResult<(AlgoKind, Option)> { - let mut alg: Option<(AlgoKind, Option)> = None; - - let mut set_or_err = |new_alg: (AlgoKind, Option)| -> UResult<()> { - if alg.is_some() { - return Err(ChecksumError::CombineMultipleAlgorithms.into()); - } - alg = Some(new_alg); - Ok(()) - }; - - if matches.get_flag("md5") { - set_or_err((AlgoKind::Md5, None))?; - } - if matches.get_flag("sha1") { - set_or_err((AlgoKind::Sha1, None))?; - } - if matches.get_flag("sha224") { - set_or_err((AlgoKind::Sha224, None))?; - } - if matches.get_flag("sha256") { - set_or_err((AlgoKind::Sha256, None))?; - } - if matches.get_flag("sha384") { - set_or_err((AlgoKind::Sha384, None))?; - } - if matches.get_flag("sha512") { - set_or_err((AlgoKind::Sha512, None))?; - } - if matches.get_flag("b2sum") { - set_or_err((AlgoKind::Blake2b, None))?; - } - if matches.get_flag("b3sum") { - set_or_err((AlgoKind::Blake3, None))?; - } - if matches.get_flag("sha3") { - match matches.get_one::(options::LENGTH) { - Some(len) => set_or_err(( - AlgoKind::Sha3, - Some(sanitize_sha2_sha3_length_str(AlgoKind::Sha3, len)?), - ))?, - None => return Err(ChecksumError::LengthRequired("SHA3".into()).into()), - } - } - if matches.get_flag("sha3-224") { - set_or_err((AlgoKind::Sha3, Some(224)))?; - } - if matches.get_flag("sha3-256") { - set_or_err((AlgoKind::Sha3, Some(256)))?; - } - if matches.get_flag("sha3-384") { - set_or_err((AlgoKind::Sha3, Some(384)))?; - } - if matches.get_flag("sha3-512") { - set_or_err((AlgoKind::Sha3, Some(512)))?; - } - if matches.get_flag("shake128") { - set_or_err((AlgoKind::Shake128, Some(128)))?; - } - if matches.get_flag("shake256") { - set_or_err((AlgoKind::Shake256, Some(256)))?; - } - - if alg.is_none() { - return Err(ChecksumError::NeedAlgorithmToHash.into()); - } - - Ok(alg.unwrap()) -} - -#[uucore::main] -pub fn uumain(mut args: impl uucore::Args) -> UResult<()> { - // if there is no program name for some reason, default to "hashsum" - let program = args.next().unwrap_or_else(|| OsString::from(NAME)); - let binary_name = Path::new(&program) - .file_stem() - .unwrap_or_else(|| OsStr::new(NAME)) - .to_string_lossy(); - - let args = iter::once(program.clone()).chain(args); - - let (command, is_hashsum_bin) = uu_app(&binary_name); - - // FIXME: this should use try_get_matches_from() and crash!(), but at the moment that just - // causes "error: " to be printed twice (once from crash!() and once from clap). With - // the current setup, the name of the utility is not printed, but I think this is at - // least somewhat better from a user's perspective. - let matches = uucore::clap_localization::handle_clap_result(command, args)?; - - let length: Option = if binary_name == "b2sum" { - if let Some(len) = matches.get_one::(options::LENGTH) { - calculate_blake2b_length_str(len)? - } else { - None - } - } else { - None - }; - - let (algo_kind, length) = if is_hashsum_bin { - create_algorithm_from_flags(&matches)? - } else { - (AlgoKind::from_bin_name(&binary_name)?, length) - }; - - let check = matches.get_flag("check"); - - let check_flag = |flag| match (check, matches.get_flag(flag)) { - (_, false) => Ok(false), - (true, true) => Ok(true), - (false, true) => Err(ChecksumError::CheckOnlyFlag(flag.into())), - }; - - // Each of the following flags are only expected in --check mode. - // If we encounter them otherwise, end with an error. - let ignore_missing = check_flag("ignore-missing")?; - let warn = check_flag("warn")?; - let quiet = check_flag("quiet")?; - let strict = check_flag("strict")?; - let status = check_flag("status")?; - - // clap provides the default value -. So we unwrap() safety. - let files = matches - .get_many::(options::FILE) - .unwrap() - .map(|s| s.as_os_str()); - - if check { - // on Windows, allow --binary/--text to be used with --check - // and keep the behavior of defaulting to binary - #[cfg(not(windows))] - { - let text_flag = matches.get_flag("text"); - let binary_flag = matches.get_flag("binary"); - - if binary_flag || text_flag { - return Err(ChecksumError::BinaryTextConflict.into()); - } - } - - let verbose = ChecksumVerbose::new(status, quiet, warn); - - let opts = ChecksumValidateOptions { - ignore_missing, - strict, - verbose, - }; - - // Execute the checksum validation - return perform_checksum_validation(files, Some(algo_kind), length, opts); - } - - let algo = SizedAlgoKind::from_unsized(algo_kind, length)?; - let line_ending = LineEnding::from_zero_flag(matches.get_flag("zero")); - let output_format = OutputFormat::from_standalone(std::env::args_os())?; - - let opts = ChecksumComputeOptions { - algo_kind: algo, - output_format, - line_ending, - }; - - // Show the hashsum of the input - perform_checksum_computation(opts, files) -} - -mod options { - //pub const ALGORITHM: &str = "algorithm"; - pub const FILE: &str = "file"; - //pub const UNTAGGED: &str = "untagged"; - pub const TAG: &str = "tag"; - pub const LENGTH: &str = "length"; - //pub const RAW: &str = "raw"; - //pub const BASE64: &str = "base64"; - pub const CHECK: &str = "check"; - pub const STRICT: &str = "strict"; - pub const TEXT: &str = "text"; - pub const BINARY: &str = "binary"; - pub const STATUS: &str = "status"; - pub const WARN: &str = "warn"; - pub const QUIET: &str = "quiet"; -} - -pub fn uu_app_common() -> Command { - Command::new(uucore::util_name()) - .version(uucore::crate_version!()) - .help_template(uucore::localized_help_template(uucore::util_name())) - .about(translate!("hashsum-about")) - .override_usage(format_usage(&translate!("hashsum-usage"))) - .infer_long_args(true) - .args_override_self(true) - .arg( - Arg::new(options::BINARY) - .short('b') - .long("binary") - .help({ - #[cfg(windows)] - { - translate!("hashsum-help-binary-windows") - } - #[cfg(not(windows))] - { - translate!("hashsum-help-binary-other") - } - }) - .action(ArgAction::SetTrue), - ) - .arg( - Arg::new(options::CHECK) - .short('c') - .long("check") - .help(translate!("hashsum-help-check")) - .action(ArgAction::SetTrue) - .conflicts_with("tag"), - ) - .arg( - Arg::new(options::TAG) - .long("tag") - .help(translate!("hashsum-help-tag")) - .action(ArgAction::SetTrue) - .conflicts_with("text"), - ) - .arg( - Arg::new(options::TEXT) - .short('t') - .long("text") - .help({ - #[cfg(windows)] - { - translate!("hashsum-help-text-windows") - } - #[cfg(not(windows))] - { - translate!("hashsum-help-text-other") - } - }) - .conflicts_with("binary") - .action(ArgAction::SetTrue), - ) - .arg( - Arg::new(options::QUIET) - .short('q') - .long(options::QUIET) - .help(translate!("hashsum-help-quiet")) - .action(ArgAction::SetTrue) - .overrides_with_all([options::STATUS, options::WARN]), - ) - .arg( - Arg::new(options::STATUS) - .short('s') - .long("status") - .help(translate!("hashsum-help-status")) - .action(ArgAction::SetTrue) - .overrides_with_all([options::QUIET, options::WARN]), - ) - .arg( - Arg::new(options::STRICT) - .long("strict") - .help(translate!("hashsum-help-strict")) - .action(ArgAction::SetTrue), - ) - .arg( - Arg::new("ignore-missing") - .long("ignore-missing") - .help(translate!("hashsum-help-ignore-missing")) - .action(ArgAction::SetTrue), - ) - .arg( - Arg::new(options::WARN) - .short('w') - .long("warn") - .help(translate!("hashsum-help-warn")) - .action(ArgAction::SetTrue) - .overrides_with_all([options::QUIET, options::STATUS]), - ) - .arg( - Arg::new("zero") - .short('z') - .long("zero") - .help(translate!("hashsum-help-zero")) - .action(ArgAction::SetTrue), - ) - .arg( - Arg::new(options::FILE) - .index(1) - .action(ArgAction::Append) - .value_name(options::FILE) - .default_value("-") - .hide_default_value(true) - .value_hint(clap::ValueHint::FilePath) - .value_parser(ValueParser::os_string()), - ) -} - -pub fn uu_app_length() -> Command { - uu_app_opt_length(uu_app_common()) -} - -fn uu_app_opt_length(command: Command) -> Command { - command.arg( - Arg::new(options::LENGTH) - .long(options::LENGTH) - .short('l') - .help(translate!("hashsum-help-length")) - .overrides_with(options::LENGTH) - .action(ArgAction::Set), - ) -} - -pub fn uu_app_custom() -> Command { - let mut command = uu_app_opt_length(uu_app_common()); - let algorithms = &[ - ("md5", translate!("hashsum-help-md5")), - ("sha1", translate!("hashsum-help-sha1")), - ("sha224", translate!("hashsum-help-sha224")), - ("sha256", translate!("hashsum-help-sha256")), - ("sha384", translate!("hashsum-help-sha384")), - ("sha512", translate!("hashsum-help-sha512")), - ("sha3", translate!("hashsum-help-sha3")), - ("sha3-224", translate!("hashsum-help-sha3-224")), - ("sha3-256", translate!("hashsum-help-sha3-256")), - ("sha3-384", translate!("hashsum-help-sha3-384")), - ("sha3-512", translate!("hashsum-help-sha3-512")), - ("shake128", translate!("hashsum-help-shake128")), - ("shake256", translate!("hashsum-help-shake256")), - ("b2sum", translate!("hashsum-help-b2sum")), - ("b3sum", translate!("hashsum-help-b3sum")), - ]; - - for (name, desc) in algorithms { - command = command.arg( - Arg::new(*name) - .long(name) - .help(desc) - .action(ArgAction::SetTrue), - ); - } - command -} - -/// hashsum is handled differently in build.rs -/// therefore, this is different from other utilities. -fn uu_app(binary_name: &str) -> (Command, bool) { - let (command, is_hashsum_bin) = match binary_name { - // These all support the same options. - "md5sum" | "sha1sum" | "sha224sum" | "sha256sum" | "sha384sum" | "sha512sum" => { - (uu_app_common(), false) - } - // b2sum supports the md5sum options plus -l/--length. - "b2sum" => (uu_app_length(), false), - // We're probably just being called as `hashsum`, so give them everything. - _ => (uu_app_custom(), true), - }; - - // If not called as generic hashsum, override the command name and usage - let command = if is_hashsum_bin { - command - } else { - let usage = translate!("hashsum-usage-specific", "utility_name" => binary_name); - command - .help_template(uucore::localized_help_template(binary_name)) - .override_usage(format_usage(&usage)) - }; - - (command, is_hashsum_bin) -} diff --git a/src/uu/hashsum/src/main.rs b/src/uu/hashsum/src/main.rs deleted file mode 100644 index c31d4a9af..000000000 --- a/src/uu/hashsum/src/main.rs +++ /dev/null @@ -1 +0,0 @@ -uucore::bin!(uu_hashsum); diff --git a/tests/by-util/test_b2sum.rs b/tests/by-util/test_b2sum.rs index 30e2c46ca..9df3170e6 100644 --- a/tests/by-util/test_b2sum.rs +++ b/tests/by-util/test_b2sum.rs @@ -286,12 +286,7 @@ fn test_check_b2sum_strict_check() { #[test] fn test_help_shows_correct_utility_name() { - // Test that help output shows the actual utility name instead of "hashsum" - let scene = TestScenario::new(util_name!()); - - // Test b2sum - scene - .ccmd("b2sum") + new_ucmd!() .arg("--help") .succeeds() .stdout_contains("Usage: b2sum") diff --git a/tests/by-util/test_hashsum.rs b/tests/by-util/test_hashsum.rs deleted file mode 100644 index c139469d6..000000000 --- a/tests/by-util/test_hashsum.rs +++ /dev/null @@ -1,1265 +0,0 @@ -// This file is part of the uutils coreutils package. -// -// For the full copyright and license information, please view the LICENSE -// file that was distributed with this source code. - -use rstest::rstest; - -use uutests::new_ucmd; -use uutests::util::TestScenario; -use uutests::util_name; -// spell-checker:ignore checkfile, testf, ntestf -macro_rules! get_hash( - ($str:expr) => ( - $str.split(' ').collect::>()[0] - ); -); - -macro_rules! test_digest { - ($id:ident, $t:ident) => { - mod $id { - use uutests::util::*; - use uutests::util_name; - static DIGEST_ARG: &'static str = concat!("--", stringify!($t)); - static EXPECTED_FILE: &'static str = concat!(stringify!($id), ".expected"); - static CHECK_FILE: &'static str = concat!(stringify!($id), ".checkfile"); - static INPUT_FILE: &'static str = "input.txt"; - - #[test] - fn test_single_file() { - let ts = TestScenario::new(util_name!()); - assert_eq!( - ts.fixtures.read(EXPECTED_FILE), - get_hash!( - ts.ucmd() - .arg(DIGEST_ARG) - .arg(INPUT_FILE) - .succeeds() - .no_stderr() - .stdout_str() - ) - ); - } - - #[test] - fn test_stdin() { - let ts = TestScenario::new(util_name!()); - assert_eq!( - ts.fixtures.read(EXPECTED_FILE), - get_hash!( - ts.ucmd() - .arg(DIGEST_ARG) - .pipe_in_fixture(INPUT_FILE) - .succeeds() - .no_stderr() - .stdout_str() - ) - ); - } - - #[test] - fn test_check() { - let ts = TestScenario::new(util_name!()); - println!("File content='{}'", ts.fixtures.read(INPUT_FILE)); - println!("Check file='{}'", ts.fixtures.read(CHECK_FILE)); - - ts.ucmd() - .args(&[DIGEST_ARG, "--check", CHECK_FILE]) - .succeeds() - .no_stderr() - .stdout_is("input.txt: OK\n"); - } - - #[test] - fn test_zero() { - let ts = TestScenario::new(util_name!()); - assert_eq!( - ts.fixtures.read(EXPECTED_FILE), - get_hash!( - ts.ucmd() - .arg(DIGEST_ARG) - .arg("--zero") - .arg(INPUT_FILE) - .succeeds() - .no_stderr() - .stdout_str() - ) - ); - } - - #[test] - fn test_missing_file() { - let ts = TestScenario::new(util_name!()); - let at = &ts.fixtures; - - at.write("a", "file1\n"); - at.write("c", "file3\n"); - - ts.ucmd() - .args(&[DIGEST_ARG, "a", "b", "c"]) - .fails() - .stdout_contains("a\n") - .stdout_contains("c\n") - .stderr_contains("b: No such file or directory"); - } - } - }; -} - -macro_rules! test_digest_with_len { - ($id:ident, $t:ident, $size:expr) => { - mod $id { - use uutests::util::*; - use uutests::util_name; - static DIGEST_ARG: &'static str = concat!("--", stringify!($t)); - static LENGTH_ARG: &'static str = concat!("--length=", stringify!($size)); - static EXPECTED_FILE: &'static str = concat!(stringify!($id), ".expected"); - static CHECK_FILE: &'static str = concat!(stringify!($id), ".checkfile"); - static INPUT_FILE: &'static str = "input.txt"; - - #[test] - fn test_single_file() { - let ts = TestScenario::new(util_name!()); - assert_eq!( - ts.fixtures.read(EXPECTED_FILE), - get_hash!( - ts.ucmd() - .arg(DIGEST_ARG) - .arg(LENGTH_ARG) - .arg(INPUT_FILE) - .succeeds() - .no_stderr() - .stdout_str() - ) - ); - } - - #[test] - fn test_stdin() { - let ts = TestScenario::new(util_name!()); - assert_eq!( - ts.fixtures.read(EXPECTED_FILE), - get_hash!( - ts.ucmd() - .arg(DIGEST_ARG) - .arg(LENGTH_ARG) - .pipe_in_fixture(INPUT_FILE) - .succeeds() - .no_stderr() - .stdout_str() - ) - ); - } - - #[test] - fn test_check() { - let ts = TestScenario::new(util_name!()); - println!("File content='{}'", ts.fixtures.read(INPUT_FILE)); - println!("Check file='{}'", ts.fixtures.read(CHECK_FILE)); - - ts.ucmd() - .args(&[DIGEST_ARG, LENGTH_ARG, "--check", CHECK_FILE]) - .succeeds() - .no_stderr() - .stdout_is("input.txt: OK\n"); - } - - #[test] - fn test_zero() { - let ts = TestScenario::new(util_name!()); - assert_eq!( - ts.fixtures.read(EXPECTED_FILE), - get_hash!( - ts.ucmd() - .arg(DIGEST_ARG) - .arg(LENGTH_ARG) - .arg("--zero") - .arg(INPUT_FILE) - .succeeds() - .no_stderr() - .stdout_str() - ) - ); - } - - #[test] - fn test_missing_file() { - let ts = TestScenario::new(util_name!()); - let at = &ts.fixtures; - - at.write("a", "file1\n"); - at.write("c", "file3\n"); - - ts.ucmd() - .args(&[DIGEST_ARG, LENGTH_ARG, "a", "b", "c"]) - .fails() - .stdout_contains("a\n") - .stdout_contains("c\n") - .stderr_contains("b: No such file or directory"); - } - } - }; -} - -test_digest! {b3sum, b3sum} -test_digest! {shake128, shake128} -test_digest! {shake256, shake256} - -test_digest_with_len! {sha3_224, sha3, 224} -test_digest_with_len! {sha3_256, sha3, 256} -test_digest_with_len! {sha3_384, sha3, 384} -test_digest_with_len! {sha3_512, sha3, 512} - -#[ignore = "moved to standalone"] -#[test] -fn test_check_sha1() { - // To make sure that #3815 doesn't happen again - let scene = TestScenario::new(util_name!()); - let at = &scene.fixtures; - - at.write("testf", "foobar\n"); - at.write( - "testf.sha1", - "988881adc9fc3655077dc2d4d757d480b5ea0e11 testf\n", - ); - scene - .ccmd("sha1sum") - .arg("-c") - .arg(at.subdir.join("testf.sha1")) - .succeeds() - .stdout_is("testf: OK\n") - .stderr_is(""); -} - -#[ignore = "moved to standalone"] -#[test] -fn test_check_md5_ignore_missing() { - let scene = TestScenario::new(util_name!()); - let at = &scene.fixtures; - - at.write("testf", "foobar\n"); - at.write( - "testf.sha1", - "14758f1afd44c09b7992073ccf00b43d testf\n14758f1afd44c09b7992073ccf00b43d testf2\n", - ); - scene - .ccmd("md5sum") - .arg("-c") - .arg(at.subdir.join("testf.sha1")) - .fails() - .stdout_contains("testf2: FAILED open or read"); - - scene - .ccmd("md5sum") - .arg("-c") - .arg("--ignore-missing") - .arg(at.subdir.join("testf.sha1")) - .succeeds() - .stdout_is("testf: OK\n") - .stderr_is(""); - - scene - .ccmd("md5sum") - .arg("--ignore-missing") - .arg(at.subdir.join("testf.sha1")) - .fails() - .stderr_contains("the --ignore-missing option is meaningful only when verifying checksums"); -} - -#[ignore = "moved to standalone"] -#[test] -fn test_check_b2sum_length_option_0() { - let scene = TestScenario::new(util_name!()); - let at = &scene.fixtures; - - at.write("testf", "foobar\n"); - at.write("testf.b2sum", "9e2bf63e933e610efee4a8d6cd4a9387e80860edee97e27db3b37a828d226ab1eb92a9cdd8ca9ca67a753edaf8bd89a0558496f67a30af6f766943839acf0110 testf\n"); - - scene - .ccmd("b2sum") - .arg("--length=0") - .arg("-c") - .arg(at.subdir.join("testf.b2sum")) - .succeeds() - .stdout_only("testf: OK\n"); -} - -#[ignore = "moved to standalone"] -#[test] -fn test_check_b2sum_length_duplicate() { - let scene = TestScenario::new(util_name!()); - let at = &scene.fixtures; - - at.write("testf", "foobar\n"); - - scene - .ccmd("b2sum") - .arg("--length=123") - .arg("--length=128") - .arg("testf") - .succeeds() - .stdout_contains("d6d45901dec53e65d2b55fb6e2ab67b0"); -} - -#[ignore = "moved to standalone"] -#[test] -fn test_check_b2sum_length_option_8() { - let scene = TestScenario::new(util_name!()); - let at = &scene.fixtures; - - at.write("testf", "foobar\n"); - at.write("testf.b2sum", "6a testf\n"); - - scene - .ccmd("b2sum") - .arg("--length=8") - .arg("-c") - .arg(at.subdir.join("testf.b2sum")) - .succeeds() - .stdout_only("testf: OK\n"); -} - -#[ignore = "moved to standalone"] -#[test] -fn test_invalid_b2sum_length_option_not_multiple_of_8() { - let scene = TestScenario::new(util_name!()); - let at = &scene.fixtures; - - at.write("testf", "foobar\n"); - - scene - .ccmd("b2sum") - .arg("--length=9") - .arg(at.subdir.join("testf")) - .fails_with_code(1) - .stderr_contains("b2sum: invalid length: '9'") - .stderr_contains("b2sum: length is not a multiple of 8"); -} - -#[rstest] -#[ignore = "moved to standalone"] -#[case("513")] -#[ignore = "moved to standalone"] -#[case("1024")] -#[ignore = "moved to standalone"] -#[case("18446744073709552000")] -fn test_invalid_b2sum_length_option_too_large(#[case] len: &str) { - let scene = TestScenario::new(util_name!()); - let at = &scene.fixtures; - - at.write("testf", "foobar\n"); - - scene - .ccmd("b2sum") - .arg("--length") - .arg(len) - .arg(at.subdir.join("testf")) - .fails_with_code(1) - .no_stdout() - .stderr_contains(format!("b2sum: invalid length: '{len}'")) - .stderr_contains("b2sum: maximum digest length for 'BLAKE2b' is 512 bits"); -} - -#[ignore = "moved to standalone"] -#[test] -fn test_check_b2sum_tag_output() { - let scene = TestScenario::new(util_name!()); - let at = &scene.fixtures; - - at.touch("f"); - - scene - .ccmd("b2sum") - .arg("--length=0") - .arg("--tag") - .arg("f") - .succeeds() - .stdout_only("BLAKE2b (f) = 786a02f742015903c6c6fd852552d272912f4740e15847618a86e217f71f5419d25e1031afee585313896444934eb04b903a685b1448b755d56f701afe9be2ce\n"); - - scene - .ccmd("b2sum") - .arg("--length=128") - .arg("--tag") - .arg("f") - .succeeds() - .stdout_only("BLAKE2b-128 (f) = cae66941d9efbd404e4d88758ea67670\n"); -} - -#[ignore = "moved to standalone"] -#[test] -fn test_check_b2sum_verify() { - let scene = TestScenario::new(util_name!()); - let at = &scene.fixtures; - - at.write("a", "a\n"); - - scene - .ccmd("b2sum") - .arg("--tag") - .arg("a") - .succeeds() - .stdout_only("BLAKE2b (a) = bedfbb90d858c2d67b7ee8f7523be3d3b54004ef9e4f02f2ad79a1d05bfdfe49b81e3c92ebf99b504102b6bf003fa342587f5b3124c205f55204e8c4b4ce7d7c\n"); - - scene - .ccmd("b2sum") - .arg("--tag") - .arg("-l") - .arg("128") - .arg("a") - .succeeds() - .stdout_only("BLAKE2b-128 (a) = b93e0fc7bb21633c08bba07c5e71dc00\n"); -} - -#[ignore = "moved to standalone"] -#[test] -fn test_check_file_not_found_warning() { - let scene = TestScenario::new(util_name!()); - let at = &scene.fixtures; - - at.write("testf", "foobar\n"); - at.write( - "testf.sha1", - "988881adc9fc3655077dc2d4d757d480b5ea0e11 testf\n", - ); - at.remove("testf"); - scene - .ccmd("sha1sum") - .arg("-c") - .arg(at.subdir.join("testf.sha1")) - .fails() - .stdout_is("testf: FAILED open or read\n") - .stderr_is("sha1sum: testf: No such file or directory\nsha1sum: WARNING: 1 listed file could not be read\n"); -} - -// Asterisk `*` is a reserved paths character on win32, nor the path can end with a whitespace. -// ref: https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file#naming-conventions -#[ignore = "moved to standalone"] -#[test] -fn test_check_md5sum() { - let scene = TestScenario::new(util_name!()); - let at = &scene.fixtures; - - #[cfg(not(windows))] - { - for f in &["a", " b", "*c", "dd", " "] { - at.write(f, &format!("{f}\n")); - } - at.write( - "check.md5sum", - "60b725f10c9c85c70d97880dfe8191b3 a\n\ - bf35d7536c785cf06730d5a40301eba2 b\n\ - f5b61709718c1ecf8db1aea8547d4698 *c\n\ - b064a020db8018f18ff5ae367d01b212 dd\n\ - d784fa8b6d98d27699781bd9a7cf19f0 ", - ); - scene - .ccmd("md5sum") - .arg("--strict") - .arg("-c") - .arg("check.md5sum") - .succeeds() - .stdout_is("a: OK\n b: OK\n*c: OK\ndd: OK\n : OK\n") - .stderr_is(""); - } - #[cfg(windows)] - { - for f in &["a", " b", "dd"] { - at.write(f, &format!("{f}\n")); - } - at.write( - "check.md5sum", - "60b725f10c9c85c70d97880dfe8191b3 a\n\ - bf35d7536c785cf06730d5a40301eba2 b\n\ - b064a020db8018f18ff5ae367d01b212 dd", - ); - scene - .ccmd("md5sum") - .arg("--strict") - .arg("-c") - .arg("check.md5sum") - .succeeds() - .stdout_is("a: OK\n b: OK\ndd: OK\n") - .stderr_is(""); - } -} - -// GNU also supports one line sep -#[ignore = "moved to standalone"] -#[test] -fn test_check_md5sum_only_one_space() { - let scene = TestScenario::new(util_name!()); - let at = &scene.fixtures; - - for f in ["a", " b", "c"] { - at.write(f, &format!("{f}\n")); - } - at.write( - "check.md5sum", - "60b725f10c9c85c70d97880dfe8191b3 a\n\ - bf35d7536c785cf06730d5a40301eba2 b\n\ - 2cd6ee2c70b0bde53fbe6cac3c8b8bb1 c\n", - ); - scene - .ccmd("md5sum") - .arg("--strict") - .arg("-c") - .arg("check.md5sum") - .succeeds() - .stdout_only("a: OK\n b: OK\nc: OK\n"); -} - -#[ignore = "moved to standalone"] -#[test] -fn test_check_md5sum_reverse_bsd() { - let scene = TestScenario::new(util_name!()); - let at = &scene.fixtures; - - #[cfg(not(windows))] - { - for f in &["a", " b", "*c", "dd", " "] { - at.write(f, &format!("{f}\n")); - } - at.write( - "check.md5sum", - "60b725f10c9c85c70d97880dfe8191b3 a\n\ - bf35d7536c785cf06730d5a40301eba2 b\n\ - f5b61709718c1ecf8db1aea8547d4698 *c\n\ - b064a020db8018f18ff5ae367d01b212 dd\n\ - d784fa8b6d98d27699781bd9a7cf19f0 ", - ); - scene - .ccmd("md5sum") - .arg("--strict") - .arg("-c") - .arg("check.md5sum") - .succeeds() - .stdout_is("a: OK\n b: OK\n*c: OK\ndd: OK\n : OK\n") - .stderr_is(""); - } - #[cfg(windows)] - { - for f in &["a", " b", "dd"] { - at.write(f, &format!("{f}\n")); - } - at.write( - "check.md5sum", - "60b725f10c9c85c70d97880dfe8191b3 a\n\ - bf35d7536c785cf06730d5a40301eba2 b\n\ - b064a020db8018f18ff5ae367d01b212 dd", - ); - scene - .ccmd("md5sum") - .arg("--strict") - .arg("-c") - .arg("check.md5sum") - .succeeds() - .stdout_is("a: OK\n b: OK\ndd: OK\n") - .stderr_is(""); - } -} - -#[ignore = "moved to standalone"] -#[test] -fn test_check_md5sum_mixed_format() { - let scene = TestScenario::new(util_name!()); - let at = &scene.fixtures; - - #[cfg(not(windows))] - { - for f in &[" b", "*c", "dd", " "] { - at.write(f, &format!("{f}\n")); - } - at.write( - "check.md5sum", - "bf35d7536c785cf06730d5a40301eba2 b\n\ - f5b61709718c1ecf8db1aea8547d4698 *c\n\ - b064a020db8018f18ff5ae367d01b212 dd\n\ - d784fa8b6d98d27699781bd9a7cf19f0 ", - ); - } - #[cfg(windows)] - { - for f in &[" b", "dd"] { - at.write(f, &format!("{f}\n")); - } - at.write( - "check.md5sum", - "bf35d7536c785cf06730d5a40301eba2 b\n\ - b064a020db8018f18ff5ae367d01b212 dd", - ); - } - scene - .ccmd("md5sum") - .arg("--strict") - .arg("-c") - .arg("check.md5sum") - .fails_with_code(1); -} - -#[test] -fn test_invalid_arg() { - new_ucmd!().arg("--definitely-invalid").fails_with_code(1); -} - -#[test] -fn test_conflicting_arg() { - new_ucmd!() - .arg("--tag") - .arg("--check") - .arg("--md5") - .fails_with_code(1); - new_ucmd!() - .arg("--tag") - .arg("--text") - .arg("--md5") - .fails_with_code(1); -} - -#[ignore = "moved to standalone"] -#[test] -fn test_tag() { - let scene = TestScenario::new(util_name!()); - let at = &scene.fixtures; - - at.write("foobar", "foo bar\n"); - scene - .ccmd("sha256sum") - .arg("--tag") - .arg("foobar") - .succeeds() - .stdout_is( - "SHA256 (foobar) = 1f2ec52b774368781bed1d1fb140a92e0eb6348090619c9291f9a5a3c8e8d151\n", - ); -} - -#[ignore = "moved to standalone"] -#[test] -#[cfg(not(windows))] -fn test_with_escape_filename() { - let scene = TestScenario::new(util_name!()); - - let at = &scene.fixtures; - let filename = "a\nb"; - at.touch(filename); - let result = scene.ccmd("md5sum").arg("--text").arg(filename).succeeds(); - let stdout = result.stdout_str(); - println!("stdout {stdout}"); - assert!(stdout.starts_with('\\')); - assert!(stdout.trim().ends_with("a\\nb")); -} - -#[ignore = "moved to standalone"] -#[test] -#[cfg(not(windows))] -fn test_with_escape_filename_zero_text() { - let scene = TestScenario::new(util_name!()); - - let at = &scene.fixtures; - let filename = "a\nb"; - at.touch(filename); - let result = scene - .ccmd("md5sum") - .arg("--text") - .arg("--zero") - .arg(filename) - .succeeds(); - let stdout = result.stdout_str(); - println!("stdout {stdout}"); - assert!(!stdout.starts_with('\\')); - assert!(stdout.contains("a\nb")); -} - -#[ignore = "moved to standalone"] -#[test] -fn test_check_empty_line() { - let scene = TestScenario::new(util_name!()); - let at = &scene.fixtures; - - at.touch("f"); - at.write( - "in.md5", - "d41d8cd98f00b204e9800998ecf8427e f\n\nd41d8cd98f00b204e9800998ecf8427e f\ninvalid\n\n", - ); - scene - .ccmd("md5sum") - .arg("--check") - .arg(at.subdir.join("in.md5")) - .succeeds() - .stderr_contains("WARNING: 1 line is improperly formatted"); -} - -#[ignore = "moved to standalone"] -#[test] -#[cfg(not(windows))] -fn test_check_with_escape_filename() { - let scene = TestScenario::new(util_name!()); - - let at = &scene.fixtures; - - let filename = "a\nb"; - at.touch(filename); - let result = scene.ccmd("md5sum").arg("--tag").arg(filename).succeeds(); - let stdout = result.stdout_str(); - println!("stdout {stdout}"); - assert!(stdout.starts_with("\\MD5")); - assert!(stdout.contains("a\\nb")); - at.write("check.md5", stdout); - let result = scene - .ccmd("md5sum") - .arg("--strict") - .arg("-c") - .arg("check.md5") - .succeeds(); - result.stdout_is("\\a\\nb: OK\n"); -} - -#[ignore = "moved to standalone"] -#[test] -fn test_check_strict_error() { - let scene = TestScenario::new(util_name!()); - let at = &scene.fixtures; - - at.touch("f"); - at.write( - "in.md5", - "ERR\nERR\nd41d8cd98f00b204e9800998ecf8427e f\nERR\n", - ); - scene - .ccmd("md5sum") - .arg("--check") - .arg("--strict") - .arg(at.subdir.join("in.md5")) - .fails() - .stderr_contains("WARNING: 3 lines are improperly formatted"); -} - -#[ignore = "moved to standalone"] -#[test] -fn test_check_warn() { - let scene = TestScenario::new(util_name!()); - let at = &scene.fixtures; - - at.touch("f"); - at.write( - "in.md5", - "d41d8cd98f00b204e9800998ecf8427e f\nd41d8cd98f00b204e9800998ecf8427e f\ninvalid\n", - ); - scene - .ccmd("md5sum") - .arg("--check") - .arg("--warn") - .arg(at.subdir.join("in.md5")) - .succeeds() - .stderr_contains("in.md5: 3: improperly formatted MD5 checksum line") - .stderr_contains("WARNING: 1 line is improperly formatted"); - - // with strict, we should fail the execution - scene - .ccmd("md5sum") - .arg("--check") - .arg("--strict") - .arg(at.subdir.join("in.md5")) - .fails(); -} - -#[ignore = "moved to standalone"] -#[test] -fn test_check_status() { - let scene = TestScenario::new(util_name!()); - let at = &scene.fixtures; - - at.touch("f"); - at.write("in.md5", "MD5(f)= d41d8cd98f00b204e9800998ecf8427f\n"); - scene - .ccmd("md5sum") - .arg("--check") - .arg("--status") - .arg(at.subdir.join("in.md5")) - .fails() - .no_output(); -} - -#[ignore = "moved to standalone"] -#[test] -fn test_check_status_code() { - let scene = TestScenario::new(util_name!()); - let at = &scene.fixtures; - - at.touch("f"); - at.write("in.md5", "d41d8cd98f00b204e9800998ecf8427f f\n"); - scene - .ccmd("md5sum") - .arg("--check") - .arg("--status") - .arg(at.subdir.join("in.md5")) - .fails() - .stderr_is("") - .stdout_is(""); -} - -#[ignore = "moved to standalone"] -#[test] -fn test_sha1_with_md5sum_should_fail() { - let scene = TestScenario::new(util_name!()); - let at = &scene.fixtures; - - at.touch("f"); - at.write("f.sha1", "SHA1 (f) = d41d8cd98f00b204e9800998ecf8427e\n"); - scene - .ccmd("md5sum") - .arg("--check") - .arg(at.subdir.join("f.sha1")) - .fails() - .stderr_contains("f.sha1: no properly formatted checksum lines found") - .stderr_does_not_contain("WARNING: 1 line is improperly formatted"); -} - -#[ignore = "moved to standalone"] -#[test] -// Disabled on Windows because of the "*" -#[cfg(not(windows))] -fn test_check_one_two_space_star() { - let scene = TestScenario::new(util_name!()); - let at = &scene.fixtures; - - at.touch("empty"); - - // with one space, the "*" is removed - at.write("in.md5", "d41d8cd98f00b204e9800998ecf8427e *empty\n"); - - scene - .ccmd("md5sum") - .arg("--check") - .arg(at.subdir.join("in.md5")) - .succeeds() - .stdout_is("empty: OK\n"); - - // with two spaces, the "*" is not removed - at.write("in.md5", "d41d8cd98f00b204e9800998ecf8427e *empty\n"); - // First should fail as *empty doesn't exit - scene - .ccmd("md5sum") - .arg("--check") - .arg(at.subdir.join("in.md5")) - .fails() - .stdout_is("*empty: FAILED open or read\n"); - - at.touch("*empty"); - // Should pass as we have the file - scene - .ccmd("md5sum") - .arg("--check") - .arg(at.subdir.join("in.md5")) - .succeeds() - .stdout_is("*empty: OK\n"); -} - -#[ignore = "moved to standalone"] -#[test] -// Disabled on Windows because of the "*" -#[cfg(not(windows))] -fn test_check_space_star_or_not() { - let scene = TestScenario::new(util_name!()); - let at = &scene.fixtures; - - at.touch("a"); - at.touch("*c"); - - // with one space, the "*" is removed - at.write( - "in.md5", - "d41d8cd98f00b204e9800998ecf8427e *c\n - d41d8cd98f00b204e9800998ecf8427e a\n", - ); - - scene - .ccmd("md5sum") - .arg("--check") - .arg(at.subdir.join("in.md5")) - .fails() - .stdout_contains("c: FAILED") - .stdout_does_not_contain("a: FAILED") - .stderr_contains("WARNING: 1 line is improperly formatted"); - - at.write( - "in.md5", - "d41d8cd98f00b204e9800998ecf8427e a\n - d41d8cd98f00b204e9800998ecf8427e *c\n", - ); - - // First should fail as *empty doesn't exit - scene - .ccmd("md5sum") - .arg("--check") - .arg(at.subdir.join("in.md5")) - .succeeds() - .stdout_contains("a: OK") - .stderr_contains("WARNING: 1 line is improperly formatted"); -} - -#[ignore = "moved to standalone"] -#[test] -fn test_check_no_backslash_no_space() { - let scene = TestScenario::new(util_name!()); - let at = &scene.fixtures; - - at.touch("f"); - at.write("in.md5", "MD5(f)= d41d8cd98f00b204e9800998ecf8427e\n"); - scene - .ccmd("md5sum") - .arg("--check") - .arg(at.subdir.join("in.md5")) - .succeeds() - .stdout_is("f: OK\n"); -} - -#[ignore = "moved to standalone"] -#[test] -fn test_incomplete_format() { - let scene = TestScenario::new(util_name!()); - let at = &scene.fixtures; - - at.touch("f"); - at.write("in.md5", "MD5 (\n"); - scene - .ccmd("md5sum") - .arg("--check") - .arg(at.subdir.join("in.md5")) - .fails() - .stderr_contains("no properly formatted checksum lines found"); -} - -#[ignore = "moved to standalone"] -#[test] -fn test_start_error() { - let scene = TestScenario::new(util_name!()); - let at = &scene.fixtures; - - at.touch("f"); - at.write("in.md5", "ERR\nd41d8cd98f00b204e9800998ecf8427e f\n"); - scene - .ccmd("md5sum") - .arg("--check") - .arg("--strict") - .arg(at.subdir.join("in.md5")) - .fails() - .stdout_is("f: OK\n") - .stderr_contains("WARNING: 1 line is improperly formatted"); -} - -#[ignore = "moved to standalone"] -#[test] -fn test_check_check_ignore_no_file() { - let scene = TestScenario::new(util_name!()); - let at = &scene.fixtures; - - at.touch("f"); - at.write("in.md5", "d41d8cd98f00b204e9800998ecf8427f missing\n"); - scene - .ccmd("md5sum") - .arg("--check") - .arg("--ignore-missing") - .arg(at.subdir.join("in.md5")) - .fails() - .stderr_contains("in.md5: no file was verified"); -} - -#[ignore = "moved to standalone"] -#[test] -fn test_check_directory_error() { - let scene = TestScenario::new(util_name!()); - let at = &scene.fixtures; - - at.mkdir("d"); - at.write("in.md5", "d41d8cd98f00b204e9800998ecf8427f d\n"); - #[cfg(not(windows))] - let err_msg = "md5sum: d: Is a directory\n"; - #[cfg(windows)] - let err_msg = "md5sum: d: Permission denied\n"; - scene - .ccmd("md5sum") - .arg("--check") - .arg(at.subdir.join("in.md5")) - .fails() - .stderr_contains(err_msg); -} - -#[ignore = "moved to standalone"] -#[test] -#[cfg(not(windows))] -fn test_continue_after_directory_error() { - let scene = TestScenario::new(util_name!()); - let at = &scene.fixtures; - - at.mkdir("d"); - at.touch("file"); - at.touch("no_read_perms"); - at.set_mode("no_read_perms", 200); - - let (out, err_msg) = ( - "d41d8cd98f00b204e9800998ecf8427e file\n", - [ - "md5sum: d: Is a directory", - "md5sum: dne: No such file or directory", - "md5sum: no_read_perms: Permission denied\n", - ] - .join("\n"), - ); - - scene - .ccmd("md5sum") - .arg("d") - .arg("dne") - .arg("no_read_perms") - .arg("file") - .fails() - .stdout_is(out) - .stderr_is(err_msg); -} - -#[ignore = "moved to standalone"] -#[test] -fn test_check_quiet() { - let scene = TestScenario::new(util_name!()); - let at = &scene.fixtures; - - at.touch("f"); - at.write("in.md5", "d41d8cd98f00b204e9800998ecf8427e f\n"); - scene - .ccmd("md5sum") - .arg("--quiet") - .arg("--check") - .arg(at.subdir.join("in.md5")) - .succeeds() - .no_output(); - - // incorrect md5 - at.write("in.md5", "d41d8cd98f00b204e9800998ecf8427f f\n"); - scene - .ccmd("md5sum") - .arg("--quiet") - .arg("--check") - .arg(at.subdir.join("in.md5")) - .fails() - .stdout_contains("f: FAILED") - .stderr_contains("WARNING: 1 computed checksum did NOT match"); - - scene - .ccmd("md5sum") - .arg("--quiet") - .arg(at.subdir.join("in.md5")) - .fails() - .stderr_contains("md5sum: the --quiet option is meaningful only when verifying checksums"); - scene - .ccmd("md5sum") - .arg("--strict") - .arg(at.subdir.join("in.md5")) - .fails() - .stderr_contains("md5sum: the --strict option is meaningful only when verifying checksums"); -} - -#[ignore = "moved to standalone"] -#[test] -fn test_star_to_start() { - let scene = TestScenario::new(util_name!()); - let at = &scene.fixtures; - - at.touch("f"); - at.write("in.md5", "d41d8cd98f00b204e9800998ecf8427e *f\n"); - scene - .ccmd("md5sum") - .arg("--check") - .arg(at.subdir.join("in.md5")) - .succeeds() - .stdout_only("f: OK\n"); -} - -#[ignore = "moved to standalone"] -#[test] -fn test_check_b2sum_strict_check() { - let scene = TestScenario::new(util_name!()); - let at = &scene.fixtures; - at.touch("f"); - - let checksums = [ - "2e f\n", - "e4a6a0577479b2b4 f\n", - "cae66941d9efbd404e4d88758ea67670 f\n", - "246c0442cd564aced8145b8b60f1370aa7 f\n", - "0e5751c026e543b2e8ab2eb06099daa1d1e5df47778f7787faab45cdf12fe3a8 f\n", - "4ded8c5fc8b12f3273f877ca585a44ad6503249a2b345d6d9c0e67d85bcb700db4178c0303e93b8f4ad758b8e2c9fd8b3d0c28e585f1928334bb77d36782e8 f\n", - "786a02f742015903c6c6fd852552d272912f4740e15847618a86e217f71f5419d25e1031afee585313896444934eb04b903a685b1448b755d56f701afe9be2ce f\n", - ]; - - at.write("ck", &checksums.join("")); - - let output = "f: OK\n".to_string().repeat(checksums.len()); - - scene - .ccmd("b2sum") - .arg("-c") - .arg(at.subdir.join("ck")) - .succeeds() - .stdout_only(&output); - - scene - .ccmd("b2sum") - .arg("--strict") - .arg("-c") - .arg(at.subdir.join("ck")) - .succeeds() - .stdout_only(&output); -} - -#[ignore = "moved to standalone"] -#[test] -fn test_check_md5_comment_line() { - // A comment in a checksum file shall be discarded unnoticed. - - let scene = TestScenario::new(util_name!()); - let at = &scene.fixtures; - - at.write("foo", "foo-content\n"); - at.write( - "MD5SUM", - "\ - # This is a comment\n\ - 8411029f3f5b781026a93db636aca721 foo\n\ - # next comment is empty\n#", - ); - - scene - .ccmd("md5sum") - .arg("--check") - .arg("MD5SUM") - .succeeds() - .stdout_contains("foo: OK") - .no_stderr(); -} - -#[ignore = "moved to standalone"] -#[test] -fn test_check_md5_comment_only() { - // A file only filled with comments is equivalent to an empty file, - // and therefore produces an error. - - let scene = TestScenario::new(util_name!()); - let at = &scene.fixtures; - - at.write("foo", "foo-content\n"); - at.write("MD5SUM", "# This is a comment\n"); - - scene - .ccmd("md5sum") - .arg("--check") - .arg("MD5SUM") - .fails() - .stderr_contains("no properly formatted checksum lines found"); -} - -#[ignore = "moved to standalone"] -#[test] -fn test_check_md5_comment_leading_space() { - // A file only filled with comments is equivalent to an empty file, - // and therefore produces an error. - - let scene = TestScenario::new(util_name!()); - let at = &scene.fixtures; - - at.write("foo", "foo-content\n"); - at.write( - "MD5SUM", - " # This is a comment\n\ - 8411029f3f5b781026a93db636aca721 foo\n", - ); - - scene - .ccmd("md5sum") - .arg("--check") - .arg("MD5SUM") - .succeeds() - .stdout_contains("foo: OK") - .stderr_contains("WARNING: 1 line is improperly formatted"); -} - -#[ignore = "moved to standalone"] -#[test] -fn test_sha256_binary() { - let ts = TestScenario::new(util_name!()); - assert_eq!( - ts.fixtures.read("binary.sha256.expected"), - get_hash!( - ts.ucmd() - .arg("--sha256") - .arg("binary.png") - .succeeds() - .no_stderr() - .stdout_str() - ) - ); -} - -#[ignore = "moved to standalone"] -#[test] -fn test_sha256_stdin_binary() { - let ts = TestScenario::new(util_name!()); - assert_eq!( - ts.fixtures.read("binary.sha256.expected"), - get_hash!( - ts.ucmd() - .arg("--sha256") - .pipe_in_fixture("binary.png") - .succeeds() - .no_stderr() - .stdout_str() - ) - ); -} - -// This test is currently disabled on windows -#[ignore = "moved to standalone"] -#[test] -fn test_check_sha256_binary() { - new_ucmd!() - .args(&["--sha256", "--check", "binary.sha256.checkfile"]) - .succeeds() - .no_stderr() - .stdout_is("binary.png: OK\n"); -} - -#[test] -fn test_help_shows_correct_utility_name() { - // Test that help output shows the actual utility name instead of "hashsum" - let scene = TestScenario::new(util_name!()); - - // Test md5sum - // scene - // .ccmd("md5sum") - // .arg("--help") - // .succeeds() - // .stdout_contains("Usage: md5sum") - // .stdout_does_not_contain("Usage: hashsum"); - - // Test sha256sum - // scene - // .ccmd("sha256sum") - // .arg("--help") - // .succeeds() - // .stdout_contains("Usage: sha256sum") - // .stdout_does_not_contain("Usage: hashsum"); - - // Test b2sum - // scene - // .ccmd("b2sum") - // .arg("--help") - // .succeeds() - // .stdout_contains("Usage: b2sum") - // .stdout_does_not_contain("Usage: hashsum"); - - // Test that generic hashsum still shows the correct usage - scene - .ccmd("hashsum") - .arg("--help") - .succeeds() - .stdout_contains("Usage: hashsum --"); -} diff --git a/tests/by-util/test_sha1sum.rs b/tests/by-util/test_sha1sum.rs index d0e7f6f3d..6a540d509 100644 --- a/tests/by-util/test_sha1sum.rs +++ b/tests/by-util/test_sha1sum.rs @@ -155,8 +155,6 @@ fn test_conflicting_arg() { #[test] fn test_help_shows_correct_utility_name() { - // Test that help output shows the actual utility name instead of "hashsum" - new_ucmd!() .arg("--help") .succeeds() diff --git a/tests/by-util/test_sha256sum.rs b/tests/by-util/test_sha256sum.rs index b3b538384..b8219e238 100644 --- a/tests/by-util/test_sha256sum.rs +++ b/tests/by-util/test_sha256sum.rs @@ -172,7 +172,6 @@ fn test_check_sha256_binary() { #[test] fn test_help_shows_correct_utility_name() { - // Test that help output shows the actual utility name instead of "hashsum" new_ucmd!() .arg("--help") .succeeds() diff --git a/tests/by-util/test_sha384sum.rs b/tests/by-util/test_sha384sum.rs index 9dbc73080..a579e041a 100644 --- a/tests/by-util/test_sha384sum.rs +++ b/tests/by-util/test_sha384sum.rs @@ -113,7 +113,6 @@ fn test_conflicting_arg() { #[test] fn test_help_shows_correct_utility_name() { - // Test that help output shows the actual utility name instead of "hashsum" new_ucmd!() .arg("--help") .succeeds() diff --git a/tests/by-util/test_sha512sum.rs b/tests/by-util/test_sha512sum.rs index 5e01ad32a..08c6d20fe 100644 --- a/tests/by-util/test_sha512sum.rs +++ b/tests/by-util/test_sha512sum.rs @@ -113,7 +113,6 @@ fn test_conflicting_arg() { #[test] fn test_help_shows_correct_utility_name() { - // Test that help output shows the actual utility name instead of "hashsum" new_ucmd!() .arg("--help") .succeeds() diff --git a/tests/test_localization_and_colors.rs b/tests/test_localization_and_colors.rs index 677e2e0b7..f2a1ff084 100644 --- a/tests/test_localization_and_colors.rs +++ b/tests/test_localization_and_colors.rs @@ -132,15 +132,6 @@ fn test_error_messages_have_colors() { println!("Testing error colors for {utility}"); let mut cmd = create_utility_command(utility); - let uu_name = format!("uu_{utility}"); - let binary_name = uucore::get_canonical_util_name(&uu_name); - - // For hashsum aliases, we need to pass the hash algorithm as a subcommand - if binary_name == "hashsum" && utility != "hashsum" { - // Extract the hash algorithm from the utility name - let algo = utility.trim_end_matches("sum"); - cmd.arg(algo); - } let output = cmd .arg("--invalid-option-that-should-not-exist") @@ -232,15 +223,6 @@ fn test_error_messages_french_translation() { println!("Testing French error translation for {utility}"); let mut cmd = create_utility_command(utility); - let uu_name = format!("uu_{utility}"); - let binary_name = uucore::get_canonical_util_name(&uu_name); - - // For hashsum aliases, we need to pass the hash algorithm as a subcommand - if binary_name == "hashsum" && utility != "hashsum" { - // Extract the hash algorithm from the utility name - let algo = utility.trim_end_matches("sum"); - cmd.arg(algo); - } let output = cmd .arg("--invalid-option-that-should-not-exist") @@ -285,15 +267,6 @@ fn test_french_colored_error_messages() { println!("Testing French colored errors for {utility}"); let mut cmd = create_utility_command(utility); - let uu_name = format!("uu_{utility}"); - let binary_name = uucore::get_canonical_util_name(&uu_name); - - // For hashsum aliases, we need to pass the hash algorithm as a subcommand - if binary_name == "hashsum" && utility != "hashsum" { - // Extract the hash algorithm from the utility name - let algo = utility.trim_end_matches("sum"); - cmd.arg(algo); - } let output = cmd .arg("--invalid-option-that-should-not-exist") diff --git a/tests/tests.rs b/tests/tests.rs index d2ecbca10..b731ad465 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -172,10 +172,6 @@ mod test_fold; #[path = "by-util/test_groups.rs"] mod test_groups; -#[cfg(feature = "hashsum")] -#[path = "by-util/test_hashsum.rs"] -mod test_hashsum; - #[cfg(feature = "head")] #[path = "by-util/test_head.rs"] mod test_head; diff --git a/util/show-utils.BAT b/util/show-utils.BAT index f6d900734..92f618160 100644 --- a/util/show-utils.BAT +++ b/util/show-utils.BAT @@ -2,7 +2,7 @@ @echo off @rem ::# spell-checker:ignore (CMD) ERRORLEVEL -@rem ::# spell-checker:ignore (utils) cksum coreutils dircolors hashsum mkdir mktemp printenv printf readlink realpath rmdir shuf tsort unexpand +@rem ::# spell-checker:ignore (utils) cksum coreutils dircolors mkdir mktemp printenv printf readlink realpath rmdir shuf tsort unexpand @rem ::# spell-checker:ignore (jq) deps startswith set "ME=%~0" @@ -12,7 +12,7 @@ set "ME_parent_dir=%~dp0.\.." @rem refs: , @rem :: default ("Tier 1" cross-platform) utility list -set "default_utils=base32 base64 basename cat cksum comm cp cut date dircolors dirname echo env expand expr factor false fmt fold hashsum head join link ln ls mkdir mktemp more mv nl od paste printenv printf ptx pwd readlink realpath rm rmdir seq shred shuf sleep sort split sum tac tail tee test tr true truncate tsort unexpand uniq wc yes" +set "default_utils=base32 base64 basename cat cksum comm cp cut date dircolors dirname echo env expand expr factor false fmt fold head join link ln ls mkdir mktemp more mv nl od paste printenv printf ptx pwd readlink realpath rm rmdir seq shred shuf sleep sort split sum tac tail tee test tr true truncate tsort unexpand uniq wc yes" set "project_dir=%ME_parent_dir%" cd "%project_dir%" diff --git a/util/show-utils.sh b/util/show-utils.sh index 66266e7a9..ff70fe25d 100755 --- a/util/show-utils.sh +++ b/util/show-utils.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # spell-checker:ignore (shell) OSTYPE -# spell-checker:ignore (utils) cksum coreutils dircolors hashsum mkdir mktemp printenv printf readlink realpath grealpath rmdir shuf tsort unexpand +# spell-checker:ignore (utils) cksum coreutils dircolors mkdir mktemp printenv printf readlink realpath grealpath rmdir shuf tsort unexpand # spell-checker:ignore (jq) deps startswith # Use GNU version for realpath on *BSD From 7a8af9e69dd9f17b0e22c06f7a67fb988b4a80a3 Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Sun, 1 Feb 2026 00:23:59 +0900 Subject: [PATCH 424/425] Additional hashsum cleanup --- src/uucore/src/lib/features/checksum/validate.rs | 2 +- tests/by-util/test_b2sum.rs | 9 --------- tests/by-util/test_md5sum.rs | 10 ---------- tests/by-util/test_sha1sum.rs | 9 --------- tests/by-util/test_sha224sum.rs | 10 ---------- tests/by-util/test_sha256sum.rs | 9 --------- tests/by-util/test_sha384sum.rs | 9 --------- tests/by-util/test_sha512sum.rs | 9 --------- util/build-gnu.sh | 4 ++-- 9 files changed, 3 insertions(+), 68 deletions(-) diff --git a/src/uucore/src/lib/features/checksum/validate.rs b/src/uucore/src/lib/features/checksum/validate.rs index 5c7aef432..68b0fbe9c 100644 --- a/src/uucore/src/lib/features/checksum/validate.rs +++ b/src/uucore/src/lib/features/checksum/validate.rs @@ -456,7 +456,7 @@ impl LineInfo { /// In case of non-algo-based format, if `cached_line_format` is Some, it must take the priority /// over the detected format. Otherwise, we must set it the the detected format. /// This specific behavior is emphasized by the test - /// `test_hashsum::test_check_md5sum_only_one_space`. + /// `test_md5sum::test_check_md5sum_only_one_space`. fn parse(s: impl AsRef, cached_line_format: &mut Option) -> Option { let line_bytes = os_str_as_bytes(s.as_ref()).ok()?; diff --git a/tests/by-util/test_b2sum.rs b/tests/by-util/test_b2sum.rs index 9df3170e6..2bbc15a8c 100644 --- a/tests/by-util/test_b2sum.rs +++ b/tests/by-util/test_b2sum.rs @@ -283,12 +283,3 @@ fn test_check_b2sum_strict_check() { .succeeds() .stdout_only(&output); } - -#[test] -fn test_help_shows_correct_utility_name() { - new_ucmd!() - .arg("--help") - .succeeds() - .stdout_contains("Usage: b2sum") - .stdout_does_not_contain("Usage: hashsum"); -} diff --git a/tests/by-util/test_md5sum.rs b/tests/by-util/test_md5sum.rs index 6ccf173cf..a7c36704f 100644 --- a/tests/by-util/test_md5sum.rs +++ b/tests/by-util/test_md5sum.rs @@ -800,13 +800,3 @@ fn test_check_md5_comment_leading_space() { .stdout_contains("foo: OK") .stderr_contains("WARNING: 1 line is improperly formatted"); } - -#[test] -fn test_help_shows_correct_utility_name() { - // Test md5sum - new_ucmd!() - .arg("--help") - .succeeds() - .stdout_contains("Usage: md5sum") - .stdout_does_not_contain("Usage: hashsum"); -} diff --git a/tests/by-util/test_sha1sum.rs b/tests/by-util/test_sha1sum.rs index 6a540d509..3c7175940 100644 --- a/tests/by-util/test_sha1sum.rs +++ b/tests/by-util/test_sha1sum.rs @@ -152,12 +152,3 @@ fn test_conflicting_arg() { new_ucmd!().arg("--tag").arg("--check").fails_with_code(1); new_ucmd!().arg("--tag").arg("--text").fails_with_code(1); } - -#[test] -fn test_help_shows_correct_utility_name() { - new_ucmd!() - .arg("--help") - .succeeds() - .stdout_contains("Usage: sha1sum") - .stdout_does_not_contain("Usage: hashsum"); -} diff --git a/tests/by-util/test_sha224sum.rs b/tests/by-util/test_sha224sum.rs index e2b7129b8..fa83ff7cc 100644 --- a/tests/by-util/test_sha224sum.rs +++ b/tests/by-util/test_sha224sum.rs @@ -108,13 +108,3 @@ fn test_invalid_arg() { fn test_conflicting_arg() { new_ucmd!().arg("--tag").arg("--check").fails_with_code(1); } - -#[test] -fn test_help_shows_correct_utility_name() { - // Test that help output shows the actual utility name instead of "hashsum" - new_ucmd!() - .arg("--help") - .succeeds() - .stdout_contains("Usage: sha224sum") - .stdout_does_not_contain("Usage: hashsum"); -} diff --git a/tests/by-util/test_sha256sum.rs b/tests/by-util/test_sha256sum.rs index b8219e238..53eed6e21 100644 --- a/tests/by-util/test_sha256sum.rs +++ b/tests/by-util/test_sha256sum.rs @@ -169,12 +169,3 @@ fn test_check_sha256_binary() { .no_stderr() .stdout_is("binary.png: OK\n"); } - -#[test] -fn test_help_shows_correct_utility_name() { - new_ucmd!() - .arg("--help") - .succeeds() - .stdout_contains("Usage: sha256sum") - .stdout_does_not_contain("Usage: hashsum"); -} diff --git a/tests/by-util/test_sha384sum.rs b/tests/by-util/test_sha384sum.rs index a579e041a..bb2a0229a 100644 --- a/tests/by-util/test_sha384sum.rs +++ b/tests/by-util/test_sha384sum.rs @@ -110,12 +110,3 @@ fn test_conflicting_arg() { new_ucmd!().arg("--tag").arg("--check").fails_with_code(1); new_ucmd!().arg("--tag").arg("--text").fails_with_code(1); } - -#[test] -fn test_help_shows_correct_utility_name() { - new_ucmd!() - .arg("--help") - .succeeds() - .stdout_contains("Usage: sha384sum") - .stdout_does_not_contain("Usage: hashsum"); -} diff --git a/tests/by-util/test_sha512sum.rs b/tests/by-util/test_sha512sum.rs index 08c6d20fe..ca25e55c1 100644 --- a/tests/by-util/test_sha512sum.rs +++ b/tests/by-util/test_sha512sum.rs @@ -110,12 +110,3 @@ fn test_conflicting_arg() { new_ucmd!().arg("--tag").arg("--check").fails_with_code(1); new_ucmd!().arg("--tag").arg("--text").fails_with_code(1); } - -#[test] -fn test_help_shows_correct_utility_name() { - new_ucmd!() - .arg("--help") - .succeeds() - .stdout_contains("Usage: sha512sum") - .stdout_does_not_contain("Usage: hashsum"); -} diff --git a/util/build-gnu.sh b/util/build-gnu.sh index 0d1c6d9e1..70886e5e9 100755 --- a/util/build-gnu.sh +++ b/util/build-gnu.sh @@ -213,7 +213,7 @@ sed -i -e "s|---dis ||g" tests/tail/overlay-headers.sh # watchers before initial read, so no exact equivalent exists. We break at # watch_with_parent as the closest semantic match. -iex suppresses Rust debug # script auto-load warnings that would cause the test to skip. -"${SED}" -i \ +sed -i \ -e "s|break_src=\"\$abs_top_srcdir/src/tail.c\"|break_src=\"${path_UUTILS}/src/uu/tail/src/follow/watch.rs\"|" \ -e 's|break_line=$(grep -n ^tail_forever_inotify "$break_src")|break_line=$(grep -n "watcher_rx.watch_with_parent" "$break_src")|' \ -e 's|gdb -nx --batch-silent|gdb -nx --batch-silent -iex "set auto-load no"|g' \ @@ -316,7 +316,7 @@ 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 +# for clap 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, From a71d6e4cbb50e99e2a8497d4193c9cdb9d743c9f Mon Sep 17 00:00:00 2001 From: Chris Dryden Date: Sat, 31 Jan 2026 11:12:36 -0500 Subject: [PATCH 425/425] date: fix %% not being preserved in locale format strings (#10577) * date: fix %% not being preserved in locale format strings * test: add locale case to percent-percent test --- src/uucore/src/lib/features/i18n/datetime.rs | 6 ++-- tests/by-util/test_date.rs | 29 ++++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/uucore/src/lib/features/i18n/datetime.rs b/src/uucore/src/lib/features/i18n/datetime.rs index 68721e8fb..dae7fef07 100644 --- a/src/uucore/src/lib/features/i18n/datetime.rs +++ b/src/uucore/src/lib/features/i18n/datetime.rs @@ -69,10 +69,12 @@ pub enum CalendarType { /// Transform a strftime format string to use locale-specific calendar values pub fn localize_format_string(format: &str, date: &JiffDate) -> String { + const PERCENT_PLACEHOLDER: &str = "\x00\x00"; + let (locale, _) = get_time_locale(); let iso_date = Date::::convert_from(*date); - let mut fmt = format.to_string(); + let mut fmt = format.replace("%%", PERCENT_PLACEHOLDER); // For non-Gregorian calendars, replace date components with converted values let calendar_type = get_locale_calendar_type(locale); @@ -126,7 +128,7 @@ pub fn localize_format_string(format: &str, date: &JiffDate) -> String { } } - fmt + fmt.replace(PERCENT_PLACEHOLDER, "%%") } #[cfg(test)] diff --git a/tests/by-util/test_date.rs b/tests/by-util/test_date.rs index 3445d4c0a..08b72ce25 100644 --- a/tests/by-util/test_date.rs +++ b/tests/by-util/test_date.rs @@ -1967,3 +1967,32 @@ fn test_locale_day_names() { check_date(loc, "2026-01-24", "+%A", sat); } } + +#[test] +fn test_percent_percent_not_replaced() { + let cases = [ + // Time conversion specifiers + ( + "+%%H%%I%%k%%l%%M%%N%%p%%P%%r%%R%%s%%S%%T%%X%%z%%Z", + "%H%I%k%l%M%N%p%P%r%R%s%S%T%X%z%Z\n", + ), + // Date conversion specifiers + ( + "+%%a%%A%%b%%B%%c%%C%%d%%D%%e%%F%%g%%G%%h%%j%%m%%u%%U%%V%%w%%W%%x%%y%%Y", + "%a%A%b%B%c%C%d%D%e%F%g%G%h%j%m%u%U%V%w%W%x%y%Y\n", + ), + ]; + for (format, expected) in cases { + new_ucmd!() + .env("TZ", "UTC") + .arg(format) + .succeeds() + .stdout_is(expected); + new_ucmd!() + .env("TZ", "UTC") + .env("LC_ALL", "fr_FR.UTF-8") + .arg(format) + .succeeds() + .stdout_is(expected); + } +}